From 818c8fef7b9281ce8dd737d63120f45af2704bf7 Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 24 Mar 2026 00:56:05 +0800 Subject: [PATCH] chore: release v0.95.0 --- .gitignore | 6 +- AGENTS.md | 14 +- RELEASE_NOTES.md | 38 +- docs/aiprompts/governance.md | 17 + docs/aiprompts/project-heatmap.md | 335 + knip.governance.json | 12 + package.json | 10 +- scripts/README.md | 30 + scripts/governance-graph.mjs | 2418 ++++ scripts/lib/governance-graph-core.mjs | 340 + scripts/lib/governance-graph-core.test.ts | 79 + scripts/project-heatmap.mjs | 2114 +++ scripts/report-legacy-surfaces.mjs | 532 +- scripts/setup-local-aster-override.mjs | 6 +- src-tauri/Cargo.lock | 64 +- src-tauri/Cargo.toml | 8 +- .../crates/agent/src/aster_runtime_support.rs | 10 + src-tauri/crates/agent/src/event_converter.rs | 112 +- src-tauri/crates/agent/src/lib.rs | 15 +- .../crates/agent/src/prompt/runtime_agents.rs | 9 +- src-tauri/crates/agent/src/session_query.rs | 167 + src-tauri/crates/agent/src/session_store.rs | 384 +- src-tauri/crates/agent/src/session_update.rs | 66 + .../crates/agent/src/subagent_control.rs | 110 +- src-tauri/crates/core/src/app_paths.rs | 44 +- .../src/database/dao/agent_thread_incident.rs | 167 + .../core/src/database/dao/agent_timeline.rs | 8 + .../src/database/dao/agent_turn_outcome.rs | 128 + src-tauri/crates/core/src/database/dao/mod.rs | 2 + src-tauri/crates/core/src/database/schema.rs | 87 + .../services/src/aster_session_store.rs | 213 + src-tauri/src/app/runner.rs | 4 + .../commands/aster_agent_cmd/command_api.rs | 6 +- .../command_api/runtime_api.rs | 137 +- src-tauri/src/commands/aster_agent_cmd/dto.rs | 941 +- src-tauri/src/commands/aster_agent_cmd/mod.rs | 50 +- .../commands/aster_agent_cmd/runtime_turn.rs | 432 +- .../aster_agent_cmd/session_runtime.rs | 11 +- .../aster_agent_cmd/subagent_runtime.rs | 80 +- .../dev_bridge/dispatcher/agent_sessions.rs | 88 + .../src/services/agent_timeline_service.rs | 34 + .../memory_source_resolver_service.rs | 10 +- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/openclaw_service.rs | 6579 --------- .../src/services/openclaw_service/config.rs | 341 + .../services/openclaw_service/diagnostics.rs | 482 + .../src/services/openclaw_service/gateway.rs | 871 ++ .../src/services/openclaw_service/install.rs | 810 ++ .../src/services/openclaw_service/mod.rs | 155 + .../src/services/openclaw_service/platform.rs | 292 + .../src/services/openclaw_service/preview.rs | 117 + .../src/services/openclaw_service/process.rs | 196 + .../src/services/openclaw_service/progress.rs | 214 + .../src/services/openclaw_service/runtime.rs | 1096 ++ .../src/services/openclaw_service/tests.rs | 1063 ++ .../src/services/openclaw_service/types.rs | 299 + .../src/services/openclaw_service/update.rs | 747 + .../runtime_agents_template_service.rs | 34 +- .../thread_reliability_projection_service.rs | 277 + src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- .../agent/chat/AgentChatHomeShell.tsx | 22 +- .../agent/chat/AgentChatWorkspace.tsx | 11872 ++-------------- .../agent/chat/agentChatWorkspaceContract.ts | 45 + .../agent/chat/agentChatWorkspaceShared.ts | 79 + src/components/agent/chat/commands/catalog.ts | 178 + .../agent/chat/commands/executor.ts | 103 + .../agent/chat/commands/formatter.ts | 97 + src/components/agent/chat/commands/index.ts | 5 + src/components/agent/chat/commands/parser.ts | 88 + src/components/agent/chat/commands/types.ts | 48 + .../components/AgentIncidentPanel.test.tsx | 87 + .../chat/components/AgentIncidentPanel.tsx | 89 + .../components/AgentThreadOutcomeSummary.tsx | 91 + .../AgentThreadReliabilityPanel.test.tsx | 615 + .../AgentThreadReliabilityPanel.tsx | 743 + .../components/AgentThreadTimeline.test.tsx | 63 + .../chat/components/AgentThreadTimeline.tsx | 183 +- .../components/CanvasWorkbenchLayout.test.tsx | 54 +- .../chat/components/CanvasWorkbenchLayout.tsx | 148 +- .../ChatModelSelector.integration.test.tsx | 34 +- .../agent/chat/components/ChatNavbar.tsx | 30 +- .../chat/components/DecisionPanel.test.tsx | 69 +- .../agent/chat/components/DecisionPanel.tsx | 329 +- .../components/HarnessStatusPanel.test.tsx | 32 + .../chat/components/HarnessStatusPanel.tsx | 137 +- .../components/CharacterMention.test.tsx | 85 +- .../Inputbar/components/CharacterMention.tsx | 399 +- .../components/CharacterMentionPanel.tsx | 57 +- .../components/QueuedTurnsPanel.test.tsx | 58 + .../Inputbar/components/QueuedTurnsPanel.tsx | 17 +- .../chat/components/MessageList.test.tsx | 67 +- .../agent/chat/components/MessageList.tsx | 46 +- .../components/TeamWorkbenchSummaryPanel.tsx | 2 +- .../components/TeamWorkspaceBoard.test.tsx | 3 +- .../chat/components/TeamWorkspaceBoard.tsx | 10 +- .../components/TeamWorkspaceDock.test.tsx | 67 + .../chat/components/TeamWorkspaceDock.tsx | 31 +- .../agent/chat/hooks/agentChatActionState.ts | 24 +- .../agent/chat/hooks/agentChatHistory.test.ts | 36 +- .../agent/chat/hooks/agentChatHistory.ts | 42 +- .../agent/chat/hooks/agentRuntimeAdapter.ts | 32 + .../agent/chat/hooks/useAgentSession.ts | 55 +- .../agent/chat/hooks/useAgentStream.ts | 144 +- .../agent/chat/hooks/useAgentTools.ts | 144 +- .../chat/hooks/useArtifactAutoPreviewSync.ts | 15 +- .../chat/hooks/useAsterAgentChat.test.tsx | 983 +- .../agent/chat/hooks/useAsterAgentChat.ts | 124 +- src/components/agent/chat/index.test.tsx | 473 +- src/components/agent/chat/index.tsx | 4 +- src/components/agent/chat/styles/index.ts | 11 +- .../agent/chat/utils/agentThreadGrouping.ts | 10 +- .../agent/chat/utils/messageArtifacts.ts | 18 + .../chat/utils/threadReliabilityView.test.ts | 179 + .../agent/chat/utils/threadReliabilityView.ts | 888 ++ .../ThemeWorkbenchSidebarSection.tsx | 106 + .../chat/workspace/WorkspaceCanvasContent.tsx | 45 + .../chat/workspace/WorkspaceChatContent.tsx | 138 + .../workspace/WorkspaceContentSyncNotice.tsx | 51 + .../workspace/WorkspaceConversationScene.tsx | 348 + .../chat/workspace/WorkspaceHarnessDialog.tsx | 30 + .../workspace/WorkspaceHarnessDialogs.tsx | 122 + .../chat/workspace/WorkspaceInputbar.tsx | 27 + .../chat/workspace/WorkspaceMainArea.tsx | 69 + .../chat/workspace/WorkspaceMainScene.tsx | 98 + .../chat/workspace/WorkspacePageShell.tsx | 50 + .../chat/workspace/WorkspaceShellScene.tsx | 99 + .../agent/chat/workspace/WorkspaceStyles.tsx | 314 + .../chat/workspace/WorkspaceThemeSidebar.tsx | 139 + .../chat/workspace/browserAssistArtifact.ts | 219 + .../canvasWorkbenchDefaultPreview.ts | 88 + .../agent/chat/workspace/chatSurfaceProps.ts | 394 + .../chat/workspace/imageWorkbenchHelpers.ts | 326 + .../chat/workspace/inputbarPresentation.ts | 40 + .../chat/workspace/runtimeTeamPreview.ts | 158 + .../workspace/teamWorkbenchPresentation.tsx | 221 + .../chat/workspace/themeWorkbenchHelpers.ts | 1178 ++ .../useThemeWorkbenchSidebarPresentation.tsx | 89 + .../chat/workspace/useWorkspaceA2UIRuntime.ts | 223 + .../useWorkspaceA2UISubmitActions.ts | 113 + .../useWorkspaceArtifactPreviewActions.ts | 435 + .../workspace/useWorkspaceAutoGuideRuntime.ts | 290 + .../useWorkspaceBrowserAssistRuntime.ts | 915 ++ .../useWorkspaceBrowserPreflightRuntime.ts | 358 + .../useWorkspaceCanvasLayoutRuntime.ts | 354 + .../useWorkspaceCanvasMessageSyncRuntime.ts | 213 + .../useWorkspaceCanvasPreviewPresentation.tsx | 516 + .../useWorkspaceCanvasScenePresentation.tsx | 132 + .../useWorkspaceCanvasSceneRuntime.tsx | 251 + .../useWorkspaceCanvasTaskFileSync.ts | 111 + .../useWorkspaceCanvasWorkflowActions.ts | 388 + .../useWorkspaceContextDetailActions.tsx | 61 + .../useWorkspaceContextHarnessRuntime.ts | 156 + ...WorkspaceConversationScenePresentation.tsx | 141 + .../useWorkspaceConversationSceneRuntime.tsx | 428 + .../useWorkspaceConversationSendRuntime.ts | 74 + ...WorkspaceConversationShellSceneRuntime.tsx | 167 + .../useWorkspaceDisplayMessagesRuntime.ts | 138 + .../useWorkspaceGeneralResourceSync.ts | 187 + .../useWorkspaceHarnessInventoryRuntime.ts | 152 + ...useWorkspaceImageWorkbenchActionRuntime.ts | 530 + .../useWorkspaceImageWorkbenchEventRuntime.ts | 236 + .../useWorkspaceInputbarPresentation.tsx | 170 + .../useWorkspaceInputbarScenePresentation.tsx | 136 + .../useWorkspaceInputbarSceneRuntime.tsx | 333 + .../useWorkspaceNavigationActions.ts | 121 + .../workspace/useWorkspaceResetRuntime.ts | 270 + ...kspaceRuntimeTeamDispatchPreviewRuntime.ts | 58 + .../chat/workspace/useWorkspaceSendActions.ts | 737 + .../workspace/useWorkspaceSessionRestore.ts | 167 + .../useWorkspaceShellChromeRuntime.ts | 114 + .../useWorkspaceShellSceneRuntime.tsx | 93 + .../useWorkspaceStyleActionPresentation.ts | 155 + .../useWorkspaceTeamSessionControlRuntime.ts | 417 + .../useWorkspaceTeamSessionRuntime.ts | 81 + ...seWorkspaceTeamWorkbenchAutoOpenRuntime.ts | 112 + ...hemeWorkbenchDocumentPersistenceRuntime.ts | 77 + .../useWorkspaceThemeWorkbenchRuntime.ts | 309 + ...eWorkspaceThemeWorkbenchScaffoldRuntime.ts | 225 + ...useWorkspaceThemeWorkbenchShellRuntime.tsx | 137 + ...seWorkspaceThemeWorkbenchSidebarRuntime.ts | 427 + ...spaceThemeWorkbenchVersionStatusRuntime.ts | 69 + .../chat/workspace/useWorkspaceTopicSwitch.ts | 215 + .../useWorkspaceWorkflowProgressSync.ts | 77 + .../workspace/useWorkspaceWriteFileAction.ts | 576 + .../agent/chat/workspace/workbenchPreview.tsx | 231 + .../workspace/workbenchPreviewHelpers.tsx | 106 + .../agent/chat/workspace/workspacePath.ts | 47 + src/lib/api/agent.test.ts | 112 + src/lib/api/agentRuntime.ts | 128 +- src/lib/api/agentStream.test.ts | 36 + src/lib/api/agentStream.ts | 8 + 192 files changed, 40182 insertions(+), 18661 deletions(-) create mode 100644 docs/aiprompts/project-heatmap.md create mode 100644 knip.governance.json create mode 100644 scripts/governance-graph.mjs create mode 100644 scripts/lib/governance-graph-core.mjs create mode 100644 scripts/lib/governance-graph-core.test.ts create mode 100644 scripts/project-heatmap.mjs create mode 100644 src-tauri/crates/agent/src/session_query.rs create mode 100644 src-tauri/crates/agent/src/session_update.rs create mode 100644 src-tauri/crates/core/src/database/dao/agent_thread_incident.rs create mode 100644 src-tauri/crates/core/src/database/dao/agent_turn_outcome.rs delete mode 100644 src-tauri/src/services/openclaw_service.rs create mode 100644 src-tauri/src/services/openclaw_service/config.rs create mode 100644 src-tauri/src/services/openclaw_service/diagnostics.rs create mode 100644 src-tauri/src/services/openclaw_service/gateway.rs create mode 100644 src-tauri/src/services/openclaw_service/install.rs create mode 100644 src-tauri/src/services/openclaw_service/mod.rs create mode 100644 src-tauri/src/services/openclaw_service/platform.rs create mode 100644 src-tauri/src/services/openclaw_service/preview.rs create mode 100644 src-tauri/src/services/openclaw_service/process.rs create mode 100644 src-tauri/src/services/openclaw_service/progress.rs create mode 100644 src-tauri/src/services/openclaw_service/runtime.rs create mode 100644 src-tauri/src/services/openclaw_service/tests.rs create mode 100644 src-tauri/src/services/openclaw_service/types.rs create mode 100644 src-tauri/src/services/openclaw_service/update.rs create mode 100644 src-tauri/src/services/thread_reliability_projection_service.rs create mode 100644 src/components/agent/chat/agentChatWorkspaceContract.ts create mode 100644 src/components/agent/chat/agentChatWorkspaceShared.ts create mode 100644 src/components/agent/chat/commands/catalog.ts create mode 100644 src/components/agent/chat/commands/executor.ts create mode 100644 src/components/agent/chat/commands/formatter.ts create mode 100644 src/components/agent/chat/commands/index.ts create mode 100644 src/components/agent/chat/commands/parser.ts create mode 100644 src/components/agent/chat/commands/types.ts create mode 100644 src/components/agent/chat/components/AgentIncidentPanel.test.tsx create mode 100644 src/components/agent/chat/components/AgentIncidentPanel.tsx create mode 100644 src/components/agent/chat/components/AgentThreadOutcomeSummary.tsx create mode 100644 src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx create mode 100644 src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx create mode 100644 src/components/agent/chat/utils/threadReliabilityView.test.ts create mode 100644 src/components/agent/chat/utils/threadReliabilityView.ts create mode 100644 src/components/agent/chat/workspace/ThemeWorkbenchSidebarSection.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceChatContent.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceContentSyncNotice.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceConversationScene.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceHarnessDialog.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceInputbar.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceMainArea.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceMainScene.tsx create mode 100644 src/components/agent/chat/workspace/WorkspacePageShell.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceShellScene.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceStyles.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceThemeSidebar.tsx create mode 100644 src/components/agent/chat/workspace/browserAssistArtifact.ts create mode 100644 src/components/agent/chat/workspace/canvasWorkbenchDefaultPreview.ts create mode 100644 src/components/agent/chat/workspace/chatSurfaceProps.ts create mode 100644 src/components/agent/chat/workspace/imageWorkbenchHelpers.ts create mode 100644 src/components/agent/chat/workspace/inputbarPresentation.ts create mode 100644 src/components/agent/chat/workspace/runtimeTeamPreview.ts create mode 100644 src/components/agent/chat/workspace/teamWorkbenchPresentation.tsx create mode 100644 src/components/agent/chat/workspace/themeWorkbenchHelpers.ts create mode 100644 src/components/agent/chat/workspace/useThemeWorkbenchSidebarPresentation.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceA2UISubmitActions.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceBrowserAssistRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceBrowserPreflightRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasMessageSyncRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasScenePresentation.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasTaskFileSync.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceContextDetailActions.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceContextHarnessRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceConversationScenePresentation.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceConversationSendRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceGeneralResourceSync.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceImageWorkbenchEventRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceInputbarPresentation.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceInputbarScenePresentation.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceNavigationActions.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceSendActions.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceSessionRestore.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceShellChromeRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceStyleActionPresentation.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchScaffoldRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchShellRuntime.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchSidebarRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchVersionStatusRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceWorkflowProgressSync.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts create mode 100644 src/components/agent/chat/workspace/workbenchPreview.tsx create mode 100644 src/components/agent/chat/workspace/workbenchPreviewHelpers.tsx create mode 100644 src/components/agent/chat/workspace/workspacePath.ts diff --git a/.gitignore b/.gitignore index 375328715..dadd1eba9 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,8 @@ lime-claw.png lime.db .codex-* -target-codex* \ No newline at end of file +target-codex* + +tmp/ + +governance/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 666b7c917..8018b4525 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ | [workspace.md](docs/aiprompts/workspace.md) | Workspace 设计文档 | | [content-creator.md](docs/aiprompts/content-creator.md) | 内容创作系统 | | [aster-integration.md](docs/aiprompts/aster-integration.md) | Aster 集成方案 | +| [project-heatmap.md](docs/aiprompts/project-heatmap.md) | 项目热力图与治理图再生成指南 | | [playwright-e2e.md](docs/aiprompts/playwright-e2e.md) | Playwright MCP 续测与 E2E 指南 | ## 构建命令 @@ -103,6 +104,10 @@ npm run lint - 需要继续浏览器 E2E、复用现有 Playwright MCP 会话、排查 DevBridge/console 错误时,先读 `docs/aiprompts/playwright-e2e.md` - 如果只是仓库级规则,不要继续往本文件堆叠步骤说明 +## 项目热力图入口 + +- 需要重新生成仓库观察热力图、治理候选图、打开现有 HTML 报告或向后续 AI 交接这套流程时,先读 `docs/aiprompts/project-heatmap.md` + ## UI 设计入口 - 需要统一配色、修正渐变、调整页面宽度策略、重排卡片工作台时,先读 `docs/aiprompts/design-language.md` @@ -114,7 +119,7 @@ npm run lint - 后端:Rust + Tauri - 数据库:SQLite (rusqlite) -### 核心模块 +### prrovide模块 1. **Provider 系统** (`src-tauri/src/providers/`) - Kiro/CodeWhisperer OAuth 认证 @@ -137,13 +142,6 @@ npm run lint - OpenAI ↔ CodeWhisperer 转换 - OpenAI ↔ Claude 转换 -### 凭证管理策略(方案 B) - -Kiro 凭证采用完全独立的副本策略: -- 上传凭证时,自动合并 `clientIdHash` 文件中的 `client_id`/`client_secret` 到副本 -- 每个副本文件完全独立,支持多账号场景 -- 刷新 Token 时只使用副本文件中的凭证,不依赖原始文件 - ## 开发指南 ### 添加新 Provider diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 563442050..ebfebc2d8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,42 +1,36 @@ -## Lime v0.94.0 +## Lime v0.95.0 ### ✨ 主要更新 -- **微信 Claw 接入打通并进入可用形态**:基于微信 Gateway 补齐扫码登录、等待登录、账号持久化、默认模型同步、账号清理与运行状态查询能力,设置页可直接生成本地二维码、等待扫码结果并在成功后自动启动网关 -- **Team Workbench 与图片工作台合流**:输入区新增 `@配图` 内建命令,主工作台补齐图片生成、编辑、变体的画布视图、输出流转和继续追问链路,围绕团队协作场景进一步统一消息区与右侧工作区 -- **高风险模型进入“稳妥模式”**:新增 Team/Provider 两层运行时并发治理,对 Zhipu/GLM 一类高风险模型改用顺序排队并显式展示等待提示,优先降低并发下直接失败的概率 -- **渠道日志与运行态诊断增强**:渠道设置页新增 Tail 视图、过滤预设、正则筛选、复制与清空操作,内存日志与持久化日志会合并去重,便于排查渠道联调问题 -- **图片模型路由与资源流转继续收口**:增强图片 Provider 识别、模型筛选与兼容性判断,补齐资源保存、输出复用和工作台跟随式操作,减少图片任务在多 Provider 场景下的手工切换成本 +- **Agent 线程可靠性面板上线**:会话线程新增 pending request、active incident、最近 outcome 与排队 turn 的统一读模型,前端可直接展示“卡在哪里、为什么卡、下一步怎么恢复” +- **恢复动作从诊断面板直达执行**:支持在线中断当前 turn、恢复线程、重放待处理请求、提升排队 turn,减少遇到等待确认、工具失败或队列阻塞时的手工排障成本 +- **Agent 工作区继续收口**:`AgentChatWorkspace` 大体量逻辑拆分到共享 contract、命令解析/执行层与多个面板组件,消息列表、决策面板、提及面板、队列面板与工作台布局的职责更清晰 +- **Codex slash command 基础接入**:新增 `/compact`、`/clear`、`/new`、`/review`、`/init`、`/diff`、`/status`、`/model`、`/help` 等命令目录与格式化/执行链路,为后续命令扩展提供统一入口 +- **项目热力图与治理图再生成能力补齐**:新增仓库热力图脚本、治理图谱脚本和操作文档,可直接生成热点模块与治理候选报告,方便后续收口 legacy/compat 路径 ### ⚠️ 兼容性说明 -- Zhipu/GLM 相关模型在高峰时会按顺序进入执行窗口,属于稳定性优先的行为调整,团队任务的并发体感会与此前不同 -- 微信渠道启用后依赖扫码登录结果、远端网关地址与 CDN 地址保持一致;迁移旧配置时建议重新核对默认账号和默认模型绑定 -- 渠道日志面板现在同时读取内存日志与持久化日志;若手动清空日志,会同步删除当前诊断数据且不可恢复 +- Agent 线程可靠性依赖新的本地数据库表 `agent_turn_outcomes` 与 `agent_thread_incidents`;升级后首次打开相关会话会自动补齐表结构 +- 线程恢复与 pending request 诊断依赖新的 Aster 线程读模型;如果本地还在用 `setup:local-aster` 覆盖,请确认联调仓库已同步到 `aster-rust v0.21.0` +- release workflow 仍由 `v*` tag 触发,`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文;只推 `main` 不会自动出包 ### 🔗 依赖同步 -- 渠道设置页新增 `qrcode` / `@types/qrcode` 用于微信扫码登录展示 -- `lime-gateway` 补充 `aes`、`ecb`、`base64`、`dirs`、`hex`、`md5`、`rand`、`rusqlite` 等依赖,为微信渠道与网关运行时能力提供支持 -- 应用版本同步提升到 `v0.94.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace 版本入口 +- `aster-core` / `aster-models` 远程 tag 固定到 `v0.21.0` +- 应用版本同步提升到 `v0.95.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace/package 入口 ### 🧪 测试 -- 发布前执行:`cd src-tauri && cargo test` -- 发布前执行:`cd src-tauri && cargo fmt --all` -- 发布前执行:`cd src-tauri && cargo clippy` +- 发布前执行:`cargo fmt --all --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml` - 发布前执行:`npm run lint` ### 📝 文档 +- 补充 `docs/aiprompts/project-heatmap.md`,明确仓库热力图、治理图与 legacy-report 的配套使用方式 - 发布说明随 `RELEASE_NOTES.md` 更新,供 GitHub Release 工作流直接读取 -### 📦 Windows 下载说明 - -- `Lime_*_x64-offline-setup.exe`:推荐优先使用,内置 WebView2 离线安装器,安装更完整 -- `Lime_*_x64-online-setup.exe`:体积更小,适合网络稳定且可访问微软下载源的环境 -- 如果在线安装失败,请改用离线安装包 - --- -**完整变更**: v0.93.0...v0.94.0 +**完整变更**: v0.94.0...v0.95.0 diff --git a/docs/aiprompts/governance.md b/docs/aiprompts/governance.md index 6739ae892..3a6997377 100644 --- a/docs/aiprompts/governance.md +++ b/docs/aiprompts/governance.md @@ -186,6 +186,23 @@ npm run test:contracts > 聊天能力后续统一收敛到 `useAgentChatUnified -> useAsterAgentChat -> agent_runtime_* + lime_core::database::agent_session_repository`。 +## 文件级治理图谱配套规则 + +如果要维护文件 / 页面级治理图谱,仓库内默认配套: + +- 人工治理规则:`governance/surfaces.yml` +- 交互图谱命令:`npm run governance:graph` +- 既有边界扫描:`npm run governance:legacy-report` + +约束: + +- `current / compat / deprecated / dead` 只能来自人工治理规则或仓库内既有治理规则 +- 自动脚本只能补 `dead-candidate`、`unused-file`、`unused-export`、`zero-inbound`、`page-unreachable` 等信号 +- `dead` 不能由脚本自动判定,必须人工确认 +- `compat` 规则必须写清 `sourceOfTruth` +- `deprecated` 规则必须写清 `exitCriteria` +- 如遇构建期 alias、运行时桥接、特殊入口等静态分析盲区,可补 `ignoreSignals` 仅压制误报 signal,不改变事实源判断 + ### 第三步:优先做减法 默认优先做这些动作,而不是再加一层抽象: diff --git a/docs/aiprompts/project-heatmap.md b/docs/aiprompts/project-heatmap.md new file mode 100644 index 000000000..d58134475 --- /dev/null +++ b/docs/aiprompts/project-heatmap.md @@ -0,0 +1,335 @@ +# 项目热力图与治理图再生成指南 + +## 目的 + +本文件用于指导后续 AI Agent 或人工维护者,稳定地重新生成 Lime 仓库的: + +- **项目热力图**:看“哪里大、哪里热、什么时候热” +- **治理图**:看“哪些模块最值得优先做收口治理” + +这里的“治理图”不是独立脚本,而是 `project-heatmap.mjs` 输出 HTML 报告中的 **`治理候选`** 板块。 + +## 适用场景 + +当用户出现以下意图时,优先使用本流程: + +- “重新生成项目热力图” +- “看一下现在仓库哪些地方最热” +- “看一下哪些模块最该治理” +- “重新做治理图 / 治理候选榜” +- “帮我打开上次那份热力图报告” + +如果用户不是要看仓库演化,而是要看 **legacy / compat / deprecated / dead** 的真实边界,请同时阅读: + +- `docs/aiprompts/governance.md` + +热力图负责 **发现热点和治理优先级**,治理报告负责 **确认边界分类和封老路状态**。 + +## 相关文件 + +- 脚本:`scripts/project-heatmap.mjs` +- 文件 / 页面治理图谱:`scripts/governance-graph.mjs` +- 命令入口:`npm run heatmap:project` +- 命令入口(带连线治理图谱):`npm run governance:graph` +- 治理规则:`docs/aiprompts/governance.md` +- 治理扫描:`npm run governance:legacy-report` + +## 输出物说明 + +每次生成都会产出两个文件: + +- `index.html`:本地静态可视化报告 +- `project-heatmap.json`:聚合后的结构化数据 + +报告中主要看三块: + +1. **治理候选**:综合体量、churn、密度、分散度、持续活跃度后的治理优先级 +2. **模块体量 + 热度**:Treemap,面积代表 `LOC`,颜色代表 `churn density` +3. **时间 × 模块热力矩阵**:看某模块是不是持续发热 + +治理图谱 2.0 另外输出: + +- `governance-graph.html`:文件 / 页面级交互图谱(带连线、状态、signals、legacy overlay) +- `governance-graph.json`:治理图谱结构化数据 + +默认命令: + +```bash +npm run governance:graph -- --output "./tmp/project-heatmap-governance" +``` + +补充说明: + +- 图谱状态来源只认仓库内治理规则与既有治理护栏 +- `dead-candidate`、`unused-file`、`zero-inbound` 只是疑似失效信号,不等于正式 `dead` +- 首期粒度是页面 / 文件,不包含函数调用图 + +## 标准操作流程 + +### 1. 先选输出目录 + +为了方便后续 AI、用户和不同平台复用,**优先显式传 `--output`**,不要依赖系统临时目录默认值。 + +推荐输出到仓库内相对目录: + +```bash +npm run heatmap:project -- --output "./tmp/project-heatmap" +``` + +推荐原因: + +- 路径稳定,方便后续 AI 继续打开 +- 不依赖 macOS / Windows 的系统临时目录差异 +- 更适合在对话里直接引用具体文件路径 + +### 2. 生成“项目热力图” + +这是默认的仓库观察视角,适合先总览: + +```bash +npm run heatmap:project -- --days 180 --depth 2 --top 18 --output "./tmp/project-heatmap" +``` + +含义: + +- `--days 180`:观察最近 180 天的 Git churn +- `--depth 2`:按目录深度 2 聚合,适合总览 `src` / `src-tauri` / `docs` +- `--top 18`:矩阵中展示前 18 个热点模块 + +### 3. 生成“治理图” + +如果目标是看 **该治理谁**,推荐使用更细一层的聚合深度: + +```bash +npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance" +``` + +推荐参数解释: + +- `--days 30`:更适合看近期治理优先级,而不是长期历史噪音 +- `--depth 3`:能把 `src/components/agent`、`src-tauri/src/commands` 这种真实模块层级打出来 +- `--top 15`:矩阵和候选榜更聚焦 + +### 4. 配套生成治理扫描结果 + +只看热力图还不够。要确认哪些路径已经被收口、哪些还是 compat / deprecated,还要跑: + +```bash +npm run governance:legacy-report +``` + +用途: + +- 确认 legacy / compat / deprecated / dead 边界 +- 验证旧入口是否被重新引用 +- 判断是不是已经封住老路 + +### 5. 打开报告 + +#### macOS + +```bash +open "./tmp/project-heatmap-governance/index.html" +``` + +#### Windows PowerShell + +```powershell +Start-Process ".\\tmp\\project-heatmap-governance\\index.html" +``` + +#### 通用降级方式 + +如果当前 AI 环境不能直接打开 GUI: + +- 返回 HTML 文件路径 +- 返回 `project-heatmap.json` 路径 +- 告诉用户“可直接在文件管理器中双击打开 `index.html`” + +补充说明: + +- 如果 AI 运行在受限沙箱或审批模式下,`open` / `Start-Process` 这类 GUI 打开动作可能需要用户批准 +- 如果无法直接打开,不要卡住流程;优先把可点击文件路径返回给用户 + +## 推荐命令模板 + +### 只做总览 + +```bash +npm run heatmap:project -- --days 180 --depth 2 --top 18 --output "./tmp/project-heatmap" +``` + +### 只看治理优先级 + +```bash +npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance" +npm run governance:legacy-report +``` + +### 同时保留两份报告 + +```bash +npm run heatmap:project -- --days 180 --depth 2 --top 18 --output "./tmp/project-heatmap" +npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance" +``` + +## AI 执行清单 + +当后续 AI 被要求“重新生成热力图/治理图”时,建议严格按下面顺序执行: + +### A. 先读规则 + +至少先读: + +- `docs/aiprompts/project-heatmap.md` +- `docs/aiprompts/governance.md` + +### B. 再生成 + +如果用户没指定参数,优先生成两份: + +1. 总览热力图 +2. 深度 3 的治理图 + +推荐命令: + +```bash +npm run heatmap:project -- --days 180 --depth 2 --top 18 --output "./tmp/project-heatmap" +npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance" +``` + +### C. 再补治理扫描 + +```bash +npm run governance:legacy-report +``` + +### D. 最后再总结 + +汇报时至少给出: + +1. 哪份 HTML 是总览热力图 +2. 哪份 HTML 是治理图 +3. 哪些模块属于 **立即治理** +4. 哪些模块属于 **尽快治理** +5. heatmap 发现的热点,与 `governance:legacy-report` 的边界扫描是否一致 + +## 如何解释结果 + +### 1. 热力图不等于分类结果 + +`治理候选` 中的: + +- `立即治理` +- `尽快治理` +- `持续观察` + +是 **优先级启发式判断**,不是 `current / compat / deprecated / dead` 的正式分类。 + +正式分类必须结合: + +- `docs/aiprompts/governance.md` +- `npm run governance:legacy-report` + +### 2. 一个模块“很热”,不一定说明它是坏的 + +需要优先治理,通常要同时满足几个条件: + +- 体量大 +- churn 高 +- 单位体量改动密 +- 文件分散 +- 连续多周活跃 + +### 3. 一个模块“很旧”,不一定值得现在下刀 + +如果 `governance:legacy-report` 显示: + +- 已零引用 +- 已删除 +- 已受控 compat + +那它不是第一优先级。 +优先级更高的通常是 **还在高速演进、还没收口的 current 主链路**。 + +## 建议默认解读方式 + +### 总览热力图重点看 + +- `src/components` +- `src-tauri/src` +- `src-tauri/crates` +- `src/lib` +- `src/features` + +### 治理图重点看 + +深度 3 结果通常更有用,优先关注例如: + +- `src/components/agent` +- `src/components/workspace` +- `src/components/settings-v2` +- `src-tauri/src/commands` +- `src-tauri/src/dev_bridge` +- `src/lib/api` +- `src-tauri/src/services` + +## 建议的 AI 结论模板 + +生成完成后,建议按下面格式汇报: + +```text +已生成两份报告: + +- 总览热力图:./tmp/project-heatmap/index.html +- 治理图:./tmp/project-heatmap-governance/index.html + +本轮最值得优先治理的模块: +- src/components/agent +- src/components/workspace +- src-tauri/src/commands + +补充验证: +- governance:legacy-report 已运行 + +注意: +- 治理候选是优先级判断,不等于 compat / deprecated 正式分类 +``` + +## 常见问题 + +### 1. 为什么看不到治理候选? + +可能原因: + +- 你打开的是旧报告 +- 输出目录复用了旧文件 +- 使用了过浅的 `--depth` + +建议: + +```bash +npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance" +``` + +### 2. 为什么路径和上次不一样? + +因为如果不显式传 `--output`,脚本会默认输出到系统临时目录。 +为了让 AI 会话之间稳定复用,建议始终传: + +```bash +--output "./tmp/project-heatmap-governance" +``` + +### 3. 为什么热力图和治理扫描结论不完全一样? + +这是正常的: + +- 热力图:看“哪里热、哪里值得先下刀” +- 治理扫描:看“旧路有没有被封住,边界有没有违规” + +两者是互补关系,不是重复关系。 + +## 一句话 + +**重新生成热力图时,默认出两份:深度 2 看全局,深度 3 看治理;再配合 `npm run governance:legacy-report` 做正式边界判断。** diff --git a/knip.governance.json b/knip.governance.json new file mode 100644 index 000000000..eb904950c --- /dev/null +++ b/knip.governance.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://unpkg.com/knip@latest/schema.json", + "entry": ["src/main.tsx"], + "project": [ + "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", + "!src/**/*.test.{ts,tsx,js,jsx,mjs,cjs}", + "!src/**/*.spec.{ts,tsx,js,jsx,mjs,cjs}", + "!src/**/__tests__/**", + "!src/**/__mocks__/**", + "!src/**/*.d.ts" + ] +} diff --git a/package.json b/package.json index a8838a913..223f75b97 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.94.0", + "version": "0.95.0", "type": "module", "engines": { "node": ">=22.0.0" @@ -12,6 +12,8 @@ }, "homepage": "https://github.com/aiclientproxy/lime", "scripts": { + "heatmap:project": "node scripts/project-heatmap.mjs", + "governance:graph": "node scripts/governance-graph.mjs", "predev": "npm run verify:app-version && node scripts/ensure-dev-port.mjs", "dev": "vite", "build": "npm run verify:app-version && tsc && vite build", @@ -156,6 +158,8 @@ "@typescript-eslint/parser": "^8.15.0", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", + "cytoscape": "^3.33.1", + "dependency-cruiser": "^16.10.4", "eslint": "^9.15.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", @@ -163,6 +167,7 @@ "globals": "^15.12.0", "husky": "^9.1.7", "jsdom": "^22.1.0", + "knip": "^6.0.2", "postcss": "^8.4.47", "prettier": "^3.3.3", "tailwindcss": "^3.4.14", @@ -170,6 +175,7 @@ "typescript": "^5.6.3", "vite": "^5.4.21", "vite-plugin-svgr": "^4.5.0", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "yaml": "^2.8.3" } } diff --git a/scripts/README.md b/scripts/README.md index d80022886..cda2be5b2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -57,3 +57,33 @@ When upstream adds new Chinese text: - **Total entries:** 3,568 - **Translated:** 3,568 (100%) - **Coverage:** 100% + +## 项目热力图 + +可以使用下面的脚本为当前仓库生成静态项目观察报告: + +```bash +npm run heatmap:project +``` + +常用参数: + +```bash +npm run heatmap:project -- --days 90 +npm run heatmap:project -- --output "./tmp/project-heatmap" +``` + +报告会输出: + +- `index.html`:本地可直接打开的热力图报告 +- `project-heatmap.json`:可复用的聚合数据 + +默认行为: + +- 分析最近 `180` 天的 Git churn +- 以目录深度 `2` 聚合模块 +- 忽略 `node_modules`、`dist`、`target`、锁文件等噪音输入 + +完整的 AI 操作流程、治理图生成参数、跨平台打开方式,请读: + +- `docs/aiprompts/project-heatmap.md` diff --git a/scripts/governance-graph.mjs b/scripts/governance-graph.mjs new file mode 100644 index 000000000..3bf621cf5 --- /dev/null +++ b/scripts/governance-graph.mjs @@ -0,0 +1,2418 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { cruise } from "dependency-cruiser"; +import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config"; +import YAML from "yaml"; +import { buildLegacySurfaceReport } from "./report-legacy-surfaces.mjs"; +import { + buildRustModuleIndex, + buildRustModulePathFromFile, + createDirNodeId, + expandRustUseTree, + isFrontendCodePath, + isPagePath, + isRustCodePath, + isTestLikePath, + normalizePath, + resolveMatchingGovernanceRule, + resolveRustUseToFile, + validateGovernanceRules, +} from "./lib/governance-graph-core.mjs"; + +const DEFAULT_SINCE_DAYS = 30; +const DEFAULT_OUTPUT_DIR = "./tmp/project-heatmap-governance"; +const DEFAULT_RULES_PATH = "./governance/surfaces.yml"; +const DEFAULT_KNIP_CONFIG = "./knip.governance.json"; + +const IGNORED_DIRECTORIES = new Set([ + ".git", + ".next", + ".nuxt", + ".output", + ".turbo", + "coverage", + "dist", + "node_modules", + "out", + "target", + "target-codex-verify", + "tmp", + "vendor", + "modified_files", +]); + +const FRONTEND_ENTRYPOINTS = ["src/main.tsx", "src/RootRouter.tsx"]; +const STATUS_PRIORITY = new Map([ + ["dead", 4], + ["deprecated", 3], + ["compat", 2], + ["current", 1], + ["unclassified", 0], +]); + +const options = parseArgs(process.argv.slice(2)); + +if (options.help) { + printHelp(); + process.exit(0); +} + +const repoRoot = resolveRepoRoot(path.resolve(options.root || process.cwd())); +const outputDir = path.resolve(repoRoot, options.output || DEFAULT_OUTPUT_DIR); +const rulesPath = path.resolve(repoRoot, options.rules || DEFAULT_RULES_PATH); +const knipConfigPath = path.resolve( + repoRoot, + options.knipConfig || DEFAULT_KNIP_CONFIG, +); + +const report = await buildGovernanceGraphReport({ + repoRoot, + outputDir, + sinceDays: options.days, + rulesPath, + knipConfigPath, +}); + +fs.mkdirSync(outputDir, { recursive: true }); + +const jsonOutputPath = path.join(outputDir, "governance-graph.json"); +const htmlOutputPath = path.join(outputDir, "governance-graph.html"); + +fs.writeFileSync(jsonOutputPath, JSON.stringify(report, null, 2), "utf8"); +fs.writeFileSync(htmlOutputPath, renderHtml(report), "utf8"); + +console.log("[governance-graph] 治理图谱已生成"); +console.log(`[governance-graph] HTML: ${htmlOutputPath}`); +console.log(`[governance-graph] JSON: ${jsonOutputPath}`); +if (report.links.heatmapHtmlHref) { + console.log( + `[governance-graph] 同目录热力图: ${path.join(outputDir, "index.html")}`, + ); +} + +function parseArgs(argv) { + const result = { + root: "", + output: "", + rules: "", + knipConfig: "", + days: DEFAULT_SINCE_DAYS, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if ((arg === "-h" || arg === "--help") && !result.help) { + result.help = true; + continue; + } + + if (arg === "--root" && argv[index + 1]) { + result.root = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--output" && argv[index + 1]) { + result.output = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--rules" && argv[index + 1]) { + result.rules = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--knip-config" && argv[index + 1]) { + result.knipConfig = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--days" && argv[index + 1]) { + result.days = normalizePositiveNumber(argv[index + 1], DEFAULT_SINCE_DAYS); + index += 1; + } + } + + return result; +} + +function normalizePositiveNumber(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return parsed; +} + +function printHelp() { + console.log(` +Lime 治理图谱生成器 + +用法: + npm run governance:graph + npm run governance:graph -- --output "./tmp/project-heatmap-governance" + npm run governance:graph -- --days 60 --rules "./governance/surfaces.yml" + +选项: + --root PATH 指定仓库根目录,默认当前目录 + --output PATH 指定输出目录,默认 ${DEFAULT_OUTPUT_DIR} + --rules PATH 指定治理规则 YAML,默认 ${DEFAULT_RULES_PATH} + --knip-config PATH 指定 knip 配置,默认 ${DEFAULT_KNIP_CONFIG} + --days N 最近 N 天 churn,默认 ${DEFAULT_SINCE_DAYS} + -h, --help 显示帮助 +`); +} + +async function buildGovernanceGraphReport({ + repoRoot, + outputDir, + sinceDays, + rulesPath, + knipConfigPath, +}) { + const fileInventory = collectGovernanceFiles(repoRoot); + const fileIndex = new Map( + fileInventory.map((fileRecord) => [fileRecord.path, fileRecord]), + ); + const gitCommand = process.platform === "win32" ? "git.exe" : "git"; + const fileChurn = collectGitChurn({ + gitCommand, + repoRoot, + sinceDays, + trackedFiles: fileIndex, + }); + const frontendGraph = await collectFrontendDependencies(repoRoot, fileIndex); + const rustGraph = collectRustDependencies(repoRoot, fileInventory, fileIndex); + const governanceRules = loadGovernanceRules(rulesPath); + const knipSignals = collectKnipSignals(repoRoot, knipConfigPath); + const legacyReport = buildLegacySurfaceReport(); + const legacyOverlays = buildLegacyOverlays(legacyReport); + const frontendReachable = collectReachableFiles( + FRONTEND_ENTRYPOINTS.filter((entryPath) => fileIndex.has(entryPath)), + frontendGraph.edges, + ); + const baseNodes = buildFileNodes({ + repoRoot, + fileInventory, + fileChurn, + governanceRules, + knipSignals, + legacyOverlays, + reachableFrontendPaths: frontendReachable, + }); + const edges = dedupeEdges([...frontendGraph.edges, ...rustGraph.edges]); + const nodes = finalizeNodeSignals(baseNodes, edges, governanceRules); + const dirNodes = buildDirectoryNodes(nodes); + const allNodes = [...dirNodes, ...nodes]; + const signalsSummary = summarizeSignals(nodes); + const summary = buildSummary(nodes, edges, signalsSummary); + const links = { + selfHtmlHref: pathToFileURL(path.join(outputDir, "governance-graph.html")).href, + selfJsonHref: pathToFileURL(path.join(outputDir, "governance-graph.json")).href, + heatmapHtmlHref: fs.existsSync(path.join(outputDir, "index.html")) + ? pathToFileURL(path.join(outputDir, "index.html")).href + : "", + }; + + return { + generatedAt: new Date().toISOString(), + repoRoot: normalizePath(repoRoot), + rulesPath: normalizePath(path.relative(repoRoot, rulesPath)), + knipConfigPath: normalizePath(path.relative(repoRoot, knipConfigPath)), + summary, + signalsSummary, + nodes: allNodes, + edges, + links, + }; +} + +function resolveRepoRoot(targetPath) { + const gitCommand = process.platform === "win32" ? "git.exe" : "git"; + + try { + const output = execFileSync( + gitCommand, + ["-C", targetPath, "rev-parse", "--show-toplevel"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); + + if (output) { + return path.resolve(output); + } + } catch { + return targetPath; + } + + return targetPath; +} + +function collectGovernanceFiles(repoRoot) { + const records = []; + const roots = ["src", "src-tauri/src"]; + + for (const root of roots) { + const absoluteRoot = path.join(repoRoot, root); + if (!fs.existsSync(absoluteRoot)) { + continue; + } + + walkDirectory(absoluteRoot, repoRoot, (absolutePath) => { + const relativePath = normalizePath(path.relative(repoRoot, absolutePath)); + const isCodePath = + isFrontendCodePath(relativePath) || isRustCodePath(relativePath); + if (!isCodePath || isTestLikePath(relativePath)) { + return; + } + + const ext = path.extname(relativePath).toLowerCase(); + const language = ext === ".rs" ? "rs" : ext.replace(/^\./, ""); + const layer = relativePath.startsWith("src-tauri/src/") ? "rust" : "frontend"; + + records.push({ + path: relativePath, + kind: isPagePath(relativePath) ? "page" : "file", + layer, + language, + loc: countLinesSafely(absolutePath), + }); + }); + } + + return records.sort((left, right) => left.path.localeCompare(right.path)); +} + +function walkDirectory(currentDir, repoRoot, onFile) { + const dirEntries = fs + .readdirSync(currentDir, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + + for (const entry of dirEntries) { + const absolutePath = path.join(currentDir, entry.name); + const relativePath = normalizePath(path.relative(repoRoot, absolutePath)); + + if (entry.isDirectory()) { + if (shouldIgnoreDirectory(entry.name, relativePath)) { + continue; + } + walkDirectory(absolutePath, repoRoot, onFile); + continue; + } + + if (entry.isFile()) { + onFile(absolutePath); + } + } +} + +function shouldIgnoreDirectory(entryName, relativePath) { + if (IGNORED_DIRECTORIES.has(entryName)) { + return true; + } + + return relativePath + .split("/") + .filter(Boolean) + .some((segment) => IGNORED_DIRECTORIES.has(segment)); +} + +function countLinesSafely(filePath) { + try { + const content = fs.readFileSync(filePath, "utf8"); + if (!content) { + return 0; + } + return content.split(/\r?\n/).length; + } catch { + return 0; + } +} + +function collectGitChurn({ gitCommand, repoRoot, sinceDays, trackedFiles }) { + const fileChurn = new Map(); + + let output = ""; + + try { + output = execFileSync( + gitCommand, + [ + "-C", + repoRoot, + "log", + `--since=${sinceDays}.days`, + "--numstat", + "--date=short", + "--format=format:@@@%cs", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + } catch { + return fileChurn; + } + + let currentDate = ""; + + for (const line of output.split(/\r?\n/)) { + if (!line) { + continue; + } + + if (line.startsWith("@@@")) { + currentDate = line.slice(3).trim(); + continue; + } + + const parts = line.split("\t"); + if (parts.length !== 3 || !currentDate) { + continue; + } + + const [added, deleted, rawPath] = parts; + if (added === "-" || deleted === "-") { + continue; + } + + const normalizedGitPath = normalizeGitPath(rawPath); + if (!trackedFiles.has(normalizedGitPath)) { + continue; + } + + const churn = Number.parseInt(added, 10) + Number.parseInt(deleted, 10); + if (!Number.isFinite(churn) || churn <= 0) { + continue; + } + + fileChurn.set(normalizedGitPath, (fileChurn.get(normalizedGitPath) || 0) + churn); + } + + return fileChurn; +} + +function normalizeGitPath(rawPath) { + let normalized = rawPath.trim().replaceAll("\\", "/"); + + if (!normalized.includes("=>")) { + return normalized; + } + + normalized = normalized.replace( + /\{([^{}]+)\s=>\s([^{}]+)\}/g, + (_match, _before, after) => after, + ); + + if (normalized.includes("=>")) { + const parts = normalized.split("=>"); + normalized = parts[parts.length - 1].trim(); + } + + return normalized.replaceAll("//", "/"); +} + +async function collectFrontendDependencies(repoRoot, fileIndex) { + const cruiseResult = await cruise( + ["src"], + { + includeOnly: "^src", + exclude: "(^|/)(node_modules|dist|coverage)(/|$)", + }, + undefined, + { + tsConfig: extractTSConfig(path.join(repoRoot, "tsconfig.json")), + }, + ); + const edges = []; + + for (const moduleRecord of cruiseResult.output.modules ?? []) { + const sourcePath = normalizePath(moduleRecord.source || ""); + if (!fileIndex.has(sourcePath)) { + continue; + } + + for (const dependency of moduleRecord.dependencies ?? []) { + const targetPath = normalizePath(dependency.resolved || ""); + if ( + !targetPath || + dependency.couldNotResolve || + !fileIndex.has(targetPath) || + targetPath === sourcePath + ) { + continue; + } + + edges.push({ + id: `import:${sourcePath}->${targetPath}`, + source: sourcePath, + target: targetPath, + kind: "import", + }); + } + } + + return { edges }; +} + +function collectRustDependencies(repoRoot, fileInventory, fileIndex) { + const rustFiles = fileInventory.filter((fileRecord) => fileRecord.layer === "rust"); + const rustFileSet = new Set(rustFiles.map((fileRecord) => fileRecord.path)); + const moduleIndex = buildRustModuleIndex([...rustFileSet]); + const edges = []; + + for (const fileRecord of rustFiles) { + const absolutePath = path.join(repoRoot, fileRecord.path); + const sourceCode = fs.readFileSync(absolutePath, "utf8"); + const currentModulePath = buildRustModulePathFromFile(fileRecord.path) || ""; + + for (const moduleName of extractRustModDeclarations(sourceCode)) { + const targetPath = resolveRustSubmodulePath( + repoRoot, + fileRecord.path, + moduleName, + ); + if (!targetPath || !fileIndex.has(targetPath)) { + continue; + } + edges.push({ + id: `rust_mod:${fileRecord.path}->${targetPath}`, + source: fileRecord.path, + target: targetPath, + kind: "rust_mod", + }); + } + + for (const statement of extractRustUseStatements(sourceCode)) { + for (const usePath of expandRustUseTree(statement)) { + const targetPath = resolveRustUseToFile( + moduleIndex, + currentModulePath, + usePath, + ); + + if (!targetPath || targetPath === fileRecord.path) { + continue; + } + + edges.push({ + id: `rust_use:${fileRecord.path}->${targetPath}:${usePath}`, + source: fileRecord.path, + target: targetPath, + kind: "rust_use", + }); + } + } + } + + return { edges }; +} + +function extractRustModDeclarations(sourceCode) { + const matches = sourceCode.matchAll( + /(?:^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/g, + ); + return [...matches].map((match) => match[1]); +} + +function extractRustUseStatements(sourceCode) { + const matches = sourceCode.matchAll( + /(?:^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([\s\S]*?);/g, + ); + return [...matches].map((match) => `use ${String(match[1]).trim()};`); +} + +function resolveRustSubmodulePath(repoRoot, relativePath, moduleName) { + const normalizedPath = normalizePath(relativePath); + const fileName = path.posix.basename(normalizedPath); + const dirName = path.posix.dirname(normalizedPath); + const moduleFolder = + fileName === "mod.rs" || fileName === "main.rs" || fileName === "lib.rs" + ? dirName + : path.posix.join(dirName, fileName.replace(/\.rs$/u, "")); + + const directCandidate = path.posix.join(moduleFolder, `${moduleName}.rs`); + if (fs.existsSync(path.join(repoRoot, directCandidate))) { + return normalizePath(directCandidate); + } + + const nestedCandidate = path.posix.join(moduleFolder, moduleName, "mod.rs"); + if (fs.existsSync(path.join(repoRoot, nestedCandidate))) { + return normalizePath(nestedCandidate); + } + + return null; +} + +function loadGovernanceRules(rulesPath) { + if (!fs.existsSync(rulesPath)) { + return []; + } + + const document = YAML.parse(fs.readFileSync(rulesPath, "utf8")) || {}; + const rules = document.rules || []; + validateGovernanceRules(rules); + return rules; +} + +function collectKnipSignals(repoRoot, knipConfigPath) { + if (!fs.existsSync(knipConfigPath)) { + return { + unusedFiles: new Set(), + unusedExportsByFile: new Map(), + }; + } + + const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx"; + const output = execFileSync( + npxCommand, + [ + "--no-install", + "knip", + "--config", + knipConfigPath, + "--reporter", + "json", + "--include", + "files,exports", + "--no-exit-code", + "--directory", + repoRoot, + ], + { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + + const parsed = JSON.parse(output); + const unusedFiles = new Set(); + const unusedExportsByFile = new Map(); + + for (const issue of parsed.issues ?? []) { + for (const fileIssue of issue.files ?? []) { + const filePath = normalizePath(fileIssue.name); + if (isFrontendCodePath(filePath)) { + unusedFiles.add(filePath); + } + } + + if ((issue.exports ?? []).length > 0) { + const filePath = normalizePath(issue.file); + if (!isFrontendCodePath(filePath)) { + continue; + } + + const exportNames = (unusedExportsByFile.get(filePath) ?? []).concat( + issue.exports.map((item) => item.name), + ); + unusedExportsByFile.set(filePath, exportNames); + } + } + + return { + unusedFiles, + unusedExportsByFile, + }; +} + +function buildLegacyOverlays(legacyReport) { + const surfaceOverlays = new Map(); + const callsiteOverlays = new Map(); + + for (const result of legacyReport.importResults) { + for (const targetPath of result.existingTargets) { + pushOverlay(surfaceOverlays, targetPath, { + source: "legacy-report", + overlayType: "surface", + monitorId: result.id, + classification: result.classification, + description: result.description, + statusHint: mapLegacyClassificationToStatus(result.classification), + }); + } + } + + for (const result of legacyReport.commandResults) { + for (const references of result.referencesByCommand.values()) { + for (const callerPath of references) { + pushOverlay(callsiteOverlays, callerPath, { + source: "legacy-report", + overlayType: "callsite", + monitorId: result.id, + classification: result.classification, + description: result.description, + }); + } + } + } + + for (const result of [...legacyReport.frontendTextResults, ...legacyReport.rustTextResults]) { + for (const callerPath of result.references) { + pushOverlay(callsiteOverlays, callerPath, { + source: "legacy-report", + overlayType: "callsite", + monitorId: result.id, + classification: result.classification, + description: result.description, + }); + } + } + + for (const result of legacyReport.rustTextCountResults) { + for (const callerPath of result.runtimeMatches.map((item) => item.relativePath)) { + pushOverlay(callsiteOverlays, callerPath, { + source: "legacy-report", + overlayType: "callsite", + monitorId: result.id, + classification: result.classification, + description: result.description, + }); + } + } + + return { + surfaceOverlays, + callsiteOverlays, + }; +} + +function pushOverlay(bucket, targetPath, overlay) { + const normalizedPath = normalizePath(targetPath); + const existing = bucket.get(normalizedPath) ?? []; + existing.push(overlay); + bucket.set(normalizedPath, existing); +} + +function mapLegacyClassificationToStatus(classification) { + if (classification === "compat" || classification === "deprecated") { + return classification; + } + return null; +} + +function buildFileNodes({ + repoRoot, + fileInventory, + fileChurn, + governanceRules, + knipSignals, + legacyOverlays, + reachableFrontendPaths, +}) { + const nodes = []; + + for (const fileRecord of fileInventory) { + const matchingRule = resolveMatchingGovernanceRule( + fileRecord.path, + governanceRules, + ); + const surfaceOverlays = + legacyOverlays.surfaceOverlays.get(fileRecord.path) ?? []; + const callsiteOverlays = + legacyOverlays.callsiteOverlays.get(fileRecord.path) ?? []; + const explicitStatus = matchingRule?.status ?? "unclassified"; + const overlayStatus = pickOverlayStatus(surfaceOverlays); + const status = explicitStatus !== "unclassified" ? explicitStatus : overlayStatus; + const ruleSource = matchingRule + ? { + reason: matchingRule.reason || "", + sourceOfTruth: matchingRule.sourceOfTruth || "", + exitCriteria: matchingRule.exitCriteria || "", + ignoreSignals: matchingRule.ignoreSignals || [], + } + : null; + const overlayReason = surfaceOverlays[0]?.description || ""; + const signals = new Set(); + + if (knipSignals.unusedFiles.has(fileRecord.path)) { + signals.add("unused-file"); + } + + if ((knipSignals.unusedExportsByFile.get(fileRecord.path) ?? []).length > 0) { + signals.add("unused-export"); + } + + if ( + surfaceOverlays.some((overlay) => overlay.classification === "dead-candidate") + ) { + signals.add("dead-candidate"); + } + + if (callsiteOverlays.length > 0) { + signals.add("legacy-callsite"); + } + + const node = { + id: fileRecord.path, + label: path.posix.basename(fileRecord.path), + path: fileRecord.path, + kind: fileRecord.kind, + layer: fileRecord.layer, + language: fileRecord.language, + status, + loc: fileRecord.loc, + churn: fileChurn.get(fileRecord.path) || 0, + size: calculateNodeSize(fileRecord.loc), + href: pathToFileURL(path.join(repoRoot, fileRecord.path)).href, + parent: createParentDirectoryId(fileRecord.path), + reason: ruleSource?.reason || overlayReason, + sourceOfTruth: ruleSource?.sourceOfTruth || "", + exitCriteria: ruleSource?.exitCriteria || "", + ignoredSignals: ruleSource?.ignoreSignals || [], + overlays: [...surfaceOverlays, ...callsiteOverlays], + signals: [...signals].sort(), + reachable: + fileRecord.layer === "frontend" + ? reachableFrontendPaths.has(fileRecord.path) + : true, + }; + + nodes.push(node); + } + + return nodes; +} + +function finalizeNodeSignals(nodes, edges, governanceRules) { + const fileNodesByPath = new Map(nodes.map((node) => [node.path, node])); + const indegree = new Map(); + + for (const edge of edges) { + indegree.set(edge.target, (indegree.get(edge.target) || 0) + 1); + } + + const inboundIndex = buildInboundIndex(edges); + + return nodes.map((node) => { + const signals = new Set(node.signals); + + if ( + !node.reachable && + node.kind === "page" && + node.layer === "frontend" + ) { + signals.add("page-unreachable"); + } + + if ( + (indegree.get(node.path) || 0) === 0 && + !isProtectedEntryNode(node, governanceRules) + ) { + signals.add("zero-inbound"); + } + + const inbound = inboundIndex.get(node.path) ?? []; + if ( + inbound.length > 0 && + inbound.every((sourcePath) => { + const sourceNode = fileNodesByPath.get(sourcePath); + return ( + sourceNode && + ["compat", "deprecated", "dead"].includes(sourceNode.status) + ); + }) + ) { + signals.add("legacy-only-incoming"); + } + + for (const ignoredSignal of node.ignoredSignals || []) { + signals.delete(ignoredSignal); + } + + return { + ...node, + signals: [...signals].sort(), + candidateScore: calculateCandidateScore({ + ...node, + signals: [...signals], + }), + }; + }); +} + +function pickOverlayStatus(surfaceOverlays) { + const candidates = surfaceOverlays + .map((overlay) => overlay.statusHint || "unclassified") + .filter((status) => status !== "unclassified"); + + if (candidates.length === 0) { + return "unclassified"; + } + + return candidates.sort( + (left, right) => + (STATUS_PRIORITY.get(right) || 0) - (STATUS_PRIORITY.get(left) || 0), + )[0]; +} + +function calculateNodeSize(loc) { + return Math.max(26, Math.min(86, Math.round(18 + Math.sqrt(Math.max(loc, 1))))); +} + +function createParentDirectoryId(relativePath) { + const dirPath = path.posix.dirname(relativePath); + if (!dirPath || dirPath === ".") { + return ""; + } + return createDirNodeId(dirPath); +} + +function buildDirectoryNodes(fileNodes) { + const dirMap = new Map(); + + for (const node of fileNodes) { + const dirPath = path.posix.dirname(node.path); + if (!dirPath || dirPath === ".") { + continue; + } + + const segments = dirPath.split("/").filter(Boolean); + for (let length = 1; length <= segments.length; length += 1) { + const currentPath = segments.slice(0, length).join("/"); + if (dirMap.has(currentPath)) { + continue; + } + dirMap.set(currentPath, { + id: createDirNodeId(currentPath), + label: segments[length - 1], + path: currentPath, + kind: "dir", + layer: currentPath.startsWith("src-tauri/") ? "rust" : "frontend", + parent: + length > 1 + ? createDirNodeId(segments.slice(0, length - 1).join("/")) + : "", + }); + } + } + + return [...dirMap.values()].sort((left, right) => left.path.localeCompare(right.path)); +} + +function collectReachableFiles(entryPaths, edges) { + const adjacency = new Map(); + const queue = [...entryPaths]; + const visited = new Set(entryPaths); + + for (const edge of edges) { + const currentTargets = adjacency.get(edge.source) ?? []; + currentTargets.push(edge.target); + adjacency.set(edge.source, currentTargets); + } + + while (queue.length > 0) { + const currentPath = queue.shift(); + for (const nextPath of adjacency.get(currentPath) ?? []) { + if (visited.has(nextPath)) { + continue; + } + visited.add(nextPath); + queue.push(nextPath); + } + } + + return visited; +} + +function dedupeEdges(edges) { + const edgeMap = new Map(); + for (const edge of edges) { + const key = `${edge.kind}:${edge.source}->${edge.target}`; + if (!edgeMap.has(key)) { + edgeMap.set(key, { + ...edge, + id: key, + }); + } + } + return [...edgeMap.values()]; +} + +function buildInboundIndex(edges) { + const inboundIndex = new Map(); + for (const edge of edges) { + const entries = inboundIndex.get(edge.target) ?? []; + entries.push(edge.source); + inboundIndex.set(edge.target, entries); + } + return inboundIndex; +} + +function isProtectedEntryNode(node, governanceRules) { + if (node.status === "current") { + return true; + } + + if (FRONTEND_ENTRYPOINTS.includes(node.path)) { + return true; + } + + if (/(?:^|\/)(main|lib)\.rs$/u.test(node.path)) { + return true; + } + + return resolveMatchingGovernanceRule(node.path, governanceRules)?.status === "current"; +} + +function calculateCandidateScore(node) { + let score = 0; + + for (const signal of node.signals) { + if (signal === "unused-file") { + score += 5; + } else if (signal === "page-unreachable") { + score += 5; + } else if (signal === "dead-candidate") { + score += 4; + } else if (signal === "zero-inbound") { + score += 2; + } else if (signal === "legacy-only-incoming") { + score += 2; + } else if (signal === "unused-export") { + score += 1; + } else if (signal === "legacy-callsite") { + score += 1; + } + } + + return score; +} + +function summarizeSignals(nodes) { + const counts = new Map(); + for (const node of nodes) { + for (const signal of node.signals) { + counts.set(signal, (counts.get(signal) || 0) + 1); + } + } + + return [...counts.entries()] + .map(([signal, count]) => ({ signal, count })) + .sort((left, right) => right.count - left.count || left.signal.localeCompare(right.signal)); +} + +function buildSummary(nodes, edges, signalsSummary) { + const fileNodes = nodes.filter((node) => node.kind !== "dir"); + const statusCounts = Object.fromEntries( + ["current", "compat", "deprecated", "dead", "unclassified"].map((status) => [ + status, + fileNodes.filter((node) => node.status === status).length, + ]), + ); + const layerCounts = Object.fromEntries( + ["frontend", "rust"].map((layer) => [ + layer, + fileNodes.filter((node) => node.layer === layer).length, + ]), + ); + const topCandidates = fileNodes + .filter((node) => node.candidateScore > 0) + .sort( + (left, right) => + right.candidateScore - left.candidateScore || + right.churn - left.churn || + left.path.localeCompare(right.path), + ) + .slice(0, 20) + .map((node) => ({ + path: node.path, + status: node.status, + candidateScore: node.candidateScore, + signals: node.signals, + churn: node.churn, + })); + + return { + nodeCount: fileNodes.length, + edgeCount: edges.length, + pageCount: fileNodes.filter((node) => node.kind === "page").length, + statusCounts, + layerCounts, + topCandidates, + signalsSummary, + }; +} + +function renderHtml(report) { + const payload = serializeForHtml(report); + + return ` + + + + + Lime 治理图谱 + + + +
+
+
+

Lime 治理图谱

+

+ 文件 / 页面级依赖图 + 治理标签 + 疑似失效信号。状态只来自 + governance/surfaces.yml 与现有治理规则;自动脚本只补 signals 与 overlay。 +

+
+ +
+ +
+ + + + + + + +
+ +
+ + +
+
+

ASCII 视图

+

默认先看治理总览与批量治理工作台;点击任意候选后,再看单节点的上游 / 当前 / 下游。

+
+
+

+        
+ + +
+
+ + + +`; +} + +function serializeForHtml(value) { + return JSON.stringify(value) + .replaceAll("<", "\\u003c") + .replaceAll(">", "\\u003e") + .replaceAll("&", "\\u0026"); +} + +function escapeAttribute(value) { + return String(value).replaceAll('"', """); +} diff --git a/scripts/lib/governance-graph-core.mjs b/scripts/lib/governance-graph-core.mjs new file mode 100644 index 000000000..e0b1b6e44 --- /dev/null +++ b/scripts/lib/governance-graph-core.mjs @@ -0,0 +1,340 @@ +import path from "node:path"; + +export const GOVERNANCE_STATUSES = new Set([ + "current", + "compat", + "deprecated", + "dead", + "unclassified", +]); + +export function normalizePath(filePath) { + return filePath.split(path.sep).join("/"); +} + +export function isFrontendCodePath(relativePath) { + return ( + relativePath.startsWith("src/") && + /\.(?:[cm]?[jt]sx?)$/i.test(relativePath) && + !relativePath.endsWith(".d.ts") + ); +} + +export function isRustCodePath(relativePath) { + return relativePath.startsWith("src-tauri/src/") && relativePath.endsWith(".rs"); +} + +export function isTestLikePath(relativePath) { + return ( + /(^|\/)tests(\/|$)/.test(relativePath) || + /(^|\/)(__tests__|__mocks__)(\/|$)/.test(relativePath) || + /\.(test|spec)\.[^/.]+$/.test(relativePath) || + /(^|\/)tests\.rs$/.test(relativePath) + ); +} + +export function isPagePath(relativePath) { + return ( + /^src\/pages\/.+\.(?:[jt]sx?)$/i.test(relativePath) && + !/^src\/pages\/index\.(?:[jt]sx?)$/i.test(relativePath) + ); +} + +export function createDirNodeId(relativePath) { + return `dir:${relativePath}`; +} + +export function validateGovernanceRules(rules) { + if (!Array.isArray(rules)) { + throw new Error("治理规则文件必须提供 rules 数组。"); + } + + for (const [index, rule] of rules.entries()) { + if (!rule || typeof rule !== "object") { + throw new Error(`治理规则第 ${index + 1} 项必须是对象。`); + } + + if (!rule.match || typeof rule.match !== "string") { + throw new Error(`治理规则第 ${index + 1} 项缺少 match。`); + } + + if (!GOVERNANCE_STATUSES.has(rule.status)) { + throw new Error( + `治理规则 ${rule.match} 的 status 无效:${String(rule.status)}`, + ); + } + + if ( + rule.ignoreSignals != null && + (!Array.isArray(rule.ignoreSignals) || + rule.ignoreSignals.some((item) => typeof item !== "string")) + ) { + throw new Error(`治理规则 ${rule.match} 的 ignoreSignals 必须是字符串数组。`); + } + + if (rule.status === "compat" && !rule.sourceOfTruth) { + throw new Error(`compat 规则 ${rule.match} 必须声明 sourceOfTruth。`); + } + + if (rule.status === "deprecated" && !rule.exitCriteria) { + throw new Error(`deprecated 规则 ${rule.match} 必须声明 exitCriteria。`); + } + } +} + +export function globToRegExp(globPattern) { + let pattern = "^"; + + for (let index = 0; index < globPattern.length; index += 1) { + const char = globPattern[index]; + const next = globPattern[index + 1]; + + if (char === "*") { + if (next === "*") { + pattern += ".*"; + index += 1; + } else { + pattern += "[^/]*"; + } + continue; + } + + if (char === "?") { + pattern += "[^/]"; + continue; + } + + if ("\\^$+?.()|{}[]".includes(char)) { + pattern += `\\${char}`; + continue; + } + + pattern += char; + } + + pattern += "$"; + return new RegExp(pattern); +} + +export function getRuleSpecificityScore(rule) { + const match = String(rule.match || ""); + const literalChars = match.replace(/[*?]/g, "").length; + const doubleStars = (match.match(/\*\*/g) || []).length; + const singleStars = (match.match(/\*/g) || []).length - doubleStars * 2; + const questions = (match.match(/\?/g) || []).length; + return literalChars * 10 - doubleStars * 6 - singleStars * 3 - questions * 2; +} + +export function resolveMatchingGovernanceRule(relativePath, rules) { + const normalizedPath = normalizePath(relativePath); + const matches = rules + .map((rule, index) => ({ + ...rule, + _index: index, + _score: getRuleSpecificityScore(rule), + _regexp: globToRegExp(rule.match), + })) + .filter((rule) => rule._regexp.test(normalizedPath)) + .sort((left, right) => right._score - left._score || left._index - right._index); + + return matches[0] ?? null; +} + +export function buildRustModulePathFromFile(relativePath) { + const normalizedPath = normalizePath(relativePath); + + if (!normalizedPath.startsWith("src-tauri/src/") || !normalizedPath.endsWith(".rs")) { + return null; + } + + const withoutRoot = normalizedPath.slice("src-tauri/src/".length); + + if (withoutRoot === "main.rs" || withoutRoot === "lib.rs") { + return ""; + } + + if (withoutRoot.endsWith("/mod.rs")) { + return withoutRoot.slice(0, -"/mod.rs".length).split("/").join("::"); + } + + return withoutRoot.slice(0, -".rs".length).split("/").join("::"); +} + +export function buildRustModuleIndex(relativePaths) { + const moduleIndex = new Map(); + + for (const relativePath of relativePaths) { + const modulePath = buildRustModulePathFromFile(relativePath); + if (modulePath == null) { + continue; + } + + if (!moduleIndex.has(modulePath)) { + moduleIndex.set(modulePath, normalizePath(relativePath)); + } + } + + return moduleIndex; +} + +export function splitTopLevel(value, separator = ",") { + const parts = []; + let depth = 0; + let current = ""; + + for (const char of value) { + if (char === "{") { + depth += 1; + current += char; + continue; + } + + if (char === "}") { + depth = Math.max(0, depth - 1); + current += char; + continue; + } + + if (char === separator && depth === 0) { + if (current.trim()) { + parts.push(current.trim()); + } + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) { + parts.push(current.trim()); + } + + return parts; +} + +function joinRustPath(prefix, segment) { + if (!prefix) { + return segment; + } + if (!segment) { + return prefix; + } + return `${prefix}::${segment}`; +} + +function stripRustVisibilityPrefix(statement) { + return statement + .trim() + .replace(/^(?:pub(?:\([^)]*\))?\s+)?use\s+/, "") + .replace(/;$/, "") + .trim(); +} + +export function expandRustUseTree(statement) { + const root = stripRustVisibilityPrefix(statement); + + if (!root) { + return []; + } + + return expandRustUseSegment(root); +} + +function expandRustUseSegment(segment, prefix = "") { + const trimmed = segment.trim(); + + if (!trimmed) { + return []; + } + + const braceIndex = findTopLevelBraceIndex(trimmed); + if (braceIndex === -1) { + const normalizedLeaf = trimmed.replace(/\s+as\s+.+$/u, "").trim(); + if (normalizedLeaf === "self") { + return prefix ? [prefix] : []; + } + return [joinRustPath(prefix, normalizedLeaf)]; + } + + const prefixPart = trimmed.slice(0, braceIndex).replace(/::$/, "").trim(); + const inner = trimmed.slice(braceIndex + 1, trimmed.lastIndexOf("}")); + const nextPrefix = joinRustPath(prefix, prefixPart); + + return splitTopLevel(inner).flatMap((part) => + expandRustUseSegment(part, nextPrefix), + ); +} + +function findTopLevelBraceIndex(value) { + let depth = 0; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (char === "{") { + if (depth === 0) { + return index; + } + depth += 1; + continue; + } + if (char === "}") { + depth = Math.max(0, depth - 1); + } + } + + return -1; +} + +export function resolveRustUseCandidateModules(currentModulePath, usePath) { + const currentSegments = currentModulePath ? currentModulePath.split("::") : []; + let remainder = usePath.trim(); + let baseSegments = []; + + if (remainder === "crate") { + return []; + } + + if (remainder.startsWith("crate::")) { + remainder = remainder.slice("crate::".length); + } else if (remainder === "self") { + remainder = ""; + baseSegments = [...currentSegments]; + } else if (remainder.startsWith("self::")) { + remainder = remainder.slice("self::".length); + baseSegments = [...currentSegments]; + } else if (remainder === "super" || remainder.startsWith("super::")) { + baseSegments = [...currentSegments]; + while (remainder === "super" || remainder.startsWith("super::")) { + baseSegments.pop(); + remainder = + remainder === "super" ? "" : remainder.slice("super::".length); + } + } else { + return []; + } + + const targetSegments = remainder ? remainder.split("::").filter(Boolean) : []; + const absoluteSegments = [...baseSegments, ...targetSegments]; + const candidates = []; + + for (let length = absoluteSegments.length; length > 0; length -= 1) { + candidates.push(absoluteSegments.slice(0, length).join("::")); + } + + return candidates; +} + +export function resolveRustUseToFile(moduleIndex, currentModulePath, usePath) { + for (const candidate of resolveRustUseCandidateModules( + currentModulePath, + usePath, + )) { + const resolvedPath = moduleIndex.get(candidate); + if (resolvedPath) { + return resolvedPath; + } + } + + return null; +} diff --git a/scripts/lib/governance-graph-core.test.ts b/scripts/lib/governance-graph-core.test.ts new file mode 100644 index 000000000..397b2b1e7 --- /dev/null +++ b/scripts/lib/governance-graph-core.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + buildRustModuleIndex, + expandRustUseTree, + resolveMatchingGovernanceRule, + resolveRustUseToFile, +} from "./governance-graph-core.mjs"; + +describe("governance-graph-core", () => { + it("应优先命中更具体的治理规则", () => { + const rules = [ + { + match: "src/**/*.tsx", + status: "deprecated", + exitCriteria: "fallback", + }, + { + match: "src/pages/smart-input.tsx", + status: "current", + sourceOfTruth: "RootRouter", + }, + ]; + + const matched = resolveMatchingGovernanceRule( + "src/pages/smart-input.tsx", + rules, + ); + + expect(matched?.status).toBe("current"); + expect(matched?.match).toBe("src/pages/smart-input.tsx"); + }); + + it("应展开带嵌套花括号的 Rust use 树", () => { + const expanded = expandRustUseTree( + "use crate::commands::{agent_cmd::{run, status}, model_cmd, self};", + ); + + expect(expanded).toEqual([ + "crate::commands::agent_cmd::run", + "crate::commands::agent_cmd::status", + "crate::commands::model_cmd", + "crate::commands", + ]); + }); + + it("应解析 crate/self/super Rust use 到文件路径", () => { + const moduleIndex = buildRustModuleIndex([ + "src-tauri/src/app/mod.rs", + "src-tauri/src/app/bootstrap.rs", + "src-tauri/src/commands/mod.rs", + "src-tauri/src/commands/agent_cmd.rs", + "src-tauri/src/commands/internal/helper.rs", + ]); + + expect( + resolveRustUseToFile( + moduleIndex, + "commands::agent_cmd", + "crate::app::bootstrap::boot", + ), + ).toBe("src-tauri/src/app/bootstrap.rs"); + + expect( + resolveRustUseToFile( + moduleIndex, + "commands::internal::helper", + "super::super::agent_cmd::run_agent", + ), + ).toBe("src-tauri/src/commands/agent_cmd.rs"); + + expect( + resolveRustUseToFile( + moduleIndex, + "commands::internal::helper", + "self::helper", + ), + ).toBe("src-tauri/src/commands/internal/helper.rs"); + }); +}); diff --git a/scripts/project-heatmap.mjs b/scripts/project-heatmap.mjs new file mode 100644 index 000000000..d0fc29e11 --- /dev/null +++ b/scripts/project-heatmap.mjs @@ -0,0 +1,2114 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { execFileSync } from "node:child_process"; + +const DEFAULT_SINCE_DAYS = 180; +const DEFAULT_MODULE_DEPTH = 2; +const DEFAULT_TOP_MODULES = 18; +const DEFAULT_OUTPUT_DIR_NAME = "lime-project-heatmap"; + +const TEXT_FILE_EXTENSIONS = new Set([ + ".cjs", + ".conf", + ".css", + ".html", + ".java", + ".js", + ".json", + ".jsx", + ".md", + ".mdx", + ".mjs", + ".mts", + ".ps1", + ".py", + ".rb", + ".rs", + ".scss", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".yaml", + ".yml", +]); + +const INCLUDED_FILE_NAMES = new Set([ + "AGENTS.md", + "CLAUDE.md", + "Dockerfile", + "LICENSE", + "Makefile", + "README.md", +]); + +const IGNORED_DIRECTORIES = new Set([ + ".git", + ".next", + ".nuxt", + ".output", + ".turbo", + "coverage", + "dist", + "node_modules", + "out", + "target", + "target-codex-verify", + "tmp", + "vendor", + "modified_files", +]); + +const IGNORED_FILES = new Set([ + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lockb", + "Cargo.lock", +]); + +const COLOR_STOPS = [ + { at: 0, color: "#ecfdf5" }, + { at: 0.2, color: "#c7f9cc" }, + { at: 0.45, color: "#6ee7b7" }, + { at: 0.7, color: "#fbbf24" }, + { at: 1, color: "#f97316" }, +]; + +const options = parseArgs(process.argv.slice(2)); + +if (options.help) { + printHelp(); + process.exit(0); +} + +const gitCommand = process.platform === "win32" ? "git.exe" : "git"; +const requestedRoot = path.resolve(options.root || process.cwd()); +const repoRoot = resolveRepoRoot(requestedRoot); +const outputDir = path.resolve( + options.output || + path.join( + os.tmpdir(), + `${path.basename(repoRoot) || DEFAULT_OUTPUT_DIR_NAME}-project-heatmap`, + ), +); + +const scanResult = scanProjectFiles(repoRoot, options.moduleDepth); +const gitChurn = collectGitChurn({ + gitCommand, + repoRoot, + sinceDays: options.days, + trackedFiles: scanResult.fileIndex, +}); +const report = buildReport({ + repoRoot, + scanResult, + gitChurn, + sinceDays: options.days, + moduleDepth: options.moduleDepth, + topModules: options.top, +}); + +fs.mkdirSync(outputDir, { recursive: true }); + +const jsonOutputPath = path.join(outputDir, "project-heatmap.json"); +const htmlOutputPath = path.join(outputDir, "index.html"); + +fs.writeFileSync(jsonOutputPath, JSON.stringify(report, null, 2), "utf8"); +fs.writeFileSync(htmlOutputPath, renderHtml(report), "utf8"); + +console.log(`[heatmap] 报告已生成`); +console.log(`[heatmap] HTML: ${htmlOutputPath}`); +console.log(`[heatmap] JSON: ${jsonOutputPath}`); +console.log(`[heatmap] 打开方式: file://${htmlOutputPath}`); + +function parseArgs(argv) { + const result = { + root: "", + output: "", + days: DEFAULT_SINCE_DAYS, + moduleDepth: DEFAULT_MODULE_DEPTH, + top: DEFAULT_TOP_MODULES, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--root" && argv[index + 1]) { + result.root = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--output" && argv[index + 1]) { + result.output = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--days" && argv[index + 1]) { + result.days = normalizePositiveNumber(argv[index + 1], DEFAULT_SINCE_DAYS); + index += 1; + continue; + } + + if (arg === "--depth" && argv[index + 1]) { + result.moduleDepth = normalizePositiveNumber( + argv[index + 1], + DEFAULT_MODULE_DEPTH, + ); + index += 1; + continue; + } + + if (arg === "--top" && argv[index + 1]) { + result.top = normalizePositiveNumber(argv[index + 1], DEFAULT_TOP_MODULES); + index += 1; + continue; + } + + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function normalizePositiveNumber(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return parsed; +} + +function printHelp() { + console.log(` +Lime 项目热力图生成器 + +用法: + npm run heatmap:project + npm run heatmap:project -- --days 90 + npm run heatmap:project -- --root "../other-repo" --output "./tmp/heatmap" + +选项: + --root PATH 指定要分析的仓库路径,默认当前 Git 根目录 + --output PATH 指定报告输出目录,默认系统临时目录 + --days N 分析最近 N 天的 Git churn,默认 ${DEFAULT_SINCE_DAYS} + --depth N 模块聚合目录深度,默认 ${DEFAULT_MODULE_DEPTH} + --top N 矩阵热力图显示前 N 个热点模块,默认 ${DEFAULT_TOP_MODULES} + -h, --help 显示帮助 + +说明: + - 默认忽略 node_modules、dist、target 等目录 + - 默认忽略 package-lock.json、pnpm-lock.yaml、Cargo.lock 等锁文件 + - HTML 报告为纯本地静态文件,可直接使用浏览器打开 +`); +} + +function resolveRepoRoot(targetPath) { + try { + const output = execFileSync(gitCommand, ["-C", targetPath, "rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + + if (output) { + return path.resolve(output); + } + } catch { + return targetPath; + } + + return targetPath; +} + +function scanProjectFiles(repoPath, moduleDepth) { + const modules = new Map(); + const files = []; + const fileIndex = new Map(); + + walkDirectory(repoPath); + + const moduleList = Array.from(modules.values()).sort((left, right) => { + if (right.loc !== left.loc) { + return right.loc - left.loc; + } + return left.path.localeCompare(right.path); + }); + + return { + files, + fileIndex, + modules: moduleList, + summary: { + fileCount: files.length, + loc: files.reduce((total, file) => total + file.loc, 0), + }, + }; + + function walkDirectory(currentDir) { + const dirEntries = fs + .readdirSync(currentDir, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + + for (const entry of dirEntries) { + const absolutePath = path.join(currentDir, entry.name); + const relativePath = toPosixPath(path.relative(repoPath, absolutePath)); + + if (entry.isDirectory()) { + if (shouldIgnoreDirectory(entry.name, relativePath)) { + continue; + } + + walkDirectory(absolutePath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + if (!shouldIncludeFile(entry.name, relativePath)) { + continue; + } + + const loc = countLinesSafely(absolutePath); + const modulePath = resolveModulePath(relativePath, moduleDepth); + const group = resolveGroup(modulePath); + + const fileRecord = { + path: relativePath, + modulePath, + group, + loc, + }; + + files.push(fileRecord); + fileIndex.set(relativePath, fileRecord); + + let moduleRecord = modules.get(modulePath); + if (!moduleRecord) { + moduleRecord = { + id: modulePath, + path: modulePath, + group, + loc: 0, + fileCount: 0, + }; + modules.set(modulePath, moduleRecord); + } + + moduleRecord.loc += loc; + moduleRecord.fileCount += 1; + } + } +} + +function shouldIgnoreDirectory(entryName, relativePath) { + if (IGNORED_DIRECTORIES.has(entryName)) { + return true; + } + + const segments = relativePath.split("/").filter(Boolean); + return segments.some((segment) => IGNORED_DIRECTORIES.has(segment)); +} + +function shouldIncludeFile(fileName, relativePath) { + if (IGNORED_FILES.has(fileName)) { + return false; + } + + if (relativePath.startsWith("docs/node_modules/")) { + return false; + } + + const extension = path.extname(fileName).toLowerCase(); + + if (TEXT_FILE_EXTENSIONS.has(extension)) { + return true; + } + + return INCLUDED_FILE_NAMES.has(fileName); +} + +function countLinesSafely(filePath) { + try { + const content = fs.readFileSync(filePath, "utf8"); + if (!content) { + return 0; + } + return content.split(/\r?\n/).length; + } catch { + return 0; + } +} + +function resolveModulePath(relativePath, moduleDepth) { + const parts = relativePath.split("/").filter(Boolean); + + if (parts.length <= 1) { + return "(root)"; + } + + const safeDepth = Math.max(1, moduleDepth); + const moduleParts = parts.slice(0, Math.min(safeDepth, parts.length - 1)); + return moduleParts.join("/"); +} + +function resolveGroup(modulePath) { + if (modulePath === "(root)") { + return "root"; + } + + const [firstSegment = "root"] = modulePath.split("/"); + return firstSegment; +} + +function collectGitChurn({ gitCommand, repoRoot, sinceDays, trackedFiles }) { + const fileChurn = new Map(); + const weekSet = new Set(); + let gitAvailable = true; + let commitCount = 0; + + let output = ""; + + try { + output = execFileSync( + gitCommand, + [ + "-C", + repoRoot, + "log", + `--since=${sinceDays}.days`, + "--numstat", + "--date=short", + "--format=format:@@@%cs", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + } catch { + gitAvailable = false; + } + + if (!gitAvailable || !output) { + return { + gitAvailable, + commitCount, + fileChurn, + weeks: [], + }; + } + + let currentDate = ""; + + for (const line of output.split(/\r?\n/)) { + if (!line) { + continue; + } + + if (line.startsWith("@@@")) { + currentDate = line.slice(3).trim(); + commitCount += 1; + continue; + } + + const parts = line.split("\t"); + if (parts.length !== 3) { + continue; + } + + const [added, deleted, rawPath] = parts; + if (added === "-" || deleted === "-" || !currentDate) { + continue; + } + + const normalizedPath = normalizeGitPath(rawPath); + const trackedFile = trackedFiles.get(normalizedPath); + if (!trackedFile) { + continue; + } + + const churn = Number.parseInt(added, 10) + Number.parseInt(deleted, 10); + if (!Number.isFinite(churn) || churn <= 0) { + continue; + } + + const weekKey = toWeekKey(currentDate); + weekSet.add(weekKey); + + let fileRecord = fileChurn.get(normalizedPath); + if (!fileRecord) { + fileRecord = { + path: normalizedPath, + churn: 0, + weekly: {}, + }; + fileChurn.set(normalizedPath, fileRecord); + } + + fileRecord.churn += churn; + fileRecord.weekly[weekKey] = (fileRecord.weekly[weekKey] || 0) + churn; + } + + return { + gitAvailable, + commitCount, + fileChurn, + weeks: Array.from(weekSet).sort(), + }; +} + +function normalizeGitPath(rawPath) { + let normalized = rawPath.trim().replaceAll("\\", "/"); + + if (!normalized.includes("=>")) { + return normalized; + } + + normalized = normalized.replace( + /\{([^{}]+)\s=>\s([^{}]+)\}/g, + (_, _before, after) => after, + ); + + if (normalized.includes("=>")) { + const segments = normalized.split("=>"); + normalized = segments[segments.length - 1].trim(); + } + + return normalized.replaceAll("//", "/"); +} + +function toWeekKey(dateString) { + const date = new Date(`${dateString}T00:00:00Z`); + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + const weekNumber = Math.ceil((((date - yearStart) / 86400000) + 1) / 7); + return `${date.getUTCFullYear()}-W${String(weekNumber).padStart(2, "0")}`; +} + +function buildReport({ + repoRoot, + scanResult, + gitChurn, + sinceDays, + moduleDepth, + topModules, +}) { + const modulesById = new Map(); + + for (const moduleRecord of scanResult.modules) { + modulesById.set(moduleRecord.path, { + ...moduleRecord, + churn: 0, + churnDensity: 0, + weekly: {}, + files: [], + }); + } + + const enrichedFiles = scanResult.files.map((file) => { + const churnRecord = gitChurn.fileChurn.get(file.path); + const churn = churnRecord?.churn || 0; + const churnDensity = file.loc > 0 ? roundTo(churn / file.loc, 4) : 0; + const weekly = churnRecord?.weekly || {}; + + const enriched = { + ...file, + churn, + churnDensity, + weekly, + }; + + const moduleRecord = modulesById.get(file.modulePath); + if (moduleRecord) { + moduleRecord.churn += churn; + moduleRecord.files.push({ + path: file.path, + loc: file.loc, + churn, + churnDensity, + }); + + for (const [week, value] of Object.entries(weekly)) { + moduleRecord.weekly[week] = (moduleRecord.weekly[week] || 0) + value; + } + } + + return enriched; + }); + + const modules = Array.from(modulesById.values()) + .map((moduleRecord) => ({ + ...moduleRecord, + activeWeekCount: Object.keys(moduleRecord.weekly).length, + churnDensity: + moduleRecord.loc > 0 + ? roundTo(moduleRecord.churn / moduleRecord.loc, 4) + : 0, + files: moduleRecord.files + .sort((left, right) => { + if (right.churn !== left.churn) { + return right.churn - left.churn; + } + return right.loc - left.loc; + }) + .slice(0, 6), + })) + .sort((left, right) => { + if (right.churn !== left.churn) { + return right.churn - left.churn; + } + if (right.loc !== left.loc) { + return right.loc - left.loc; + } + return left.path.localeCompare(right.path); + }); + + const hotFiles = enrichedFiles + .filter((file) => file.churn > 0 || file.loc > 0) + .sort((left, right) => { + if (right.churn !== left.churn) { + return right.churn - left.churn; + } + if (right.loc !== left.loc) { + return right.loc - left.loc; + } + return left.path.localeCompare(right.path); + }) + .slice(0, 24); + + const lastTouchedByPath = resolveLastTouchedDates( + repoRoot, + hotFiles.map((file) => file.path), + ); + + const hotFilesWithDates = hotFiles.map((file) => ({ + ...file, + lastTouchedAt: lastTouchedByPath.get(file.path) || "", + })); + + const groups = buildGroups(modules); + const weeks = gitChurn.weeks; + const governance = buildGovernanceCandidates( + modules, + Math.max(8, Math.min(12, topModules)), + ); + + return { + meta: { + repoName: path.basename(repoRoot), + repoRoot: toPosixPath(repoRoot), + generatedAt: new Date().toISOString(), + sinceDays, + moduleDepth, + topModules, + gitAvailable: gitChurn.gitAvailable, + commitCount: gitChurn.commitCount, + ignoredDirectories: Array.from(IGNORED_DIRECTORIES).sort(), + ignoredFiles: Array.from(IGNORED_FILES).sort(), + notes: [ + "面积代表 LOC,颜色代表最近窗口内的 churn 强度。", + "默认只统计当前仍存在的文件;历史已删除文件不会进入报告。", + "重命名文件的早期历史可能无法完全归并到当前路径,这是当前 MVP 的已知取舍。", + ], + }, + summary: { + totalFiles: scanResult.summary.fileCount, + totalLoc: scanResult.summary.loc, + totalModules: modules.length, + totalChurn: modules.reduce((total, moduleRecord) => total + moduleRecord.churn, 0), + activeWeeks: weeks.length, + }, + weeks, + groups, + modules, + hotFiles: hotFilesWithDates, + governance, + }; +} + +function buildGovernanceCandidates(modules, topN) { + if (modules.length === 0) { + return { + thresholds: { + locHigh: 0, + churnHigh: 0, + densityHigh: 0, + filesHigh: 0, + activeWeeksHigh: 0, + }, + candidates: [], + }; + } + + const locValues = modules.map((moduleRecord) => moduleRecord.loc); + const churnValues = modules.map((moduleRecord) => moduleRecord.churn); + const densityValues = modules.map((moduleRecord) => moduleRecord.churnDensity); + const fileCountValues = modules.map((moduleRecord) => moduleRecord.fileCount); + const activeWeekValues = modules.map( + (moduleRecord) => moduleRecord.activeWeekCount, + ); + + const thresholds = { + locHigh: percentileValue(locValues, 0.85), + churnHigh: percentileValue(churnValues, 0.85), + densityHigh: percentileValue(densityValues, 0.8), + filesHigh: percentileValue(fileCountValues, 0.85), + activeWeeksHigh: percentileValue(activeWeekValues, 0.8), + }; + + const maxLoc = Math.max(...locValues, 1); + const maxChurn = Math.max(...churnValues, 1); + const maxDensity = Math.max(...densityValues, 0.0001); + const maxFiles = Math.max(...fileCountValues, 1); + const maxActiveWeeks = Math.max(...activeWeekValues, 1); + + const candidates = modules + .map((moduleRecord) => { + const sizeScore = Math.log1p(moduleRecord.loc) / Math.log1p(maxLoc); + const churnScore = + Math.log1p(moduleRecord.churn) / Math.log1p(Math.max(1, maxChurn)); + const densityScore = moduleRecord.churnDensity / maxDensity; + const scatterScore = + Math.log1p(moduleRecord.fileCount) / Math.log1p(maxFiles); + const persistenceScore = + Math.log1p(moduleRecord.activeWeekCount) / Math.log1p(maxActiveWeeks); + const governanceScore = roundTo( + (sizeScore * 0.32 + + churnScore * 0.28 + + densityScore * 0.18 + + scatterScore * 0.12 + + persistenceScore * 0.1) * + 100, + 1, + ); + + const reasons = buildGovernanceReasons(moduleRecord, thresholds); + + return { + path: moduleRecord.path, + group: moduleRecord.group, + loc: moduleRecord.loc, + churn: moduleRecord.churn, + churnDensity: moduleRecord.churnDensity, + fileCount: moduleRecord.fileCount, + activeWeekCount: moduleRecord.activeWeekCount, + governanceScore, + severity: resolveGovernanceSeverity(governanceScore, reasons), + reasons, + suggestion: buildGovernanceSuggestion(reasons), + }; + }) + .sort((left, right) => { + if (right.governanceScore !== left.governanceScore) { + return right.governanceScore - left.governanceScore; + } + if (right.churn !== left.churn) { + return right.churn - left.churn; + } + return right.loc - left.loc; + }) + .slice(0, topN); + + return { + thresholds: { + ...thresholds, + densityHigh: roundTo(thresholds.densityHigh, 4), + }, + candidates, + }; +} + +function percentileValue(values, percentile) { + if (!values || values.length === 0) { + return 0; + } + + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min( + sorted.length - 1, + Math.max(0, Math.floor((sorted.length - 1) * percentile)), + ); + return sorted[index]; +} + +function buildGovernanceReasons(moduleRecord, thresholds) { + const reasons = []; + + if (moduleRecord.loc >= thresholds.locHigh) { + reasons.push("体量大"); + } + + if (moduleRecord.churn >= thresholds.churnHigh && moduleRecord.churn > 0) { + reasons.push("近期变更频繁"); + } + + if ( + moduleRecord.churnDensity >= thresholds.densityHigh && + moduleRecord.churn > 0 + ) { + reasons.push("单位体量改动密"); + } + + if (moduleRecord.fileCount >= thresholds.filesHigh) { + reasons.push("文件分散"); + } + + if ( + moduleRecord.activeWeekCount >= thresholds.activeWeeksHigh && + moduleRecord.activeWeekCount > 1 + ) { + reasons.push("持续发热"); + } + + if (reasons.length === 0) { + reasons.push("需要观察"); + } + + return reasons; +} + +function resolveGovernanceSeverity(governanceScore, reasons) { + const hasScaleSignal = + reasons.includes("体量大") || reasons.includes("文件分散"); + const hasHeatSignal = + reasons.includes("近期变更频繁") || reasons.includes("单位体量改动密"); + + if (governanceScore >= 80 && hasScaleSignal && hasHeatSignal) { + return "立即治理"; + } + + if (governanceScore >= 60 && reasons.length >= 2) { + return "尽快治理"; + } + + return "持续观察"; +} + +function buildGovernanceSuggestion(reasons) { + if (reasons.includes("体量大") && reasons.includes("文件分散")) { + return "先定义唯一事实源,再收敛入口与目录边界。"; + } + + if (reasons.includes("单位体量改动密") && reasons.includes("持续发热")) { + return "先冻结抽象,再补守卫,避免继续长出平级实现。"; + } + + if (reasons.includes("近期变更频繁")) { + return "优先盘点入口层与服务层,找出重复分支后做减法。"; + } + + return "先保持观测,等下一轮需求前确认是否要收口。"; +} + +function buildGroups(modules) { + const groupMap = new Map(); + + for (const moduleRecord of modules) { + let group = groupMap.get(moduleRecord.group); + if (!group) { + group = { + id: moduleRecord.group, + label: moduleRecord.group === "root" ? "根目录" : moduleRecord.group, + loc: 0, + churn: 0, + moduleCount: 0, + fileCount: 0, + }; + groupMap.set(moduleRecord.group, group); + } + + group.loc += moduleRecord.loc; + group.churn += moduleRecord.churn; + group.moduleCount += 1; + group.fileCount += moduleRecord.fileCount; + } + + const result = [ + { + id: "all", + label: "全部", + loc: modules.reduce((total, moduleRecord) => total + moduleRecord.loc, 0), + churn: modules.reduce( + (total, moduleRecord) => total + moduleRecord.churn, + 0, + ), + moduleCount: modules.length, + fileCount: modules.reduce( + (total, moduleRecord) => total + moduleRecord.fileCount, + 0, + ), + }, + ...Array.from(groupMap.values()).sort((left, right) => { + if (right.churn !== left.churn) { + return right.churn - left.churn; + } + return right.loc - left.loc; + }), + ]; + + return result; +} + +function resolveLastTouchedDates(repoRoot, filePaths) { + const result = new Map(); + + if (filePaths.length === 0) { + return result; + } + + for (const filePath of filePaths) { + try { + const value = execFileSync( + gitCommand, + ["-C", repoRoot, "log", "-1", "--date=short", "--format=%cs", "--", filePath], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); + + if (value) { + result.set(filePath, value); + } + } catch { + result.set(filePath, ""); + } + } + + return result; +} + +function renderHtml(report) { + const embeddedData = JSON.stringify(report).replace(/ + + + + + ${escapeHtml(report.meta.repoName)} 项目热力图 + + + +
+
+
+
项目观察热力图 · ${escapeHtml(report.meta.repoName)}
+

用规模、变更和时间热度观察仓库演化

+

+ 这份报告聚合了当前仓库文件规模、最近 ${report.meta.sinceDays} + 天 Git churn,以及按周汇总的模块活跃度。 + 面积优先回答“哪里大”,颜色优先回答“哪里热”,矩阵优先回答“什么时候热”。 +

+
+
+
+ 仓库路径 + ${escapeHtml(report.meta.repoRoot)} +
+
+ 生成时间 + ${escapeHtml(formatTimestamp(report.meta.generatedAt))} +
+
+ 统计窗口 + 最近 ${report.meta.sinceDays} 天 · 模块深度 ${report.meta.moduleDepth} +
+
+
+ +
+
+
+

摘要

+

先用总量判断项目体积,再用 churn 观察演化速度。

+
+
+
+
+ 纳入统计文件 + ${formatNumber(report.summary.totalFiles)} +
+
+ 总代码/文档行数 + ${formatNumber(report.summary.totalLoc)} +
+
+ 聚合模块数 + ${formatNumber(report.summary.totalModules)} +
+
+ ${report.meta.sinceDays} 天 churn + ${formatNumber(report.summary.totalChurn)} +
+
+
+ +
+
+
+

治理候选

+

+ 这里不是单纯看“谁最热”,而是综合了体量、近期 churn、单位体量热度、文件分散度和持续活跃度。 + 分数越高,越适合优先做“收口、减法、封老路”。 +

+
+
+
+ ${report.governance.candidates + .map((candidate) => { + const severityClass = + candidate.severity === "立即治理" + ? "urgent" + : candidate.severity === "尽快治理" + ? "warn" + : ""; + return `
+
+
+

${escapeHtml(candidate.path)}

+
${escapeHtml(candidate.severity)}
+
+
${candidate.governanceScore}
+
+
+ ${candidate.reasons + .map( + (reason) => + `${escapeHtml(reason)}`, + ) + .join("")} +
+
+
LOC:${formatNumber(candidate.loc)}
+
文件:${formatNumber(candidate.fileCount)}
+
churn:${formatNumber(candidate.churn)}
+
活跃周:${formatNumber(candidate.activeWeekCount)}
+
+
${escapeHtml(candidate.suggestion)}
+
`; + }) + .join("")} +
+
+ +
+
+
+

模块体量 + 热度

+

+ Treemap 使用模块级聚合结果:矩形面积代表 LOC,颜色深浅代表 churn/LOC。 + 这样既能看出大模块,也能看出“单位体量上特别热”的区域。 +

+
+
+ +
+
+
+ 颜色:低热 → 高热 +
+
+
+ +
+
+ +
+ +
+
+ +
+
+
+

时间 × 模块热力矩阵

+

+ 横轴是按周聚合的时间窗口,纵轴是当前筛选下 churn 最高的模块。 + 这张图最适合看“某一类模块是否持续发热”。 +

+
+
+ +
+ +
+
+ +
+
+
+

热点文件

+

+ 这里列出 churn 最高的当前文件,帮助从模块热区继续向下钻取到具体实现。 +

+
+
+
+ + + + + + + + + + + + +
文件模块LOC${report.meta.sinceDays} 天 churn热度密度最近触达
+
+
+ +
+
+
+

说明

+

这份报告刻意保持 KISS:只做项目观察最有用的三类指标,不混入过多推断。

+
+
+
+ ${report.meta.notes + .map( + (note, index) => `
说明 ${index + 1}${escapeHtml(note)}
`, + ) + .join("")} +
+ +
+
+ +
+ + + +`; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatTimestamp(isoString) { + const date = new Date(isoString); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +function roundTo(value, precision) { + const scale = 10 ** precision; + return Math.round(value * scale) / scale; +} + +function formatNumber(value) { + return new Intl.NumberFormat("zh-CN").format(value || 0); +} + +function toPosixPath(filePath) { + return filePath.split(path.sep).join("/"); +} diff --git a/scripts/report-legacy-surfaces.mjs b/scripts/report-legacy-surfaces.mjs index 94ff53698..f207da2c2 100644 --- a/scripts/report-legacy-surfaces.mjs +++ b/scripts/report-legacy-surfaces.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import { fileURLToPath } from "node:url"; import agentCommandCatalog from "../src/lib/governance/agentCommandCatalog.json" with { type: "json" }; const repoRoot = path.resolve(process.cwd()); @@ -676,12 +677,12 @@ const rustTextSurfaceMonitors = [ }, { id: "rust-agent-runtime-legacy-queue-migration-leak", - classification: "dead-candidate", + classification: "deprecated", description: - "已零引用的 legacy runtime queue 启动迁移 helper 回流到其他模块", + "legacy runtime queue 启动迁移 helper 只允许停留在 aster runtime support 边界", patterns: ["migrate_legacy_runtime_queue_to_aster_store("], includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], - allowedPaths: [], + allowedPaths: ["src-tauri/crates/agent/src/aster_runtime_support.rs"], }, { id: "rust-agent-session-legacy-todo-state-leak", @@ -803,6 +804,99 @@ const rustTextSurfaceMonitors = [ includePathPrefixes: ["src-tauri/src"], allowedPaths: [], }, + { + id: "rust-agent-session-get-direct-read", + classification: "deprecated", + description: "session get_session 直读只允许统一 session_query helper 持有", + patterns: ["SessionManager::get_session("], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: ["src-tauri/crates/agent/src/session_query.rs"], + }, + { + id: "rust-agent-subagent-child-session-direct-read", + classification: "deprecated", + description: "subagent child session 列表直读只允许统一 session_query helper 持有", + patterns: ["list_subagent_child_sessions("], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: ["src-tauri/crates/agent/src/session_query.rs"], + }, + { + id: "rust-agent-subagent-session-list-direct-read", + classification: "deprecated", + description: "subagent 全量 session 列表直读只允许统一 session_query helper 持有", + patterns: ["list_subagent_sessions_with_metadata("], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: ["src-tauri/crates/agent/src/session_query.rs"], + }, + { + id: "rust-agent-subagent-metadata-direct-read", + classification: "deprecated", + description: "subagent metadata 直读只允许 query 与 session_store 投影边界持有", + patterns: ["resolve_subagent_session_metadata("], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: [ + "src-tauri/crates/agent/src/session_query.rs", + "src-tauri/crates/agent/src/session_store.rs", + ], + }, + { + id: "rust-agent-session-extension-data-direct-update", + classification: "deprecated", + description: + "session extension_data 直写 builder 只允许统一 session_update helper 持有", + patterns: [], + regexPatterns: [ + String.raw`SessionManager::update_session\([\s\S]*?\)\s*[\s\S]*?\.extension_data\([\s\S]*?\)\s*[\s\S]*?\.apply\(\)`, + ], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"], + }, + { + id: "rust-agent-session-update-direct-call", + classification: "deprecated", + description: "session update_session 直写只允许统一 session_update helper 持有", + patterns: ["SessionManager::update_session("], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"], + }, + { + id: "rust-agent-session-compaction-metrics-direct-update", + classification: "deprecated", + description: + "session compaction token 指标直写 builder 只允许统一 session_update helper 持有", + patterns: [], + regexPatterns: [ + String.raw`SessionManager::update_session\([\s\S]*?\)\s*[\s\S]*?\.schedule_id\([\s\S]*?\)\s*[\s\S]*?\.total_tokens\([\s\S]*?\)\s*[\s\S]*?\.input_tokens\([\s\S]*?\)\s*[\s\S]*?\.output_tokens\([\s\S]*?\)\s*[\s\S]*?\.accumulated_total_tokens\([\s\S]*?\)\s*[\s\S]*?\.accumulated_input_tokens\([\s\S]*?\)\s*[\s\S]*?\.accumulated_output_tokens\([\s\S]*?\)\s*[\s\S]*?\.apply\(\)`, + ], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"], + }, + { + id: "rust-agent-session-replace-conversation-direct-update", + classification: "deprecated", + description: + "session conversation 直写 replace_conversation 只允许统一 session_update helper 持有", + patterns: ["SessionManager::replace_conversation("], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"], + }, + { + id: "rust-agent-session-create-direct-update", + classification: "deprecated", + description: + "session create_session 直写只允许统一 session_update helper 持有", + patterns: ["SessionManager::create_session("], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"], + }, + { + id: "rust-agent-session-delete-direct-update", + classification: "dead-candidate", + description: "已零引用的 session delete_session 直写回流", + patterns: ["SessionManager::delete_session("], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: [], + }, { id: "rust-agent-session-record-create-api-leak", classification: "dead-candidate", @@ -811,6 +905,14 @@ const rustTextSurfaceMonitors = [ includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"], allowedPaths: [], }, + { + id: "rust-agent-command-subagent-metadata-direct-read", + classification: "dead-candidate", + description: "命令层不再 direct 解析 subagent metadata,统一向 lime_agent 读取边界收敛", + patterns: ["resolve_subagent_session_metadata("], + includePathPrefixes: ["src-tauri/src/commands/aster_agent_cmd"], + allowedPaths: [], + }, { id: "rust-agent-tool-permission-public-module-leak", classification: "dead-candidate", @@ -1375,7 +1477,7 @@ function extractInvokeCommands(sourceCode) { function stripRustTestModules(sourceCode) { return sourceCode.replace( - /(?:^|\n)\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\][\s\S]*$/m, + /(?:^|\n)\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]\s*(?:pub\s+)?mod\s+\w+\s*(?:\{[\s\S]*$|;)/m, "\n", ); } @@ -1770,12 +1872,7 @@ function printTextReport(result) { } function printTextCountReport(result) { - const status = - result.violations.length > 0 - ? "违规" - : result.runtimeMatches.length === 0 - ? "零引用" - : "受控"; + const status = getTextCountStatus(result); console.log( `- [${status}] ${result.id} (${result.classification}):${result.description}`, @@ -1811,161 +1908,268 @@ function printTextCountReport(result) { } } -const { runtimeSources, testSources } = collectSources(); -const { - runtimeSources: frontendRuntimeTextSources, - testSources: frontendTestTextSources, -} = collectTextSources(sourceRoots, sourceExtensions); -const { runtimeSources: rustRuntimeSources, testSources: rustTestSources } = - collectTextSources(rustSourceRoots, rustSourceExtensions); -const importResults = importSurfaceMonitors.map((monitor) => - evaluateImportMonitor(monitor, runtimeSources, testSources), -); -const commandResults = commandSurfaceMonitors.map((monitor) => - evaluateCommandMonitor(monitor, runtimeSources, testSources), -); -const frontendTextResults = frontendTextSurfaceMonitors.map((monitor) => - evaluateTextMonitor( - monitor, - frontendRuntimeTextSources, - frontendTestTextSources, - ), -); -const rustTextResults = rustTextSurfaceMonitors.map((monitor) => - evaluateTextMonitor(monitor, rustRuntimeSources, rustTestSources), -); -const rustTextCountResults = rustTextCountMonitors.map((monitor) => - evaluateTextCountMonitor(monitor, rustRuntimeSources, rustTestSources), -); - -const zeroReferenceCandidates = importResults - .filter( - (result) => - result.references.length === 0 && result.existingTargets.length > 0, - ) - .map((result) => `${result.id} (${result.description})`); -const classificationDriftCandidates = [ - ...importResults - .filter((result) => - isStatusClassificationDrift( - getImportStatus(result), - result.classification, - ), - ) - .map( - (result) => - `${result.id} -> ${result.classification} / ${getImportStatus(result)}`, - ), - ...commandResults - .filter((result) => - isStatusClassificationDrift( - getCommandStatus(result), - result.classification, - ), - ) - .map( - (result) => - `${result.id} -> ${result.classification} / ${getCommandStatus(result)}`, - ), - ...frontendTextResults - .filter((result) => - isStatusClassificationDrift(getTextStatus(result), result.classification), - ) - .map( - (result) => - `${result.id} -> ${result.classification} / ${getTextStatus(result)}`, - ), - ...rustTextResults - .filter((result) => - isStatusClassificationDrift(getTextStatus(result), result.classification), - ) - .map( - (result) => - `${result.id} -> ${result.classification} / ${getTextStatus(result)}`, - ), - ...rustTextCountResults - .filter((result) => - isStatusClassificationDrift( - result.runtimeMatches.length === 0 ? "零引用" : "受控", - result.classification, - ), - ) - .map( - (result) => - `${result.id} -> ${result.classification} / ${ - result.runtimeMatches.length === 0 ? "零引用" : "受控" - }`, - ), -]; -const violations = [ - ...importResults.flatMap((result) => - result.violations.map((item) => `${result.id} -> ${item}`), - ), - ...commandResults.flatMap((result) => - result.violations.map((item) => `${result.id} -> ${item}`), - ), - ...frontendTextResults.flatMap((result) => - result.violations.map((item) => `${result.id} -> ${item}`), - ), - ...rustTextResults.flatMap((result) => - result.violations.map((item) => `${result.id} -> ${item}`), - ), - ...rustTextCountResults.flatMap((result) => - result.violations.map((item) => `${result.id} -> ${item}`), - ), -]; - -console.log("[lime] legacy surface report"); -console.log(""); -console.log("## 入口引用"); -for (const result of importResults) { - printImportReport(result); +function getTextCountStatus(result) { + return result.violations.length > 0 + ? "违规" + : result.runtimeMatches.length === 0 + ? "零引用" + : "受控"; } -console.log(""); -console.log("## 命令边界"); -for (const result of commandResults) { - printCommandReport(result); -} - -console.log(""); -console.log("## 前端护栏"); -for (const result of frontendTextResults) { - printTextReport(result); -} - -console.log(""); -console.log("## Rust 护栏"); -for (const result of rustTextResults) { - printTextReport(result); -} -for (const result of rustTextCountResults) { - printTextCountReport(result); -} - -console.log(""); -console.log("## 摘要"); -console.log(`- 扫描文件数:${runtimeSources.length}`); -console.log(`- 测试文件数:${testSources.length}`); -console.log(`- Rust 扫描文件数:${rustRuntimeSources.length}`); -console.log(`- Rust 测试文件数:${rustTestSources.length}`); -console.log(`- 零引用候选:${zeroReferenceCandidates.length}`); -for (const candidate of zeroReferenceCandidates) { - console.log(` - ${candidate}`); -} -console.log(`- 分类漂移候选:${classificationDriftCandidates.length}`); -for (const candidate of classificationDriftCandidates) { - console.log(` - ${candidate}`); -} -console.log(`- 边界违规:${violations.length}`); -for (const violation of violations) { - console.log(` - ${violation}`); -} - -if (violations.length > 0) { - console.error(""); - console.error( - "[lime] legacy surface report 检测到边界违规,请先治理再继续扩展。", +export function buildLegacySurfaceReport() { + const { runtimeSources, testSources } = collectSources(); + const { + runtimeSources: frontendRuntimeTextSources, + testSources: frontendTestTextSources, + } = collectTextSources(sourceRoots, sourceExtensions); + const { runtimeSources: rustRuntimeSources, testSources: rustTestSources } = + collectTextSources(rustSourceRoots, rustSourceExtensions); + const importResults = importSurfaceMonitors.map((monitor) => + evaluateImportMonitor(monitor, runtimeSources, testSources), ); - process.exit(1); + const commandResults = commandSurfaceMonitors.map((monitor) => + evaluateCommandMonitor(monitor, runtimeSources, testSources), + ); + const frontendTextResults = frontendTextSurfaceMonitors.map((monitor) => + evaluateTextMonitor( + monitor, + frontendRuntimeTextSources, + frontendTestTextSources, + ), + ); + const rustTextResults = rustTextSurfaceMonitors.map((monitor) => + evaluateTextMonitor(monitor, rustRuntimeSources, rustTestSources), + ); + const rustTextCountResults = rustTextCountMonitors.map((monitor) => + evaluateTextCountMonitor(monitor, rustRuntimeSources, rustTestSources), + ); + + const zeroReferenceCandidates = importResults + .filter( + (result) => + result.references.length === 0 && result.existingTargets.length > 0, + ) + .map((result) => `${result.id} (${result.description})`); + const classificationDriftCandidates = [ + ...importResults + .filter((result) => + isStatusClassificationDrift( + getImportStatus(result), + result.classification, + ), + ) + .map( + (result) => + `${result.id} -> ${result.classification} / ${getImportStatus(result)}`, + ), + ...commandResults + .filter((result) => + isStatusClassificationDrift( + getCommandStatus(result), + result.classification, + ), + ) + .map( + (result) => + `${result.id} -> ${result.classification} / ${getCommandStatus(result)}`, + ), + ...frontendTextResults + .filter((result) => + isStatusClassificationDrift(getTextStatus(result), result.classification), + ) + .map( + (result) => + `${result.id} -> ${result.classification} / ${getTextStatus(result)}`, + ), + ...rustTextResults + .filter((result) => + isStatusClassificationDrift(getTextStatus(result), result.classification), + ) + .map( + (result) => + `${result.id} -> ${result.classification} / ${getTextStatus(result)}`, + ), + ...rustTextCountResults + .filter((result) => + isStatusClassificationDrift( + getTextCountStatus(result), + result.classification, + ), + ) + .map( + (result) => + `${result.id} -> ${result.classification} / ${getTextCountStatus(result)}`, + ), + ]; + const violations = [ + ...importResults.flatMap((result) => + result.violations.map((item) => `${result.id} -> ${item}`), + ), + ...commandResults.flatMap((result) => + result.violations.map((item) => `${result.id} -> ${item}`), + ), + ...frontendTextResults.flatMap((result) => + result.violations.map((item) => `${result.id} -> ${item}`), + ), + ...rustTextResults.flatMap((result) => + result.violations.map((item) => `${result.id} -> ${item}`), + ), + ...rustTextCountResults.flatMap((result) => + result.violations.map((item) => `${result.id} -> ${item}`), + ), + ]; + + return { + repoRoot, + runtimeSources, + testSources, + rustRuntimeSources, + rustTestSources, + importResults, + commandResults, + frontendTextResults, + rustTextResults, + rustTextCountResults, + zeroReferenceCandidates, + classificationDriftCandidates, + violations, + }; +} + +function serializeMapEntries(map) { + return Object.fromEntries(map.entries()); +} + +export function toSerializableLegacySurfaceReport(report) { + return { + repoRoot: report.repoRoot, + summary: { + runtimeSourceCount: report.runtimeSources.length, + testSourceCount: report.testSources.length, + rustRuntimeSourceCount: report.rustRuntimeSources.length, + rustTestSourceCount: report.rustTestSources.length, + zeroReferenceCandidates: report.zeroReferenceCandidates, + classificationDriftCandidates: report.classificationDriftCandidates, + violations: report.violations, + }, + importResults: report.importResults, + commandResults: report.commandResults.map((result) => ({ + ...result, + referencesByCommand: serializeMapEntries(result.referencesByCommand), + testReferencesByCommand: serializeMapEntries(result.testReferencesByCommand), + })), + frontendTextResults: report.frontendTextResults, + rustTextResults: report.rustTextResults, + rustTextCountResults: report.rustTextCountResults, + }; +} + +export function printLegacySurfaceReport(report) { + console.log("[lime] legacy surface report"); + console.log(""); + console.log("## 入口引用"); + for (const result of report.importResults) { + printImportReport(result); + } + + console.log(""); + console.log("## 命令边界"); + for (const result of report.commandResults) { + printCommandReport(result); + } + + console.log(""); + console.log("## 前端护栏"); + for (const result of report.frontendTextResults) { + printTextReport(result); + } + + console.log(""); + console.log("## Rust 护栏"); + for (const result of report.rustTextResults) { + printTextReport(result); + } + for (const result of report.rustTextCountResults) { + printTextCountReport(result); + } + + console.log(""); + console.log("## 摘要"); + console.log(`- 扫描文件数:${report.runtimeSources.length}`); + console.log(`- 测试文件数:${report.testSources.length}`); + console.log(`- Rust 扫描文件数:${report.rustRuntimeSources.length}`); + console.log(`- Rust 测试文件数:${report.rustTestSources.length}`); + console.log(`- 零引用候选:${report.zeroReferenceCandidates.length}`); + for (const candidate of report.zeroReferenceCandidates) { + console.log(` - ${candidate}`); + } + console.log(`- 分类漂移候选:${report.classificationDriftCandidates.length}`); + for (const candidate of report.classificationDriftCandidates) { + console.log(` - ${candidate}`); + } + console.log(`- 边界违规:${report.violations.length}`); + for (const violation of report.violations) { + console.log(` - ${violation}`); + } +} + +function parseCliArgs(argv) { + const options = { + json: false, + output: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--json") { + options.json = true; + continue; + } + + if (arg === "--output" && argv[index + 1]) { + options.output = String(argv[index + 1]).trim(); + index += 1; + } + } + + return options; +} + +function runLegacySurfaceReportCli() { + const options = parseCliArgs(process.argv.slice(2)); + const report = buildLegacySurfaceReport(); + + if (options.json) { + const serialized = JSON.stringify( + toSerializableLegacySurfaceReport(report), + null, + 2, + ); + if (options.output) { + fs.mkdirSync(path.dirname(options.output), { recursive: true }); + fs.writeFileSync(options.output, serialized, "utf8"); + console.log(`[lime] legacy surface report JSON: ${options.output}`); + } else { + console.log(serialized); + } + } else { + printLegacySurfaceReport(report); + } + + if (report.violations.length > 0) { + console.error(""); + console.error( + "[lime] legacy surface report 检测到边界违规,请先治理再继续扩展。", + ); + process.exit(1); + } +} + +const isMainModule = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMainModule) { + runLegacySurfaceReportCli(); } diff --git a/scripts/setup-local-aster-override.mjs b/scripts/setup-local-aster-override.mjs index 177590ee9..a65e45225 100644 --- a/scripts/setup-local-aster-override.mjs +++ b/scripts/setup-local-aster-override.mjs @@ -54,11 +54,9 @@ function validateAsterRepo(asterRepoPath) { } function buildConfigContent(asterRepoPath) { - const asterPath = normalizePath( - path.relative(cargoConfigDir, path.join(asterRepoPath, "crates", "aster")), - ); + const asterPath = normalizePath(path.join(asterRepoPath, "crates", "aster")); const asterModelsPath = normalizePath( - path.relative(cargoConfigDir, path.join(asterRepoPath, "crates", "aster-models")), + path.join(asterRepoPath, "crates", "aster-models"), ); return `${blockStart} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ba52461ad..a9481bee7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -378,8 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.20.1" -source = "git+https://github.com/astercloud/aster-rust?tag=v0.20.1#35f1bd92028e38df4edf5897f68a54369e929141" +version = "0.21.0" dependencies = [ "ahash", "anyhow", @@ -471,8 +470,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.20.1" -source = "git+https://github.com/astercloud/aster-rust?tag=v0.20.1#35f1bd92028e38df4edf5897f68a54369e929141" +version = "0.21.0" dependencies = [ "serde", "serde_json", @@ -2419,7 +2417,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.10.1", + "phf 0.8.0", "proc-macro2", "quote", "smallvec", @@ -2435,7 +2433,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.3", + "phf 0.8.0", "smallvec", ] @@ -4376,7 +4374,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.56.0", ] [[package]] @@ -5103,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "0.94.0" +version = "0.95.0" dependencies = [ "anyhow", "arboard", @@ -5207,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "0.94.0" +version = "0.95.0" dependencies = [ "anyhow", "aster-core", @@ -5236,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "0.94.0" +version = "0.95.0" dependencies = [ "chrono", "futures", @@ -5253,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "0.94.0" +version = "0.95.0" dependencies = [ "async-trait", "lime-core", @@ -5269,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "0.94.0" +version = "0.95.0" dependencies = [ "aster-models", "async-trait", @@ -5309,7 +5307,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "0.94.0" +version = "0.95.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5344,7 +5342,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "0.94.0" +version = "0.95.0" dependencies = [ "aes", "axum 0.7.9", @@ -5374,7 +5372,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "0.94.0" +version = "0.95.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5394,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "0.94.0" +version = "0.95.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5426,7 +5424,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "0.94.0" +version = "0.95.0" dependencies = [ "async-trait", "lime-core", @@ -5445,7 +5443,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "0.94.0" +version = "0.95.0" dependencies = [ "anyhow", "async-stream", @@ -5500,7 +5498,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "0.94.0" +version = "0.95.0" dependencies = [ "aster-core", "async-stream", @@ -5545,7 +5543,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "0.94.0" +version = "0.95.0" dependencies = [ "axum 0.7.9", "futures", @@ -5560,7 +5558,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "0.94.0" +version = "0.95.0" dependencies = [ "anyhow", "aster-core", @@ -5602,7 +5600,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "0.94.0" +version = "0.95.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5620,7 +5618,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "0.94.0" +version = "0.95.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5647,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "0.94.0" +version = "0.95.0" dependencies = [ "axum 0.7.9", "chrono", @@ -6326,7 +6324,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.117", @@ -7079,7 +7077,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]] @@ -7088,9 +7088,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]] @@ -7194,12 +7192,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", @@ -7611,7 +7609,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", @@ -9110,7 +9108,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]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1fac6e0b3..537f136be 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.94.0" +version = "0.95.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.1" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.20.1" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.21.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.21.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -192,7 +192,7 @@ version = "2.4" [package] name = "lime" -version = "0.94.0" +version = "0.95.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/aster_runtime_support.rs b/src-tauri/crates/agent/src/aster_runtime_support.rs index 87f0e15df..dbe3fb350 100644 --- a/src-tauri/crates/agent/src/aster_runtime_support.rs +++ b/src-tauri/crates/agent/src/aster_runtime_support.rs @@ -150,6 +150,16 @@ pub(crate) async fn prepare_aster_runtime_queue_resumption() -> Result, +) -> Result<(), String> { + for queued_turn in queued_turns { + enqueue_aster_runtime_turn(queued_turn).await?; + } + Ok(()) +} + pub(crate) async fn enqueue_aster_runtime_turn( queued_turn: QueuedTurnRuntime, ) -> Result { diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 21182c415..5cd4f1508 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -604,6 +604,24 @@ pub enum TauriAgentEvent { #[serde(rename = "context_trace")] ContextTrace { steps: Vec }, + /// 上下文压缩开始 + #[serde(rename = "context_compaction_started")] + ContextCompactionStarted { + item_id: String, + trigger: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, + + /// 上下文压缩完成 + #[serde(rename = "context_compaction_completed")] + ContextCompactionCompleted { + item_id: String, + trigger: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, + /// 当前回合运行态摘要 #[serde(rename = "runtime_status")] RuntimeStatus { status: TauriRuntimeStatus }, @@ -822,12 +840,7 @@ pub fn convert_agent_event(event: AgentEvent) -> Vec { AgentEvent::ModelChange { model, mode } => { vec![TauriAgentEvent::ModelChange { model, mode }] } - AgentEvent::HistoryReplaced(_conversation) => vec![TauriAgentEvent::ContextTrace { - steps: vec![TauriContextTraceStep { - stage: "context_management".to_string(), - detail: "会话历史已自动压缩,以继续当前对话。".to_string(), - }], - }], + AgentEvent::HistoryReplaced(_conversation) => vec![], AgentEvent::ContextTrace { steps } => vec![TauriAgentEvent::ContextTrace { steps: steps .into_iter() @@ -837,6 +850,28 @@ pub fn convert_agent_event(event: AgentEvent) -> Vec { }) .collect(), }], + AgentEvent::ContextCompactionStarted { + item_id, + trigger, + detail, + } => vec![TauriAgentEvent::ContextCompactionStarted { + item_id, + trigger, + detail, + }], + AgentEvent::ContextCompactionCompleted { + item_id, + trigger, + detail, + } => vec![TauriAgentEvent::ContextCompactionCompleted { + item_id, + trigger, + detail, + }], + AgentEvent::ContextCompactionWarning { message } => vec![TauriAgentEvent::Warning { + code: Some("context_compaction_accuracy".to_string()), + message, + }], } } @@ -1439,18 +1474,67 @@ mod tests { } #[test] - fn test_convert_history_replaced_to_context_management_trace() { + fn test_convert_history_replaced_returns_empty_for_runtime_projection() { let event = AgentEvent::HistoryReplaced(aster::conversation::Conversation::empty()); let events = convert_agent_event(event); - assert_eq!(events.len(), 1); - match &events[0] { - TauriAgentEvent::ContextTrace { steps } => { - assert_eq!(steps.len(), 1); - assert_eq!(steps[0].stage, "context_management"); - assert!(steps[0].detail.contains("自动压缩")); + assert!(events.is_empty()); + } + + #[test] + fn test_convert_context_compaction_lifecycle_events() { + let started_events = convert_agent_event(AgentEvent::ContextCompactionStarted { + item_id: "compact-1".to_string(), + trigger: "manual".to_string(), + detail: Some("压缩最近 8 轮历史".to_string()), + }); + assert_eq!(started_events.len(), 1); + match &started_events[0] { + TauriAgentEvent::ContextCompactionStarted { + item_id, + trigger, + detail, + } => { + assert_eq!(item_id, "compact-1"); + assert_eq!(trigger, "manual"); + assert_eq!(detail.as_deref(), Some("压缩最近 8 轮历史")); } - _ => panic!("Expected ContextTrace event"), + other => panic!("Expected ContextCompactionStarted event, got {other:?}"), + } + + let completed_events = convert_agent_event(AgentEvent::ContextCompactionCompleted { + item_id: "compact-1".to_string(), + trigger: "auto".to_string(), + detail: Some("已生成摘要并替换旧上下文".to_string()), + }); + assert_eq!(completed_events.len(), 1); + match &completed_events[0] { + TauriAgentEvent::ContextCompactionCompleted { + item_id, + trigger, + detail, + } => { + assert_eq!(item_id, "compact-1"); + assert_eq!(trigger, "auto"); + assert_eq!(detail.as_deref(), Some("已生成摘要并替换旧上下文")); + } + other => panic!("Expected ContextCompactionCompleted event, got {other:?}"), + } + + let warning_events = convert_agent_event(AgentEvent::ContextCompactionWarning { + message: "长对话和多次上下文压缩会降低模型准确性;如果后续结果开始漂移,建议新开会话。" + .to_string(), + }); + assert_eq!(warning_events.len(), 1); + match &warning_events[0] { + TauriAgentEvent::Warning { code, message } => { + assert_eq!(code.as_deref(), Some("context_compaction_accuracy")); + assert_eq!( + message, + "长对话和多次上下文压缩会降低模型准确性;如果后续结果开始漂移,建议新开会话。" + ); + } + other => panic!("Expected Warning event, got {other:?}"), } } diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index fd19bad5f..a1fcae28e 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -30,8 +30,10 @@ pub mod queued_turn; pub mod request_tool_policy; pub mod runtime_projection_snapshot; pub mod runtime_queue; +mod session_query; pub mod session_state_snapshot; mod session_store; +mod session_update; pub mod skill_execution; pub mod subagent_control; pub mod subagent_profiles; @@ -44,7 +46,7 @@ pub mod turn_state; mod write_artifact_events; pub use ask_bridge::{create_ask_callback, extract_response as extract_ask_response}; -pub use aster_runtime_support::initialize_aster_runtime; +pub use aster_runtime_support::{initialize_aster_runtime, restore_aster_runtime_queued_turns}; pub use aster_state::{AsterAgentState, ProviderConfig, QueuedTurnTask}; pub use aster_state_support::{ build_project_system_prompt, create_lime_identity, create_lime_tool_config, message_helpers, @@ -92,6 +94,10 @@ pub use runtime_queue::{ resume_runtime_queue_if_needed, submit_runtime_turn, RuntimeQueueEventEmitter, RuntimeQueueExecutor, }; +pub use session_query::{ + collect_subagent_cascade_session_ids, list_child_subagent_sessions, + list_subagent_cascade_session_ids, list_subagent_status_scope_session_ids, read_session, +}; pub use session_state_snapshot::SessionStateSnapshot; pub use session_store::{ create_session_sync, delete_session, get_persisted_session_metadata_sync, @@ -101,13 +107,16 @@ pub use session_store::{ PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage, SessionTodoItem, SubagentParentContext, }; +pub use session_update::{ + create_subagent_session, persist_compaction_session_metrics_update, + persist_session_extension_data, replace_session_conversation, CompactionSessionMetricsUpdate, +}; pub use skill_execution::{ execute_skill_prompt, execute_skill_workflow, SkillEventEmitter, SkillExecutionError, SkillExecutionResult, SkillWorkflowExecution, StepResult, }; pub use subagent_control::{ - collect_subagent_cascade_session_ids, derive_subagent_runtime_status_kind, - list_subagent_cascade_session_ids, load_subagent_runtime_status, read_subagent_control_state, + derive_subagent_runtime_status_kind, load_subagent_runtime_status, read_subagent_control_state, write_subagent_control_state, SubagentControlState, SubagentRuntimeStatus, SubagentRuntimeStatusInput, SubagentRuntimeStatusKind, }; diff --git a/src-tauri/crates/agent/src/prompt/runtime_agents.rs b/src-tauri/crates/agent/src/prompt/runtime_agents.rs index 28727f2ee..82cec0595 100644 --- a/src-tauri/crates/agent/src/prompt/runtime_agents.rs +++ b/src-tauri/crates/agent/src/prompt/runtime_agents.rs @@ -1,8 +1,8 @@ //! Lime 运行时 AGENTS 指令加载 //! //! 仅用于 Lime 应用运行时会话: -//! - 全局:`~/.lime/AGENTS.md` -//! - 工作区:`/.lime/AGENTS.md` +//! - 全局:`app_paths::resolve_user_memory_path()` +//! - 工作区:当前 workspace 的 runtime AGENTS 文件 use lime_core::app_paths; use std::collections::HashSet; @@ -33,7 +33,7 @@ pub fn merge_system_prompt_with_runtime_agents( 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")); + let workspace_path = working_dir.map(app_paths::resolve_workspace_runtime_agents_path); build_runtime_agents_prompt_with_paths(Some(global_path.as_path()), workspace_path.as_deref()) } @@ -99,7 +99,8 @@ mod tests { 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"); + let workspace_root = tmp.path().join("workspace"); + let workspace_path = app_paths::resolve_workspace_runtime_agents_path(&workspace_root); 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"); diff --git a/src-tauri/crates/agent/src/session_query.rs b/src-tauri/crates/agent/src/session_query.rs new file mode 100644 index 000000000..a9cb9185f --- /dev/null +++ b/src-tauri/crates/agent/src/session_query.rs @@ -0,0 +1,167 @@ +use aster::session::{ + list_subagent_child_sessions, list_subagent_sessions_with_metadata, + resolve_subagent_session_metadata, Session, SessionManager, SessionType, +}; +use std::collections::{HashMap, HashSet, VecDeque}; + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +pub(crate) fn ensure_subagent_session(session: &Session) -> Result<(), String> { + if session.session_type != SessionType::SubAgent { + return Err(format!( + "会话不是 subagent session: session_id={}, session_type={}", + session.id, session.session_type + )); + } + Ok(()) +} + +pub async fn read_session( + session_id: &str, + with_conversation: bool, + error_context: &str, +) -> Result { + SessionManager::get_session(session_id, with_conversation) + .await + .map_err(|error| format!("{error_context}: {error}")) +} + +pub async fn list_child_subagent_sessions( + parent_session_id: &str, + error_context: &str, +) -> Result, String> { + list_subagent_child_sessions(parent_session_id) + .await + .map_err(|error| format!("{error_context}: {error}")) +} + +async fn list_subagent_sessions_with_metadata_query() -> Result, String> { + list_subagent_sessions_with_metadata() + .await + .map_err(|error| format!("读取 subagent session 列表失败: {error}")) +} + +pub(crate) async fn read_subagent_session( + session_id: &str, + error_context: &str, +) -> Result { + let session = read_session(session_id, false, error_context).await?; + ensure_subagent_session(&session)?; + Ok(session) +} + +pub(crate) fn resolve_subagent_parent_session_id(session: &Session) -> Option { + let metadata = resolve_subagent_session_metadata(&session.extension_data)?; + normalize_optional_text(Some(metadata.parent_session_id)) +} + +pub 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 read_session(¤t_session_id, false, "解析 team 事件 scope 失败") + .await + { + Ok(session) => session, + Err(error) => { + tracing::warn!( + "[SessionQuery] 解析 team 事件 scope 失败: session_id={}, error={}", + current_session_id, + error + ); + break; + } + }; + let Some(parent_session_id) = resolve_subagent_parent_session_id(&session) else { + break; + }; + current_session_id = parent_session_id; + } + + scope_ids +} + +pub async fn list_subagent_cascade_session_ids(session_id: &str) -> Result, String> { + let _ = read_subagent_session(session_id, "读取 subagent session 失败").await?; + let sessions = list_subagent_sessions_with_metadata_query().await?; + Ok(collect_subagent_cascade_session_ids(session_id, &sessions)) +} + +pub fn collect_subagent_cascade_session_ids(session_id: &str, sessions: &[Session]) -> Vec { + let mut children_by_parent: HashMap> = HashMap::new(); + for session in sessions { + let Some(parent_session_id) = resolve_subagent_parent_session_id(session) else { + continue; + }; + children_by_parent + .entry(parent_session_id) + .or_default() + .push(session.id.clone()); + } + + let mut ordered = vec![session_id.to_string()]; + let mut queue = VecDeque::from([session_id.to_string()]); + while let Some(parent_id) = queue.pop_front() { + let Some(children) = children_by_parent.get(&parent_id) else { + continue; + }; + for child_id in children { + ordered.push(child_id.clone()); + queue.push_back(child_id.clone()); + } + } + ordered +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + + #[test] + fn collect_subagent_cascade_session_ids_returns_breadth_first_tree() { + let now = Utc::now(); + let child_a = Session { + id: "child-a".to_string(), + session_type: SessionType::SubAgent, + updated_at: now, + extension_data: aster::session::SubagentSessionMetadata::new("root") + .into_updated_extension_data(&Session::default()) + .unwrap(), + ..Session::default() + }; + let child_b = Session { + id: "child-b".to_string(), + session_type: SessionType::SubAgent, + updated_at: now - Duration::minutes(1), + extension_data: aster::session::SubagentSessionMetadata::new("root") + .into_updated_extension_data(&Session::default()) + .unwrap(), + ..Session::default() + }; + let grandchild = Session { + id: "grandchild".to_string(), + session_type: SessionType::SubAgent, + updated_at: now - Duration::minutes(2), + extension_data: aster::session::SubagentSessionMetadata::new("child-a") + .into_updated_extension_data(&Session::default()) + .unwrap(), + ..Session::default() + }; + + let ids = collect_subagent_cascade_session_ids("root", &[child_a, child_b, grandchild]); + + assert_eq!(ids, vec!["root", "child-a", "child-b", "grandchild"]); + } +} diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index a3d9455e4..91971f06a 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -5,8 +5,7 @@ use aster::session::extension_data::{resolve_todo_list_state, TodoListItem, TodoListItemStatus}; use aster::session::{ - list_subagent_child_sessions, resolve_subagent_session_metadata, Session as AsterSession, - SessionManager, SessionRuntimeSnapshot, + resolve_subagent_session_metadata, Session as AsterSession, SessionRuntimeSnapshot, }; use chrono::Utc; use lime_core::agent::types::{AgentMessage, AgentSession, ContentPart, MessageContent}; @@ -28,6 +27,7 @@ use crate::aster_runtime_support::load_aster_runtime_snapshot; use crate::event_converter::{ convert_item_runtime, convert_turn_runtime, TauriMessage, TauriMessageContent, }; +use crate::session_query::{list_child_subagent_sessions, read_session}; use crate::subagent_control::{load_subagent_runtime_status, SubagentRuntimeStatusKind}; use crate::subagent_profiles::{SubagentCustomizationState, SubagentSkillSummary}; use crate::tool_io_offload::{ @@ -142,6 +142,57 @@ pub struct ChildSubagentSession { pub retryable_overload: bool, } +impl ChildSubagentSession { + fn new_base( + id: String, + name: String, + created_at: i64, + updated_at: i64, + session_type: String, + model: Option, + provider_name: Option, + working_dir: Option, + workspace_id: Option, + ) -> Self { + Self { + id, + name, + created_at, + updated_at, + session_type, + model, + provider_name, + working_dir, + workspace_id, + task_summary: None, + role_hint: None, + origin_tool: None, + created_from_turn_id: None, + blueprint_role_id: None, + blueprint_role_label: None, + profile_id: None, + profile_name: None, + role_key: None, + team_preset_id: None, + theme: None, + output_contract: None, + skill_ids: Vec::new(), + skills: Vec::new(), + runtime_status: None, + latest_turn_status: None, + queued_turn_count: 0, + team_phase: None, + team_parallel_budget: None, + team_active_count: None, + team_queued_count: None, + provider_concurrency_group: None, + provider_parallel_budget: None, + queue_reason: None, + retryable_overload: false, + } + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SubagentParentContext { pub parent_session_id: String, @@ -378,12 +429,111 @@ fn resolve_child_subagent_runtime_status_from_snapshot( .unwrap_or(ChildSubagentRuntimeStatus::Idle) } +#[derive(Debug, Clone, Default)] +struct SubagentPresentationProjection { + parent_session_id: String, + task_summary: Option, + role_hint: Option, + origin_tool: Option, + created_from_turn_id: Option, + blueprint_role_id: Option, + blueprint_role_label: Option, + profile_id: Option, + profile_name: Option, + role_key: Option, + team_preset_id: Option, + theme: Option, + output_contract: Option, + skill_ids: Vec, + skills: Vec, +} + +impl SubagentPresentationProjection { + fn from_session(session: &AsterSession) -> Option { + let metadata = resolve_subagent_session_metadata(&session.extension_data)?; + let customization = SubagentCustomizationState::from_session(session).unwrap_or_default(); + Some(Self { + parent_session_id: metadata.parent_session_id, + task_summary: normalize_optional_nonempty_body(metadata.task_summary), + role_hint: normalize_optional_text(metadata.role_hint), + origin_tool: normalize_optional_text(Some(metadata.origin_tool)), + created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id), + blueprint_role_id: customization.blueprint_role_id, + blueprint_role_label: customization.blueprint_role_label, + profile_id: customization.profile_id, + profile_name: customization.profile_name, + role_key: customization.role_key, + team_preset_id: customization.team_preset_id, + theme: customization.theme, + output_contract: customization.output_contract, + skill_ids: customization.skill_ids, + skills: customization.skills, + }) + } + + fn apply_to_child_summary(self, summary: &mut ChildSubagentSession) { + summary.task_summary = self.task_summary; + summary.role_hint = self.role_hint; + summary.origin_tool = self.origin_tool; + summary.created_from_turn_id = self.created_from_turn_id; + summary.blueprint_role_id = self.blueprint_role_id; + summary.blueprint_role_label = self.blueprint_role_label; + summary.profile_id = self.profile_id; + summary.profile_name = self.profile_name; + summary.role_key = self.role_key; + summary.team_preset_id = self.team_preset_id; + summary.theme = self.theme; + summary.output_contract = self.output_contract; + summary.skill_ids = self.skill_ids; + summary.skills = self.skills; + } + + fn into_parent_context( + self, + parent_session_name: String, + current_session_id: &str, + sibling_subagent_sessions: Vec, + ) -> SubagentParentContext { + SubagentParentContext { + parent_session_id: self.parent_session_id, + parent_session_name, + role_hint: self.role_hint, + task_summary: self.task_summary, + origin_tool: self.origin_tool, + created_from_turn_id: self.created_from_turn_id, + blueprint_role_id: self.blueprint_role_id, + blueprint_role_label: self.blueprint_role_label, + profile_id: self.profile_id, + profile_name: self.profile_name, + role_key: self.role_key, + team_preset_id: self.team_preset_id, + theme: self.theme, + output_contract: self.output_contract, + skill_ids: self.skill_ids, + skills: self.skills, + sibling_subagent_sessions: filter_sibling_subagent_sessions( + current_session_id, + sibling_subagent_sessions, + ), + } + } +} + +fn filter_sibling_subagent_sessions( + current_session_id: &str, + sibling_subagent_sessions: Vec, +) -> Vec { + sibling_subagent_sessions + .into_iter() + .filter(|session| session.id != current_session_id) + .collect() +} + fn build_child_subagent_session_summary( db: Option<&DbConnection>, session: AsterSession, ) -> Option { - let metadata = resolve_subagent_session_metadata(&session.extension_data)?; - let customization = SubagentCustomizationState::from_session(&session).unwrap_or_default(); + let projection = SubagentPresentationProjection::from_session(&session)?; let working_dir = normalize_optional_text(Some(session.working_dir.to_string_lossy().to_string())); let workspace_id = @@ -393,42 +543,19 @@ fn build_child_subagent_session_summary( let name = normalize_optional_text(Some(session.name.clone())) .unwrap_or_else(|| "子代理会话".to_string()); - Some(ChildSubagentSession { - id: session.id, + let mut summary = ChildSubagentSession::new_base( + session.id, name, - created_at: session.created_at.timestamp(), - updated_at: session.updated_at.timestamp(), - session_type: session.session_type.to_string(), + session.created_at.timestamp(), + session.updated_at.timestamp(), + session.session_type.to_string(), model, provider_name, working_dir, workspace_id, - task_summary: normalize_optional_nonempty_body(metadata.task_summary), - role_hint: normalize_optional_text(metadata.role_hint), - origin_tool: normalize_optional_text(Some(metadata.origin_tool)), - created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id), - blueprint_role_id: customization.blueprint_role_id, - blueprint_role_label: customization.blueprint_role_label, - profile_id: customization.profile_id, - profile_name: customization.profile_name, - role_key: customization.role_key, - team_preset_id: customization.team_preset_id, - theme: customization.theme, - output_contract: customization.output_contract, - skill_ids: customization.skill_ids, - skills: customization.skills, - runtime_status: None, - latest_turn_status: None, - queued_turn_count: 0, - team_phase: None, - team_parallel_budget: None, - team_active_count: None, - team_queued_count: None, - provider_concurrency_group: None, - provider_parallel_budget: None, - queue_reason: None, - retryable_overload: false, - }) + ); + projection.apply_to_child_summary(&mut summary); + Some(summary) } fn apply_runtime_status_to_child_subagent_session( @@ -471,46 +598,26 @@ fn build_child_subagent_session_summaries( fn build_subagent_parent_context( current_session_id: &str, parent_session: Option<&AsterSession>, - metadata: aster::session::SubagentSessionMetadata, - customization: Option, + projection: SubagentPresentationProjection, sibling_subagent_sessions: Vec, ) -> SubagentParentContext { let parent_session_name = parent_session .and_then(|session| normalize_optional_text(Some(session.name.clone()))) .unwrap_or_else(|| "父会话".to_string()); - let customization = customization.unwrap_or_default(); - SubagentParentContext { - parent_session_id: metadata.parent_session_id, + projection.into_parent_context( parent_session_name, - role_hint: normalize_optional_text(metadata.role_hint), - task_summary: normalize_optional_nonempty_body(metadata.task_summary), - origin_tool: normalize_optional_text(Some(metadata.origin_tool)), - created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id), - blueprint_role_id: customization.blueprint_role_id, - blueprint_role_label: customization.blueprint_role_label, - profile_id: customization.profile_id, - profile_name: customization.profile_name, - role_key: customization.role_key, - team_preset_id: customization.team_preset_id, - theme: customization.theme, - output_contract: customization.output_contract, - skill_ids: customization.skill_ids, - skills: customization.skills, - sibling_subagent_sessions: sibling_subagent_sessions - .into_iter() - .filter(|session| session.id != current_session_id) - .collect(), - } + current_session_id, + sibling_subagent_sessions, + ) } async fn load_child_subagent_sessions( db: &DbConnection, session_id: &str, ) -> Result, String> { - let sessions = list_subagent_child_sessions(session_id) - .await - .map_err(|error| format!("读取 child subagent sessions 失败: {error}"))?; + let sessions = + list_child_subagent_sessions(session_id, "读取 child subagent sessions 失败").await?; let mut summaries = build_child_subagent_session_summaries(Some(db), sessions); for summary in &mut summaries { match load_subagent_runtime_status(&summary.id).await { @@ -531,39 +638,35 @@ async fn load_subagent_parent_context( db: &DbConnection, session_id: &str, ) -> Result, String> { - let current_session = SessionManager::get_session(session_id, false) - .await - .map_err(|error| format!("读取当前 subagent session 失败: {error}"))?; - let Some(metadata) = resolve_subagent_session_metadata(¤t_session.extension_data) else { + let current_session = read_session(session_id, false, "读取当前 subagent session 失败").await?; + let Some(projection) = SubagentPresentationProjection::from_session(¤t_session) else { return Ok(None); }; + let parent_session_id = projection.parent_session_id.clone(); - let parent_session = match SessionManager::get_session(&metadata.parent_session_id, false).await + let parent_session = match read_session(&parent_session_id, false, "读取 parent session 失败") + .await { Ok(session) => Some(session), Err(error) => { tracing::warn!( "[SessionStore] 读取 parent session 失败,已降级为匿名父会话: session_id={}, parent_session_id={}, error={}", session_id, - metadata.parent_session_id, + parent_session_id, error ); None } }; - let sibling_subagent_sessions = match load_child_subagent_sessions( - db, - &metadata.parent_session_id, - ) - .await + let sibling_subagent_sessions = match load_child_subagent_sessions(db, &parent_session_id).await { Ok(sessions) => sessions, Err(error) => { tracing::warn!( "[SessionStore] 读取 sibling subagent sessions 失败,已降级为空列表: session_id={}, parent_session_id={}, error={}", session_id, - metadata.parent_session_id, + parent_session_id, error ); Vec::new() @@ -573,8 +676,7 @@ async fn load_subagent_parent_context( Ok(Some(build_subagent_parent_context( session_id, parent_session.as_ref(), - metadata, - SubagentCustomizationState::from_session(¤t_session), + projection, sibling_subagent_sessions, ))) } @@ -1375,10 +1477,15 @@ mod tests { #[test] fn build_subagent_parent_context_should_keep_parent_name_and_filter_current_session() { let now = Utc::now(); - let metadata = SubagentSessionMetadata::new("parent-1".to_string()) - .with_task_summary(Some("处理父线程拆分出来的图片任务".to_string())) - .with_role_hint(Some("Image #1".to_string())) - .with_created_from_turn_id(Some("turn-2".to_string())); + let session = build_test_subagent_session( + "child-current", + "Image #1", + Some("parent-1"), + now - Duration::seconds(10), + Some("处理父线程拆分出来的图片任务"), + Some("Image #1"), + Some("turn-2"), + ); let parent_session = AsterSession { id: "parent-1".to_string(), name: "主线程会话".to_string(), @@ -1408,12 +1515,13 @@ mod tests { ), ], ); + let projection = + SubagentPresentationProjection::from_session(&session).expect("parent projection"); let context = build_subagent_parent_context( "child-current", Some(&parent_session), - metadata, - None, + projection, sibling_subagent_sessions, ); @@ -1429,6 +1537,67 @@ mod tests { assert_eq!(context.sibling_subagent_sessions[0].id, "child-sibling"); } + #[test] + fn build_subagent_parent_context_should_merge_customization_projection() { + let now = Utc::now(); + let mut session = build_test_subagent_session( + "child-customized", + "自定义子代理", + Some("parent-1"), + now, + Some("整理 customization"), + Some("Image #1"), + Some("turn-9"), + ); + session.extension_data = SubagentCustomizationState { + blueprint_role_id: Some("runtime-explorer".to_string()), + blueprint_role_label: Some("分析".to_string()), + profile_id: Some("code-explorer".to_string()), + profile_name: Some("代码分析员".to_string()), + role_key: Some("explorer".to_string()), + team_preset_id: Some("code-triage-team".to_string()), + theme: Some("engineering".to_string()), + output_contract: Some("输出证据、影响面与建议。".to_string()), + system_overlay: None, + skill_ids: vec!["repo-exploration".to_string()], + skills: vec![SubagentSkillSummary { + id: "repo-exploration".to_string(), + name: "仓库探索".to_string(), + description: Some("优先读事实源".to_string()), + source: Some("builtin".to_string()), + directory: None, + }], + } + .into_updated_extension_data(&session) + .expect("merge customization"); + + let context = build_subagent_parent_context( + "child-customized", + None, + SubagentPresentationProjection::from_session(&session) + .expect("parent projection should exist"), + Vec::new(), + ); + + assert_eq!( + context.blueprint_role_id.as_deref(), + Some("runtime-explorer") + ); + assert_eq!(context.blueprint_role_label.as_deref(), Some("分析")); + assert_eq!(context.profile_id.as_deref(), Some("code-explorer")); + assert_eq!(context.profile_name.as_deref(), Some("代码分析员")); + assert_eq!(context.role_key.as_deref(), Some("explorer")); + assert_eq!(context.team_preset_id.as_deref(), Some("code-triage-team")); + assert_eq!(context.theme.as_deref(), Some("engineering")); + assert_eq!( + context.output_contract.as_deref(), + Some("输出证据、影响面与建议。") + ); + assert_eq!(context.skill_ids, vec!["repo-exploration".to_string()]); + assert_eq!(context.skills.len(), 1); + assert_eq!(context.skills[0].name, "仓库探索"); + } + #[test] fn resolve_child_subagent_runtime_status_from_snapshot_should_use_latest_turn_status() { let now = Utc::now(); @@ -1480,42 +1649,21 @@ mod tests { #[test] fn apply_runtime_status_to_child_subagent_session_should_keep_runtime_detail() { - let mut summary = ChildSubagentSession { - id: "child-1".to_string(), - name: "研究员".to_string(), - created_at: 1_710_000_000, - updated_at: 1_710_000_100, - session_type: "sub_agent".to_string(), - model: Some("claude-sonnet-4".to_string()), - provider_name: Some("openai".to_string()), - working_dir: Some("/tmp/workspace-child".to_string()), - workspace_id: Some("workspace-1".to_string()), - task_summary: Some("整理事实源".to_string()), - role_hint: Some("explorer".to_string()), - origin_tool: Some("spawn_agent".to_string()), - created_from_turn_id: Some("turn-1".to_string()), - blueprint_role_id: None, - blueprint_role_label: None, - profile_id: None, - profile_name: None, - role_key: None, - team_preset_id: None, - theme: None, - output_contract: None, - skill_ids: Vec::new(), - skills: Vec::new(), - runtime_status: None, - latest_turn_status: None, - queued_turn_count: 0, - team_phase: None, - team_parallel_budget: None, - team_active_count: None, - team_queued_count: None, - provider_concurrency_group: None, - provider_parallel_budget: None, - queue_reason: None, - retryable_overload: false, - }; + let mut summary = ChildSubagentSession::new_base( + "child-1".to_string(), + "研究员".to_string(), + 1_710_000_000, + 1_710_000_100, + "sub_agent".to_string(), + Some("claude-sonnet-4".to_string()), + Some("openai".to_string()), + Some("/tmp/workspace-child".to_string()), + Some("workspace-1".to_string()), + ); + summary.task_summary = Some("整理事实源".to_string()); + summary.role_hint = Some("explorer".to_string()); + summary.origin_tool = Some("spawn_agent".to_string()); + summary.created_from_turn_id = Some("turn-1".to_string()); apply_runtime_status_to_child_subagent_session( &mut summary, diff --git a/src-tauri/crates/agent/src/session_update.rs b/src-tauri/crates/agent/src/session_update.rs new file mode 100644 index 000000000..e303f0820 --- /dev/null +++ b/src-tauri/crates/agent/src/session_update.rs @@ -0,0 +1,66 @@ +use aster::conversation::Conversation; +use aster::session::extension_data::ExtensionData; +use aster::session::{Session, SessionManager, SessionType}; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompactionSessionMetricsUpdate { + pub schedule_id: Option, + pub current_window_tokens: i32, + pub accumulated_total_tokens: Option, + pub accumulated_input_tokens: Option, + pub accumulated_output_tokens: Option, +} + +/// 收口 session extension_data 的持久化边界,避免散落 direct builder 调用。 +pub async fn persist_session_extension_data( + session_id: &str, + extension_data: ExtensionData, + action_label: &str, +) -> Result<(), String> { + SessionManager::update_session(session_id) + .extension_data(extension_data) + .apply() + .await + .map_err(|error| format!("{action_label}失败: {error}")) +} + +/// 收口 subagent session 创建边界,避免业务层直接持有 create_session 调用。 +pub async fn create_subagent_session( + working_dir: PathBuf, + session_name: String, +) -> Result { + SessionManager::create_session(working_dir, session_name, SessionType::SubAgent) + .await + .map_err(|error| format!("创建 subagent session 失败: {error}")) +} + +/// 收口 session conversation 整体替换边界,避免业务层直接持有 replace_conversation 调用。 +pub async fn replace_session_conversation( + session_id: &str, + conversation: &Conversation, + action_label: &str, +) -> Result<(), String> { + SessionManager::replace_conversation(session_id, conversation) + .await + .map_err(|error| format!("{action_label}失败: {error}")) +} + +/// 收口 compaction 后 session token 指标写回边界,避免业务层直接持有 builder 链。 +pub async fn persist_compaction_session_metrics_update( + session_id: &str, + update: &CompactionSessionMetricsUpdate, +) -> Result<(), String> { + SessionManager::update_session(session_id) + // 显式保留已有 schedule_id,避免把保留旧值的行为隐含在 store 的 COALESCE 语义里。 + .schedule_id(update.schedule_id.clone()) + .total_tokens(Some(update.current_window_tokens)) + .input_tokens(Some(update.current_window_tokens)) + .output_tokens(Some(0)) + .accumulated_total_tokens(update.accumulated_total_tokens) + .accumulated_input_tokens(update.accumulated_input_tokens) + .accumulated_output_tokens(update.accumulated_output_tokens) + .apply() + .await + .map_err(|error| format!("更新压缩后的 token 统计失败: {error}")) +} diff --git a/src-tauri/crates/agent/src/subagent_control.rs b/src-tauri/crates/agent/src/subagent_control.rs index dc84f9718..eb310e4c4 100644 --- a/src-tauri/crates/agent/src/subagent_control.rs +++ b/src-tauri/crates/agent/src/subagent_control.rs @@ -1,13 +1,12 @@ use crate::aster_runtime_support::{list_aster_runtime_queued_turns, load_aster_runtime_snapshot}; +use crate::session_query::{ensure_subagent_session, read_subagent_session}; +use crate::session_update::persist_session_extension_data; use crate::team_runtime_governor::snapshot_team_runtime_session; use aster::session::extension_data::{ExtensionData, ExtensionState}; use aster::session::{ - list_subagent_sessions_with_metadata, require_shared_session_runtime_queue_service, - resolve_subagent_session_metadata, QueuedTurnRuntime, Session, SessionManager, SessionType, - TurnStatus, + require_shared_session_runtime_queue_service, QueuedTurnRuntime, Session, TurnStatus, }; use chrono::Utc; -use std::collections::{HashMap, VecDeque}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] pub struct SubagentControlState { @@ -168,16 +167,6 @@ fn looks_like_session_not_found(error: &str) -> bool { normalized.contains("not found") || error.contains("不存在") } -fn ensure_subagent_session(session: &Session) -> Result<(), String> { - if session.session_type != SessionType::SubAgent { - return Err(format!( - "会话不是 subagent session: session_id={}, session_type={}", - session.id, session.session_type - )); - } - Ok(()) -} - fn map_turn_status(status: TurnStatus) -> SubagentRuntimeStatusKind { match status { TurnStatus::Queued => SubagentRuntimeStatusKind::Queued, @@ -231,10 +220,7 @@ pub fn derive_subagent_runtime_status_kind( pub async fn read_subagent_control_state( session_id: &str, ) -> Result<(Session, SubagentControlState), String> { - let session = SessionManager::get_session(session_id, false) - .await - .map_err(|error| format!("读取 subagent session 失败: {error}"))?; - ensure_subagent_session(&session)?; + let session = read_subagent_session(session_id, "读取 subagent session 失败").await?; Ok(( session.clone(), SubagentControlState::from_session(&session).unwrap_or_default(), @@ -250,27 +236,23 @@ pub async fn write_subagent_control_state( .clone() .into_updated_extension_data(session) .map_err(|error| format!("写入 subagent control state 失败: {error}"))?; - SessionManager::update_session(&session.id) - .extension_data(extension_data) - .apply() + persist_session_extension_data(&session.id, extension_data, "持久化 subagent control state") .await - .map_err(|error| format!("持久化 subagent control state 失败: {error}")) } pub async fn load_subagent_runtime_status( session_id: &str, ) -> Result { - let session = match SessionManager::get_session(session_id, false).await { + let session = match read_subagent_session(session_id, "读取 subagent session 失败").await { Ok(session) => session, Err(error) => { let message = error.to_string(); if looks_like_session_not_found(&message) { return Ok(SubagentRuntimeStatus::not_found(session_id)); } - return Err(format!("读取 subagent session 失败: {message}")); + return Err(message); } }; - ensure_subagent_session(&session)?; let control_state = SubagentControlState::from_session(&session).unwrap_or_default(); let latest_turn = match load_aster_runtime_snapshot(session_id).await { @@ -346,49 +328,9 @@ pub async fn load_subagent_runtime_status( }) } -pub async fn list_subagent_cascade_session_ids(session_id: &str) -> Result, String> { - let root_session = SessionManager::get_session(session_id, false) - .await - .map_err(|error| format!("读取 subagent session 失败: {error}"))?; - ensure_subagent_session(&root_session)?; - - let sessions = list_subagent_sessions_with_metadata() - .await - .map_err(|error| format!("读取 subagent session 列表失败: {error}"))?; - Ok(collect_subagent_cascade_session_ids(session_id, &sessions)) -} - -pub fn collect_subagent_cascade_session_ids(session_id: &str, sessions: &[Session]) -> Vec { - let mut children_by_parent: HashMap> = HashMap::new(); - for session in sessions { - let Some(metadata) = resolve_subagent_session_metadata(&session.extension_data) else { - continue; - }; - children_by_parent - .entry(metadata.parent_session_id) - .or_default() - .push(session.id.clone()); - } - - let mut ordered = vec![session_id.to_string()]; - let mut queue = VecDeque::from([session_id.to_string()]); - while let Some(parent_id) = queue.pop_front() { - let Some(children) = children_by_parent.get(&parent_id) else { - continue; - }; - for child_id in children { - ordered.push(child_id.clone()); - queue.push_back(child_id.clone()); - } - } - ordered -} - #[cfg(test)] mod tests { use super::*; - use aster::session::Session; - use chrono::{Duration, Utc}; #[test] fn subagent_control_state_roundtrip() { @@ -402,7 +344,7 @@ mod tests { created_at: 1, image_count: 0, payload: serde_json::json!({ "message": "test" }), - metadata: HashMap::new(), + metadata: std::collections::HashMap::new(), }], ); @@ -413,42 +355,6 @@ mod tests { assert_eq!(restored, state); } - #[test] - fn collect_subagent_cascade_session_ids_returns_breadth_first_tree() { - let now = Utc::now(); - let child_a = Session { - id: "child-a".to_string(), - session_type: SessionType::SubAgent, - updated_at: now, - extension_data: aster::session::SubagentSessionMetadata::new("root") - .into_updated_extension_data(&Session::default()) - .unwrap(), - ..Session::default() - }; - let child_b = Session { - id: "child-b".to_string(), - session_type: SessionType::SubAgent, - updated_at: now - Duration::minutes(1), - extension_data: aster::session::SubagentSessionMetadata::new("root") - .into_updated_extension_data(&Session::default()) - .unwrap(), - ..Session::default() - }; - let grandchild = Session { - id: "grandchild".to_string(), - session_type: SessionType::SubAgent, - updated_at: now - Duration::minutes(2), - extension_data: aster::session::SubagentSessionMetadata::new("child-a") - .into_updated_extension_data(&Session::default()) - .unwrap(), - ..Session::default() - }; - - let ids = collect_subagent_cascade_session_ids("root", &[child_a, child_b, grandchild]); - - assert_eq!(ids, vec!["root", "child-a", "child-b", "grandchild"]); - } - #[test] fn derive_subagent_runtime_status_kind_prioritizes_closed_and_final_states() { assert_eq!( diff --git a/src-tauri/crates/core/src/app_paths.rs b/src-tauri/crates/core/src/app_paths.rs index e056c2bbf..7ad047030 100644 --- a/src-tauri/crates/core/src/app_paths.rs +++ b/src-tauri/crates/core/src/app_paths.rs @@ -10,7 +10,10 @@ const COMPAT_HOME_DIR_NAME: &str = ".lime"; const DATABASE_FILE_NAME: &str = "lime.db"; const LEGACY_DATABASE_FILE_NAME: &str = "proxycast.db"; const MIGRATION_MARKER_FILE: &str = ".migration_completed"; +const USER_MEMORY_FILE_NAME: &str = "AGENTS.md"; const LEGACY_USER_MEMORY_FILE_NAMES: &[&str] = &["AGENTS.md", "AGENT.md", "instructions.md"]; +const WORKSPACE_RUNTIME_DIR_NAME: &str = ".lime"; +const WORKSPACE_LOCAL_RUNTIME_AGENTS_FILE_NAME: &str = "AGENTS.local.md"; const USER_SIGNAL_TABLES: &[&str] = &[ "contents", "agent_sessions", @@ -19,6 +22,7 @@ const USER_SIGNAL_TABLES: &[&str] = &[ "api_keys", "heartbeat_executions", ]; +pub const WORKSPACE_LOCAL_RUNTIME_AGENTS_GITIGNORE_ENTRY: &str = ".lime/AGENTS.local.md"; pub fn preferred_data_dir() -> Result { let dir = dirs::data_dir() @@ -97,6 +101,18 @@ pub fn resolve_lime_skill_roots() -> Result, String> { Ok(roots) } +pub fn resolve_workspace_runtime_agents_path(working_dir: &Path) -> PathBuf { + working_dir + .join(WORKSPACE_RUNTIME_DIR_NAME) + .join(USER_MEMORY_FILE_NAME) +} + +pub fn resolve_workspace_local_runtime_agents_path(working_dir: &Path) -> PathBuf { + working_dir + .join(WORKSPACE_RUNTIME_DIR_NAME) + .join(WORKSPACE_LOCAL_RUNTIME_AGENTS_FILE_NAME) +} + pub fn resolve_user_memory_path() -> Result { let preferred_root = compat_home_dir()?; let mut legacy_roots = Vec::new(); @@ -188,7 +204,7 @@ 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") + .join(USER_MEMORY_FILE_NAME) } fn resolve_project_skills_dir_from_cwd(cwd: &Path) -> PathBuf { @@ -254,7 +270,7 @@ fn resolve_user_memory_path_from_source_roots( preferred_root: &Path, legacy_roots: &[PathBuf], ) -> Result { - let preferred_path = preferred_root.join("AGENTS.md"); + let preferred_path = preferred_root.join(USER_MEMORY_FILE_NAME); if preferred_path.exists() { return Ok(preferred_path); } @@ -803,6 +819,30 @@ mod tests { assert_eq!(resolved, cwd.join(".agents").join("skills")); } + #[test] + fn resolve_workspace_runtime_agents_path_builds_workspace_file_path() { + let workspace_root = Path::new("/tmp/workspace"); + let resolved = resolve_workspace_runtime_agents_path(workspace_root); + assert_eq!( + resolved, + workspace_root + .join(WORKSPACE_RUNTIME_DIR_NAME) + .join(USER_MEMORY_FILE_NAME) + ); + } + + #[test] + fn resolve_workspace_local_runtime_agents_path_builds_workspace_local_file_path() { + let workspace_root = Path::new("/tmp/workspace"); + let resolved = resolve_workspace_local_runtime_agents_path(workspace_root); + assert_eq!( + resolved, + workspace_root + .join(WORKSPACE_RUNTIME_DIR_NAME) + .join(WORKSPACE_LOCAL_RUNTIME_AGENTS_FILE_NAME) + ); + } + #[test] fn resolve_user_memory_path_copies_legacy_agents_file() { let temp = tempdir().unwrap(); diff --git a/src-tauri/crates/core/src/database/dao/agent_thread_incident.rs b/src-tauri/crates/core/src/database/dao/agent_thread_incident.rs new file mode 100644 index 000000000..2b7495a2f --- /dev/null +++ b/src-tauri/crates/core/src/database/dao/agent_thread_incident.rs @@ -0,0 +1,167 @@ +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentThreadIncidentRecord { + pub id: String, + pub thread_id: String, + pub turn_id: Option, + pub item_id: Option, + pub incident_type: String, + pub severity: String, + pub status: String, + pub title: String, + pub details_json: Option, + pub detected_at: String, + pub cleared_at: Option, + pub created_at: String, + pub updated_at: String, +} + +pub struct AgentThreadIncidentDao; + +impl AgentThreadIncidentDao { + pub fn upsert_active( + conn: &Connection, + record: &AgentThreadIncidentRecord, + ) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT INTO agent_thread_incidents ( + id, thread_id, turn_id, item_id, incident_type, severity, status, title, + details_json, detected_at, cleared_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(id) DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + item_id = excluded.item_id, + incident_type = excluded.incident_type, + severity = excluded.severity, + status = excluded.status, + title = excluded.title, + details_json = excluded.details_json, + detected_at = excluded.detected_at, + cleared_at = excluded.cleared_at, + updated_at = excluded.updated_at", + params![ + record.id, + record.thread_id, + record.turn_id, + record.item_id, + record.incident_type, + record.severity, + record.status, + record.title, + record.details_json, + record.detected_at, + record.cleared_at, + record.created_at, + record.updated_at, + ], + )?; + Ok(()) + } + + pub fn clear( + conn: &Connection, + id: &str, + cleared_at: &str, + updated_at: &str, + ) -> Result { + let changed = conn.execute( + "UPDATE agent_thread_incidents + SET status = 'cleared', + cleared_at = ?2, + updated_at = ?3 + WHERE id = ?1 + AND status != 'cleared'", + params![id, cleared_at, updated_at], + )?; + Ok(changed > 0) + } + + pub fn list_active_by_thread( + conn: &Connection, + thread_id: &str, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, thread_id, turn_id, item_id, incident_type, severity, status, title, + details_json, detected_at, cleared_at, created_at, updated_at + FROM agent_thread_incidents + WHERE thread_id = ?1 + AND status = 'active' + ORDER BY detected_at DESC", + )?; + + let iter = stmt.query_map([thread_id], map_incident_row)?; + iter.collect() + } +} + +fn map_incident_row(row: &rusqlite::Row<'_>) -> Result { + Ok(AgentThreadIncidentRecord { + id: row.get(0)?, + thread_id: row.get(1)?, + turn_id: row.get(2)?, + item_id: row.get(3)?, + incident_type: row.get(4)?, + severity: row.get(5)?, + status: row.get(6)?, + title: row.get(7)?, + details_json: row.get(8)?, + detected_at: row.get(9)?, + cleared_at: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use rusqlite::Connection; + + #[test] + fn should_upsert_and_clear_active_incident() { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("创建表结构失败"); + + let record = AgentThreadIncidentRecord { + id: "incident-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: None, + incident_type: "approval_timeout".to_string(), + severity: "high".to_string(), + status: "active".to_string(), + title: "审批等待超过阈值".to_string(), + details_json: Some(r#"{"minutes":5}"#.to_string()), + detected_at: "2026-03-23T10:00:00Z".to_string(), + cleared_at: None, + created_at: "2026-03-23T10:00:00Z".to_string(), + updated_at: "2026-03-23T10:00:00Z".to_string(), + }; + + AgentThreadIncidentDao::upsert_active(&conn, &record).expect("应成功写入 incident"); + assert_eq!( + AgentThreadIncidentDao::list_active_by_thread(&conn, "thread-1") + .expect("查询 active incident 应成功") + .len(), + 1 + ); + + AgentThreadIncidentDao::clear( + &conn, + "incident-1", + "2026-03-23T10:03:00Z", + "2026-03-23T10:03:00Z", + ) + .expect("清理 incident 应成功"); + + assert!( + AgentThreadIncidentDao::list_active_by_thread(&conn, "thread-1") + .expect("查询 active incident 应成功") + .is_empty() + ); + } +} diff --git a/src-tauri/crates/core/src/database/dao/agent_timeline.rs b/src-tauri/crates/core/src/database/dao/agent_timeline.rs index 8827d8faf..bc470fe5b 100644 --- a/src-tauri/crates/core/src/database/dao/agent_timeline.rs +++ b/src-tauri/crates/core/src/database/dao/agent_timeline.rs @@ -184,6 +184,13 @@ pub enum AgentThreadItemPayload { #[serde(skip_serializing_if = "Option::is_none")] code: Option, }, + ContextCompaction { + stage: String, + #[serde(skip_serializing_if = "Option::is_none")] + trigger: Option, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, Error { message: String, }, @@ -207,6 +214,7 @@ impl AgentThreadItemPayload { Self::FileArtifact { .. } => "file_artifact", Self::SubagentActivity { .. } => "subagent_activity", Self::Warning { .. } => "warning", + Self::ContextCompaction { .. } => "context_compaction", Self::Error { .. } => "error", Self::TurnSummary { .. } => "turn_summary", } diff --git a/src-tauri/crates/core/src/database/dao/agent_turn_outcome.rs b/src-tauri/crates/core/src/database/dao/agent_turn_outcome.rs new file mode 100644 index 000000000..091e38cea --- /dev/null +++ b/src-tauri/crates/core/src/database/dao/agent_turn_outcome.rs @@ -0,0 +1,128 @@ +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentTurnOutcomeRecord { + pub turn_id: String, + pub thread_id: String, + pub outcome_type: String, + pub summary: String, + pub primary_cause: Option, + pub retryable: bool, + pub details_json: Option, + pub ended_at: String, + pub created_at: String, + pub updated_at: String, +} + +pub struct AgentTurnOutcomeDao; + +impl AgentTurnOutcomeDao { + pub fn upsert( + conn: &Connection, + record: &AgentTurnOutcomeRecord, + ) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT INTO agent_turn_outcomes ( + turn_id, thread_id, outcome_type, summary, primary_cause, retryable, + details_json, ended_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(turn_id) DO UPDATE SET + thread_id = excluded.thread_id, + outcome_type = excluded.outcome_type, + summary = excluded.summary, + primary_cause = excluded.primary_cause, + retryable = excluded.retryable, + details_json = excluded.details_json, + ended_at = excluded.ended_at, + updated_at = excluded.updated_at", + params![ + record.turn_id, + record.thread_id, + record.outcome_type, + record.summary, + record.primary_cause, + if record.retryable { 1 } else { 0 }, + record.details_json, + record.ended_at, + record.created_at, + record.updated_at, + ], + )?; + Ok(()) + } + + pub fn get_by_turn( + conn: &Connection, + turn_id: &str, + ) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT turn_id, thread_id, outcome_type, summary, primary_cause, retryable, + details_json, ended_at, created_at, updated_at + FROM agent_turn_outcomes + WHERE turn_id = ?1", + [turn_id], + map_outcome_row, + ) + .optional() + } +} + +fn map_outcome_row(row: &rusqlite::Row<'_>) -> Result { + Ok(AgentTurnOutcomeRecord { + turn_id: row.get(0)?, + thread_id: row.get(1)?, + outcome_type: row.get(2)?, + summary: row.get(3)?, + primary_cause: row.get(4)?, + retryable: row.get::<_, i64>(5)? != 0, + details_json: row.get(6)?, + ended_at: row.get(7)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use rusqlite::Connection; + + #[test] + fn should_upsert_and_query_turn_outcome() { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("创建表结构失败"); + + let record = AgentTurnOutcomeRecord { + turn_id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + outcome_type: "failed_provider".to_string(), + summary: "Provider 请求失败".to_string(), + primary_cause: Some("429 rate limit".to_string()), + retryable: true, + details_json: Some(r#"{"source":"test"}"#.to_string()), + ended_at: "2026-03-23T10:00:00Z".to_string(), + created_at: "2026-03-23T10:00:00Z".to_string(), + updated_at: "2026-03-23T10:00:00Z".to_string(), + }; + + AgentTurnOutcomeDao::upsert(&conn, &record).expect("首次 upsert 应成功"); + + let updated = AgentTurnOutcomeRecord { + summary: "Provider 请求失败(已重试)".to_string(), + updated_at: "2026-03-23T10:01:00Z".to_string(), + ..record.clone() + }; + + AgentTurnOutcomeDao::upsert(&conn, &updated).expect("二次 upsert 应成功"); + + let stored = AgentTurnOutcomeDao::get_by_turn(&conn, "turn-1") + .expect("查询 outcome 应成功") + .expect("应存在 outcome"); + + assert_eq!(stored.summary, "Provider 请求失败(已重试)"); + assert_eq!(stored.primary_cause.as_deref(), Some("429 rate limit")); + assert!(stored.retryable); + } +} diff --git a/src-tauri/crates/core/src/database/dao/mod.rs b/src-tauri/crates/core/src/database/dao/mod.rs index bf6ceabff..0a4e71e5b 100644 --- a/src-tauri/crates/core/src/database/dao/mod.rs +++ b/src-tauri/crates/core/src/database/dao/mod.rs @@ -1,7 +1,9 @@ pub mod a2ui_form_dao; pub mod agent; pub mod agent_run; +pub mod agent_thread_incident; pub mod agent_timeline; +pub mod agent_turn_outcome; pub mod api_key_provider; pub mod automation_job; pub mod brand_persona_dao; diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index 303f7eba9..63ed9933a 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -632,6 +632,63 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // Agent turn outcome 表 + // 存储每个 turn 的稳定结果摘要,用于 operator-facing reliability 读模型 + conn.execute( + "CREATE TABLE IF NOT EXISTS agent_turn_outcomes ( + turn_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + outcome_type TEXT NOT NULL, + summary TEXT NOT NULL, + primary_cause TEXT, + retryable INTEGER NOT NULL DEFAULT 0, + details_json TEXT, + ended_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_agent_turn_outcomes_thread_ended + ON agent_turn_outcomes(thread_id, ended_at DESC)", + [], + )?; + + // Agent thread incident 表 + // 存储 thread 级当前活跃与已清理的 reliability incident + conn.execute( + "CREATE TABLE IF NOT EXISTS agent_thread_incidents ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_id TEXT, + item_id TEXT, + incident_type TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL, + title TEXT NOT NULL, + details_json TEXT, + detected_at TEXT NOT NULL, + cleared_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_agent_thread_incidents_thread_status_detected + ON agent_thread_incidents(thread_id, status, detected_at DESC)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_agent_thread_incidents_turn_status + ON agent_thread_incidents(turn_id, status)", + [], + )?; + // ============================================================================ // Workspace 相关表 // ============================================================================ @@ -1663,4 +1720,34 @@ mod tests { assert!(column_names.iter().any(|name| name == "enabled_gemini")); assert!(column_names.iter().any(|name| name == "created_at")); } + + #[test] + fn should_create_reliability_projection_tables() { + let conn = Connection::open_in_memory().unwrap(); + + create_tables(&conn).expect("应成功创建 reliability projection 表"); + + let mut tables = conn + .prepare( + "SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name IN ('agent_turn_outcomes', 'agent_thread_incidents') + ORDER BY name", + ) + .unwrap(); + let table_names = tables + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert_eq!( + table_names, + vec![ + "agent_thread_incidents".to_string(), + "agent_turn_outcomes".to_string(), + ] + ); + } } diff --git a/src-tauri/crates/services/src/aster_session_store.rs b/src-tauri/crates/services/src/aster_session_store.rs index 9f4827136..2f8b99847 100644 --- a/src-tauri/crates/services/src/aster_session_store.rs +++ b/src-tauri/crates/services/src/aster_session_store.rs @@ -795,6 +795,8 @@ impl SessionStore for LimeSessionStore { async fn update_token_stats(&self, session_id: &str, stats: TokenStatsUpdate) -> Result<()> { let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?; let now = Utc::now().to_rfc3339(); + // 当前 store 边界把 None 视为“跳过更新”,不是“清空字段”。 + // 调用方若要重置当前窗口 token,必须显式写 Some(0);schedule_id 也不能靠 None/空串清空。 conn.execute( "UPDATE agent_sessions SET total_tokens = COALESCE(?1, total_tokens), @@ -844,6 +846,7 @@ impl SessionStore for LimeSessionStore { let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?; let now = Utc::now().to_rfc3339(); + // provider/model_config 走“保留旧值”语义,None 不会清空已持久化的 provider 配置。 conn.execute( "UPDATE agent_sessions SET provider_name = COALESCE(?1, provider_name), @@ -880,6 +883,7 @@ impl SessionStore for LimeSessionStore { .map(serde_json::to_string) .transpose() .map_err(|e| anyhow!("序列化 user_recipe_values 失败: {e}"))?; + // recipe 走“直接覆盖”语义,None 会落库为 NULL,用于显式清空旧 recipe。 conn.execute( "UPDATE agent_sessions SET recipe_json = ?1, @@ -1299,4 +1303,213 @@ mod tests { Some("0.2") ); } + + #[tokio::test] + async fn update_provider_config_should_keep_existing_values_when_input_is_none() { + let store = setup_test_store(); + let session = store + .create_session( + PathBuf::from("."), + "provider 守卫测试".to_string(), + SessionType::User, + ) + .await + .expect("创建会话失败"); + + store + .update_provider_config( + &session.id, + Some("openai".to_string()), + Some(ModelConfig::new("gpt-4.1").expect("model config")), + ) + .await + .expect("初始化 provider 配置失败"); + + store + .update_provider_config(&session.id, None, None) + .await + .expect("更新空 provider 配置失败"); + + let loaded = store + .get_session(&session.id, false) + .await + .expect("读取会话失败"); + + assert_eq!(loaded.provider_name.as_deref(), Some("openai")); + assert_eq!( + loaded + .model_config + .as_ref() + .map(|config| config.model_name.as_str()), + Some("gpt-4.1") + ); + } + + #[tokio::test] + async fn update_recipe_should_clear_existing_values_when_input_is_none() { + let store = setup_test_store(); + let session = store + .create_session( + PathBuf::from("."), + "recipe 清空测试".to_string(), + SessionType::User, + ) + .await + .expect("创建会话失败"); + + store + .update_recipe( + &session.id, + Some(Recipe { + version: "1.0.0".to_string(), + title: "demo".to_string(), + description: "demo recipe".to_string(), + instructions: None, + prompt: None, + extensions: None, + settings: None, + activities: None, + author: None, + parameters: None, + response: None, + sub_recipes: None, + retry: None, + }), + Some(HashMap::from([( + "temperature".to_string(), + "0.2".to_string(), + )])), + ) + .await + .expect("初始化 recipe 失败"); + + store + .update_recipe(&session.id, None, None) + .await + .expect("清空 recipe 失败"); + + let loaded = store + .get_session(&session.id, false) + .await + .expect("读取会话失败"); + + assert!(loaded.recipe.is_none()); + assert!(loaded.user_recipe_values.is_none()); + } + + #[tokio::test] + async fn update_token_stats_should_keep_existing_values_when_fields_are_none() { + let store = setup_test_store(); + let session = store + .create_session( + PathBuf::from("."), + "token 守卫测试".to_string(), + SessionType::User, + ) + .await + .expect("创建会话失败"); + + store + .update_token_stats( + &session.id, + TokenStatsUpdate { + schedule_id: Some("job-1".to_string()), + total_tokens: Some(100), + input_tokens: Some(60), + output_tokens: Some(40), + accumulated_total: Some(300), + accumulated_input: Some(180), + accumulated_output: Some(120), + }, + ) + .await + .expect("初始化 token 统计失败"); + + store + .update_token_stats( + &session.id, + TokenStatsUpdate { + schedule_id: None, + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total: None, + accumulated_input: None, + accumulated_output: None, + }, + ) + .await + .expect("更新空 token 统计失败"); + + let loaded = store + .get_session(&session.id, false) + .await + .expect("读取会话失败"); + + assert_eq!(loaded.schedule_id.as_deref(), Some("job-1")); + assert_eq!(loaded.total_tokens, Some(100)); + assert_eq!(loaded.input_tokens, Some(60)); + assert_eq!(loaded.output_tokens, Some(40)); + assert_eq!(loaded.accumulated_total_tokens, Some(300)); + assert_eq!(loaded.accumulated_input_tokens, Some(180)); + assert_eq!(loaded.accumulated_output_tokens, Some(120)); + } + + #[tokio::test] + async fn update_token_stats_should_overwrite_current_window_with_explicit_zero() { + let store = setup_test_store(); + let session = store + .create_session( + PathBuf::from("."), + "token 清零测试".to_string(), + SessionType::User, + ) + .await + .expect("创建会话失败"); + + store + .update_token_stats( + &session.id, + TokenStatsUpdate { + schedule_id: Some("job-1".to_string()), + total_tokens: Some(100), + input_tokens: Some(60), + output_tokens: Some(40), + accumulated_total: Some(300), + accumulated_input: Some(180), + accumulated_output: Some(120), + }, + ) + .await + .expect("初始化 token 统计失败"); + + store + .update_token_stats( + &session.id, + TokenStatsUpdate { + schedule_id: None, + total_tokens: Some(0), + input_tokens: Some(0), + output_tokens: Some(0), + accumulated_total: None, + accumulated_input: None, + accumulated_output: None, + }, + ) + .await + .expect("清零当前窗口 token 失败"); + + let loaded = store + .get_session(&session.id, false) + .await + .expect("读取会话失败"); + + assert_eq!(loaded.schedule_id.as_deref(), Some("job-1")); + assert_eq!(loaded.total_tokens, Some(0)); + assert_eq!(loaded.input_tokens, Some(0)); + assert_eq!(loaded.output_tokens, Some(0)); + assert_eq!(loaded.accumulated_total_tokens, Some(300)); + assert_eq!(loaded.accumulated_input_tokens, Some(180)); + assert_eq!(loaded.accumulated_output_tokens, Some(120)); + } } diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 63cca8005..36374a740 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1412,11 +1412,15 @@ pub fn run() { 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_compact_session, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_resume_thread, 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::runtime_api::agent_runtime_replay_request, 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_thread_read, 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, diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api.rs index 6e61fc399..24b9cdd1c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api.rs @@ -47,8 +47,10 @@ pub(crate) use provider_api::{ 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, + agent_runtime_compact_session, agent_runtime_get_session, agent_runtime_get_thread_read, + agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn, + agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn, + agent_runtime_replay_request, agent_runtime_resume_thread, agent_runtime_submit_turn, }; pub(crate) use session_api::{ agent_runtime_create_session, agent_runtime_list_sessions, agent_runtime_update_session, 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 index 82a7e567f..dbb934af3 100644 --- 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 @@ -1,4 +1,5 @@ use super::*; +use crate::services::thread_reliability_projection_service::sync_thread_reliability_projection; #[tauri::command] pub async fn agent_runtime_submit_turn( @@ -44,6 +45,56 @@ pub async fn agent_runtime_interrupt_turn( Ok(cancelled || !cleared.is_empty()) } +/// 统一运行时:压缩当前会话上下文。 +#[tauri::command] +pub async fn agent_runtime_compact_session( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + request: AgentRuntimeCompactSessionRequest, +) -> Result<(), String> { + crate::commands::aster_agent_cmd::runtime_turn::compact_runtime_session_internal( + &app, + state.inner(), + db.inner(), + request, + ) + .await +} + +/// 统一运行时:恢复当前线程的排队执行。 +#[tauri::command] +pub async fn agent_runtime_resume_thread( + 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: AgentRuntimeResumeThreadRequest, +) -> Result { + let session_id = request.session_id.trim().to_string(); + if session_id.is_empty() { + return Ok(false); + } + + 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 +} + /// 统一运行时:获取会话详情。 #[tauri::command] pub async fn agent_runtime_get_session( @@ -58,8 +109,6 @@ pub async fn agent_runtime_get_session( 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(), @@ -81,10 +130,88 @@ pub async fn agent_runtime_get_session( ); } + let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?; let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?; - Ok(AgentRuntimeSessionDetail::from_session_detail( - detail, - queued_turns, + let projection = sync_thread_reliability_projection(db.inner(), &detail)?; + let thread_read = AgentRuntimeThreadReadModel::from_parts( + &detail, + &queued_turns, + projection.pending_requests, + projection.last_outcome, + projection.incidents, + ); + Ok( + AgentRuntimeSessionDetail::from_session_detail_with_thread_read( + detail, + queued_turns, + thread_read, + ), + ) +} + +/// 统一运行时:仅获取线程稳定读模型。 +#[tauri::command] +pub async fn agent_runtime_get_thread_read( + 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); + 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 detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?; + let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?; + let projection = sync_thread_reliability_projection(db.inner(), &detail)?; + Ok(AgentRuntimeThreadReadModel::from_parts( + &detail, + &queued_turns, + projection.pending_requests, + projection.last_outcome, + projection.incidents, + )) +} + +/// 统一运行时:重新拉起指定 pending request 的前端交互载荷。 +#[tauri::command] +pub async fn agent_runtime_replay_request( + db: State<'_, DbConnection>, + request: AgentRuntimeReplayRequestRequest, +) -> Result, String> { + let session_id = request.session_id.trim().to_string(); + let request_id = request.request_id.trim().to_string(); + if session_id.is_empty() || request_id.is_empty() { + return Ok(None); + } + + let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?; + Ok(AgentRuntimeReplayedActionRequiredView::from_session_detail( + &detail, + &request_id, )) } diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index f8f2531b3..351d09ccf 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -1,4 +1,5 @@ use super::*; +use chrono::{DateTime, Utc}; /// Aster Agent 状态信息 #[derive(Debug, Serialize)] @@ -177,6 +178,28 @@ pub struct AgentRuntimeInterruptTurnRequest { pub turn_id: Option, } +#[derive(Debug, Deserialize)] +pub struct AgentRuntimeCompactSessionRequest { + #[serde(alias = "sessionId")] + pub session_id: String, + #[serde(alias = "eventName")] + pub event_name: String, +} + +#[derive(Debug, Deserialize)] +pub struct AgentRuntimeResumeThreadRequest { + #[serde(alias = "sessionId")] + pub session_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct AgentRuntimeReplayRequestRequest { + #[serde(alias = "sessionId")] + pub session_id: String, + #[serde(alias = "requestId")] + pub request_id: String, +} + #[derive(Debug, Deserialize)] pub struct AgentRuntimeRemoveQueuedTurnRequest { #[serde(alias = "sessionId")] @@ -208,16 +231,118 @@ pub struct AgentRuntimeSessionDetail { pub todo_items: Vec, #[serde(default)] pub queued_turns: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_read: Option, #[serde(default)] pub child_subagent_sessions: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub subagent_parent_context: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeRequestView { + pub id: String, + pub thread_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub item_id: Option, + pub request_type: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeOutcomeView { + pub thread_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub outcome_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_cause: Option, + pub retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub ended_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeIncidentView { + pub id: String, + pub thread_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub item_id: Option, + pub incident_type: String, + pub severity: String, + pub status: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detected_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cleared_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeThreadReadModel { + pub thread_id: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_turn_id: Option, + #[serde(default)] + pub pending_requests: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_outcome: Option, + #[serde(default)] + pub incidents: Vec, + #[serde(default)] + pub queued_turns: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub interrupt_state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeReplayedActionRequiredView { + #[serde(rename = "type")] + pub event_type: String, + pub request_id: String, + pub action_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub questions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, +} + impl AgentRuntimeSessionDetail { - pub(crate) fn from_session_detail( + pub(crate) fn from_session_detail_with_thread_read( detail: SessionDetail, queued_turns: Vec, + thread_read: AgentRuntimeThreadReadModel, ) -> Self { Self { id: detail.id, @@ -231,12 +356,580 @@ impl AgentRuntimeSessionDetail { items: detail.items, todo_items: detail.todo_items, queued_turns, + thread_read: Some(thread_read), child_subagent_sessions: detail.child_subagent_sessions, subagent_parent_context: detail.subagent_parent_context, } } } +impl AgentRuntimeReplayedActionRequiredView { + pub(crate) fn from_session_detail(detail: &SessionDetail, request_id: &str) -> Option { + let trimmed_request_id = request_id.trim(); + if trimmed_request_id.is_empty() { + return None; + } + + detail.items.iter().rev().find_map(|item| { + if !matches!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress + ) { + return None; + } + + let scope = Some(AgentRuntimeActionScope { + session_id: Some(detail.id.clone()), + thread_id: Some(item.thread_id.clone()), + turn_id: Some(item.turn_id.clone()), + }); + + match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ApprovalRequest { + request_id, + action_type, + prompt, + tool_name, + arguments, + .. + } if request_id == trimmed_request_id => Some(Self { + event_type: "action_required".to_string(), + request_id: request_id.clone(), + action_type: action_type.clone(), + tool_name: tool_name.clone(), + arguments: arguments.clone(), + prompt: prompt.clone(), + questions: None, + requested_schema: None, + scope, + }), + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::RequestUserInput { + request_id, + action_type, + prompt, + questions, + .. + } if request_id == trimmed_request_id => Some(Self { + event_type: "action_required".to_string(), + request_id: request_id.clone(), + action_type: action_type.clone(), + tool_name: None, + arguments: None, + prompt: prompt.clone(), + questions: questions + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + requested_schema: None, + scope, + }), + _ => None, + } + }) + } +} + +impl AgentRuntimeThreadReadModel { + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn from_session_detail( + detail: &SessionDetail, + queued_turns: &[QueuedTurnSnapshot], + ) -> Self { + let pending_requests = build_pending_requests(detail); + let last_outcome = build_last_outcome(detail); + let incidents = build_incidents(detail, &pending_requests); + Self::from_parts( + detail, + queued_turns, + pending_requests, + last_outcome, + incidents, + ) + } + + pub(crate) fn from_parts( + detail: &SessionDetail, + queued_turns: &[QueuedTurnSnapshot], + pending_requests: Vec, + last_outcome: Option, + incidents: Vec, + ) -> Self { + let latest_turn = detail.turns.last(); + let active_turn = detail + .turns + .iter() + .rev() + .find(|turn| { + matches!( + turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running + ) + }) + .or(latest_turn); + let status = if !pending_requests.is_empty() { + "waiting_request".to_string() + } else if active_turn + .map(|turn| { + matches!( + turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running + ) + }) + .unwrap_or(false) + { + "running".to_string() + } else if let Some(turn) = latest_turn { + turn.status.as_str().to_string() + } else if !queued_turns.is_empty() { + "queued".to_string() + } else { + "idle".to_string() + }; + let interrupt_state = latest_turn.and_then(|turn| { + if matches!( + turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Aborted + ) { + Some("interrupted".to_string()) + } else { + None + } + }); + + Self { + thread_id: detail.thread_id.clone(), + status, + active_turn_id: active_turn.map(|turn| turn.id.clone()), + pending_requests, + last_outcome, + incidents, + queued_turns: queued_turns.to_vec(), + interrupt_state, + updated_at: latest_turn + .map(|turn| turn.updated_at.clone()) + .or_else(|| Some(detail.updated_at.to_string())), + } + } +} + +const APPROVAL_TIMEOUT_SECONDS: i64 = 180; +const USER_INPUT_TIMEOUT_SECONDS: i64 = 300; +const TURN_STUCK_TIMEOUT_SECONDS: i64 = 180; + +fn parse_rfc3339_utc(raw: &str) -> Option> { + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|value| value.with_timezone(&Utc)) +} + +fn elapsed_seconds_since(raw: Option<&str>, now: &DateTime) -> Option { + let parsed = parse_rfc3339_utc(raw?)?; + Some(now.signed_duration_since(parsed).num_seconds().max(0)) +} + +fn is_tool_confirmation_request(request_type: &str) -> bool { + let normalized = request_type.to_ascii_lowercase(); + normalized.contains("tool") || normalized.contains("approval") +} + +fn is_user_input_request(request_type: &str) -> bool { + let normalized = request_type.to_ascii_lowercase(); + normalized.contains("ask") || normalized.contains("user") || normalized.contains("elicitation") +} + +fn request_timeout_threshold_seconds(request: &AgentRuntimeRequestView) -> Option { + if is_tool_confirmation_request(&request.request_type) { + return Some(APPROVAL_TIMEOUT_SECONDS); + } + if is_user_input_request(&request.request_type) { + return Some(USER_INPUT_TIMEOUT_SECONDS); + } + None +} + +fn build_pending_request_incident( + request: &AgentRuntimeRequestView, + now: &DateTime, +) -> AgentRuntimeIncidentView { + let waited_seconds = elapsed_seconds_since(request.created_at.as_deref(), now).unwrap_or(0); + let timeout_seconds = + request_timeout_threshold_seconds(request).unwrap_or(USER_INPUT_TIMEOUT_SECONDS); + let waited_minutes = ((waited_seconds + 59) / 60).max(1); + let request_title = request + .title + .clone() + .unwrap_or_else(|| "线程正在等待人工处理".to_string()); + + let (incident_type, severity, title, details) = + if is_tool_confirmation_request(&request.request_type) { + if waited_seconds >= timeout_seconds { + ( + "approval_timeout".to_string(), + "high".to_string(), + "审批等待超过阈值".to_string(), + Some(serde_json::Value::String(format!( + "工具确认已等待 {waited_minutes} 分钟:{request_title}" + ))), + ) + } else { + ( + "waiting_approval".to_string(), + "medium".to_string(), + "线程正在等待工具确认".to_string(), + Some(serde_json::Value::String(request_title)), + ) + } + } else if waited_seconds >= timeout_seconds { + ( + "user_input_timeout".to_string(), + "high".to_string(), + "人工输入等待超过阈值".to_string(), + Some(serde_json::Value::String(format!( + "人工输入已等待 {waited_minutes} 分钟:{request_title}" + ))), + ) + } else { + ( + "waiting_user_input".to_string(), + "medium".to_string(), + "线程正在等待人工输入".to_string(), + Some(serde_json::Value::String(request_title)), + ) + }; + + AgentRuntimeIncidentView { + id: format!("incident-{}", request.id), + thread_id: request.thread_id.clone(), + turn_id: request.turn_id.clone(), + item_id: request.item_id.clone(), + incident_type, + severity, + status: "active".to_string(), + title, + details, + detected_at: request.created_at.clone(), + cleared_at: None, + } +} + +pub(crate) fn build_pending_requests(detail: &SessionDetail) -> Vec { + detail + .items + .iter() + .filter_map(|item| match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ApprovalRequest { + request_id, + action_type, + prompt, + tool_name, + arguments, + response, + } if matches!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress + ) => + { + Some(AgentRuntimeRequestView { + id: request_id.clone(), + thread_id: item.thread_id.clone(), + turn_id: Some(item.turn_id.clone()), + item_id: Some(item.id.clone()), + request_type: action_type.clone(), + status: "pending".to_string(), + title: prompt + .clone() + .or_else(|| tool_name.as_ref().map(|value| format!("等待确认工具:{value}"))), + payload: arguments.clone(), + decision: response.clone(), + scope: Some(serde_json::json!({ + "thread_id": item.thread_id, + "turn_id": item.turn_id, + "item_id": item.id, + })), + created_at: Some(item.started_at.clone()), + resolved_at: None, + }) + } + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::RequestUserInput { + request_id, + action_type, + prompt, + questions, + response, + } if matches!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress + ) => + { + Some(AgentRuntimeRequestView { + id: request_id.clone(), + thread_id: item.thread_id.clone(), + turn_id: Some(item.turn_id.clone()), + item_id: Some(item.id.clone()), + request_type: action_type.clone(), + status: "pending".to_string(), + title: prompt.clone().or_else(|| { + questions + .as_ref() + .and_then(|items| items.first()) + .map(|question| question.question.clone()) + }), + payload: questions + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + decision: response.clone(), + scope: Some(serde_json::json!({ + "thread_id": item.thread_id, + "turn_id": item.turn_id, + "item_id": item.id, + })), + created_at: Some(item.started_at.clone()), + resolved_at: None, + }) + } + _ => None, + }) + .collect() +} + +pub(crate) fn build_last_outcome(detail: &SessionDetail) -> Option { + let latest_turn = detail.turns.last()?; + let latest_turn_summary = detail + .items + .iter() + .rev() + .find_map(|item| match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::TurnSummary { + text, + } if item.turn_id == latest_turn.id => Some(text.clone()), + _ => None, + }); + let latest_failed_item = detail.items.iter().rev().find(|item| { + item.turn_id == latest_turn.id + && matches!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed + ) + }); + + match latest_turn.status { + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Completed => { + Some(AgentRuntimeOutcomeView { + thread_id: latest_turn.thread_id.clone(), + turn_id: Some(latest_turn.id.clone()), + outcome_type: "completed".to_string(), + summary: latest_turn_summary.or_else(|| Some("最近一次回合已稳定完成".to_string())), + primary_cause: None, + retryable: false, + ended_at: latest_turn.completed_at.clone(), + }) + } + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Aborted => { + Some(AgentRuntimeOutcomeView { + thread_id: latest_turn.thread_id.clone(), + turn_id: Some(latest_turn.id.clone()), + outcome_type: "interrupted".to_string(), + summary: Some("最近一次回合已被中断".to_string()), + primary_cause: None, + retryable: true, + ended_at: latest_turn.completed_at.clone(), + }) + } + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Failed => { + let (outcome_type, primary_cause) = + classify_failed_turn(latest_turn, latest_failed_item); + Some(AgentRuntimeOutcomeView { + thread_id: latest_turn.thread_id.clone(), + turn_id: Some(latest_turn.id.clone()), + outcome_type, + summary: latest_turn + .error_message + .clone() + .or_else(|| primary_cause.clone()) + .or_else(|| Some("最近一次回合执行失败".to_string())), + primary_cause, + retryable: true, + ended_at: latest_turn.completed_at.clone(), + }) + } + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running => None, + } +} + +fn classify_failed_turn( + turn: &lime_core::database::dao::agent_timeline::AgentThreadTurn, + failed_item: Option<&lime_core::database::dao::agent_timeline::AgentThreadItem>, +) -> (String, Option) { + if let Some(item) = failed_item { + match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ToolCall { + error, .. + } + | lime_core::database::dao::agent_timeline::AgentThreadItemPayload::CommandExecution { + error, .. + } => { + return ( + "failed_tool".to_string(), + error.clone().or_else(|| turn.error_message.clone()), + ); + } + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Error { message } => { + return ("failed_tool".to_string(), Some(message.clone())); + } + _ => {} + } + } + + let lowered_error = turn + .error_message + .as_deref() + .map(|value| value.to_lowercase()) + .unwrap_or_default(); + if lowered_error.contains("provider") + || lowered_error.contains("rate limit") + || lowered_error.contains("authentication") + || lowered_error.contains("network") + || lowered_error.contains("api") + { + return ("failed_provider".to_string(), turn.error_message.clone()); + } + + ("failed_model".to_string(), turn.error_message.clone()) +} + +pub(crate) fn build_incidents( + detail: &SessionDetail, + pending_requests: &[AgentRuntimeRequestView], +) -> Vec { + let now = Utc::now(); + + if let Some(request) = pending_requests.first() { + return vec![build_pending_request_incident(request, &now)]; + } + + let latest_turn = match detail.turns.last() { + Some(value) => value, + None => return Vec::new(), + }; + + if matches!( + latest_turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Failed + ) { + let latest_failed_item = detail.items.iter().rev().find(|item| { + item.turn_id == latest_turn.id + && matches!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed + ) + }); + let (outcome_type, primary_cause) = classify_failed_turn(latest_turn, latest_failed_item); + let (incident_type, title) = match outcome_type.as_str() { + "failed_tool" => ("tool_failed".to_string(), "工具执行失败".to_string()), + "failed_provider" => ( + "provider_error".to_string(), + "Provider 请求失败".to_string(), + ), + _ => ( + "turn_failed".to_string(), + "最近一次回合执行失败".to_string(), + ), + }; + + return vec![AgentRuntimeIncidentView { + id: format!("incident-turn-failed-{}", latest_turn.id), + thread_id: latest_turn.thread_id.clone(), + turn_id: Some(latest_turn.id.clone()), + item_id: latest_failed_item.map(|item| item.id.clone()), + incident_type, + severity: "high".to_string(), + status: "active".to_string(), + title, + details: primary_cause.map(serde_json::Value::String), + detected_at: Some(latest_turn.updated_at.clone()), + cleared_at: None, + }]; + } + + if matches!( + latest_turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running + ) && elapsed_seconds_since(Some(latest_turn.updated_at.as_str()), &now) + .map(|value| value >= TURN_STUCK_TIMEOUT_SECONDS) + .unwrap_or(false) + { + let waited_seconds = + elapsed_seconds_since(Some(latest_turn.updated_at.as_str()), &now).unwrap_or(0); + let waited_minutes = ((waited_seconds + 59) / 60).max(1); + let prompt_preview = latest_turn.prompt_text.trim(); + let details = if prompt_preview.is_empty() { + format!("最近 {waited_minutes} 分钟内没有新的线程更新,可尝试停止后恢复执行。") + } else { + format!( + "回合“{prompt_preview}”最近 {waited_minutes} 分钟内没有新的线程更新,可尝试停止后恢复执行。" + ) + }; + + return vec![AgentRuntimeIncidentView { + id: format!("incident-turn-stuck-{}", latest_turn.id), + thread_id: latest_turn.thread_id.clone(), + turn_id: Some(latest_turn.id.clone()), + item_id: None, + incident_type: "turn_stuck".to_string(), + severity: "high".to_string(), + status: "active".to_string(), + title: "当前回合长时间无进展".to_string(), + details: Some(serde_json::Value::String(details)), + detected_at: Some(latest_turn.updated_at.clone()), + cleared_at: None, + }]; + } + + let latest_issue_item = detail.items.iter().rev().find(|item| match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Warning { .. } + | lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Error { .. } => true, + _ => false, + }); + + match latest_issue_item { + Some(item) => { + let (incident_type, title, details, severity) = match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Warning { + message, + code, + } => ( + "runtime_warning".to_string(), + "时间线记录到警告项".to_string(), + Some(serde_json::json!({ "message": message, "code": code })), + "medium".to_string(), + ), + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Error { + message, + } => ( + "runtime_error".to_string(), + "时间线记录到异常项".to_string(), + Some(serde_json::json!({ "message": message })), + "high".to_string(), + ), + _ => unreachable!(), + }; + vec![AgentRuntimeIncidentView { + id: format!("incident-item-{}", item.id), + thread_id: item.thread_id.clone(), + turn_id: Some(item.turn_id.clone()), + item_id: Some(item.id.clone()), + incident_type, + severity, + status: "active".to_string(), + title, + details, + detected_at: Some(item.updated_at.clone()), + cleared_at: None, + }] + } + None => Vec::new(), + } +} + #[derive(Debug, Clone, Deserialize)] pub struct AgentRuntimeSpawnSubagentRequest { #[serde(alias = "parentSessionId")] @@ -429,3 +1122,249 @@ impl AutoContinuePayload { } } } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + use lime_agent::queued_turn::QueuedTurnSnapshot; + use lime_core::database::dao::agent_timeline::{ + AgentRequestQuestion, AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, + AgentThreadTurn, AgentThreadTurnStatus, + }; + + fn build_session_detail( + turns: Vec, + items: Vec, + ) -> SessionDetail { + SessionDetail { + id: "session-1".to_string(), + name: "测试会话".to_string(), + created_at: 1, + updated_at: 2, + thread_id: "thread-1".to_string(), + model: None, + working_dir: None, + workspace_id: None, + messages: Vec::new(), + execution_strategy: None, + turns, + items, + todo_items: Vec::new(), + child_subagent_sessions: Vec::new(), + subagent_parent_context: None, + } + } + + fn seconds_ago(seconds: i64) -> String { + (Utc::now() - Duration::seconds(seconds)).to_rfc3339() + } + + #[test] + fn thread_read_should_expose_pending_request_and_waiting_incident() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续发布".to_string(), + status: AgentThreadTurnStatus::Running, + started_at: seconds_ago(20), + completed_at: None, + error_message: None, + created_at: seconds_ago(20), + updated_at: seconds_ago(10), + }], + vec![AgentThreadItem { + id: "item-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 1, + status: AgentThreadItemStatus::InProgress, + started_at: seconds_ago(15), + completed_at: None, + updated_at: seconds_ago(15), + payload: AgentThreadItemPayload::RequestUserInput { + request_id: "req-1".to_string(), + action_type: "ask_user".to_string(), + prompt: Some("请确认是否继续发布".to_string()), + questions: Some(vec![AgentRequestQuestion { + question: "是否继续?".to_string(), + header: None, + options: None, + multi_select: None, + }]), + response: None, + }, + }], + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!(thread_read.status, "waiting_request"); + assert_eq!(thread_read.active_turn_id.as_deref(), Some("turn-1")); + assert_eq!(thread_read.pending_requests.len(), 1); + assert_eq!(thread_read.pending_requests[0].id, "req-1"); + assert_eq!(thread_read.incidents.len(), 1); + assert_eq!(thread_read.incidents[0].incident_type, "waiting_user_input"); + } + + #[test] + fn thread_read_should_escalate_tool_confirmation_timeout() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-timeout".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续执行工具调用".to_string(), + status: AgentThreadTurnStatus::Running, + started_at: seconds_ago(400), + completed_at: None, + error_message: None, + created_at: seconds_ago(400), + updated_at: seconds_ago(200), + }], + vec![AgentThreadItem { + id: "item-timeout".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-timeout".to_string(), + sequence: 1, + status: AgentThreadItemStatus::InProgress, + started_at: seconds_ago(APPROVAL_TIMEOUT_SECONDS + 60), + completed_at: None, + updated_at: seconds_ago(APPROVAL_TIMEOUT_SECONDS + 60), + payload: AgentThreadItemPayload::ApprovalRequest { + request_id: "req-timeout".to_string(), + action_type: "tool_confirmation".to_string(), + prompt: Some("请确认是否执行 apply_patch".to_string()), + tool_name: Some("apply_patch".to_string()), + arguments: None, + response: None, + }, + }], + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!(thread_read.status, "waiting_request"); + assert_eq!(thread_read.incidents.len(), 1); + assert_eq!(thread_read.incidents[0].incident_type, "approval_timeout"); + assert_eq!(thread_read.incidents[0].severity, "high"); + } + + #[test] + fn thread_read_should_expose_failed_outcome_and_queue_snapshot() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-2".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "执行外部调用".to_string(), + status: AgentThreadTurnStatus::Failed, + started_at: "2026-03-23T09:10:00Z".to_string(), + completed_at: Some("2026-03-23T09:10:30Z".to_string()), + error_message: Some("Provider 错误: rate limit".to_string()), + created_at: "2026-03-23T09:10:00Z".to_string(), + updated_at: "2026-03-23T09:10:30Z".to_string(), + }], + Vec::new(), + ); + let queued_turns = vec![QueuedTurnSnapshot { + queued_turn_id: "queued-1".to_string(), + message_preview: "继续重试".to_string(), + message_text: "继续重试 provider 请求".to_string(), + created_at: 1_742_721_830, + image_count: 0, + position: 1, + }]; + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &queued_turns); + + assert_eq!(thread_read.status, "failed"); + assert_eq!(thread_read.queued_turns.len(), 1); + assert_eq!( + thread_read + .last_outcome + .as_ref() + .map(|value| value.outcome_type.as_str()), + Some("failed_provider") + ); + assert_eq!(thread_read.incidents.len(), 1); + assert_eq!(thread_read.incidents[0].incident_type, "provider_error"); + } + + #[test] + fn thread_read_should_classify_running_turn_stuck() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-stuck".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "长时间执行无响应".to_string(), + status: AgentThreadTurnStatus::Running, + started_at: seconds_ago(TURN_STUCK_TIMEOUT_SECONDS + 120), + completed_at: None, + error_message: None, + created_at: seconds_ago(TURN_STUCK_TIMEOUT_SECONDS + 120), + updated_at: seconds_ago(TURN_STUCK_TIMEOUT_SECONDS + 30), + }], + Vec::new(), + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!(thread_read.status, "running"); + assert_eq!(thread_read.incidents.len(), 1); + assert_eq!(thread_read.incidents[0].incident_type, "turn_stuck"); + assert_eq!(thread_read.incidents[0].severity, "high"); + } + + #[test] + fn replay_request_should_rebuild_pending_action_payload() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-replay".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续等待输入".to_string(), + status: AgentThreadTurnStatus::Running, + started_at: seconds_ago(30), + completed_at: None, + error_message: None, + created_at: seconds_ago(30), + updated_at: seconds_ago(10), + }], + vec![AgentThreadItem { + id: "item-replay".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-replay".to_string(), + sequence: 1, + status: AgentThreadItemStatus::InProgress, + started_at: seconds_ago(20), + completed_at: None, + updated_at: seconds_ago(15), + payload: AgentThreadItemPayload::RequestUserInput { + request_id: "req-replay".to_string(), + action_type: "ask_user".to_string(), + prompt: Some("请确认是否继续发布".to_string()), + questions: Some(vec![AgentRequestQuestion { + question: "是否继续?".to_string(), + header: None, + options: None, + multi_select: None, + }]), + response: None, + }, + }], + ); + + let replayed = + AgentRuntimeReplayedActionRequiredView::from_session_detail(&detail, "req-replay") + .expect("应能重建 replay 请求"); + + assert_eq!(replayed.event_type, "action_required"); + assert_eq!(replayed.request_id, "req-replay"); + assert_eq!(replayed.action_type, "ask_user"); + assert_eq!(replayed.prompt.as_deref(), Some("请确认是否继续发布")); + assert!(replayed.questions.is_some()); + assert_eq!( + replayed.scope.and_then(|scope| scope.turn_id), + Some("turn-replay".to_string()) + ); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 89c9ef0a4..f07885824 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -70,10 +70,7 @@ 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::session::{SessionType, SubagentSessionMetadata}; use aster::tools::task_output_tool::TaskOutputInput; use aster::tools::{ BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager, @@ -93,19 +90,21 @@ use lime_agent::request_tool_policy::{ use lime_agent::{ acquire_provider_runtime_permit, acquire_team_runtime_permit, 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, + builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id, create_subagent_session, + is_virtual_memory_path, list_child_subagent_sessions, list_subagent_cascade_session_ids, + list_subagent_status_scope_session_ids, load_subagent_runtime_status, merge_system_prompt_with_runtime_agents, message_suggests_news_expansion, - normalize_team_runtime_provider_group, preview_provider_runtime_wait_snapshot, - preview_team_runtime_wait_snapshot, read_subagent_control_state, - release_provider_runtime_permit, release_team_runtime_permit, + normalize_team_runtime_provider_group, persist_compaction_session_metrics_update, + persist_session_extension_data, preview_provider_runtime_wait_snapshot, + preview_team_runtime_wait_snapshot, read_session, read_subagent_control_state, + release_provider_runtime_permit, release_team_runtime_permit, replace_session_conversation, resolve_provider_runtime_parallel_budget, resolve_virtual_memory_path, snapshot_provider_runtime_lease, snapshot_team_runtime_session, summarize_builtin_skill, - virtual_memory_relative_path, write_subagent_control_state, ProviderContinuationCapability, - ProviderContinuationCapable, ProviderContinuationState, ProviderRuntimeGovernorSnapshot, - RuntimeProjectionSnapshot, SessionStateSnapshot, SubagentControlState, - SubagentCustomizationState, SubagentRuntimeStatus, SubagentRuntimeStatusKind, - SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus, + virtual_memory_relative_path, write_subagent_control_state, CompactionSessionMetricsUpdate, + ProviderContinuationCapability, ProviderContinuationCapable, ProviderContinuationState, + ProviderRuntimeGovernorSnapshot, RuntimeProjectionSnapshot, SessionStateSnapshot, + SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus, + SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus, TeamRuntimeGovernorSnapshot, TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot, TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, DURABLE_MEMORY_VIRTUAL_ROOT, @@ -297,22 +296,27 @@ pub(crate) use browser_assist::{ }; #[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_close_subagent, agent_runtime_compact_session, agent_runtime_create_session, + agent_runtime_get_session, agent_runtime_get_thread_read, 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_replay_request, agent_runtime_resume_subagent, + agent_runtime_resume_thread, 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, + build_incidents, build_last_outcome, build_pending_requests, AgentRuntimeActionType, + AgentRuntimeCloseSubagentRequest, AgentRuntimeCloseSubagentResponse, + AgentRuntimeCompactSessionRequest, AgentRuntimeIncidentView, AgentRuntimeInterruptTurnRequest, + AgentRuntimeOutcomeView, AgentRuntimePromoteQueuedTurnRequest, + AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeReplayRequestRequest, + AgentRuntimeReplayedActionRequiredView, AgentRuntimeRequestView, + AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest, + AgentRuntimeResumeSubagentResponse, AgentRuntimeResumeThreadRequest, AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse, AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse, - AgentRuntimeSubmitTurnRequest, AgentRuntimeToolInventoryRequest, + AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadReadModel, AgentRuntimeToolInventoryRequest, AgentRuntimeUpdateSessionRequest, AgentRuntimeWaitSubagentsRequest, AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload, ConfigureFromPoolRequest, ConfigureProviderRequest, diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index b0f88a660..ceb160b84 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -216,9 +216,9 @@ pub(crate) fn build_runtime_prepared_team_spawn_message( async fn collect_runtime_prepared_team_candidates( parent_session_id: &str, ) -> Result, String> { - let child_sessions = list_subagent_child_sessions(parent_session_id) - .await - .map_err(|error| format!("读取 runtime team child sessions 失败: {error}"))?; + let child_sessions = + list_child_subagent_sessions(parent_session_id, "读取 runtime team child sessions 失败") + .await?; let mut candidates = Vec::new(); for child_session in child_sessions { @@ -1310,6 +1310,236 @@ fn build_queued_turn_preview(message: &str) -> String { } } +async fn update_compaction_session_metrics( + session_config: &aster::agents::SessionConfig, + usage: &aster::providers::base::ProviderUsage, +) -> Result<(), String> { + let session = read_session(&session_config.id, false, "读取会话 token 统计失败").await?; + + let update = build_compaction_session_metrics_update(&session, session_config, usage); + persist_compaction_session_metrics_update(&session_config.id, &update).await +} + +fn build_compaction_session_metrics_update( + session: &aster::session::Session, + session_config: &aster::agents::SessionConfig, + usage: &aster::providers::base::ProviderUsage, +) -> CompactionSessionMetricsUpdate { + let schedule_id = session_config + .schedule_id + .clone() + .or(session.schedule_id.clone()); + + let accumulate = |current: Option, delta: Option| match (current, delta) { + (Some(lhs), Some(rhs)) => Some(lhs + rhs), + _ => current.or(delta), + }; + + let accumulated_total = accumulate(session.accumulated_total_tokens, usage.usage.total_tokens); + let accumulated_input = accumulate(session.accumulated_input_tokens, usage.usage.input_tokens); + let accumulated_output = + accumulate(session.accumulated_output_tokens, usage.usage.output_tokens); + + let current_window_tokens = usage + .usage + .output_tokens + .or(usage.usage.total_tokens) + .unwrap_or(0); + + CompactionSessionMetricsUpdate { + schedule_id, + current_window_tokens, + accumulated_total_tokens: accumulated_total, + accumulated_input_tokens: accumulated_input, + accumulated_output_tokens: accumulated_output, + } +} + +pub(crate) async fn compact_runtime_session_internal( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + request: AgentRuntimeCompactSessionRequest, +) -> Result<(), String> { + let session_id = normalize_required_text(&request.session_id, "session_id")?; + let event_name = normalize_required_text(&request.event_name, "event_name")?; + let cancel_token = state.create_cancel_token(&session_id).await; + 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] 压缩上下文前读取 runtime snapshot 失败,继续使用 session 默认线程: session_id={}, error={}", + session_id, + error + ); + None + } + } + }; + let runtime_projection_snapshot = + RuntimeProjectionSnapshot::from_snapshot(&session_id, runtime_snapshot.as_ref()); + let resolved_thread_id = runtime_projection_snapshot + .primary_thread_id() + .map(str::to_string) + .unwrap_or_else(|| session_id.clone()); + let resolved_turn_id = Uuid::new_v4().to_string(); + let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create( + db.clone(), + resolved_thread_id.clone(), + resolved_turn_id.clone(), + "压缩上下文", + )?)); + let session_config = SessionConfigBuilder::new(&session_id) + .thread_id(resolved_thread_id) + .turn_id(resolved_turn_id) + .build(); + + let final_result: Result<(), String> = { + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + let turn = agent + .ensure_runtime_turn_initialized(&session_config, Some("压缩上下文".to_string())) + .await + .map_err(|error| format!("初始化压缩 turn 失败: {error}"))?; + for event in + lime_agent::event_converter::convert_agent_event(AgentEvent::TurnStarted { turn }) + { + { + 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, "") { + tracing::warn!( + "[AsterAgent] 记录压缩时间线事件失败(已降级继续): {}", + error + ); + } + } + if let Err(error) = app.emit(&event_name, &event) { + tracing::error!("[AsterAgent] 发送压缩事件失败: {}", error); + } + } + + let compaction_turn_id = session_config + .turn_id + .clone() + .unwrap_or_else(|| session_id.clone()); + let compaction_item_id = format!("context_compaction:{compaction_turn_id}"); + let start_event = TauriAgentEvent::ContextCompactionStarted { + item_id: compaction_item_id.clone(), + trigger: "manual".to_string(), + detail: Some("系统正在将较早消息整理为摘要,以释放上下文窗口。".to_string()), + }; + { + 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, &start_event, "") { + tracing::warn!( + "[AsterAgent] 记录压缩开始时间线失败(已降级继续): {}", + error + ); + } + } + if let Err(error) = app.emit(&event_name, &start_event) { + tracing::error!("[AsterAgent] 发送压缩开始事件失败: {}", error); + } + + let session = read_session(&session_id, true, "读取会话失败").await?; + let conversation = session + .conversation + .ok_or_else(|| "Session has no conversation".to_string())?; + let provider = agent + .provider() + .await + .map_err(|error| format!("读取 provider 失败: {error}"))?; + let (compacted_conversation, usage) = + aster::context_mgmt::compact_messages(provider.as_ref(), &conversation, true) + .await + .map_err(|error| format!("压缩上下文失败: {error}"))?; + replace_session_conversation(&session_id, &compacted_conversation, "写回压缩后的会话") + .await?; + update_compaction_session_metrics(&session_config, &usage).await?; + + let completed_event = TauriAgentEvent::ContextCompactionCompleted { + item_id: compaction_item_id, + trigger: "manual".to_string(), + detail: Some("较早消息已替换为摘要,后续回复会基于压缩后的上下文继续。".to_string()), + }; + { + 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, &completed_event, "") + { + tracing::warn!( + "[AsterAgent] 记录压缩完成时间线失败(已降级继续): {}", + error + ); + } + } + if let Err(error) = app.emit(&event_name, &completed_event) { + tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error); + } + + Ok(()) + }; + + match final_result { + Ok(()) => { + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(error) = recorder.complete_turn_success(app, &event_name) { + tracing::warn!( + "[AsterAgent] 完成压缩 turn 时间线失败(已降级继续): {}", + error + ); + } + let done_event = TauriAgentEvent::FinalDone { usage: None }; + if let Err(error) = app.emit(&event_name, &done_event) { + tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error); + } + } + Err(error) => { + { + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(timeline_error) = recorder.fail_turn(app, &event_name, &error) { + tracing::warn!( + "[AsterAgent] 记录压缩失败 turn 时间线失败(已降级继续): {}", + timeline_error + ); + } + let error_event = TauriAgentEvent::Error { + message: error.clone(), + }; + if let Err(emit_error) = app.emit(&event_name, &error_event) { + tracing::error!("[AsterAgent] 发送压缩错误事件失败: {}", emit_error); + } + } + state.remove_cancel_token(&session_id).await; + return Err(error); + } + } + + drop(cancel_token); + state.remove_cancel_token(&session_id).await; + Ok(()) +} + fn extract_subagent_parent_session_id(metadata: Option<&serde_json::Value>) -> Option { metadata .and_then(|value| value.get("subagent")) @@ -1337,7 +1567,7 @@ async fn resolve_team_runtime_provider_group_for_request(request: &AsterChatRequ return normalize_team_runtime_provider_group(&provider_config.provider_name); } - match SessionManager::get_session(&request.session_id, false).await { + match read_session(&request.session_id, false, "读取 provider 会话上下文失败").await { Ok(session) => { let provider_selector = resolve_session_provider_selector(&session) .or_else(|| normalize_optional_text(session.provider_name.clone())); @@ -1720,3 +1950,197 @@ pub(crate) fn build_runtime_queue_executor() -> RuntimeQueueExecutor { .boxed() }) } + +#[cfg(test)] +mod tests { + use super::*; + use aster::providers::base::{ProviderUsage, Usage}; + use aster::session::{ + initialize_shared_session_runtime_with_root, is_global_session_store_set, SessionManager, + SessionType, + }; + use lime_core::database::schema::create_tables; + use lime_services::aster_session_store::LimeSessionStore; + use rusqlite::Connection; + use std::fs; + use tokio::sync::OnceCell; + + async fn ensure_runtime_turn_test_session_manager() { + static INIT: OnceCell<()> = OnceCell::const_new(); + + INIT.get_or_init(|| async { + if is_global_session_store_set() { + return; + } + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + + let runtime_root = + std::env::temp_dir().join(format!("lime-runtime-turn-tests-{}", Uuid::new_v4())); + fs::create_dir_all(&runtime_root).expect("创建 runtime 测试目录失败"); + + let session_store = Arc::new(LimeSessionStore::new(Arc::new(Mutex::new(conn)))); + initialize_shared_session_runtime_with_root(runtime_root, Some(session_store)) + .await + .expect("初始化测试 session manager 失败"); + }) + .await; + } + + #[tokio::test] + async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() { + ensure_runtime_turn_test_session_manager().await; + + let session = SessionManager::create_session( + PathBuf::from("."), + "压缩统计测试".to_string(), + SessionType::User, + ) + .await + .expect("创建测试会话失败"); + + SessionManager::update_session(&session.id) + .schedule_id(Some("job-before".to_string())) + .total_tokens(Some(90)) + .input_tokens(Some(60)) + .output_tokens(Some(30)) + .accumulated_total_tokens(Some(300)) + .accumulated_input_tokens(Some(200)) + .accumulated_output_tokens(Some(100)) + .apply() + .await + .expect("预置 token 统计失败"); + + let mut session_config = SessionConfigBuilder::new(&session.id).build(); + session_config.schedule_id = Some("job-compact".to_string()); + + let usage = ProviderUsage::new( + "gpt-4.1".to_string(), + Usage::new(Some(120), Some(45), Some(165)), + ); + + update_compaction_session_metrics(&session_config, &usage) + .await + .expect("更新压缩 token 统计失败"); + + let updated = SessionManager::get_session(&session.id, false) + .await + .expect("读取更新后的会话失败"); + + assert_eq!(updated.schedule_id.as_deref(), Some("job-compact")); + assert_eq!(updated.total_tokens, Some(45)); + assert_eq!(updated.input_tokens, Some(45)); + assert_eq!(updated.output_tokens, Some(0)); + assert_eq!(updated.accumulated_total_tokens, Some(465)); + assert_eq!(updated.accumulated_input_tokens, Some(320)); + assert_eq!(updated.accumulated_output_tokens, Some(145)); + + SessionManager::delete_session(&session.id) + .await + .expect("清理测试会话失败"); + } + + #[tokio::test] + async fn update_compaction_session_metrics_should_reset_current_window_when_usage_tokens_missing( + ) { + ensure_runtime_turn_test_session_manager().await; + + let session = SessionManager::create_session( + PathBuf::from("."), + "压缩统计缺字段测试".to_string(), + SessionType::User, + ) + .await + .expect("创建测试会话失败"); + + SessionManager::update_session(&session.id) + .schedule_id(Some("job-before".to_string())) + .total_tokens(Some(180)) + .input_tokens(Some(120)) + .output_tokens(Some(60)) + .accumulated_total_tokens(Some(700)) + .accumulated_input_tokens(Some(500)) + .accumulated_output_tokens(Some(200)) + .apply() + .await + .expect("预置 token 统计失败"); + + let mut session_config = SessionConfigBuilder::new(&session.id).build(); + session_config.schedule_id = Some("job-compact-missing".to_string()); + + let usage = ProviderUsage::new("gpt-4.1".to_string(), Usage::default()); + + update_compaction_session_metrics(&session_config, &usage) + .await + .expect("更新压缩 token 统计失败"); + + let updated = SessionManager::get_session(&session.id, false) + .await + .expect("读取更新后的会话失败"); + + assert_eq!(updated.schedule_id.as_deref(), Some("job-compact-missing")); + assert_eq!(updated.total_tokens, Some(0)); + assert_eq!(updated.input_tokens, Some(0)); + assert_eq!(updated.output_tokens, Some(0)); + assert_eq!(updated.accumulated_total_tokens, Some(700)); + assert_eq!(updated.accumulated_input_tokens, Some(500)); + assert_eq!(updated.accumulated_output_tokens, Some(200)); + + SessionManager::delete_session(&session.id) + .await + .expect("清理测试会话失败"); + } + + #[tokio::test] + async fn update_compaction_session_metrics_should_preserve_existing_schedule_id_when_request_is_empty( + ) { + ensure_runtime_turn_test_session_manager().await; + + let session = SessionManager::create_session( + PathBuf::from("."), + "压缩统计保留任务测试".to_string(), + SessionType::User, + ) + .await + .expect("创建测试会话失败"); + + SessionManager::update_session(&session.id) + .schedule_id(Some("job-existing".to_string())) + .total_tokens(Some(20)) + .input_tokens(Some(10)) + .output_tokens(Some(10)) + .accumulated_total_tokens(Some(200)) + .accumulated_input_tokens(Some(120)) + .accumulated_output_tokens(Some(80)) + .apply() + .await + .expect("预置 token 统计失败"); + + let session_config = SessionConfigBuilder::new(&session.id).build(); + let usage = ProviderUsage::new( + "gpt-4.1".to_string(), + Usage::new(Some(30), Some(15), Some(45)), + ); + + update_compaction_session_metrics(&session_config, &usage) + .await + .expect("更新压缩 token 统计失败"); + + let updated = SessionManager::get_session(&session.id, false) + .await + .expect("读取更新后的会话失败"); + + assert_eq!(updated.schedule_id.as_deref(), Some("job-existing")); + assert_eq!(updated.total_tokens, Some(15)); + assert_eq!(updated.input_tokens, Some(15)); + assert_eq!(updated.output_tokens, Some(0)); + assert_eq!(updated.accumulated_total_tokens, Some(245)); + assert_eq!(updated.accumulated_input_tokens, Some(150)); + assert_eq!(updated.accumulated_output_tokens, Some(95)); + + SessionManager::delete_session(&session.id) + .await + .expect("清理测试会话失败"); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs index 38baa5cd9..cbe7704a5 100644 --- a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs @@ -46,15 +46,10 @@ pub(crate) async fn persist_session_provider_routing( 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 session = read_session(session_id, false, "读取会话 provider 路由上下文失败").await?; 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}"))?; + persist_session_extension_data(session_id, extension_data, "持久化会话 provider 路由上下文") + .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 index 44f8f9593..0952363da 100644 --- a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -1,4 +1,5 @@ use super::*; +use lime_agent::restore_aster_runtime_queued_turns; const SUBAGENT_RUNTIME_EVENT_PREFIX: &str = "agent_subagent_stream"; const SUBAGENT_STATUS_EVENT_PREFIX: &str = "agent_subagent_status"; @@ -338,38 +339,6 @@ fn should_emit_subagent_status_for_runtime_event(event: &TauriAgentEvent) -> boo ) } -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, @@ -454,9 +423,8 @@ fn normalize_wait_timeout_ms(timeout_ms: Option) -> Result { } 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 child_sessions = + list_child_subagent_sessions(parent_session_id, "读取 team child sessions 失败").await?; let mut active_count = 0usize; for child_session in child_sessions { @@ -479,9 +447,7 @@ pub(crate) fn subagent_counts_toward_team_limit(status: SubagentRuntimeStatusKin } 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}"))?; + let parent_session = read_session(parent_session_id, false, "读取父会话失败").await?; if parent_session.session_type == SessionType::SubAgent { return Err( @@ -526,15 +492,9 @@ async fn restore_stashed_subagent_queue( 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(()) + restore_aster_runtime_queued_turns(queued_turns) + .await + .map_err(|error| format!("恢复 subagent queued turn 失败: {error}")) } async fn inherit_subagent_provider( @@ -543,9 +503,8 @@ async fn inherit_subagent_provider( 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_session = + read_session(parent_session_id, false, "读取父会话 provider 信息失败").await?; let parent_provider_selector = resolve_session_provider_selector(&parent_session) .or_else(|| normalize_optional_text(parent_session.provider_name.clone())); @@ -599,9 +558,7 @@ async fn create_runtime_subagent_session( 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 parent_session = read_session(&parent_session_id, false, "读取父会话失败").await?; let customization = build_subagent_customization_state(request)?; let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?; let profile_name = customization @@ -609,7 +566,7 @@ async fn create_runtime_subagent_session( .and_then(|state| state.profile_name.as_deref()); let role_hint = resolve_subagent_role_hint(request, customization.as_ref()); - let session = SessionManager::create_session( + let session = create_subagent_session( parent_session.working_dir.clone(), build_subagent_session_name( &message, @@ -619,10 +576,8 @@ async fn create_runtime_subagent_session( .and_then(|state| state.blueprint_role_label.as_deref()), profile_name, ), - SessionType::SubAgent, ) - .await - .map_err(|error| format!("创建 subagent session 失败: {error}"))?; + .await?; if let Some(parent_metadata) = AsterAgentWrapper::get_persisted_session_metadata_sync(&runtime.db, &parent_session_id)? @@ -652,11 +607,12 @@ async fn create_runtime_subagent_session( .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}"))?; + persist_session_extension_data( + &session.id, + extension_data, + "写入 subagent session metadata", + ) + .await?; inherit_subagent_provider( runtime, diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs index ee106a5af..aaf5d793d 100644 --- a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs +++ b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs @@ -19,9 +19,13 @@ pub(super) async fn try_handle( cmd, "agent_runtime_submit_turn" | "agent_runtime_interrupt_turn" + | "agent_runtime_compact_session" + | "agent_runtime_resume_thread" | "agent_runtime_create_session" | "agent_runtime_list_sessions" | "agent_runtime_get_session" + | "agent_runtime_get_thread_read" + | "agent_runtime_replay_request" | "agent_runtime_update_session" | "agent_runtime_delete_session" | "agent_runtime_promote_queued_turn" @@ -77,6 +81,51 @@ pub(super) async fn try_handle( .await?, )? } + "agent_runtime_compact_session" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeCompactSessionRequest, + >(args)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + crate::commands::aster_agent_cmd::agent_runtime_compact_session( + app_handle.clone(), + aster_state, + db, + request, + ) + .await?; + JsonValue::Null + } + "agent_runtime_resume_thread" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeResumeThreadRequest, + >(args)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_resume_thread( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?, + )? + } "agent_runtime_create_session" => { let args = args_or_default(args); let workspace_id = get_string_arg(&args, "workspaceId", "workspace_id")?; @@ -143,6 +192,45 @@ pub(super) async fn try_handle( .await?, )? } + "agent_runtime_get_thread_read" => { + let args = args_or_default(args); + let session_id = get_string_arg(&args, "sessionId", "session_id")?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_get_thread_read( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + session_id, + ) + .await?, + )? + } + "agent_runtime_replay_request" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeReplayRequestRequest, + >(args)?; + let db = app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_replay_request(db, request).await?, + )? + } "agent_runtime_update_session" => { let request = parse_request::< crate::commands::aster_agent_cmd::AgentRuntimeUpdateSessionRequest, diff --git a/src-tauri/src/services/agent_timeline_service.rs b/src-tauri/src/services/agent_timeline_service.rs index 4b628e5f5..da499988c 100644 --- a/src-tauri/src/services/agent_timeline_service.rs +++ b/src-tauri/src/services/agent_timeline_service.rs @@ -171,6 +171,40 @@ impl AgentTimelineRecorder { self.persist_and_emit_item(app, event_name, item)?; } TauriAgentEvent::ActionRequired { .. } => {} + TauriAgentEvent::ContextCompactionStarted { + item_id, + trigger, + detail, + } => { + let item = self.build_item( + item_id.clone(), + AgentThreadItemStatus::InProgress, + None, + AgentThreadItemPayload::ContextCompaction { + stage: "started".to_string(), + trigger: Some(trigger.clone()), + detail: detail.clone(), + }, + ); + self.persist_and_emit_item(app, event_name, item)?; + } + TauriAgentEvent::ContextCompactionCompleted { + item_id, + trigger, + detail, + } => { + let item = self.build_item( + item_id.clone(), + AgentThreadItemStatus::Completed, + Some(Utc::now().to_rfc3339()), + AgentThreadItemPayload::ContextCompaction { + stage: "completed".to_string(), + trigger: Some(trigger.clone()), + detail: detail.clone(), + }, + ); + self.persist_and_emit_item(app, event_name, item)?; + } TauriAgentEvent::Warning { code, message } => { let item = self.build_item( format!("warning:{}:{}", self.turn_id, self.sequence_counter + 1), diff --git a/src-tauri/src/services/memory_source_resolver_service.rs b/src-tauri/src/services/memory_source_resolver_service.rs index 9613601f2..bf353d166 100644 --- a/src-tauri/src/services/memory_source_resolver_service.rs +++ b/src-tauri/src/services/memory_source_resolver_service.rs @@ -325,7 +325,9 @@ pub fn build_memory_sources_prompt_with_options( 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.insert(normalize_path( + &app_paths::resolve_workspace_runtime_agents_path(working_dir), + )); paths } @@ -1059,18 +1061,24 @@ mod tests { #[test] fn workspace_local_instruction_path_should_not_walk_ancestors() { + let _env_lock = durable_memory_env_lock().lock().expect("lock env"); let tmp = TempDir::new().expect("create temp dir"); let project_root = tmp.path().join("repo"); let nested = project_root.join("workspace"); + let empty_durable_root = tmp.path().join("durable-empty"); 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::create_dir_all(&empty_durable_root).expect("create empty durable root"); 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 _durable_guard = DurableMemoryEnvGuard::set(&empty_durable_root); let mut cfg = Config::default(); cfg.memory.enabled = true; + cfg.memory.sources.managed_policy_path = Some("missing-managed.md".to_string()); + cfg.memory.sources.user_memory_path = Some("missing-user.md".to_string()); cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()]; let resolved = resolve_effective_sources(&cfg, &nested, None); diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index d3845e76e..9b28a38ed 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -24,6 +24,7 @@ pub mod novel_service; pub mod openclaw_service; pub mod runtime_agents_template_service; pub mod sysinfo_service; +pub mod thread_reliability_projection_service; pub mod update_check_service; pub mod update_window; pub mod web_search_prompt_service; diff --git a/src-tauri/src/services/openclaw_service.rs b/src-tauri/src/services/openclaw_service.rs deleted file mode 100644 index 68793b6e0..000000000 --- a/src-tauri/src/services/openclaw_service.rs +++ /dev/null @@ -1,6579 +0,0 @@ -use crate::app::AppState; -use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType}; -use dirs::{data_dir, home_dir}; -use lime_core::openclaw_install::{ - build_openclaw_cleanup_command as core_build_openclaw_cleanup_command, - build_openclaw_install_command as core_build_openclaw_install_command, - build_winget_install_command as core_build_winget_install_command, - command_bin_dir_for as core_command_bin_dir_for, - resolve_windows_dependency_install_plan as core_resolve_windows_dependency_install_plan, - select_best_semver_candidate as core_select_best_semver_candidate, - select_preferred_path_candidate as core_select_preferred_path_candidate, - shell_command_escape_for as core_shell_command_escape_for, - shell_command_invocation_prefix_for as core_shell_command_invocation_prefix_for, - shell_npm_prefix_assignment_for as core_shell_npm_prefix_assignment_for, - shell_path_assignment_for as core_shell_path_assignment_for, - windows_manual_install_message as core_windows_manual_install_message, - OpenClawInstallDependencyKind, ShellPlatform, WindowsDependencyInstallPlan, -}; -use rand::{distributions::Alphanumeric, Rng}; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; -use std::cmp::Ordering; -use std::collections::{HashSet, VecDeque}; -use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::{Arc, Mutex as StdMutex, OnceLock}; -use std::time::SystemTime; -use sysinfo::{Pid, Signal, System}; -use tauri::{AppHandle, Emitter, Manager}; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader}; -use tokio::net::TcpStream; -use tokio::process::{Child, Command}; -use tokio::sync::Mutex; -use tokio::time::{sleep, timeout, Duration}; -#[cfg(target_os = "windows")] -use winapi::shared::minwindef::{DWORD, HKEY}; -#[cfg(target_os = "windows")] -use winapi::shared::winerror::ERROR_SUCCESS; -#[cfg(target_os = "windows")] -use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; - -const DEFAULT_GATEWAY_PORT: u16 = 18790; -const OPENCLAW_INSTALL_EVENT: &str = "openclaw:install-progress"; -const OPENCLAW_CONFIG_ENV: &str = "OPENCLAW_CONFIG_PATH"; - -fn shell_escape(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\"'\"'")) -} -const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest"; -const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest"; -const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com"; -const NODE_MIN_VERSION: (u64, u64, u64) = (22, 12, 0); -const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400; -const OPENCLAW_INSTALLER_USER_AGENT: &str = "Lime-OpenClaw"; -#[cfg(not(target_os = "windows"))] -const OPENCLAW_TEMP_CARGO_CHECK_DIR: &str = "/tmp/lime-cargo-check"; -#[cfg(target_os = "windows")] -const CREATE_NO_WINDOW: u32 = 0x08000000; - -static OPENCLAW_PREFERRED_RUNTIME_BIN_DIR: OnceLock>> = OnceLock::new(); - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BinaryInstallStatus { - pub installed: bool, - pub path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BinaryAvailabilityStatus { - pub available: bool, - pub path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NodeCheckResult { - pub status: String, - pub version: Option, - pub path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ActionResult { - pub success: bool, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DependencyStatus { - pub status: String, - pub version: Option, - pub path: Option, - pub message: String, - pub auto_install_supported: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EnvironmentStatus { - pub node: DependencyStatus, - pub git: DependencyStatus, - pub openclaw: DependencyStatus, - pub recommended_action: String, - pub summary: String, - #[serde(default)] - pub diagnostics: EnvironmentDiagnostics, - #[serde(default)] - pub temp_artifacts: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct EnvironmentDiagnostics { - pub npm_path: Option, - pub npm_global_prefix: Option, - pub openclaw_package_path: Option, - #[serde(default)] - pub where_candidates: Vec, - #[serde(default)] - pub supplemental_search_dirs: Vec, - #[serde(default)] - pub supplemental_command_candidates: Vec, - #[serde(default)] - pub git_where_candidates: Vec, - #[serde(default)] - pub git_supplemental_search_dirs: Vec, - #[serde(default)] - pub git_supplemental_command_candidates: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CommandPreview { - pub title: String, - pub command: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayStatusInfo { - pub status: GatewayStatus, - pub port: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum GatewayStatus { - Stopped, - Starting, - Running, - Error, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HealthInfo { - pub status: String, - pub gateway_port: u16, - pub uptime: Option, - pub version: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateInfo { - pub has_update: bool, - pub current_version: Option, - pub latest_version: Option, - pub channel: Option, - pub install_kind: Option, - pub package_manager: Option, - pub message: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenClawRuntimeCandidate { - pub id: String, - pub source: String, - pub bin_dir: String, - pub node_path: String, - pub node_version: Option, - pub npm_path: Option, - pub npm_global_prefix: Option, - pub openclaw_path: Option, - pub openclaw_version: Option, - pub openclaw_package_path: Option, - pub is_active: bool, - pub is_preferred: bool, -} - -#[derive(Debug, Default, Clone)] -struct OpenClawUpdateExecutionContext { - root: Option, - install_kind: Option, - package_manager: Option, -} - -#[derive(Debug, Clone)] -struct OpenClawDirectUpgradePlan { - runtime_source: String, - runtime_bin_dir: PathBuf, - package_manager: String, - package_spec: String, - command_line: String, -} - -#[derive(Debug, Clone)] -struct OpenClawDirectUpgradeResult { - runtime_source: String, - runtime_bin_dir: PathBuf, - package_manager: String, - package_spec: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ResolvedOpenClawCommand { - Binary { - binary_path: PathBuf, - }, - NodeCli { - node_path: PathBuf, - cli_path: PathBuf, - package_version: Option, - }, -} - -impl ResolvedOpenClawCommand { - fn build_command_with_args(&self, args: I) -> Command - where - I: IntoIterator, - S: AsRef, - { - let command_path = self.command_path(); - let command_path_string = command_path.to_string_lossy().to_string(); - let mut command = Command::new(command_path); - apply_binary_runtime_path(&mut command, &command_path_string); - - if let Self::NodeCli { cli_path, .. } = self { - command.arg(cli_path); - } - - command.args(args); - - command - } - - fn command_path(&self) -> &Path { - match self { - Self::Binary { binary_path } => binary_path.as_path(), - Self::NodeCli { node_path, .. } => node_path.as_path(), - } - } - - fn install_path_display(&self) -> String { - match self { - Self::Binary { binary_path } => binary_path.display().to_string(), - Self::NodeCli { cli_path, .. } => cli_path.display().to_string(), - } - } - - fn invocation_display(&self) -> String { - match self { - Self::Binary { binary_path } => binary_path.display().to_string(), - Self::NodeCli { - node_path, - cli_path, - .. - } => { - format!("{} {}", node_path.display(), cli_path.display()) - } - } - } - - fn preview_invocation(&self) -> String { - match self { - Self::Binary { binary_path } => shell_escape(binary_path.to_string_lossy().as_ref()), - Self::NodeCli { - node_path, - cli_path, - .. - } => format!( - "{} {}", - shell_escape(node_path.to_string_lossy().as_ref()), - shell_escape(cli_path.to_string_lossy().as_ref()) - ), - } - } - - fn fallback_version(&self) -> Option { - match self { - Self::Binary { .. } => None, - Self::NodeCli { - package_version, .. - } => package_version.clone(), - } - } - - fn dedupe_key(&self) -> String { - match self { - Self::Binary { binary_path } => format!("binary:{}", binary_path.display()), - Self::NodeCli { - node_path, - cli_path, - .. - } => format!("node:{}:{}", node_path.display(), cli_path.display()), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChannelInfo { - pub id: String, - pub name: String, - pub channel_type: String, - pub status: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallProgressEvent { - pub message: String, - pub level: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SyncModelEntry { - pub id: String, - pub name: String, - pub context_window: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DependencyKind { - Node, - Git, -} - -impl DependencyKind { - fn label(self) -> &'static str { - match self { - Self::Node => "Node.js", - Self::Git => "Git", - } - } -} - -#[derive(Debug, Clone)] -struct InstallerAsset { - filename: String, - download_url: String, -} - -#[derive(Debug)] -pub struct OpenClawService { - gateway_process: Option, - gateway_status: GatewayStatus, - gateway_port: u16, - gateway_auth_token: String, - gateway_started_at: Option, - progress_logs: VecDeque, -} - -impl Default for OpenClawService { - fn default() -> Self { - Self { - gateway_process: None, - gateway_status: GatewayStatus::Stopped, - gateway_port: DEFAULT_GATEWAY_PORT, - gateway_auth_token: String::new(), - gateway_started_at: None, - progress_logs: VecDeque::new(), - } - } -} - -pub struct OpenClawServiceState(pub std::sync::Arc>); - -impl Default for OpenClawServiceState { - fn default() -> Self { - Self(std::sync::Arc::new(Mutex::new(OpenClawService::default()))) - } -} - -impl OpenClawService { - pub fn clear_progress_logs(&mut self) { - self.progress_logs.clear(); - } - - pub fn get_progress_logs(&self) -> Vec { - self.progress_logs.iter().cloned().collect() - } - - fn clear_gateway_runtime_state(&mut self) { - self.gateway_process = None; - self.gateway_started_at = None; - self.gateway_status = GatewayStatus::Stopped; - } - - pub async fn list_runtime_candidates(&self) -> Result, String> { - list_openclaw_runtime_candidates().await - } - - pub async fn set_preferred_runtime( - &self, - runtime_id: Option<&str>, - ) -> Result { - let normalized = runtime_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(PathBuf::from); - - let Some(runtime_bin_dir) = normalized else { - set_preferred_runtime_bin_dir(None); - return Ok(ActionResult { - success: true, - message: "已切换为自动选择执行环境。".to_string(), - }); - }; - - let candidates = list_openclaw_runtime_candidates().await?; - let runtime_id = runtime_bin_dir.display().to_string(); - let Some(candidate) = candidates.iter().find(|item| item.id == runtime_id) else { - return Ok(ActionResult { - success: false, - message: "未找到指定的 OpenClaw 执行环境,请重新检测后再试。".to_string(), - }); - }; - - set_preferred_runtime_bin_dir(Some(runtime_bin_dir)); - Ok(ActionResult { - success: true, - message: format!( - "已固定使用执行环境:{}{}。", - candidate.source, - candidate - .node_version - .as_deref() - .map(|version| format!(" · Node {version}")) - .unwrap_or_default() - ), - }) - } - - fn push_progress_log(&mut self, message: String, level: String) { - if self.progress_logs.len() >= OPENCLAW_PROGRESS_LOG_LIMIT { - self.progress_logs.pop_front(); - } - self.progress_logs - .push_back(InstallProgressEvent { message, level }); - } - - pub async fn get_command_preview( - &mut self, - app: &AppHandle, - operation: &str, - port: Option, - ) -> Result { - match operation { - "install" => self.build_install_command_preview(app).await, - "uninstall" => self.build_uninstall_command_preview().await, - "restart" => self.build_restart_command_preview(port).await, - "start" => self.build_start_command_preview(port).await, - "stop" => self.build_stop_command_preview(port).await, - _ => Err(format!("不支持的 OpenClaw 操作预览: {operation}")), - } - } - - pub async fn get_environment_status(&self) -> Result { - let node = inspect_node_dependency_status().await?; - let git = inspect_git_dependency_status().await?; - let openclaw = inspect_openclaw_dependency_status().await?; - let diagnostics = collect_environment_diagnostics().await; - - Ok(build_environment_status(node, git, openclaw, diagnostics)) - } - - pub async fn check_installed(&self) -> Result { - let openclaw = inspect_openclaw_dependency_status().await?; - Ok(BinaryInstallStatus { - installed: openclaw.status == "ok", - path: openclaw.path, - }) - } - - pub async fn check_git_available(&self) -> Result { - let git = inspect_git_dependency_status().await?; - Ok(BinaryAvailabilityStatus { - available: git.status == "ok", - path: git.path, - }) - } - - pub async fn check_node_version(&self) -> Result { - let node = inspect_node_dependency_status().await?; - Ok(NodeCheckResult { - status: match node.status.as_str() { - "missing" => "not_found".to_string(), - other => other.to_string(), - }, - version: node.version, - path: node.path, - }) - } - - pub fn get_node_download_url(&self) -> String { - if cfg!(target_os = "windows") { - "https://nodejs.org/en/download".to_string() - } else if cfg!(target_os = "macos") { - "https://nodejs.org/en/download".to_string() - } else if cfg!(target_os = "linux") { - "https://nodejs.org/en/download".to_string() - } else { - "https://nodejs.org/en/download".to_string() - } - } - - pub fn get_git_download_url(&self) -> String { - if cfg!(target_os = "windows") { - "https://git-scm.com/download/win".to_string() - } else if cfg!(target_os = "macos") { - "https://git-scm.com/download/mac".to_string() - } else if cfg!(target_os = "linux") { - "https://git-scm.com/download/linux".to_string() - } else { - "https://git-scm.com/downloads".to_string() - } - } - - pub async fn install(&mut self, app: &AppHandle) -> Result { - emit_install_progress(app, "开始准备 OpenClaw 环境。", "info"); - - #[cfg(target_os = "windows")] - { - let node_status = self.inspect_dependency_status(DependencyKind::Node).await?; - let git_status = self.inspect_dependency_status(DependencyKind::Git).await?; - if let Some(result) = windows_install_block_result(&node_status, &git_status) { - emit_install_progress(app, &result.message, "warn"); - return Ok(result); - } - } - - let node_result = self - .ensure_dependency_ready(app, DependencyKind::Node) - .await?; - if !node_result.success { - return Ok(node_result); - } - - let git_result = self - .ensure_dependency_ready(app, DependencyKind::Git) - .await?; - if !git_result.success { - return Ok(git_result); - } - - let (_, npm_path, npm_prefix, cleanup_command, install_command) = - self.resolve_install_commands(app).await?; - - emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); - if let Some(prefix) = npm_prefix { - emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info"); - } - emit_install_progress(app, "安装前先清理已有 OpenClaw 全局包。", "info"); - let cleanup_result = run_shell_command_with_progress(app, &cleanup_command).await?; - if !cleanup_result.success { - emit_install_progress( - app, - &format!( - "清理旧版 OpenClaw 失败,继续尝试安装:{}", - cleanup_result.message - ), - "warn", - ); - } - - emit_install_progress(app, &format!("执行安装命令: {install_command}"), "info"); - let result = run_shell_command_with_progress(app, &install_command).await?; - if !result.success { - return Ok(result); - } - - let installed = self.check_installed().await?; - if installed.installed { - emit_install_progress(app, "已检测到 OpenClaw 可执行文件。", "info"); - return Ok(ActionResult { - success: true, - message: installed - .path - .map(|path| format!("OpenClaw 安装完成:{path}")) - .unwrap_or_else(|| "OpenClaw 安装完成。".to_string()), - }); - } - - Ok(ActionResult { - success: false, - message: - "安装命令执行完成,但仍未检测到 OpenClaw 可执行文件,请检查 npm 全局目录或权限设置。" - .to_string(), - }) - } - - pub async fn install_dependency( - &mut self, - app: &AppHandle, - kind: &str, - ) -> Result { - let dependency = match kind { - "node" => DependencyKind::Node, - "git" => DependencyKind::Git, - _ => return Err(format!("不支持的依赖类型: {kind}")), - }; - - #[cfg(target_os = "windows")] - { - let status = self.inspect_dependency_status(dependency).await?; - if status.status == "ok" { - emit_install_progress( - app, - &format!( - "{} 已就绪{}。", - dependency.label(), - status - .version - .as_deref() - .map(|version| format!(" · {version}")) - .unwrap_or_default() - ), - "info", - ); - return Ok(ActionResult { - success: true, - message: format!("{} 已满足要求。", dependency.label()), - }); - } - - let result = windows_dependency_action_result(dependency, &status); - emit_install_progress(app, &result.message, "warn"); - return Ok(result); - } - - #[cfg(not(target_os = "windows"))] - { - self.ensure_dependency_ready(app, dependency).await - } - } - - pub async fn cleanup_temp_artifacts( - &mut self, - app: Option<&AppHandle>, - ) -> Result { - let mut removed = Vec::new(); - let mut failed = Vec::new(); - - for target in collect_temp_artifact_paths(app) { - if !target.exists() { - continue; - } - - let result = if target.is_dir() { - std::fs::remove_dir_all(&target) - } else { - std::fs::remove_file(&target) - }; - - match result { - Ok(_) => { - if let Some(app) = app { - emit_install_progress( - app, - &format!("已清理临时文件:{}", target.display()), - "info", - ); - } - removed.push(target.display().to_string()); - } - Err(error) => { - if let Some(app) = app { - emit_install_progress( - app, - &format!("清理临时文件失败({}): {error}", target.display()), - "warn", - ); - } - failed.push(format!("{}: {error}", target.display())); - } - } - } - - if failed.is_empty() { - Ok(ActionResult { - success: true, - message: if removed.is_empty() { - "未发现需要清理的 OpenClaw 临时文件。".to_string() - } else { - format!("已清理 {} 项临时文件。", removed.len()) - }, - }) - } else { - Ok(ActionResult { - success: false, - message: format!("部分临时文件清理失败:{}", failed.join(";")), - }) - } - } - - pub async fn uninstall(&mut self, app: &AppHandle) -> Result { - if self.gateway_status == GatewayStatus::Running || self.gateway_process.is_some() { - let _ = self.stop_gateway(None).await; - } - - let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?; - - emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); - if let Some(prefix) = npm_prefix { - emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info"); - } - emit_install_progress(app, &format!("执行卸载命令: {command}"), "info"); - run_shell_command_with_progress(app, &command).await - } - - async fn ensure_dependency_ready( - &mut self, - app: &AppHandle, - dependency: DependencyKind, - ) -> Result { - let status = self.inspect_dependency_status(dependency).await?; - if status.status == "ok" { - emit_install_progress( - app, - &format!( - "{} 已就绪{}。", - dependency.label(), - status - .version - .as_deref() - .map(|version| format!(" · {version}")) - .unwrap_or_default() - ), - "info", - ); - return Ok(ActionResult { - success: true, - message: format!("{} 已满足要求。", dependency.label()), - }); - } - - emit_install_progress( - app, - &format!("{},开始修复 {} 环境。", status.message, dependency.label()), - "warn", - ); - - match dependency { - DependencyKind::Node => self.install_node_runtime(app).await, - DependencyKind::Git => self.install_git_runtime(app).await, - } - } - - async fn inspect_dependency_status( - &self, - dependency: DependencyKind, - ) -> Result { - match dependency { - DependencyKind::Node => inspect_node_dependency_status().await, - DependencyKind::Git => inspect_git_dependency_status().await, - } - } - - async fn install_node_runtime(&mut self, app: &AppHandle) -> Result { - #[cfg(target_os = "windows")] - { - let winget_path = find_command_in_shell("winget").await?; - match resolve_windows_dependency_install_plan( - DependencyKind::Node, - winget_path.is_some(), - ) { - WindowsDependencyInstallPlan::Winget { package_id } => { - let winget_path = winget_path.expect("winget path should exist"); - emit_install_progress( - app, - "检测到 winget,准备通过 winget 安装 Node.js。", - "info", - ); - let command = build_winget_install_command(&winget_path, package_id); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); - } - return self - .verify_dependency_after_install(app, DependencyKind::Node) - .await; - } - WindowsDependencyInstallPlan::OfficialInstaller => { - emit_install_progress( - app, - "未检测到 winget,准备下载官方 Node.js 安装器。", - "warn", - ); - let asset = resolve_node_installer_asset().await?; - let installer_path = download_installer_asset(app, &asset).await?; - launch_installer(&installer_path)?; - return self - .wait_for_dependency_ready(app, DependencyKind::Node, 900) - .await; - } - WindowsDependencyInstallPlan::ManualDownload => { - unreachable!("Node.js 在 Windows 上不应返回手动下载计划") - } - } - } - - #[cfg(target_os = "macos")] - { - if let Some(brew_path) = find_command_in_shell("brew").await? { - emit_install_progress( - app, - "检测到 Homebrew,准备通过 Homebrew 安装 Node.js。", - "info", - ); - let brew_cmd = shell_command_escape(&brew_path); - let path_env = shell_path_assignment(&brew_path); - let command = format!( - "{path_env}{brew_cmd} install node || {path_env}{brew_cmd} upgrade node" - ); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); - } - return self - .verify_dependency_after_install(app, DependencyKind::Node) - .await; - } - - emit_install_progress( - app, - "未检测到 Homebrew,准备下载官方 Node.js 安装器。", - "warn", - ); - let asset = resolve_node_installer_asset().await?; - let installer_path = download_installer_asset(app, &asset).await?; - launch_installer(&installer_path)?; - return self - .wait_for_dependency_ready(app, DependencyKind::Node, 900) - .await; - } - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - { - let message = "当前平台暂不支持应用内自动安装 Node.js,请手动安装 Node.js 22+ 后重试。" - .to_string(); - emit_install_progress(app, &message, "warn"); - Ok(ActionResult { - success: false, - message, - }) - } - } - - async fn install_git_runtime(&mut self, app: &AppHandle) -> Result { - #[cfg(target_os = "windows")] - { - let winget_path = find_command_in_shell("winget").await?; - match resolve_windows_dependency_install_plan( - DependencyKind::Git, - winget_path.is_some(), - ) { - WindowsDependencyInstallPlan::Winget { package_id } => { - let winget_path = winget_path.expect("winget path should exist"); - emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Git。", "info"); - let command = build_winget_install_command(&winget_path, package_id); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); - } - return self - .verify_dependency_after_install(app, DependencyKind::Git) - .await; - } - WindowsDependencyInstallPlan::OfficialInstaller => { - unreachable!("Git 在 Windows 上不应返回官方安装器计划") - } - WindowsDependencyInstallPlan::ManualDownload => { - let message = windows_manual_install_message(DependencyKind::Git).to_string(); - emit_install_progress(app, &message, "warn"); - return Ok(ActionResult { - success: false, - message, - }); - } - } - } - - #[cfg(target_os = "macos")] - { - if let Some(brew_path) = find_command_in_shell("brew").await? { - emit_install_progress(app, "检测到 Homebrew,准备通过 Homebrew 安装 Git。", "info"); - let brew_cmd = shell_command_escape(&brew_path); - let path_env = shell_path_assignment(&brew_path); - let command = - format!("{path_env}{brew_cmd} install git || {path_env}{brew_cmd} upgrade git"); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); - } - return self - .verify_dependency_after_install(app, DependencyKind::Git) - .await; - } - - emit_install_progress( - app, - "未检测到 Homebrew,准备拉起 macOS Command Line Tools 安装器。", - "warn", - ); - let trigger_result = trigger_macos_command_line_tools_install().await?; - emit_install_progress(app, &trigger_result, "info"); - return self - .wait_for_dependency_ready(app, DependencyKind::Git, 1200) - .await; - } - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - { - let message = "当前平台暂不支持应用内自动安装 Git,请使用系统包管理器手动安装后重试。" - .to_string(); - emit_install_progress(app, &message, "warn"); - Ok(ActionResult { - success: false, - message, - }) - } - } - - async fn verify_dependency_after_install( - &self, - app: &AppHandle, - dependency: DependencyKind, - ) -> Result { - // 在 Windows 上刷新 PATH 环境变量 - #[cfg(target_os = "windows")] - { - if let Err(e) = refresh_windows_path_from_registry() { - emit_install_progress(app, &format!("刷新环境变量失败: {}", e), "warn"); - } else { - emit_install_progress(app, "已刷新系统环境变量。", "info"); - } - } - - let status = self.inspect_dependency_status(dependency).await?; - if status.status == "ok" { - emit_install_progress( - app, - &format!( - "{} 已准备完成{}。", - dependency.label(), - status - .version - .as_deref() - .map(|version| format!(" · {version}")) - .unwrap_or_default() - ), - "info", - ); - return Ok(ActionResult { - success: true, - message: format!("{} 已安装完成。", dependency.label()), - }); - } - - Ok(ActionResult { - success: false, - message: format!( - "{} 安装完成后仍未通过校验:{}", - dependency.label(), - status.message - ), - }) - } - - async fn wait_for_dependency_ready( - &self, - app: &AppHandle, - dependency: DependencyKind, - timeout_secs: u64, - ) -> Result { - emit_install_progress( - app, - &format!( - "已拉起 {} 安装器,正在等待安装完成(最长 {} 秒)。", - dependency.label(), - timeout_secs - ), - "info", - ); - - let start = tokio::time::Instant::now(); - let mut last_notice_at = 0_u64; - #[cfg(target_os = "windows")] - let mut last_refresh_at = 0_u64; - - while start.elapsed() < Duration::from_secs(timeout_secs) { - let elapsed = start.elapsed().as_secs(); - - // 每 10 秒刷新一次 Windows PATH(因为用户可能在安装过程中) - #[cfg(target_os = "windows")] - if elapsed >= last_refresh_at + 10 { - last_refresh_at = elapsed; - let _ = refresh_windows_path_from_registry(); - } - - if elapsed >= last_notice_at + 15 { - last_notice_at = elapsed; - emit_install_progress( - app, - &format!("正在等待 {} 安装完成…", dependency.label()), - "info", - ); - } - - sleep(Duration::from_secs(2)).await; - let status = self.inspect_dependency_status(dependency).await?; - if status.status == "ok" { - emit_install_progress( - app, - &format!( - "{} 已检测通过{}。", - dependency.label(), - status - .version - .as_deref() - .map(|version| format!(" · {version}")) - .unwrap_or_default() - ), - "info", - ); - return Ok(ActionResult { - success: true, - message: format!("{} 已安装完成。", dependency.label()), - }); - } - } - - Ok(ActionResult { - success: false, - message: format!( - "等待 {} 安装完成超时,请完成安装后重新点击重试。", - dependency.label() - ), - }) - } - - pub async fn start_gateway( - &mut self, - app: Option<&AppHandle>, - port: Option, - ) -> Result { - if let Some(next_port) = port { - self.gateway_port = next_port.max(1); - } - - if let Some(app) = app { - emit_install_progress( - app, - &format!("准备启动 Gateway,目标端口 {}。", self.gateway_port), - "info", - ); - } - - self.ensure_runtime_config(None, None)?; - self.refresh_process_state().await?; - - if self.gateway_status == GatewayStatus::Running { - if let Some(app) = app { - emit_install_progress( - app, - &format!("检测到 Gateway 已在端口 {} 运行。", self.gateway_port), - "info", - ); - } - return Ok(ActionResult { - success: true, - message: format!("Gateway 已在端口 {} 运行", self.gateway_port), - }); - } - - let Some(openclaw_command) = resolve_openclaw_command().await? else { - self.gateway_status = GatewayStatus::Error; - if let Some(app) = app { - emit_install_progress(app, "未检测到 OpenClaw 可执行文件,请先安装。", "error"); - } - return Ok(ActionResult { - success: false, - message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(), - }); - }; - - self.gateway_status = GatewayStatus::Starting; - - let config_path = openclaw_lime_config_path(); - if let Some(app) = app { - emit_install_progress( - app, - &format!("使用配置文件启动 Gateway: {}", config_path.display()), - "info", - ); - } - let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token); - let mut command = openclaw_command.build_command_with_args(&start_args); - command - .env(OPENCLAW_CONFIG_ENV, &config_path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let mut child = command - .spawn() - .map_err(|e| format!("启动 Gateway 失败: {e}"))?; - - let gateway_error_lines = Arc::new(StdMutex::new(Vec::::new())); - - if let Some(stdout) = child.stdout.take() { - let app = app.cloned(); - tokio::spawn(async move { - let mut lines = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::info!(target: "openclaw", "Gateway stdout: {}", line); - if let Some(app) = app.as_ref() { - emit_install_progress(app, &line, classify_progress_level(&line, "info")); - } - } - }); - } - - if let Some(stderr) = child.stderr.take() { - let app = app.cloned(); - let gateway_error_lines = gateway_error_lines.clone(); - tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::warn!(target: "openclaw", "Gateway stderr: {}", line); - if let Ok(mut slot) = gateway_error_lines.lock() { - push_gateway_error_line(&mut slot, &line); - } - if let Some(app) = app.as_ref() { - emit_install_progress(app, &line, classify_progress_level(&line, "warn")); - } - } - }); - } - - self.gateway_process = Some(child); - self.gateway_started_at = Some(SystemTime::now()); - - if let Some(app) = app { - emit_install_progress(app, "Gateway 进程已拉起,等待服务就绪。", "info"); - } - - let start_at = tokio::time::Instant::now(); - while start_at.elapsed() < Duration::from_secs(30) { - sleep(Duration::from_millis(300)).await; - self.refresh_process_state().await?; - let gateway_error_lines = gateway_error_lines - .lock() - .ok() - .map(|slot| slot.clone()) - .unwrap_or_default(); - - if self.gateway_process.is_none() && self.gateway_status == GatewayStatus::Error { - let message = format_gateway_start_failure_message( - select_gateway_start_failure_detail(&gateway_error_lines), - ); - if let Some(app) = app { - emit_install_progress(app, &message, "error"); - } - return Ok(ActionResult { - success: false, - message, - }); - } - - if self.gateway_status == GatewayStatus::Running { - if let Some(app) = app { - emit_install_progress( - app, - &format!("Gateway 启动成功,监听端口 {}。", self.gateway_port), - "info", - ); - } - return Ok(ActionResult { - success: true, - message: format!("Gateway 已启动,端口 {}", self.gateway_port), - }); - } - - if self.check_port_open().await { - self.gateway_status = GatewayStatus::Running; - if let Some(app) = app { - emit_install_progress( - app, - &format!("Gateway 探测成功,监听端口 {}。", self.gateway_port), - "info", - ); - } - return Ok(ActionResult { - success: true, - message: format!("Gateway 已启动,端口 {}", self.gateway_port), - }); - } - } - - self.gateway_status = GatewayStatus::Error; - let gateway_error_lines = gateway_error_lines - .lock() - .ok() - .map(|slot| slot.clone()) - .unwrap_or_default(); - let message = format_gateway_start_failure_message(select_gateway_start_failure_detail( - &gateway_error_lines, - )); - if let Some(app) = app { - emit_install_progress(app, &message, "error"); - } - Ok(ActionResult { - success: false, - message, - }) - } - - pub async fn stop_gateway(&mut self, app: Option<&AppHandle>) -> Result { - if let Some(app) = app { - emit_install_progress(app, "准备停止 Gateway。", "info"); - } - - self.restore_auth_token_from_config(); - - if let Some(mut child) = self.gateway_process.take() { - if let Some(app) = app { - emit_install_progress(app, "正在终止当前托管的 Gateway 子进程。", "info"); - } - let _ = child.kill().await; - let _ = timeout(Duration::from_secs(3), child.wait()).await; - } - - if self - .wait_for_gateway_shutdown(Duration::from_secs(3)) - .await? - { - if let Some(app) = app { - emit_install_progress(app, "Gateway 已停止。", "info"); - } - return Ok(ActionResult { - success: true, - message: "Gateway 已停止。".to_string(), - }); - } - - let stop_commands = self.collect_gateway_stop_commands().await?; - if stop_commands.is_empty() { - if let Some(app) = app { - emit_install_progress( - app, - "未检测到可用的 OpenClaw 停止命令,将尝试按端口回收旧 Gateway。", - "warn", - ); - } - } else { - for command_spec in &stop_commands { - self.request_gateway_stop_via_command(command_spec, app) - .await; - if self - .wait_for_gateway_shutdown(Duration::from_secs(4)) - .await? - { - if let Some(app) = app { - emit_install_progress(app, "Gateway 已停止。", "info"); - } - return Ok(ActionResult { - success: true, - message: "Gateway 已停止。".to_string(), - }); - } - } - } - - let reclaimed_by_pid = self.force_stop_gateway_listener_processes(app).await?; - if reclaimed_by_pid - && self - .wait_for_gateway_shutdown(Duration::from_secs(5)) - .await? - { - if let Some(app) = app { - emit_install_progress(app, "Gateway 已停止。", "info"); - } - return Ok(ActionResult { - success: true, - message: "Gateway 已停止。".to_string(), - }); - } - - self.refresh_process_state().await?; - let message = if self.check_port_open().await { - format!( - "Gateway 停止失败:端口 {} 仍被旧进程占用,升级已中止。请使用“立即重启生效”或结束旧 OpenClaw 进程后重试。", - self.gateway_port - ) - } else { - "Gateway 停止流程已结束,但未能确认运行态完全退出,请重试。".to_string() - }; - - if let Some(app) = app { - emit_install_progress(app, &message, "error"); - } - - Ok(ActionResult { - success: false, - message, - }) - } - - pub async fn restart_gateway(&mut self, app: &AppHandle) -> Result { - emit_install_progress(app, "开始重启 Gateway。", "info"); - let stop_result = self.stop_gateway(Some(app)).await?; - if !stop_result.success { - return Ok(stop_result); - } - emit_install_progress(app, "Gateway 停止阶段结束,开始重新启动。", "info"); - self.start_gateway(Some(app), Some(self.gateway_port)).await - } - - pub async fn get_status(&mut self) -> Result { - self.refresh_process_state().await?; - Ok(GatewayStatusInfo { - status: self.gateway_status.clone(), - port: self.gateway_port, - }) - } - - pub async fn check_health(&mut self) -> Result { - self.refresh_process_state().await?; - - self.restore_auth_token_from_config(); - - let health_snapshot = self.fetch_authenticated_gateway_health_json().await; - let healthy = self.gateway_status == GatewayStatus::Running - && self.check_port_open().await - && health_snapshot - .as_ref() - .and_then(|value| value.get("ok").and_then(Value::as_bool)) - .unwrap_or(false); - let version = self.read_openclaw_version().await.ok().flatten(); - let uptime = self.gateway_started_at.and_then(|start| { - SystemTime::now() - .duration_since(start) - .ok() - .map(|elapsed| elapsed.as_secs()) - }); - - Ok(HealthInfo { - status: if healthy { "healthy" } else { "unhealthy" }.to_string(), - gateway_port: self.gateway_port, - uptime, - version, - }) - } - - pub async fn check_update(&self) -> Result { - let Some(openclaw_command) = resolve_openclaw_command().await? else { - return Ok(UpdateInfo { - has_update: false, - current_version: None, - latest_version: None, - channel: None, - install_kind: None, - package_manager: None, - message: Some("未检测到 OpenClaw 可执行文件,请先安装。".to_string()), - }); - }; - - let current_version = self - .read_openclaw_version() - .await? - .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))); - - let payload = match read_openclaw_update_status_payload(&openclaw_command).await { - Ok(payload) => payload, - Err(message) => { - return Ok(UpdateInfo { - has_update: false, - current_version, - latest_version: None, - channel: None, - install_kind: None, - package_manager: None, - message: Some(message), - }); - } - }; - - Ok(UpdateInfo { - has_update: payload - .pointer("/availability/available") - .and_then(Value::as_bool) - .unwrap_or(false), - current_version, - latest_version: payload - .pointer("/availability/latestVersion") - .and_then(Value::as_str) - .map(str::to_string), - channel: payload - .pointer("/channel/label") - .or_else(|| payload.pointer("/channel/value")) - .and_then(Value::as_str) - .map(str::to_string), - install_kind: payload - .pointer("/update/installKind") - .and_then(Value::as_str) - .map(str::to_string), - package_manager: payload - .pointer("/update/packageManager") - .and_then(Value::as_str) - .map(str::to_string), - message: payload - .pointer("/update/registry/error") - .and_then(Value::as_str) - .map(str::to_string), - }) - } - - pub async fn perform_update(&mut self, app: &AppHandle) -> Result { - emit_install_progress(app, "开始执行 OpenClaw 升级。", "info"); - - let Some(openclaw_command) = resolve_openclaw_command().await? else { - return Ok(ActionResult { - success: false, - message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(), - }); - }; - let current_runtime_bin_dir = openclaw_command - .command_path() - .parent() - .map(Path::to_path_buf); - - self.refresh_process_state().await?; - let gateway_was_running = self.gateway_status == GatewayStatus::Running; - if self.gateway_status == GatewayStatus::Running { - emit_install_progress( - app, - "升级前先停止 Gateway,避免占用正在运行的 OpenClaw。", - "info", - ); - let stop_result = self.stop_gateway(Some(app)).await?; - if !stop_result.success { - return Ok(stop_result); - } - } - - if let Some(current_version) = self - .read_openclaw_version() - .await - .ok() - .flatten() - .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))) - { - emit_install_progress( - app, - &format!("当前版本 {current_version},开始执行升级命令。"), - "info", - ); - } - - let update_status_payload = - match read_openclaw_update_status_payload(&openclaw_command).await { - Ok(payload) => payload, - Err(message) => { - emit_install_progress(app, &message, "warn"); - match attempt_direct_openclaw_package_upgrade( - app, - current_runtime_bin_dir.as_deref(), - None, - None, - ) - .await - { - Ok(result) => { - set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone())); - emit_install_progress( - app, - &format!( - "已自动切换后续执行环境到 {}。", - result.runtime_bin_dir.display() - ), - "info", - ); - return self - .finalize_successful_openclaw_update( - app, - gateway_was_running, - Some(format!( - "OpenClaw 已通过 {} 的 {} 全局升级完成({})。", - result.runtime_source, - result.package_manager, - result.package_spec - )), - ) - .await; - } - Err(fallback_error) => { - emit_install_progress(app, &fallback_error, "error"); - return Ok(ActionResult { - success: false, - message, - }); - } - } - } - }; - let update_context = extract_openclaw_update_execution_context(&update_status_payload); - if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) { - emit_install_progress( - app, - &format!("已切换到 OpenClaw 安装根目录执行升级:{}", root.display()), - "info", - ); - } - if let Some(install_kind) = update_context.install_kind.as_deref() { - let package_manager = update_context - .package_manager - .as_deref() - .unwrap_or("默认包管理器"); - emit_install_progress( - app, - &format!("检测到安装方式:{install_kind}(包管理器:{package_manager})。"), - "info", - ); - } - - let mut command = openclaw_command.build_command_with_args(["update", "--yes", "--json"]); - if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) { - command.current_dir(root); - } - let output = command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("执行 OpenClaw 升级失败: {e}"))?; - - let stdout_lines = command_output_lines(&output.stdout); - for line in &stdout_lines { - emit_install_progress(app, &line, classify_progress_level(&line, "info")); - } - let stderr_lines = command_output_lines(&output.stderr); - for line in &stderr_lines { - emit_install_progress(app, line, classify_progress_level(line, "warn")); - } - let stdout_payload = serde_json::from_slice::(&output.stdout).ok(); - - if !output.status.success() { - let failure_detail = select_openclaw_update_failure_detail( - stdout_payload.as_ref(), - &stderr_lines, - &stdout_lines, - ); - let message = format_openclaw_update_failure_message(failure_detail.as_deref()); - emit_install_progress(app, &message, "warn"); - - match attempt_direct_openclaw_package_upgrade( - app, - current_runtime_bin_dir.as_deref(), - update_context.root.as_deref(), - update_context.package_manager.as_deref(), - ) - .await - { - Ok(result) => { - set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone())); - emit_install_progress( - app, - &format!( - "已自动切换后续执行环境到 {}。", - result.runtime_bin_dir.display() - ), - "info", - ); - return self - .finalize_successful_openclaw_update( - app, - gateway_was_running, - Some(format!( - "OpenClaw 已通过 {} 的 {} 全局升级完成({})。", - result.runtime_source, result.package_manager, result.package_spec - )), - ) - .await; - } - Err(fallback_error) => { - emit_install_progress(app, &fallback_error, "error"); - return Ok(ActionResult { - success: false, - message, - }); - } - } - } - - self.finalize_successful_openclaw_update(app, gateway_was_running, None) - .await - } - - async fn finalize_successful_openclaw_update( - &mut self, - app: &AppHandle, - gateway_was_running: bool, - success_message_override: Option, - ) -> Result { - self.refresh_process_state().await?; - let updated_version = self - .read_openclaw_version() - .await - .ok() - .flatten() - .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))); - - if gateway_was_running { - emit_install_progress(app, "升级前 Gateway 处于运行态,开始自动恢复服务。", "info"); - let restart_result = self - .start_gateway(Some(app), Some(self.gateway_port)) - .await?; - if !restart_result.success { - return Ok(restart_result); - } - } - - let message = success_message_override.unwrap_or_else(|| { - updated_version - .as_ref() - .map(|version| format!("OpenClaw 已升级完成,当前版本 {version}。")) - .unwrap_or_else(|| "OpenClaw 已升级完成。".to_string()) - }); - emit_install_progress(app, &message, "info"); - Ok(ActionResult { - success: true, - message, - }) - } - - pub fn get_dashboard_url(&mut self) -> String { - self.restore_auth_token_from_config(); - let mut url = format!("http://127.0.0.1:{}", self.gateway_port); - if !self.gateway_auth_token.is_empty() { - url.push_str(&format!( - "/#token={}", - urlencoding::encode(&self.gateway_auth_token) - )); - } - url - } - - pub async fn get_channels(&mut self) -> Result, String> { - self.refresh_process_state().await?; - if self.gateway_status != GatewayStatus::Running { - return Ok(Vec::new()); - } - - self.restore_auth_token_from_config(); - - let Some(body) = self.fetch_authenticated_gateway_health_json().await else { - return Ok(Vec::new()); - }; - - let channels_map = body - .get("channels") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let labels = body - .get("channelLabels") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let ordered_ids = body - .get("channelOrder") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - let mut ordered = Vec::new(); - for channel_id in ordered_ids.iter().filter_map(Value::as_str) { - if let Some(entry) = channels_map.get(channel_id) { - ordered.push(build_channel_info( - channel_id, - entry, - labels.get(channel_id), - )); - } - } - - if ordered.is_empty() { - ordered = channels_map - .iter() - .map(|(channel_id, entry)| { - build_channel_info(channel_id, entry, labels.get(channel_id)) - }) - .collect(); - } - - Ok(ordered) - } - - pub fn sync_provider_config( - &mut self, - provider: &ApiKeyProvider, - api_key: &str, - primary_model_id: &str, - models: &[SyncModelEntry], - ) -> Result { - if api_key.trim().is_empty() && provider.provider_type != ApiProviderType::Ollama { - return Ok(ActionResult { - success: false, - message: "该 Provider 没有可用的 API Key。".to_string(), - }); - } - - let api_type = determine_api_type(provider.provider_type)?; - let base_url = format_provider_base_url(provider)?; - let provider_key = format!("lime-{}", provider.id); - - let normalized_models = if models.is_empty() { - vec![SyncModelEntry { - id: primary_model_id.to_string(), - name: primary_model_id.to_string(), - context_window: None, - }] - } else { - let mut items = models.to_vec(); - if !items.iter().any(|item| item.id == primary_model_id) { - items.insert( - 0, - SyncModelEntry { - id: primary_model_id.to_string(), - name: primary_model_id.to_string(), - context_window: None, - }, - ); - } - items - }; - - self.ensure_runtime_config( - Some(( - &provider_key, - json!({ - "baseUrl": base_url, - "apiKey": api_key, - "api": api_type, - "models": normalized_models - .iter() - .map(sync_model_entry_to_config_value) - .collect::>() - }), - )), - Some(format!("{provider_key}/{primary_model_id}")), - )?; - - Ok(ActionResult { - success: true, - message: format!("已同步 Provider“{}”到 OpenClaw。", provider.name), - }) - } - - async fn wait_for_gateway_shutdown(&mut self, max_wait: Duration) -> Result { - let start_at = tokio::time::Instant::now(); - while start_at.elapsed() < max_wait { - self.refresh_process_state().await?; - if !self.check_port_open().await { - self.clear_gateway_runtime_state(); - return Ok(true); - } - sleep(Duration::from_millis(250)).await; - } - - self.refresh_process_state().await?; - if !self.check_port_open().await { - self.clear_gateway_runtime_state(); - return Ok(true); - } - - Ok(false) - } - - async fn collect_gateway_stop_commands(&self) -> Result, String> { - let mut commands = Vec::new(); - - if let Some(command) = resolve_openclaw_command().await? { - commands.push(command); - } - - let mut runtime_candidates = list_openclaw_runtime_candidates().await?; - runtime_candidates.sort_by(compare_openclaw_runtime_candidates); - commands.extend( - runtime_candidates - .iter() - .filter_map(resolve_openclaw_command_from_runtime_candidate), - ); - - Ok(dedupe_openclaw_commands(commands)) - } - - async fn request_gateway_stop_via_command( - &self, - command_spec: &ResolvedOpenClawCommand, - app: Option<&AppHandle>, - ) { - let binary_label = command_spec.invocation_display(); - if let Some(app) = app { - emit_install_progress( - app, - &format!("尝试通过 {} 停止 Gateway。", binary_label), - "info", - ); - } - - let stop_args = vec![ - "gateway".to_string(), - "stop".to_string(), - "--url".to_string(), - self.gateway_ws_url(), - "--token".to_string(), - self.gateway_auth_token.clone(), - ]; - let mut command = command_spec.build_command_with_args(&stop_args); - let output = timeout( - Duration::from_secs(8), - command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output(), - ) - .await; - - match output { - Ok(Ok(result)) => { - for line in command_output_lines(&result.stdout) { - if let Some(app) = app { - emit_install_progress(app, &line, classify_progress_level(&line, "info")); - } - } - for line in command_output_lines(&result.stderr) { - if let Some(app) = app { - emit_install_progress(app, &line, classify_progress_level(&line, "warn")); - } - } - - if let Some(app) = app { - if result.status.success() { - emit_install_progress(app, "已发送 Gateway 停止命令。", "info"); - } else { - emit_install_progress( - app, - &format!("Gateway 停止命令返回异常状态: {:?}", result.status.code()), - "warn", - ); - } - } - } - Ok(Err(error)) => { - if let Some(app) = app { - emit_install_progress( - app, - &format!("执行 Gateway 停止命令失败: {error}"), - "warn", - ); - } - } - Err(_) => { - if let Some(app) = app { - emit_install_progress( - app, - "Gateway 停止命令超时,继续尝试自动回收旧进程。", - "warn", - ); - } - } - } - } - - async fn force_stop_gateway_listener_processes( - &mut self, - app: Option<&AppHandle>, - ) -> Result { - let listener_pids = collect_listening_port_pids(self.gateway_port).await; - if listener_pids.is_empty() { - return Ok(false); - } - - let mut system = System::new_all(); - system.refresh_all(); - - let target_pids = collect_openclaw_process_family_pids(&system, &listener_pids); - if target_pids.is_empty() { - if let Some(app) = app { - emit_install_progress( - app, - &format!( - "检测到端口 {} 仍被占用,但监听进程不是 OpenClaw,未执行自动终止。", - self.gateway_port - ), - "warn", - ); - } - return Ok(false); - } - - if let Some(app) = app { - emit_install_progress( - app, - &format!( - "检测到旧 Gateway 仍占用端口 {},准备回收进程:{}。", - self.gateway_port, - target_pids - .iter() - .map(|pid| pid.as_u32().to_string()) - .collect::>() - .join(", ") - ), - "warn", - ); - } - - terminate_sysinfo_processes(&mut system, &target_pids).await; - Ok(true) - } - - async fn refresh_process_state(&mut self) -> Result<(), String> { - let mut process_exited = false; - - if let Some(child) = self.gateway_process.as_mut() { - match child.try_wait() { - Ok(Some(status)) => { - tracing::info!(target: "openclaw", "Gateway 进程已退出: {}", status); - process_exited = true; - } - Ok(None) => {} - Err(error) => { - tracing::warn!(target: "openclaw", "检查 Gateway 进程状态失败: {}", error); - process_exited = true; - } - } - } - - if process_exited { - self.gateway_process = None; - self.gateway_started_at = None; - } - - let openclaw_command = resolve_openclaw_command().await?; - let running = self.check_port_open().await - || self.check_gateway_status(openclaw_command.as_ref()).await?; - - self.gateway_status = if running { - GatewayStatus::Running - } else if self.gateway_status == GatewayStatus::Starting { - GatewayStatus::Error - } else { - GatewayStatus::Stopped - }; - - if !running { - self.gateway_process = None; - self.gateway_started_at = None; - } - - Ok(()) - } - - async fn check_port_open(&self) -> bool { - timeout( - Duration::from_secs(2), - TcpStream::connect(("127.0.0.1", self.gateway_port)), - ) - .await - .map(|result| result.is_ok()) - .unwrap_or(false) - } - - async fn check_gateway_status( - &self, - command_spec: Option<&ResolvedOpenClawCommand>, - ) -> Result { - let Some(command_spec) = command_spec else { - return Ok(false); - }; - - let status_args = vec![ - "gateway".to_string(), - "status".to_string(), - "--url".to_string(), - self.gateway_ws_url(), - "--token".to_string(), - self.gateway_auth_token.clone(), - ]; - let mut command = command_spec.build_command_with_args(&status_args); - let output = command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await; - - match output { - Ok(result) => { - let stdout = String::from_utf8_lossy(&result.stdout).to_lowercase(); - let stderr = String::from_utf8_lossy(&result.stderr).to_lowercase(); - Ok(result.status.success() - && (stdout.contains("listening") - || stdout.contains("running") - || stderr.contains("listening"))) - } - Err(_) => Ok(false), - } - } - - async fn read_openclaw_version(&self) -> Result, String> { - let Some(command_spec) = resolve_openclaw_command().await? else { - return Ok(None); - }; - - read_openclaw_version_from_command(&command_spec).await - } - - fn gateway_ws_url(&self) -> String { - format!("ws://127.0.0.1:{}", self.gateway_port) - } - - fn restore_auth_token_from_config(&mut self) { - if !self.gateway_auth_token.is_empty() { - return; - } - - match read_base_openclaw_config() - .ok() - .and_then(|config| extract_gateway_auth_token(&config)) - { - Some(token) => { - self.gateway_auth_token = token; - } - None => { - tracing::warn!( - target: "openclaw", - "未能从 OpenClaw 配置恢复 gateway token,Dashboard 访问可能鉴权失败" - ); - } - } - } - - async fn fetch_authenticated_gateway_health_json(&self) -> Option { - if self.gateway_auth_token.is_empty() { - return None; - } - - let Some(command_spec) = resolve_openclaw_command().await.ok().flatten() else { - return None; - }; - - let health_args = vec![ - "gateway".to_string(), - "health".to_string(), - "--url".to_string(), - self.gateway_ws_url(), - "--token".to_string(), - self.gateway_auth_token.clone(), - "--json".to_string(), - ]; - let mut command = command_spec.build_command_with_args(&health_args); - let output = command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await; - - match output { - Ok(output) if output.status.success() => { - serde_json::from_slice::(&output.stdout) - .map_err(|error| { - tracing::warn!( - target: "openclaw", - "解析 Gateway 官方健康检查结果失败: {}", - error - ); - error - }) - .ok() - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - tracing::warn!( - target: "openclaw", - "Gateway 官方健康检查失败: {}", - stderr.trim() - ); - None - } - Err(error) => { - tracing::warn!(target: "openclaw", "执行 Gateway 官方健康检查失败: {}", error); - None - } - } - } - - fn ensure_runtime_config( - &mut self, - provider_entry: Option<(&str, Value)>, - primary_model: Option, - ) -> Result<(), String> { - let config_dir = openclaw_config_dir(); - std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建配置目录失败: {e}"))?; - - let lime_config_path = openclaw_lime_config_path(); - let mut config = read_base_openclaw_config()?; - sanitize_runtime_config(&mut config); - - if self.gateway_auth_token.is_empty() { - self.gateway_auth_token = generate_auth_token(); - } - - apply_gateway_runtime_defaults(&mut config, self.gateway_port, &self.gateway_auth_token); - - if let Some((provider_key, provider_value)) = provider_entry { - set_json_path( - &mut config, - &["models", "mode"], - Value::String("merge".to_string()), - ); - set_json_path( - &mut config, - &["models", "providers", provider_key], - provider_value, - ); - } - - if let Some(primary) = primary_model { - set_json_path( - &mut config, - &["agents", "defaults", "model", "primary"], - Value::String(primary), - ); - } - - let content = - serde_json::to_string_pretty(&config).map_err(|e| format!("序列化配置失败: {e}"))?; - std::fs::write(lime_config_path, content).map_err(|e| format!("写入配置失败: {e}"))?; - Ok(()) - } - - async fn resolve_install_commands( - &self, - app: &AppHandle, - ) -> Result<(String, String, Option, String, String), String> { - let npm_path = find_command_in_shell("npm") - .await? - .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; - let npm_prefix = detect_npm_global_prefix(&npm_path).await; - let use_china_package = should_use_china_package(app).await; - let package = if use_china_package { - OPENCLAW_CN_PACKAGE - } else { - OPENCLAW_DEFAULT_PACKAGE - }; - let shell_platform = current_shell_platform(); - let cleanup_command = - build_openclaw_cleanup_command(shell_platform, &npm_path, npm_prefix.as_deref()); - let install_command = build_openclaw_install_command( - shell_platform, - &npm_path, - npm_prefix.as_deref(), - package, - use_china_package.then_some(NPM_MIRROR_CN), - ); - Ok(( - package.to_string(), - npm_path, - npm_prefix, - cleanup_command, - install_command, - )) - } - - async fn resolve_uninstall_command(&self) -> Result<(String, Option, String), String> { - let npm_path = find_command_in_shell("npm") - .await? - .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; - let npm_prefix = detect_npm_global_prefix(&npm_path).await; - let command = build_openclaw_cleanup_command( - current_shell_platform(), - &npm_path, - npm_prefix.as_deref(), - ); - Ok((npm_path, npm_prefix, command)) - } - - async fn build_install_command_preview( - &self, - app: &AppHandle, - ) -> Result { - let (package, npm_path, npm_prefix, cleanup_command, install_command) = - self.resolve_install_commands(app).await?; - let prefix_note = npm_prefix - .map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n")) - .unwrap_or_else(|| format!("npm: {npm_path}\n")); - Ok(CommandPreview { - title: format!("安装 {package}"), - command: format!("{prefix_note}{cleanup_command}\n{install_command}"), - }) - } - - async fn build_uninstall_command_preview(&self) -> Result { - let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?; - let prefix_note = npm_prefix - .map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n")) - .unwrap_or_else(|| format!("npm: {npm_path}\n")); - Ok(CommandPreview { - title: "卸载 OpenClaw".to_string(), - command: format!("{prefix_note}{command}"), - }) - } - - async fn build_start_command_preview( - &mut self, - port: Option, - ) -> Result { - if let Some(next_port) = port { - self.gateway_port = next_port.max(1); - } - self.restore_auth_token_from_config(); - if self.gateway_auth_token.is_empty() { - self.gateway_auth_token = generate_auth_token(); - } - let openclaw_command = resolve_openclaw_command() - .await? - .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; - let config_path = openclaw_lime_config_path(); - let command = gateway_start_args(self.gateway_port, &self.gateway_auth_token) - .into_iter() - .map(|arg| shell_escape(&arg)) - .collect::>() - .join(" "); - Ok(CommandPreview { - title: "启动 Gateway".to_string(), - command: format!( - "{}OPENCLAW_CONFIG_PATH={} {} {}", - if cfg!(target_os = "windows") { - "set " - } else { - "" - }, - shell_escape(config_path.to_string_lossy().as_ref()), - openclaw_command.preview_invocation(), - command - ), - }) - } - - async fn build_stop_command_preview( - &mut self, - port: Option, - ) -> Result { - if let Some(next_port) = port { - self.gateway_port = next_port.max(1); - } - self.restore_auth_token_from_config(); - let openclaw_command = resolve_openclaw_command() - .await? - .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; - let config_path = openclaw_lime_config_path(); - Ok(CommandPreview { - title: "停止 Gateway".to_string(), - command: format!( - "OPENCLAW_CONFIG_PATH={} {} gateway stop --url {} --token {}", - shell_escape(config_path.to_string_lossy().as_ref()), - openclaw_command.preview_invocation(), - self.gateway_ws_url(), - shell_escape(&self.gateway_auth_token) - ), - }) - } - - async fn build_restart_command_preview( - &mut self, - port: Option, - ) -> Result { - let stop = self.build_stop_command_preview(port).await?; - let start = self.build_start_command_preview(port).await?; - Ok(CommandPreview { - title: "重启 Gateway".to_string(), - command: format!("{}\n{}", stop.command, start.command), - }) - } -} - -pub fn openclaw_install_event_name() -> &'static str { - OPENCLAW_INSTALL_EVENT -} - -fn openclaw_config_dir() -> PathBuf { - home_dir() - .or_else(data_dir) - .unwrap_or_else(|| PathBuf::from(".")) - .join(".openclaw") -} - -fn openclaw_original_config_path() -> PathBuf { - openclaw_config_dir().join("openclaw.json") -} - -fn openclaw_lime_config_path() -> PathBuf { - openclaw_config_dir().join("openclaw.lime.json") -} - -#[cfg(any(target_os = "windows", test))] -fn windows_dependency_setup_message( - dependency: DependencyKind, - status: &DependencyStatus, -) -> String { - let guidance = match dependency { - DependencyKind::Node => format!( - "Windows 下请先从 nodejs.org 安装或升级 Node.js {}+,完成后点击“重新检测”,再安装 OpenClaw。", - NODE_MIN_VERSION.0 - ), - DependencyKind::Git => { - "Windows 下请先从 git-scm.com 安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。" - .to_string() - } - }; - - format!("{} {}", status.message, guidance) -} - -#[cfg(any(target_os = "windows", test))] -fn windows_dependency_action_result( - dependency: DependencyKind, - status: &DependencyStatus, -) -> ActionResult { - ActionResult { - success: false, - message: windows_dependency_setup_message(dependency, status), - } -} - -#[cfg(any(target_os = "windows", test))] -fn windows_install_block_result( - node_status: &DependencyStatus, - git_status: &DependencyStatus, -) -> Option { - if node_status.status != "ok" { - return Some(windows_dependency_action_result( - DependencyKind::Node, - node_status, - )); - } - - if git_status.status != "ok" { - return Some(windows_dependency_action_result( - DependencyKind::Git, - git_status, - )); - } - - None -} - -fn dependency_setup_summary(dependency: DependencyKind) -> String { - if cfg!(target_os = "windows") { - return match dependency { - DependencyKind::Node => format!( - "当前缺少可用的 Node.js {}+ 运行时,Windows 下请先手动安装 Node.js,完成后点击“重新检测”,再安装 OpenClaw。", - NODE_MIN_VERSION.0 - ), - DependencyKind::Git => { - "当前缺少可用的 Git,Windows 下请先手动安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。" - .to_string() - } - }; - } - - if cfg!(target_os = "macos") { - return match dependency { - DependencyKind::Node => format!( - "当前缺少可用的 Node.js {}+ 运行时,建议先一键安装或修复 Node.js。", - format_semver(NODE_MIN_VERSION) - ), - DependencyKind::Git => "当前缺少可用的 Git,建议先一键安装或修复 Git。".to_string(), - }; - } - - match dependency { - DependencyKind::Node => format!( - "当前缺少可用的 Node.js {}+ 运行时,请先手动安装后重新检测。", - format_semver(NODE_MIN_VERSION) - ), - DependencyKind::Git => "当前缺少可用的 Git,请先手动安装后重新检测。".to_string(), - } -} - -fn openclaw_installer_download_dir(app: &AppHandle) -> Result { - let _ = app; - let app_data_dir = lime_core::app_paths::preferred_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; - let dir = app_data_dir.join("downloads").join("openclaw-installers"); - std::fs::create_dir_all(&dir).map_err(|e| format!("创建 OpenClaw 下载目录失败: {e}"))?; - Ok(dir) -} - -fn collect_temp_artifact_paths(app: Option<&AppHandle>) -> Vec { - let mut targets = Vec::new(); - - #[cfg(not(target_os = "windows"))] - { - targets.push(PathBuf::from(OPENCLAW_TEMP_CARGO_CHECK_DIR)); - } - - if let Some(app) = app { - if let Ok(dir) = openclaw_installer_download_dir(app) { - targets.push(dir); - } - } - - targets -} - -fn build_environment_status( - node: DependencyStatus, - git: DependencyStatus, - mut openclaw: DependencyStatus, - diagnostics: EnvironmentDiagnostics, -) -> EnvironmentStatus { - let node_ready = node.status == "ok"; - let git_ready = git.status == "ok"; - openclaw.auto_install_supported = node_ready && git_ready; - - let (recommended_action, summary) = if !node_ready { - ( - "install_node".to_string(), - dependency_setup_summary(DependencyKind::Node), - ) - } else if !git_ready { - ( - "install_git".to_string(), - dependency_setup_summary(DependencyKind::Git), - ) - } else if openclaw.status == "needs_reload" { - ( - "refresh_openclaw_env".to_string(), - "已检测到 OpenClaw 包,但命令尚未生效;请点击“重新检测”,必要时重启 Lime。".to_string(), - ) - } else if openclaw.status != "ok" { - ( - "install_openclaw".to_string(), - "运行环境已就绪,可以继续一键安装 OpenClaw。".to_string(), - ) - } else { - ( - "ready".to_string(), - "Node.js、Git 和 OpenClaw 均已就绪,可以继续配置与启动。".to_string(), - ) - }; - - EnvironmentStatus { - node, - git, - openclaw, - recommended_action, - summary, - diagnostics, - temp_artifacts: collect_temp_artifact_paths(None) - .into_iter() - .filter(|path| path.exists()) - .map(|path| path.display().to_string()) - .collect(), - } -} - -async fn inspect_node_dependency_status() -> Result { - let Some(path) = find_command_in_shell("node").await? else { - return Ok(DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: format!( - "未检测到 Node.js,需要安装 {}+。", - format_semver(NODE_MIN_VERSION) - ), - auto_install_supported: cfg!(target_os = "macos"), - }); - }; - - let version_text = read_command_version_text(&path, &["--version"]).await?; - let Some(version) = parse_semver_from_text(&version_text) else { - return Ok(DependencyStatus { - status: "version_low".to_string(), - version: Some(version_text.clone()), - path: Some(path), - message: format!( - "检测到 Node.js,但无法识别版本:{version_text}。请安装 {}+。", - format_semver(NODE_MIN_VERSION) - ), - auto_install_supported: cfg!(target_os = "macos"), - }); - }; - - let normalized = format_semver(version); - if version >= NODE_MIN_VERSION { - Ok(DependencyStatus { - status: "ok".to_string(), - version: Some(normalized.clone()), - path: Some(path), - message: format!("Node.js 已就绪:{normalized}"), - auto_install_supported: cfg!(target_os = "macos"), - }) - } else { - Ok(DependencyStatus { - status: "version_low".to_string(), - version: Some(normalized.clone()), - path: Some(path), - message: format!( - "Node.js 版本过低:{normalized},需要 {}+。", - format_semver(NODE_MIN_VERSION) - ), - auto_install_supported: cfg!(target_os = "macos"), - }) - } -} - -async fn inspect_git_dependency_status() -> Result { - let Some(path) = find_command_in_shell("git").await? else { - return Ok(DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Git。".to_string(), - auto_install_supported: git_auto_install_supported().await?, - }); - }; - - let version_text = read_command_version_text(&path, &["--version"]).await?; - let version = parse_semver_from_text(&version_text).map(format_semver); - let detail = version.clone().unwrap_or(version_text); - - Ok(DependencyStatus { - status: "ok".to_string(), - version, - path: Some(path), - message: format!("Git 已就绪:{detail}"), - auto_install_supported: git_auto_install_supported().await?, - }) -} - -async fn inspect_openclaw_dependency_status() -> Result { - let Some(command) = resolve_openclaw_command().await? else { - if let Some(status) = inspect_openclaw_package_reload_status().await? { - return Ok(status); - } - - return Ok(DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 OpenClaw,可在环境就绪后一键安装。".to_string(), - auto_install_supported: false, - }); - }; - - let version_text = read_openclaw_version_from_command(&command) - .await? - .unwrap_or_default(); - Ok(DependencyStatus { - status: "ok".to_string(), - version: if version_text.is_empty() { - None - } else { - Some(version_text.clone()) - }, - path: Some(command.install_path_display()), - message: if matches!(command, ResolvedOpenClawCommand::NodeCli { .. }) { - if version_text.is_empty() { - "已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动。".to_string() - } else { - format!("已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动:{version_text}") - } - } else if version_text.is_empty() { - "已检测到 OpenClaw。".to_string() - } else { - format!("已检测到 OpenClaw:{version_text}") - }, - auto_install_supported: false, - }) -} - -async fn inspect_openclaw_package_reload_status() -> Result, String> { - let Some(npm_path) = find_command_in_standard_locations("npm").await? else { - return Ok(None); - }; - let Some(prefix) = detect_npm_global_prefix(&npm_path).await else { - return Ok(None); - }; - let Some(package) = find_installed_openclaw_package_details(&prefix) else { - return Ok(None); - }; - - let version_suffix = package - .version - .as_deref() - .map(|item| format!("({item})")) - .unwrap_or_default(); - - Ok(Some(DependencyStatus { - status: "needs_reload".to_string(), - version: package.version.clone(), - path: Some(prefix.clone()), - message: format!( - "已在 npm 全局目录检测到 {}{},但当前进程尚未解析到 openclaw 命令。请点击“重新检测”;若仍失败,请重启 Lime,或确认 {prefix} 已加入 PATH。", package.name, version_suffix - ), - auto_install_supported: false, - })) -} - -async fn git_auto_install_supported() -> Result { - #[cfg(target_os = "macos")] - { - Ok(true) - } - - #[cfg(not(target_os = "macos"))] - { - Ok(false) - } -} - -async fn read_command_version_text(command_path: &str, args: &[&str]) -> Result { - let mut command = Command::new(command_path); - apply_binary_runtime_path(&mut command, command_path); - for arg in args { - command.arg(arg); - } - let output = command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("执行命令失败({command_path}): {e}"))?; - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if !stdout.is_empty() { - Ok(stdout) - } else { - Ok(stderr) - } -} - -async fn resolve_node_installer_asset() -> Result { - let client = reqwest::Client::new(); - let response = client - .get("https://nodejs.org/dist/index.json") - .header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT) - .send() - .await - .map_err(|e| format!("请求 Node.js 版本列表失败: {e}"))?; - - if !response.status().is_success() { - return Err(format!( - "获取 Node.js 版本列表失败: HTTP {}", - response.status() - )); - } - - let releases: Vec = response - .json() - .await - .map_err(|e| format!("解析 Node.js 版本列表失败: {e}"))?; - - let select_version = |only_lts: bool| -> Option { - releases.iter().find_map(|release| { - let version = release.get("version")?.as_str()?; - let parsed = parse_semver(version)?; - let is_lts = release - .get("lts") - .map(|value| match value { - Value::Bool(flag) => *flag, - Value::String(text) => !text.trim().is_empty() && text != "false", - _ => false, - }) - .unwrap_or(false); - if parsed >= NODE_MIN_VERSION && (!only_lts || is_lts) { - Some(version.to_string()) - } else { - None - } - }) - }; - - let version = select_version(true) - .or_else(|| select_version(false)) - .ok_or_else(|| "未找到满足要求的 Node.js 官方安装包版本。".to_string())?; - - #[cfg(target_os = "windows")] - let filename = { - #[cfg(target_arch = "aarch64")] - { - format!("node-{version}-arm64.msi") - } - #[cfg(not(target_arch = "aarch64"))] - { - format!("node-{version}-x64.msi") - } - }; - - #[cfg(target_os = "macos")] - let filename = format!("node-{version}.pkg"); - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - let filename = String::new(); - - if filename.is_empty() { - return Err("当前平台暂不支持自动下载官方 Node.js 安装器。".to_string()); - } - - Ok(InstallerAsset { - download_url: format!("https://nodejs.org/dist/{version}/{filename}"), - filename, - }) -} - -async fn download_installer_asset( - app: &AppHandle, - asset: &InstallerAsset, -) -> Result { - let download_dir = openclaw_installer_download_dir(app)?; - let installer_path = download_dir.join(&asset.filename); - if installer_path.exists() { - let _ = std::fs::remove_file(&installer_path); - } - - emit_install_progress( - app, - &format!("开始下载安装器:{}", asset.download_url), - "info", - ); - - let client = reqwest::Client::new(); - let response = client - .get(&asset.download_url) - .header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT) - .send() - .await - .map_err(|e| format!("下载官方安装器失败: {e}"))?; - - if !response.status().is_success() { - return Err(format!("下载安装器失败: HTTP {}", response.status())); - } - - let bytes = response - .bytes() - .await - .map_err(|e| format!("读取安装器文件失败: {e}"))?; - std::fs::write(&installer_path, bytes) - .map_err(|e| format!("保存安装器失败({}): {e}", installer_path.display()))?; - - emit_install_progress( - app, - &format!("安装器已保存到:{}", installer_path.display()), - "info", - ); - - Ok(installer_path) -} - -fn launch_installer(file_path: &Path) -> Result<(), String> { - let extension = file_path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - - match extension.as_str() { - "exe" => { - #[cfg(target_os = "windows")] - { - std::process::Command::new(file_path) - .spawn() - .map_err(|e| format!("启动安装程序失败: {e}"))?; - } - - #[cfg(not(target_os = "windows"))] - { - return Err("EXE 安装器只能在 Windows 上运行。".to_string()); - } - } - "msi" => { - #[cfg(target_os = "windows")] - { - std::process::Command::new("msiexec") - .arg("/i") - .arg(file_path) - .spawn() - .map_err(|e| format!("启动 MSI 安装程序失败: {e}"))?; - } - - #[cfg(not(target_os = "windows"))] - { - return Err("MSI 安装器只能在 Windows 上运行。".to_string()); - } - } - "pkg" | "dmg" => { - #[cfg(target_os = "macos")] - { - std::process::Command::new("open") - .arg(file_path) - .spawn() - .map_err(|e| format!("打开 macOS 安装器失败: {e}"))?; - } - - #[cfg(not(target_os = "macos"))] - { - return Err("该安装器只能在 macOS 上运行。".to_string()); - } - } - _ => return Err(format!("不支持的安装器文件类型: {extension}")), - } - - Ok(()) -} - -#[cfg(target_os = "macos")] -async fn trigger_macos_command_line_tools_install() -> Result { - let output = Command::new("/usr/bin/xcode-select") - .arg("--install") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("拉起 macOS 开发者工具安装器失败: {e}"))?; - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let combined = if !stderr.is_empty() { stderr } else { stdout }; - let lower = combined.to_ascii_lowercase(); - - if output.status.success() - || lower.contains("install requested") - || lower.contains("already been requested") - { - return Ok("已拉起 macOS 开发者工具安装器。".to_string()); - } - - if lower.contains("already installed") { - return Err( - "系统提示 Command Line Tools 已安装,但当前仍未检测到 Git,请先执行系统更新或安装 Homebrew 后重试。" - .to_string(), - ); - } - - Err(format!("拉起 macOS 开发者工具安装器失败: {combined}")) -} - -#[cfg(not(target_os = "macos"))] -#[allow(dead_code)] -async fn trigger_macos_command_line_tools_install() -> Result { - Err("当前平台不支持拉起 macOS 开发者工具安装器。".to_string()) -} - -fn read_base_openclaw_config() -> Result { - let lime_path = openclaw_lime_config_path(); - if lime_path.exists() { - return read_json_file(&lime_path); - } - - let original_path = openclaw_original_config_path(); - if original_path.exists() { - return read_json_file(&original_path); - } - - Ok(json!({})) -} - -fn read_json_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("读取配置文件失败({}): {e}", path.display()))?; - serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败({}): {e}", path.display())) -} - -fn ensure_path_object<'a>(root: &'a mut Value, path: &[&str]) -> &'a mut Map { - let mut current = root; - for segment in path { - let object = ensure_value_object(current); - current = object - .entry((*segment).to_string()) - .or_insert_with(|| Value::Object(Map::new())); - } - ensure_value_object(current) -} - -fn set_json_path(root: &mut Value, path: &[&str], value: Value) { - if path.is_empty() { - *root = value; - return; - } - - let parent = ensure_path_object(root, &path[..path.len() - 1]); - parent.insert(path[path.len() - 1].to_string(), value); -} - -fn sync_model_entry_to_config_value(model: &SyncModelEntry) -> Value { - let mut entry = Map::new(); - entry.insert("id".to_string(), Value::String(model.id.clone())); - entry.insert("name".to_string(), Value::String(model.name.clone())); - if let Some(context_window) = model.context_window { - entry.insert( - "contextWindow".to_string(), - Value::Number(context_window.into()), - ); - } - Value::Object(entry) -} - -fn sanitize_runtime_config(config: &mut Value) { - let Some(providers) = config - .get_mut("models") - .and_then(|models| models.get_mut("providers")) - .and_then(Value::as_object_mut) - else { - return; - }; - - for provider in providers.values_mut() { - let Some(models) = provider.get_mut("models").and_then(Value::as_array_mut) else { - continue; - }; - - for model in models { - let Some(entry) = model.as_object_mut() else { - continue; - }; - - if matches!(entry.get("contextWindow"), Some(Value::Null)) { - entry.remove("contextWindow"); - } - } - } -} - -fn apply_gateway_runtime_defaults(config: &mut Value, gateway_port: u16, gateway_auth_token: &str) { - ensure_path_object(config, &["gateway"]); - set_json_path( - config, - &["gateway", "mode"], - Value::String("local".to_string()), - ); - set_json_path( - config, - &["gateway", "bind"], - Value::String("loopback".to_string()), - ); - set_json_path( - config, - &["gateway", "port"], - Value::Number(gateway_port.into()), - ); - set_json_path( - config, - &["gateway", "auth", "mode"], - Value::String("token".to_string()), - ); - set_json_path( - config, - &["gateway", "auth", "token"], - Value::String(gateway_auth_token.to_string()), - ); - set_json_path( - config, - &["gateway", "remote", "token"], - Value::String(gateway_auth_token.to_string()), - ); -} - -fn gateway_start_args(gateway_port: u16, gateway_auth_token: &str) -> Vec { - vec![ - "gateway".to_string(), - "--allow-unconfigured".to_string(), - "--bind".to_string(), - "loopback".to_string(), - "--auth".to_string(), - "token".to_string(), - "--token".to_string(), - gateway_auth_token.to_string(), - "--port".to_string(), - gateway_port.to_string(), - ] -} - -fn push_gateway_error_line(lines: &mut Vec, line: &str) { - let trimmed = line.trim(); - if trimmed.is_empty() { - return; - } - - if lines.len() >= 32 { - lines.remove(0); - } - lines.push(trimmed.to_string()); -} - -fn select_gateway_start_failure_detail<'a>(lines: &'a [String]) -> Option<&'a str> { - lines - .iter() - .filter_map(|line| { - let trimmed = line.trim(); - let score = gateway_failure_line_score(trimmed); - (score > 0).then_some((score, trimmed)) - }) - .max_by_key(|(score, _)| *score) - .map(|(_, line)| line) -} - -fn gateway_failure_line_score(line: &str) -> u8 { - if line.is_empty() { - return 0; - } - - let normalized = line.to_ascii_lowercase(); - if normalized.starts_with("run: openclaw doctor") - || normalized == "config invalid" - || normalized.starts_with("file:") - || normalized == "problem:" - { - return 0; - } - - if normalized.contains("invalid config") { - return 100; - } - - if normalized.contains("contextwindow") && normalized.contains("received null") { - return 95; - } - - if normalized.contains("address already in use") - || normalized.contains("eaddrinuse") - || normalized.contains("resolved to non-loopback host") - { - return 90; - } - - if normalized.contains("missing config") - || normalized.contains("gateway.mode=local") - || normalized.contains("gateway.auth.mode") - { - return 85; - } - - if normalized.starts_with("- ") { - return 60; - } - - 20 -} - -fn command_output_lines(output: &[u8]) -> Vec { - String::from_utf8_lossy(output) - .lines() - .map(sanitize_progress_line) - .filter(|line| !line.is_empty()) - .collect() -} - -fn normalize_process_probe_text(value: &str) -> String { - value.replace('\\', "/").to_ascii_lowercase() -} - -fn process_looks_like_openclaw_process( - process_name: &str, - exe_path: Option<&Path>, - command_args: &[OsString], -) -> bool { - let process_name = normalize_process_probe_text(process_name); - let exe_path = exe_path - .map(|path| normalize_process_probe_text(&path.display().to_string())) - .unwrap_or_default(); - let command_line = normalize_process_probe_text( - &command_args - .iter() - .map(|arg| arg.to_string_lossy()) - .collect::>() - .join(" "), - ); - - process_name.contains("openclaw") - || exe_path.contains("openclaw") - || command_line.contains("openclaw") -} - -fn collect_openclaw_process_family_pids(system: &System, listener_pids: &[u32]) -> Vec { - let mut target_pids = HashSet::new(); - - for listener_pid in listener_pids { - let pid = Pid::from_u32(*listener_pid); - let Some(process) = system.process(pid) else { - continue; - }; - if !process_looks_like_openclaw_process( - &process.name().to_string_lossy(), - process.exe(), - process.cmd(), - ) { - continue; - } - - target_pids.insert(pid); - let mut parent_pid = process.parent(); - while let Some(next_parent) = parent_pid { - let Some(parent_process) = system.process(next_parent) else { - break; - }; - if !process_looks_like_openclaw_process( - &parent_process.name().to_string_lossy(), - parent_process.exe(), - parent_process.cmd(), - ) { - break; - } - - target_pids.insert(next_parent); - parent_pid = parent_process.parent(); - } - } - - let mut target_pids = target_pids.into_iter().collect::>(); - target_pids.sort_by_key(|pid| pid.as_u32()); - target_pids -} - -async fn terminate_sysinfo_processes(system: &mut System, target_pids: &[Pid]) { - for pid in target_pids { - if let Some(process) = system.process(*pid) { - let terminated = process.kill_with(Signal::Term).unwrap_or(false); - if !terminated { - let _ = process.kill(); - } - } - } - - sleep(Duration::from_millis(900)).await; - system.refresh_all(); - - for pid in target_pids { - if let Some(process) = system.process(*pid) { - let _ = process.kill(); - } - } -} - -#[cfg_attr(target_os = "windows", allow(dead_code))] -fn parse_lsof_listener_pids(output: &str) -> Vec { - let mut pids = output - .lines() - .filter_map(|line| line.trim().parse::().ok()) - .collect::>(); - pids.sort_unstable(); - pids.dedup(); - pids -} - -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -fn parse_windows_netstat_listener_pids(output: &str, port: u16) -> Vec { - let mut pids = output - .lines() - .filter_map(|line| { - let columns = line.split_whitespace().collect::>(); - if columns.len() < 5 { - return None; - } - - let local_address = columns.get(1).copied().unwrap_or_default(); - let state = columns.get(3).copied().unwrap_or_default(); - if !state.eq_ignore_ascii_case("LISTENING") - || !local_address.ends_with(&format!(":{port}")) - { - return None; - } - - columns.last().and_then(|value| value.parse::().ok()) - }) - .collect::>(); - pids.sort_unstable(); - pids.dedup(); - pids -} - -async fn collect_listening_port_pids(port: u16) -> Vec { - #[cfg(target_os = "windows")] - { - collect_listening_port_pids_windows(port).await - } - - #[cfg(not(target_os = "windows"))] - { - collect_listening_port_pids_unix(port).await - } -} - -#[cfg(not(target_os = "windows"))] -async fn collect_listening_port_pids_unix(port: u16) -> Vec { - let mut command = Command::new("lsof"); - apply_windows_no_window(&mut command); - let output = timeout( - Duration::from_secs(3), - command - .arg("-nP") - .arg(format!("-iTCP:{port}")) - .arg("-sTCP:LISTEN") - .arg("-t") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output(), - ) - .await; - - match output { - Ok(Ok(result)) if result.status.success() => { - parse_lsof_listener_pids(&String::from_utf8_lossy(&result.stdout)) - } - _ => Vec::new(), - } -} - -#[cfg(target_os = "windows")] -async fn collect_listening_port_pids_windows(port: u16) -> Vec { - let mut command = Command::new("netstat"); - apply_windows_no_window(&mut command); - let output = timeout( - Duration::from_secs(3), - command - .arg("-ano") - .arg("-p") - .arg("tcp") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output(), - ) - .await; - - match output { - Ok(Ok(result)) if result.status.success() => { - parse_windows_netstat_listener_pids(&String::from_utf8_lossy(&result.stdout), port) - } - _ => Vec::new(), - } -} - -fn parse_openclaw_release_version(value: &str) -> Option { - static VERSION_RE: OnceLock = OnceLock::new(); - VERSION_RE - .get_or_init(|| Regex::new(r"(?i)openclaw\s+([0-9]+(?:\.[0-9]+)+)").expect("valid regex")) - .captures(value) - .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())) -} - -async fn read_openclaw_update_status_payload( - command_spec: &ResolvedOpenClawCommand, -) -> Result { - let mut command = command_spec.build_command_with_args(["update", "status", "--json"]); - let output = command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("检查 OpenClaw 更新失败: {e}"))?; - - let stdout_lines = command_output_lines(&output.stdout); - let stderr_lines = command_output_lines(&output.stderr); - let payload = serde_json::from_slice::(&output.stdout).map_err(|error| { - let detail = select_openclaw_update_failure_detail(None, &stderr_lines, &stdout_lines) - .unwrap_or_else(|| format!("解析更新状态失败: {error}")); - format_openclaw_update_failure_message(Some(detail.as_str())) - })?; - - if !output.status.success() { - let detail = - select_openclaw_update_failure_detail(Some(&payload), &stderr_lines, &stdout_lines); - return Err(format_openclaw_update_failure_message(detail.as_deref())); - } - - Ok(payload) -} - -fn extract_openclaw_update_execution_context(payload: &Value) -> OpenClawUpdateExecutionContext { - OpenClawUpdateExecutionContext { - root: payload - .pointer("/update/root") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(PathBuf::from), - install_kind: payload - .pointer("/update/installKind") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - package_manager: payload - .pointer("/update/packageManager") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - } -} - -fn select_openclaw_update_failure_detail( - payload: Option<&Value>, - stderr_lines: &[String], - stdout_lines: &[String], -) -> Option { - if let Some(payload) = payload { - if let Some(reason) = payload.get("reason").and_then(Value::as_str) { - let root_suffix = payload - .get("root") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|root| format!(" ({root})")) - .unwrap_or_default(); - return Some(format!("{reason}{root_suffix}")); - } - - if let Some(message) = payload.get("message").and_then(Value::as_str) { - let trimmed = message.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - - if let Some(message) = payload - .pointer("/update/registry/error") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return Some(message.to_string()); - } - } - - stderr_lines - .iter() - .chain(stdout_lines.iter()) - .filter_map(|line| { - let trimmed = line.trim(); - let score = openclaw_update_failure_line_score(trimmed); - (score > 0).then_some((score, trimmed)) - }) - .max_by_key(|(score, _)| *score) - .map(|(_, line)| line.to_string()) -} - -fn openclaw_update_failure_line_score(line: &str) -> u8 { - if line.is_empty() { - return 0; - } - - let normalized = line.to_ascii_lowercase(); - if normalized.starts_with("updating openclaw") - || normalized.starts_with("update result") - || normalized.starts_with("total time") - || normalized.starts_with("root:") - { - return 0; - } - - if normalized.contains("not-openclaw-root") { - return 100; - } - - if normalized.contains("node.js") && normalized.contains("required") { - return 95; - } - - if normalized.contains("fetch failed") || normalized.contains("network") { - return 90; - } - - if normalized.contains("pnpm") && normalized.contains("not found") - || normalized.contains("npm") && normalized.contains("not found") - { - return 85; - } - - if normalized.contains("already up to date") || normalized.contains("not modified") { - return 80; - } - - if normalized.starts_with("reason:") { - return 70; - } - - 20 -} - -fn format_openclaw_update_failure_message(detail: Option<&str>) -> String { - let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else { - return "OpenClaw 升级失败,请查看日志输出。".to_string(); - }; - - let normalized = detail.to_ascii_lowercase(); - if normalized.contains("not-openclaw-root") { - return "OpenClaw 升级失败:未在 OpenClaw 安装根目录执行更新。Lime 会优先切换到安装目录;如仍失败,请重新检测安装状态后重试。" - .to_string(); - } - - if normalized.contains("node.js") && normalized.contains("required") { - return format!( - "OpenClaw 升级失败:当前用于执行 openclaw 的 Node.js 版本过低,需要 {}+。请切换到满足要求的 Node.js 后重试。", - format_semver(NODE_MIN_VERSION) - ); - } - - if normalized.contains("fetch failed") || normalized.contains("network") { - return "OpenClaw 升级失败:当前无法访问更新源,请检查网络或代理设置后重试。".to_string(); - } - - if normalized.contains("not modified") || normalized.contains("already up to date") { - return "OpenClaw 当前已经是最新版本,无需升级。".to_string(); - } - - if normalized.contains("pnpm") && normalized.contains("not found") { - return "OpenClaw 升级失败:当前安装方式依赖 pnpm,但系统未找到 pnpm。请先修复 Node.js / pnpm 环境后重试。" - .to_string(); - } - - if normalized.contains("npm") && normalized.contains("not found") { - return "OpenClaw 升级失败:当前安装方式依赖 npm,但系统未找到 npm。请先修复 Node.js / npm 环境后重试。" - .to_string(); - } - - format!("OpenClaw 升级失败:{detail}") -} - -async fn attempt_direct_openclaw_package_upgrade( - app: &AppHandle, - runtime_bin_dir_hint: Option<&Path>, - install_root_hint: Option<&Path>, - package_manager_hint: Option<&str>, -) -> Result { - let mut runtime_candidates = list_openclaw_runtime_candidates().await?; - runtime_candidates.sort_by(|left, right| { - let left_matches_root = install_root_hint - .map(|root| runtime_candidate_matches_install_root(left, root)) - .unwrap_or(false); - let right_matches_root = install_root_hint - .map(|root| runtime_candidate_matches_install_root(right, root)) - .unwrap_or(false); - let left_matches_bin = runtime_bin_dir_hint - .map(|hint| Path::new(&left.bin_dir) == hint) - .unwrap_or(false); - let right_matches_bin = runtime_bin_dir_hint - .map(|hint| Path::new(&right.bin_dir) == hint) - .unwrap_or(false); - - right_matches_root - .cmp(&left_matches_root) - .then_with(|| right_matches_bin.cmp(&left_matches_bin)) - .then_with(|| compare_openclaw_runtime_candidates(left, right)) - }); - let mut last_error = None; - let mut attempted = 0usize; - - if let Some(install_root_hint) = install_root_hint { - emit_install_progress( - app, - &format!( - "官方 updater 报告的安装根目录为 {},将优先匹配该安装来源执行兜底升级。", - install_root_hint.display() - ), - "info", - ); - } - - for candidate in runtime_candidates { - let Some(plan) = resolve_direct_openclaw_upgrade_plan( - app, - &candidate, - runtime_bin_dir_hint, - install_root_hint, - package_manager_hint, - ) - .await? - else { - continue; - }; - - attempted += 1; - emit_install_progress( - app, - &format!( - "官方自更新未能完成,开始尝试全局包升级兜底:{} · {}。", - plan.runtime_source, plan.package_manager - ), - "warn", - ); - emit_install_progress( - app, - &format!("目标执行环境:{}", plan.runtime_bin_dir.display()), - "info", - ); - emit_install_progress(app, &format!("升级包:{}", plan.package_spec), "info"); - - let result = run_shell_command_with_progress(app, &plan.command_line).await?; - if result.success { - emit_install_progress( - app, - &format!( - "已通过 {} 的 {} 全局安装方式完成兜底升级。", - plan.runtime_source, plan.package_manager - ), - "info", - ); - return Ok(OpenClawDirectUpgradeResult { - runtime_source: plan.runtime_source, - runtime_bin_dir: plan.runtime_bin_dir, - package_manager: plan.package_manager, - package_spec: plan.package_spec, - }); - } - - emit_install_progress( - app, - &format!( - "全局包升级兜底失败:{} · {}。", - plan.runtime_source, result.message - ), - "warn", - ); - last_error = Some(result.message); - } - - if attempted == 0 { - return Err("未检测到可用于全局升级的 OpenClaw 安装来源。".to_string()); - } - - Err(last_error.unwrap_or_else(|| "已自动尝试全局升级兜底,但仍未成功。".to_string())) -} - -async fn resolve_direct_openclaw_upgrade_plan( - app: &AppHandle, - candidate: &OpenClawRuntimeCandidate, - runtime_bin_dir_hint: Option<&Path>, - install_root_hint: Option<&Path>, - package_manager_hint: Option<&str>, -) -> Result, String> { - let runtime_bin_dir = PathBuf::from(&candidate.bin_dir); - let runtime_matches_hint = runtime_bin_dir_hint - .map(|hint| hint == runtime_bin_dir.as_path()) - .unwrap_or(false); - let runtime_matches_install_root = install_root_hint - .map(|root| runtime_candidate_matches_install_root(candidate, root)) - .unwrap_or(false); - - if runtime_bin_dir_hint.is_some() - && !runtime_matches_hint - && !runtime_matches_install_root - && candidate.openclaw_path.is_none() - && candidate.openclaw_package_path.is_none() - { - return Ok(None); - } - - if candidate.openclaw_path.is_none() && candidate.openclaw_package_path.is_none() { - return Ok(None); - } - - let package_spec = resolve_openclaw_upgrade_package_spec(app, candidate).await?; - let registry = package_registry_for_package_spec(&package_spec); - let package_manager_hint = package_manager_hint - .map(|value| value.trim().to_ascii_lowercase()) - .filter(|value| !value.is_empty()); - let npm_path = candidate - .npm_path - .as_deref() - .map(PathBuf::from) - .or_else(|| find_command_in_bin_dir("npm", &runtime_bin_dir)); - let pnpm_path = find_command_in_bin_dir("pnpm", &runtime_bin_dir); - let shell_platform = current_shell_platform(); - - let (package_manager, command_line) = match package_manager_hint.as_deref() { - Some("pnpm") => { - if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) { - ( - "pnpm".to_string(), - build_openclaw_pnpm_install_command( - shell_platform, - pnpm_path, - &package_spec, - registry, - ), - ) - } else if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) { - ( - "npm".to_string(), - build_openclaw_install_command( - shell_platform, - npm_path, - candidate.npm_global_prefix.as_deref(), - &package_spec, - registry, - ), - ) - } else { - return Ok(None); - } - } - _ => { - if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) { - ( - "npm".to_string(), - build_openclaw_install_command( - shell_platform, - npm_path, - candidate.npm_global_prefix.as_deref(), - &package_spec, - registry, - ), - ) - } else if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) { - ( - "pnpm".to_string(), - build_openclaw_pnpm_install_command( - shell_platform, - pnpm_path, - &package_spec, - registry, - ), - ) - } else { - return Ok(None); - } - } - }; - - Ok(Some(OpenClawDirectUpgradePlan { - runtime_source: candidate.source.clone(), - runtime_bin_dir, - package_manager, - package_spec, - command_line, - })) -} - -async fn resolve_openclaw_upgrade_package_spec( - app: &AppHandle, - candidate: &OpenClawRuntimeCandidate, -) -> Result { - if let Some(prefix) = candidate.npm_global_prefix.as_deref() { - if let Some(package) = find_installed_openclaw_package_details(prefix) { - return Ok(format!("{}@latest", package.name)); - } - } - - if let Some(package_path) = candidate.openclaw_package_path.as_deref() { - if let Some(package_name) = infer_openclaw_package_name_from_path(Path::new(package_path)) { - return Ok(format!("{package_name}@latest")); - } - } - - Ok(if should_use_china_package(app).await { - OPENCLAW_CN_PACKAGE.to_string() - } else { - OPENCLAW_DEFAULT_PACKAGE.to_string() - }) -} - -fn infer_openclaw_package_name_from_path(path: &Path) -> Option<&'static str> { - let normalized = path - .display() - .to_string() - .replace('\\', "/") - .to_ascii_lowercase(); - if normalized.contains("@qingchencloud/openclaw-zh") { - return Some("@qingchencloud/openclaw-zh"); - } - if normalized.contains("/openclaw/package.json") { - return Some("openclaw"); - } - None -} - -fn package_registry_for_package_spec(package_spec: &str) -> Option<&'static str> { - package_spec - .starts_with("@qingchencloud/openclaw-zh@") - .then_some(NPM_MIRROR_CN) -} - -fn runtime_candidate_matches_install_root( - candidate: &OpenClawRuntimeCandidate, - install_root_hint: &Path, -) -> bool { - [ - Some(candidate.bin_dir.as_str()), - Some(candidate.node_path.as_str()), - candidate.npm_path.as_deref(), - candidate.npm_global_prefix.as_deref(), - candidate.openclaw_path.as_deref(), - candidate.openclaw_package_path.as_deref(), - ] - .into_iter() - .flatten() - .map(PathBuf::from) - .any(|path| path.starts_with(install_root_hint)) -} - -fn format_gateway_start_failure_message(detail: Option<&str>) -> String { - let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else { - return "Gateway 启动超时,请检查配置或端口占用。".to_string(); - }; - - let normalized = detail.to_ascii_lowercase(); - if normalized.contains("invalid config") || normalized.contains("config invalid") { - if normalized.contains("contextwindow") && normalized.contains("received null") { - return "Gateway 启动失败:当前 OpenClaw 配置包含空的 contextWindow 字段。Lime 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。" - .to_string(); - } - return "Gateway 启动失败:OpenClaw 配置文件无效,请重新同步模型配置后再试。".to_string(); - } - - if normalized.contains("missing config") || normalized.contains("gateway.mode=local") { - return "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。" - .to_string(); - } - - if normalized.contains("gateway.auth.mode") { - return "Gateway 启动失败:缺少网关认证模式,已自动切换为 token 模式,请重试。".to_string(); - } - - if normalized.contains("address already in use") || normalized.contains("eaddrinuse") { - return "Gateway 启动失败:目标端口已被占用,请更换端口或停止占用进程。".to_string(); - } - - if normalized.contains("resolved to non-loopback host") { - return "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。".to_string(); - } - - if normalized.contains("allowedorigins") || normalized.contains("host-header origin fallback") { - return "Gateway 启动失败:当前绑定方式需要配置 Control UI 允许来源,请检查 gateway.controlUi.allowedOrigins。".to_string(); - } - - if normalized.contains("doctor --fix") { - return "Gateway 启动失败:OpenClaw 检测到本地环境或配置异常,请先在安装页执行“重新检测”或“修复环境”后再试。" - .to_string(); - } - - format!("Gateway 启动失败:{detail}") -} - -fn ensure_value_object(value: &mut Value) -> &mut Map { - if !value.is_object() { - *value = Value::Object(Map::new()); - } - value.as_object_mut().expect("value should be object") -} - -fn build_channel_info(channel_id: &str, entry: &Value, label: Option<&Value>) -> ChannelInfo { - ChannelInfo { - id: channel_id.to_string(), - name: entry - .get("name") - .and_then(Value::as_str) - .or_else(|| label.and_then(Value::as_str)) - .unwrap_or("未命名通道") - .to_string(), - channel_type: entry - .get("type") - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(), - status: entry - .get("status") - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(), - } -} - -fn extract_gateway_auth_token(config: &Value) -> Option { - config - .get("gateway") - .and_then(|gateway| { - gateway - .get("auth") - .and_then(|auth| auth.get("token")) - .or_else(|| gateway.get("remote").and_then(|remote| remote.get("token"))) - }) - .and_then(Value::as_str) - .map(str::trim) - .filter(|token| !token.is_empty()) - .map(ToString::to_string) -} - -fn determine_api_type(provider_type: ApiProviderType) -> Result<&'static str, String> { - match provider_type { - ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => { - Ok("anthropic-messages") - } - ApiProviderType::OpenaiResponse => Ok("openai-responses"), - ApiProviderType::Openai - | ApiProviderType::Codex - | ApiProviderType::Gemini - | ApiProviderType::Ollama - | ApiProviderType::Fal - | ApiProviderType::NewApi - | ApiProviderType::Gateway => Ok("openai-completions"), - ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => { - Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string()) - } - } -} - -fn format_provider_base_url(provider: &ApiKeyProvider) -> Result { - let api_host = trim_trailing_slash(&provider.api_host); - - match provider.provider_type { - ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => Ok(api_host), - ApiProviderType::Gemini => { - if api_host.contains("generativelanguage.googleapis.com") { - if api_host.ends_with("/v1beta/openai") { - Ok(api_host) - } else { - Ok(format!("{api_host}/v1beta/openai")) - } - } else if has_api_version(&api_host) { - Ok(api_host) - } else { - Ok(format!("{api_host}/v1")) - } - } - ApiProviderType::Gateway => { - if api_host.ends_with("/v1/ai") { - Ok(api_host.trim_end_matches("/ai").to_string()) - } else if has_api_version(&api_host) { - Ok(api_host) - } else { - Ok(format!("{api_host}/v1")) - } - } - ApiProviderType::Openai - | ApiProviderType::OpenaiResponse - | ApiProviderType::Codex - | ApiProviderType::Ollama - | ApiProviderType::Fal - | ApiProviderType::NewApi => { - if has_api_version(&api_host) { - Ok(api_host) - } else { - Ok(format!("{api_host}/v1")) - } - } - ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => { - Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string()) - } - } -} - -fn trim_trailing_slash(value: &str) -> String { - value.trim().trim_end_matches('/').to_string() -} - -fn has_api_version(url: &str) -> bool { - static VERSION_RE: OnceLock = OnceLock::new(); - VERSION_RE - .get_or_init(|| Regex::new(r"/v\d+(?:[./]|$)").expect("regex should compile")) - .is_match(url) -} - -fn generate_auth_token() -> String { - rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(48) - .map(char::from) - .collect() -} - -async fn should_use_china_package(app: &AppHandle) -> bool { - if let Some(app_state) = app.try_state::() { - let language = { - let state = app_state.read().await; - state.config.language.clone() - }; - - if language.starts_with("zh") { - return true; - } - } - - let locale = std::env::var("LC_ALL") - .ok() - .or_else(|| std::env::var("LANG").ok()) - .unwrap_or_default() - .to_lowercase(); - let timezone = std::env::var("TZ").unwrap_or_default().to_lowercase(); - locale.contains("zh_cn") || locale.contains("zh-hans") || timezone.contains("shanghai") -} - -async fn detect_npm_global_prefix(npm_path: &str) -> Option { - let mut command = Command::new(npm_path); - apply_binary_runtime_path(&mut command, npm_path); - let output = command - .arg("config") - .arg("get") - .arg("prefix") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await - .ok()?; - - if !output.status.success() { - return None; - } - - let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if prefix.is_empty() || prefix.eq_ignore_ascii_case("undefined") { - None - } else { - Some(prefix) - } -} - -fn current_shell_platform() -> ShellPlatform { - if cfg!(target_os = "windows") { - ShellPlatform::Windows - } else { - ShellPlatform::Unix - } -} - -#[allow(dead_code)] -fn command_bin_dir_for(platform: ShellPlatform, binary_path: &str) -> Option { - core_command_bin_dir_for(platform, binary_path) -} - -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) -} - -#[allow(dead_code)] -fn shell_npm_prefix_assignment_for(platform: ShellPlatform, value: &str) -> String { - core_shell_npm_prefix_assignment_for(platform, value) -} - -fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> String { - core_shell_path_assignment_for(platform, binary_path) -} - -#[cfg_attr(target_os = "windows", allow(dead_code))] -fn shell_path_assignment(binary_path: &str) -> String { - shell_path_assignment_for(current_shell_platform(), binary_path) -} - -fn build_openclaw_cleanup_command( - platform: ShellPlatform, - npm_path: &str, - npm_prefix: Option<&str>, -) -> String { - core_build_openclaw_cleanup_command(platform, npm_path, npm_prefix) -} - -fn build_openclaw_install_command( - platform: ShellPlatform, - npm_path: &str, - npm_prefix: Option<&str>, - package: &str, - registry: Option<&str>, -) -> String { - core_build_openclaw_install_command(platform, npm_path, npm_prefix, package, registry) -} - -fn build_openclaw_pnpm_install_command( - platform: ShellPlatform, - pnpm_path: &str, - package: &str, - registry: Option<&str>, -) -> String { - let mut command = format!( - "{}{}{} add -g {}", - shell_path_assignment_for(platform, pnpm_path), - shell_command_invocation_prefix_for(platform, pnpm_path), - shell_command_escape_for(platform, pnpm_path), - shell_command_escape_for(platform, package), - ); - - if let Some(registry) = registry { - command.push(' '); - command.push_str("--registry="); - command.push_str(&shell_command_escape_for(platform, registry)); - } - - command -} - -#[allow(dead_code)] -fn resolve_windows_dependency_install_plan( - dependency: DependencyKind, - has_winget: bool, -) -> WindowsDependencyInstallPlan { - core_resolve_windows_dependency_install_plan( - match dependency { - DependencyKind::Node => OpenClawInstallDependencyKind::Node, - DependencyKind::Git => OpenClawInstallDependencyKind::Git, - }, - has_winget, - ) -} - -#[allow(dead_code)] -fn build_winget_install_command(winget_path: &str, package_id: &str) -> String { - core_build_winget_install_command(winget_path, package_id) -} - -#[allow(dead_code)] -fn windows_manual_install_message(dependency: DependencyKind) -> &'static str { - core_windows_manual_install_message(match dependency { - DependencyKind::Node => OpenClawInstallDependencyKind::Node, - DependencyKind::Git => OpenClawInstallDependencyKind::Git, - }) -} - -fn prepend_path(dir: &Path) -> Option { - let mut paths = vec![dir.to_path_buf()]; - if let Some(current) = std::env::var_os("PATH") { - paths.extend(std::env::split_paths(¤t)); - } - std::env::join_paths(paths).ok() -} - -fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) { - apply_windows_no_window(command); - - let Some(bin_dir) = Path::new(binary_path).parent() else { - return; - }; - if let Some(path) = prepend_path(bin_dir) { - command.env("PATH", path); - } -} - -fn apply_windows_no_window(_command: &mut Command) { - #[cfg(target_os = "windows")] - { - _command.creation_flags(CREATE_NO_WINDOW); - } -} - -fn preferred_runtime_bin_dir_store() -> &'static StdMutex> { - OPENCLAW_PREFERRED_RUNTIME_BIN_DIR.get_or_init(|| StdMutex::new(None)) -} - -fn get_preferred_runtime_bin_dir() -> Option { - preferred_runtime_bin_dir_store() - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clone() -} - -fn set_preferred_runtime_bin_dir(next: Option) { - let mut guard = preferred_runtime_bin_dir_store() - .lock() - .unwrap_or_else(|error| error.into_inner()); - *guard = next.filter(|path| !path.as_os_str().is_empty()); -} - -fn command_uses_node_runtime(command_name: &str) -> bool { - matches!(command_name, "node" | "npm" | "npx" | "openclaw") -} - -fn find_command_in_bin_dir(command_name: &str, bin_dir: &Path) -> Option { - select_preferred_path_candidate(find_all_commands_in_paths( - command_name, - &[bin_dir.to_path_buf()], - )) -} - -fn collect_existing_unique_dirs(candidates: I) -> Vec -where - I: IntoIterator, -{ - let mut dirs = Vec::new(); - let mut seen = HashSet::new(); - - for dir in candidates { - if dir.as_os_str().is_empty() || !dir.exists() { - continue; - } - if seen.insert(dir.clone()) { - dirs.push(dir); - } - } - - dirs -} - -async fn collect_preferred_runtime_command_dirs( - command_name: &str, - preferred_bin_dir: &Path, -) -> Result, String> { - let mut candidate_dirs = vec![preferred_bin_dir.to_path_buf()]; - - if command_name == "openclaw" { - if let Some(npm_path) = find_command_in_bin_dir("npm", preferred_bin_dir) - .and_then(|path| path.to_str().map(str::to_string)) - { - if let Some(prefix) = detect_npm_global_prefix(&npm_path).await { - for dir in npm_global_command_dirs(&prefix) { - candidate_dirs.push(dir); - } - } - } - } - - Ok(collect_existing_unique_dirs(candidate_dirs)) -} - -async fn collect_preferred_runtime_command_candidates( - command_name: &str, -) -> Result, String> { - if !command_uses_node_runtime(command_name) { - return Ok(Vec::new()); - } - - let Some(preferred_bin_dir) = get_preferred_runtime_bin_dir() else { - return Ok(Vec::new()); - }; - - let search_dirs = - collect_preferred_runtime_command_dirs(command_name, &preferred_bin_dir).await?; - Ok(find_all_commands_in_paths(command_name, &search_dirs)) -} - -async fn find_command_in_shell(command_name: &str) -> Result, String> { - let mut candidates = collect_standard_command_candidates(command_name).await?; - - if command_name == "openclaw" { - candidates.extend(find_commands_via_npm_global_prefix(command_name).await?); - } - - Ok(select_command_path(command_name, candidates) - .await? - .map(|path| path.to_string_lossy().to_string())) -} - -async fn find_command_in_standard_locations(command_name: &str) -> Result, String> { - Ok(select_command_path( - command_name, - collect_standard_command_candidates(command_name).await?, - ) - .await? - .map(|path| path.to_string_lossy().to_string())) -} - -async fn collect_standard_command_candidates(command_name: &str) -> Result, String> { - collect_standard_command_candidates_with_preference(command_name, true).await -} - -async fn collect_standard_command_candidates_without_preference( - command_name: &str, -) -> Result, String> { - collect_standard_command_candidates_with_preference(command_name, false).await -} - -async fn collect_standard_command_candidates_with_preference( - command_name: &str, - include_preferred_runtime: bool, -) -> Result, String> { - #[cfg(target_os = "windows")] - { - let _ = refresh_windows_path_from_registry(); - } - - let mut candidates = Vec::new(); - - if include_preferred_runtime { - candidates.extend(collect_preferred_runtime_command_candidates(command_name).await?); - } - - #[cfg(target_os = "windows")] - { - candidates.extend(find_commands_via_where(command_name).await?); - } - - candidates.extend(find_all_commands_in_known_locations(command_name)); - - Ok(candidates) -} - -async fn select_command_path( - command_name: &str, - candidates: Vec, -) -> Result, String> { - select_command_candidate(command_name, dedupe_paths(candidates)).await -} - -#[cfg(target_os = "windows")] -async fn find_commands_via_where(command_name: &str) -> Result, String> { - let mut command = Command::new("cmd"); - apply_windows_no_window(&mut command); - let output = command - .arg("/C") - .arg("where") - .arg(command_name) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await - .map_err(|e| format!("查找命令失败: {e}"))?; - - if !output.status.success() { - return Ok(Vec::new()); - } - - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(PathBuf::from) - .collect()) -} - -async fn select_command_candidate( - command_name: &str, - candidates: Vec, -) -> Result, String> { - if candidates.is_empty() { - return Ok(None); - } - - let has_preferred_runtime = - command_uses_node_runtime(command_name) && get_preferred_runtime_bin_dir().is_some(); - if let Some(candidate) = select_preferred_runtime_candidate(command_name, &candidates).await? { - return Ok(Some(candidate)); - } - if has_preferred_runtime { - return Ok(None); - } - - if command_name == "node" { - return select_best_node_candidate(candidates).await; - } - - if matches!(command_name, "npm" | "npx" | "openclaw") { - return select_node_runtime_candidate(candidates).await; - } - - if command_name == "git" { - return Ok(select_best_git_candidate(candidates)); - } - - Ok(candidates.into_iter().next()) -} - -fn select_best_git_candidate(candidates: Vec) -> Option { - select_preferred_path_candidate(candidates.clone()).or_else(|| candidates.into_iter().next()) -} - -async fn select_preferred_runtime_candidate( - command_name: &str, - candidates: &[PathBuf], -) -> Result, String> { - if !command_uses_node_runtime(command_name) { - return Ok(None); - } - - let Some(preferred_bin_dir) = get_preferred_runtime_bin_dir() else { - return Ok(None); - }; - - let preferred_dirs = - collect_preferred_runtime_command_dirs(command_name, &preferred_bin_dir).await?; - if preferred_dirs.is_empty() { - return Ok(None); - } - - Ok(select_preferred_path_candidate( - candidates - .iter() - .filter(|candidate| { - candidate - .parent() - .is_some_and(|parent| preferred_dirs.iter().any(|dir| dir.as_path() == parent)) - }) - .cloned() - .collect(), - )) -} - -fn find_all_commands_in_known_locations(command_name: &str) -> Vec { - let search_dirs = collect_known_command_search_dirs(command_name); - find_all_commands_in_paths(command_name, &search_dirs) -} - -fn collect_known_command_search_dirs(_command_name: &str) -> Vec { - let mut search_dirs = Vec::new(); - - if let Some(path_var) = std::env::var_os("PATH") { - search_dirs.extend(std::env::split_paths(&path_var)); - } - - if let Some(home) = home_dir() { - search_dirs.extend([ - home.join(".npm-global/bin"), - home.join(".local/bin"), - home.join(".bun/bin"), - home.join(".volta/bin"), - home.join(".asdf/shims"), - home.join(".local/share/mise/shims"), - home.join("Library/PhpWebStudy/env/node/bin"), - ]); - - let nvm_versions = home.join(".nvm/versions/node"); - if let Ok(entries) = std::fs::read_dir(nvm_versions) { - for entry in entries.flatten() { - search_dirs.push(entry.path().join("bin")); - } - } - - let fnm_versions = home.join(".fnm/node-versions"); - if let Ok(entries) = std::fs::read_dir(fnm_versions) { - for entry in entries.flatten() { - search_dirs.push(entry.path().join("installation/bin")); - } - } - } - - #[cfg(target_os = "windows")] - { - search_dirs.extend(windows_known_command_dirs_from_env()); - if _command_name == "git" { - search_dirs.extend(windows_known_git_command_dirs_from_env()); - } - } - - if cfg!(target_os = "macos") { - search_dirs.extend([ - PathBuf::from("/opt/homebrew/bin"), - PathBuf::from("/usr/local/bin"), - PathBuf::from("/usr/bin"), - PathBuf::from("/bin"), - ]); - } - - collect_existing_unique_dirs(search_dirs) -} - -#[cfg(target_os = "windows")] -fn windows_known_command_dirs_from_env() -> Vec { - let mut dirs = Vec::new(); - - if let Some(appdata) = std::env::var_os("APPDATA") { - dirs.push(PathBuf::from(appdata).join("npm")); - } - - if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") { - let localappdata = PathBuf::from(localappdata); - dirs.push(localappdata.join("Programs").join("nodejs")); - dirs.push(localappdata.join("Volta").join("bin")); - } - - if let Some(program_files) = std::env::var_os("ProgramFiles") { - dirs.push(PathBuf::from(program_files).join("nodejs")); - } - - if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { - dirs.push(PathBuf::from(program_files_x86).join("nodejs")); - } - - if let Some(home) = home_dir() { - dirs.push(home.join("AppData").join("Roaming").join("npm")); - dirs.push( - home.join("AppData") - .join("Local") - .join("Programs") - .join("nodejs"), - ); - } - - dirs -} - -#[cfg(any(target_os = "windows", test))] -fn windows_git_install_dir_variants(root: PathBuf) -> Vec { - vec![ - root.join("cmd"), - root.join("bin"), - root.join("mingw64").join("bin"), - ] -} - -#[cfg(target_os = "windows")] -fn windows_known_git_command_dirs_from_env() -> Vec { - let mut dirs = Vec::new(); - - if let Some(program_files) = std::env::var_os("ProgramFiles") { - dirs.extend(windows_git_install_dir_variants( - PathBuf::from(program_files).join("Git"), - )); - } - - if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { - dirs.extend(windows_git_install_dir_variants( - PathBuf::from(program_files_x86).join("Git"), - )); - } - - if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") { - dirs.extend(windows_git_install_dir_variants( - PathBuf::from(localappdata).join("Programs").join("Git"), - )); - } - - if let Some(home) = home_dir() { - dirs.extend(windows_git_install_dir_variants( - home.join("scoop").join("apps").join("git").join("current"), - )); - } - - dirs -} - -fn find_all_commands_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Vec { - find_all_commands_in_paths_for(current_shell_platform(), command_name, search_dirs) -} - -fn find_all_commands_in_paths_for( - platform: ShellPlatform, - command_name: &str, - search_dirs: &[PathBuf], -) -> Vec { - let candidates = match platform { - ShellPlatform::Windows => vec![ - format!("{command_name}.exe"), - format!("{command_name}.cmd"), - format!("{command_name}.bat"), - command_name.to_string(), - ], - ShellPlatform::Unix => vec![command_name.to_string()], - }; - - let mut matches = Vec::new(); - let mut seen = HashSet::new(); - for dir in search_dirs { - for candidate in &candidates { - let path = dir.join(candidate); - if path.is_file() && seen.insert(path.clone()) { - matches.push(path); - } - } - } - - matches -} - -async fn find_commands_via_npm_global_prefix(command_name: &str) -> Result, String> { - let Some(npm_path) = find_command_in_standard_locations("npm").await? else { - return Ok(Vec::new()); - }; - let Some(prefix) = detect_npm_global_prefix(&npm_path).await else { - return Ok(Vec::new()); - }; - - Ok(find_all_commands_in_paths( - command_name, - &npm_global_command_dirs(&prefix), - )) -} - -fn npm_global_command_dirs(prefix: &str) -> Vec { - npm_global_command_dirs_for(current_shell_platform(), prefix) -} - -fn npm_global_command_dirs_for(platform: ShellPlatform, prefix: &str) -> Vec { - let prefix_path = PathBuf::from(prefix); - - match platform { - ShellPlatform::Windows => vec![prefix_path], - ShellPlatform::Unix => vec![prefix_path.join("bin"), prefix_path], - } -} - -fn npm_global_node_modules_dirs_for(platform: ShellPlatform, prefix: &str) -> Vec { - let prefix_path = PathBuf::from(prefix); - - match platform { - ShellPlatform::Windows => vec![prefix_path.join("node_modules")], - ShellPlatform::Unix => vec![ - prefix_path.join("lib").join("node_modules"), - prefix_path.join("node_modules"), - ], - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct InstalledOpenClawPackage { - name: &'static str, - version: Option, - path: PathBuf, -} - -#[cfg(test)] -fn find_installed_openclaw_package(prefix: &str) -> Option<(&'static str, Option)> { - find_installed_openclaw_package_details(prefix).map(|package| (package.name, package.version)) -} - -fn find_installed_openclaw_package_details(prefix: &str) -> Option { - for node_modules_dir in npm_global_node_modules_dirs_for(current_shell_platform(), prefix) { - let openclaw_manifest = node_modules_dir.join("openclaw").join("package.json"); - if openclaw_manifest.is_file() { - return Some(InstalledOpenClawPackage { - name: "openclaw", - version: read_package_version(&openclaw_manifest), - path: openclaw_manifest, - }); - } - - let zh_manifest = node_modules_dir - .join("@qingchencloud") - .join("openclaw-zh") - .join("package.json"); - if zh_manifest.is_file() { - return Some(InstalledOpenClawPackage { - name: "@qingchencloud/openclaw-zh", - version: read_package_version(&zh_manifest), - path: zh_manifest, - }); - } - } - - None -} - -fn read_package_version(manifest_path: &Path) -> Option { - #[derive(Deserialize)] - struct PackageManifest { - version: Option, - } - - let content = std::fs::read_to_string(manifest_path).ok()?; - let manifest = serde_json::from_str::(&content).ok()?; - manifest.version.filter(|item| !item.trim().is_empty()) -} - -fn resolve_openclaw_cli_entry_from_package_manifest(manifest_path: &Path) -> Option { - let package_root = manifest_path.parent()?; - let content = std::fs::read_to_string(manifest_path).ok()?; - let manifest = serde_json::from_str::(&content).ok()?; - - let mut candidates = Vec::new(); - - if let Some(bin_value) = manifest.get("bin") { - let bin_entry = match bin_value { - Value::String(value) => Some(value.as_str()), - Value::Object(entries) => entries - .get("openclaw") - .and_then(Value::as_str) - .or_else(|| entries.values().find_map(Value::as_str)), - _ => None, - }; - - if let Some(entry) = bin_entry { - candidates.push(package_root.join(entry)); - } - } - - candidates.push(package_root.join("dist").join("index.js")); - candidates.push(package_root.join("dist").join("index.mjs")); - candidates.push(package_root.join("dist").join("entry.js")); - candidates.push(package_root.join("dist").join("entry.mjs")); - - candidates.into_iter().find(|path| path.is_file()) -} - -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 { - let openclaw_path = candidate - .openclaw_path - .as_deref() - .map(PathBuf::from) - .filter(|path| path.is_file()); - - if let Some(openclaw_path) = openclaw_path.as_ref() { - if !prefers_node_cli_for_openclaw_path(platform, openclaw_path) { - return Some(ResolvedOpenClawCommand::Binary { - binary_path: openclaw_path.clone(), - }); - } - } - - let node_path = PathBuf::from(candidate.node_path.as_str()); - let manifest_path = candidate - .openclaw_package_path - .as_deref() - .map(PathBuf::from); - - if node_path.is_file() { - if let Some(manifest_path) = manifest_path { - if let Some(cli_path) = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path) - { - return Some(ResolvedOpenClawCommand::NodeCli { - node_path, - cli_path, - package_version: read_package_version(&manifest_path), - }); - } - } - } - - openclaw_path.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path }) -} - -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( - commands: Vec, -) -> Vec { - let mut deduped = Vec::with_capacity(commands.len()); - let mut seen = HashSet::new(); - for command in commands { - if seen.insert(command.dedupe_key()) { - deduped.push(command); - } - } - deduped -} - -fn dedupe_paths(candidates: Vec) -> Vec { - let mut deduped = Vec::with_capacity(candidates.len()); - let mut seen = HashSet::new(); - for candidate in candidates { - if seen.insert(candidate.clone()) { - deduped.push(candidate); - } - } - deduped -} - -async fn resolve_openclaw_command() -> Result, String> { - let shell_platform = current_shell_platform(); - let shell_binary = find_command_in_shell("openclaw").await?.map(PathBuf::from); - let mut runtime_candidates = list_openclaw_runtime_candidates().await?; - runtime_candidates.sort_by(compare_openclaw_runtime_candidates); - - if let Some(shell_binary) = shell_binary.as_ref() { - if !prefers_node_cli_for_openclaw_path(shell_platform, shell_binary) { - return Ok(Some(ResolvedOpenClawCommand::Binary { - binary_path: shell_binary.clone(), - })); - } - - if let Some(command) = runtime_candidates - .iter() - .filter(|candidate| runtime_candidate_matches_openclaw_path(candidate, shell_binary)) - .find_map(|candidate| { - resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate) - }) - { - return Ok(Some(command)); - } - } - - if let Some(command) = runtime_candidates.iter().find_map(|candidate| { - resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate) - }) { - return Ok(Some(command)); - } - - Ok(shell_binary.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path })) -} - -async fn read_openclaw_version_from_command( - command_spec: &ResolvedOpenClawCommand, -) -> Result, String> { - if let Some(version) = command_spec.fallback_version() { - return Ok(Some(version)); - } - - let output = command_spec - .build_command_with_args(["--version"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?; - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if stdout.is_empty() { - Ok(None) - } else { - Ok(Some(stdout)) - } -} - -async fn list_openclaw_runtime_candidates() -> Result, String> { - let node_candidates = - dedupe_paths(collect_standard_command_candidates_without_preference("node").await?); - let preferred_bin_dir = get_preferred_runtime_bin_dir().filter(|dir| dir.is_dir()); - let auto_selected_node = select_best_node_candidate(node_candidates.clone()).await?; - let active_bin_dir = preferred_bin_dir.clone().or_else(|| { - auto_selected_node - .as_ref() - .and_then(|path| path.parent().map(Path::to_path_buf)) - }); - - let mut runtimes = Vec::new(); - let mut seen_bin_dirs = HashSet::new(); - for node_path in node_candidates { - let Some(bin_dir) = node_path.parent().map(Path::to_path_buf) else { - continue; - }; - if !seen_bin_dirs.insert(bin_dir.clone()) { - continue; - } - - runtimes.push( - inspect_openclaw_runtime_candidate( - &bin_dir, - preferred_bin_dir.as_deref(), - active_bin_dir.as_deref(), - ) - .await, - ); - } - - runtimes.sort_by(compare_openclaw_runtime_candidates); - Ok(runtimes) -} - -async fn inspect_openclaw_runtime_candidate( - bin_dir: &Path, - preferred_bin_dir: Option<&Path>, - active_bin_dir: Option<&Path>, -) -> OpenClawRuntimeCandidate { - let node_path = find_command_in_bin_dir("node", bin_dir).unwrap_or_else(|| { - #[cfg(target_os = "windows")] - let node_name = "node.exe"; - #[cfg(not(target_os = "windows"))] - let node_name = "node"; - - bin_dir.join(node_name) - }); - - let npm_path = find_command_in_bin_dir("npm", bin_dir); - let npm_path_string = npm_path.as_ref().map(|path| path.display().to_string()); - let npm_global_prefix = match npm_path_string.as_deref() { - Some(path) => detect_npm_global_prefix(path).await, - None => None, - }; - let openclaw_path = resolve_runtime_openclaw_path(bin_dir, npm_global_prefix.as_deref()); - let openclaw_package_path = npm_global_prefix - .as_deref() - .and_then(find_installed_openclaw_package_details) - .map(|package| package.path.display().to_string()); - - OpenClawRuntimeCandidate { - id: bin_dir.display().to_string(), - source: infer_openclaw_runtime_source(bin_dir), - bin_dir: bin_dir.display().to_string(), - node_path: node_path.display().to_string(), - node_version: read_display_version_text(&node_path).await, - npm_path: npm_path_string, - npm_global_prefix, - openclaw_path: openclaw_path - .as_ref() - .map(|path| path.display().to_string()), - openclaw_version: match openclaw_path.as_deref() { - Some(path) => read_display_version_text(path).await, - None => None, - }, - openclaw_package_path, - is_active: active_bin_dir.is_some_and(|path| path == bin_dir), - is_preferred: preferred_bin_dir.is_some_and(|path| path == bin_dir), - } -} - -fn resolve_runtime_openclaw_path( - bin_dir: &Path, - npm_global_prefix: Option<&str>, -) -> Option { - find_command_in_bin_dir("openclaw", bin_dir).or_else(|| { - npm_global_prefix.and_then(|prefix| { - select_preferred_path_candidate(find_all_commands_in_paths( - "openclaw", - &npm_global_command_dirs(prefix), - )) - }) - }) -} - -async fn read_display_version_text(binary_path: &Path) -> Option { - let path = binary_path.to_str()?; - let version_text = read_command_version_text(path, &["--version"]).await.ok()?; - let version_text = version_text.trim(); - if version_text.is_empty() { - return None; - } - - Some( - parse_semver_from_text(version_text) - .map(format_semver) - .unwrap_or_else(|| version_text.to_string()), - ) -} - -fn infer_openclaw_runtime_source(bin_dir: &Path) -> String { - let normalized = bin_dir - .display() - .to_string() - .replace('\\', "/") - .to_lowercase(); - - if normalized.contains("/.nvm/") { - return "nvm".to_string(); - } - if normalized.contains("/phpwebstudy/") { - return "PhpWebStudy".to_string(); - } - if normalized.contains("/.volta/") { - return "Volta".to_string(); - } - if normalized.contains("/.fnm/") { - return "fnm".to_string(); - } - if normalized.contains("/.asdf/") { - return "asdf".to_string(); - } - if normalized.contains("/mise/") { - return "mise".to_string(); - } - if normalized.contains("/opt/homebrew/") { - return "Homebrew".to_string(); - } - if normalized.ends_with("/usr/local/bin") - || normalized.ends_with("/usr/bin") - || normalized.ends_with("/bin") - || normalized.contains("/program files/nodejs") - || normalized.contains("/program files (x86)/nodejs") - { - return "系统".to_string(); - } - - "PATH".to_string() -} - -fn compare_openclaw_runtime_candidates( - left: &OpenClawRuntimeCandidate, - right: &OpenClawRuntimeCandidate, -) -> Ordering { - right - .is_active - .cmp(&left.is_active) - .then_with(|| right.is_preferred.cmp(&left.is_preferred)) - .then_with(|| { - right - .openclaw_path - .is_some() - .cmp(&left.openclaw_path.is_some()) - }) - .then_with(|| { - compare_optional_semver_desc( - left.node_version.as_deref(), - right.node_version.as_deref(), - ) - }) - .then_with(|| left.bin_dir.cmp(&right.bin_dir)) -} - -fn compare_optional_semver_desc(left: Option<&str>, right: Option<&str>) -> Ordering { - match ( - left.and_then(parse_semver_from_text), - right.and_then(parse_semver_from_text), - ) { - (Some(left), Some(right)) => right.cmp(&left), - (Some(_), None) => Ordering::Less, - (None, Some(_)) => Ordering::Greater, - (None, None) => Ordering::Equal, - } -} - -async fn collect_environment_diagnostics() -> EnvironmentDiagnostics { - let npm_path = find_command_in_standard_locations("npm") - .await - .ok() - .flatten(); - let npm_global_prefix = match npm_path.as_deref() { - Some(path) => detect_npm_global_prefix(path).await, - None => None, - }; - - #[cfg(target_os = "windows")] - let where_candidates = find_commands_via_where("openclaw") - .await - .unwrap_or_default() - .into_iter() - .map(|path| path.display().to_string()) - .collect(); - - #[cfg(not(target_os = "windows"))] - let where_candidates = Vec::new(); - - let supplemental_search_dirs = - collect_supplemental_openclaw_search_dirs(npm_global_prefix.as_deref()); - let supplemental_command_candidates = - find_all_commands_in_paths("openclaw", &supplemental_search_dirs) - .into_iter() - .map(|path| path.display().to_string()) - .collect(); - let openclaw_package_path = npm_global_prefix - .as_deref() - .and_then(find_installed_openclaw_package_details) - .map(|package| package.path.display().to_string()); - - #[cfg(target_os = "windows")] - let git_where_candidates = find_commands_via_where("git") - .await - .unwrap_or_default() - .into_iter() - .map(|path| path.display().to_string()) - .collect(); - - #[cfg(not(target_os = "windows"))] - let git_where_candidates = Vec::new(); - - let git_supplemental_search_dirs = collect_supplemental_git_search_dirs(); - let git_supplemental_command_candidates = - find_all_commands_in_paths("git", &git_supplemental_search_dirs) - .into_iter() - .map(|path| path.display().to_string()) - .collect(); - - EnvironmentDiagnostics { - npm_path, - npm_global_prefix, - openclaw_package_path, - where_candidates, - supplemental_search_dirs: supplemental_search_dirs - .into_iter() - .map(|path| path.display().to_string()) - .collect(), - supplemental_command_candidates, - git_where_candidates, - git_supplemental_search_dirs: git_supplemental_search_dirs - .into_iter() - .map(|path| path.display().to_string()) - .collect(), - git_supplemental_command_candidates, - } -} - -fn collect_supplemental_openclaw_search_dirs(npm_global_prefix: Option<&str>) -> Vec { - let mut dirs = Vec::new(); - - #[cfg(target_os = "windows")] - { - dirs.extend(windows_known_command_dirs_from_env()); - } - - if let Some(prefix) = npm_global_prefix { - dirs.extend(npm_global_command_dirs(prefix)); - } - - collect_existing_unique_dirs(dirs) -} - -fn collect_supplemental_git_search_dirs() -> Vec { - #[cfg(target_os = "windows")] - { - return collect_existing_unique_dirs(windows_known_git_command_dirs_from_env()); - } - - #[cfg(not(target_os = "windows"))] - { - Vec::new() - } -} - -async fn select_best_node_candidate(candidates: Vec) -> Result, String> { - let mut versioned = Vec::with_capacity(candidates.len()); - for candidate in candidates { - let openclaw_signal = match candidate.parent() { - Some(bin_dir) => inspect_node_runtime_openclaw_signal(bin_dir).await, - None => 0, - }; - let version = read_binary_semver(&candidate).await; - versioned.push((candidate, openclaw_signal, version)); - } - Ok(select_best_node_runtime_candidate(versioned)) -} - -async fn select_node_runtime_candidate( - candidates: Vec, -) -> Result, String> { - let mut runtime_ranked = Vec::with_capacity(candidates.len()); - for candidate in &candidates { - let openclaw_signal = match candidate.parent() { - Some(bin_dir) => inspect_node_runtime_openclaw_signal(bin_dir).await, - None => 0, - }; - let version = match sibling_node_path(candidate) { - Some(node_path) => read_binary_semver(&node_path).await, - None => None, - }; - runtime_ranked.push((candidate.clone(), openclaw_signal, version)); - } - - if let Some(candidate) = select_best_node_runtime_candidate(runtime_ranked.clone()) { - let candidate_signal = runtime_ranked - .iter() - .find(|(path, _, _)| path == &candidate) - .map(|(_, signal, _)| *signal) - .unwrap_or(0); - if candidate_signal > 0 { - return Ok(Some(candidate)); - } - } - - let preferred_node = - select_best_node_candidate(find_all_commands_in_known_locations("node")).await?; - if let Some(preferred_bin_dir) = preferred_node.as_deref().and_then(Path::parent) { - if let Some(candidate) = select_preferred_path_candidate( - candidates - .iter() - .filter(|candidate| candidate.parent() == Some(preferred_bin_dir)) - .cloned() - .collect(), - ) { - return Ok(Some(candidate)); - } - } - - Ok( - select_best_node_runtime_candidate(runtime_ranked) - .or_else(|| candidates.into_iter().next()), - ) -} - -async fn inspect_node_runtime_openclaw_signal(bin_dir: &Path) -> u8 { - if find_command_in_bin_dir("openclaw", bin_dir).is_some() { - return 3; - } - - let Some(npm_path) = - find_command_in_bin_dir("npm", bin_dir).and_then(|path| path.to_str().map(str::to_string)) - else { - return 0; - }; - - let Some(prefix) = detect_npm_global_prefix(&npm_path).await else { - return 0; - }; - - if select_preferred_path_candidate(find_all_commands_in_paths( - "openclaw", - &npm_global_command_dirs(&prefix), - )) - .is_some() - { - return 3; - } - - if find_installed_openclaw_package_details(&prefix).is_some() { - return 2; - } - - 0 -} - -fn select_best_node_runtime_candidate( - candidates: Vec<(PathBuf, u8, Option<(u64, u64, u64)>)>, -) -> Option { - candidates - .into_iter() - .max_by( - |(left_path, left_signal, left_version), (right_path, right_signal, right_version)| { - left_signal - .cmp(right_signal) - .then_with(|| left_version.cmp(right_version)) - .then_with(|| { - let preferred = select_preferred_path_candidate(vec![ - left_path.clone(), - right_path.clone(), - ]); - match preferred.as_ref() { - Some(path) if path == left_path => std::cmp::Ordering::Greater, - Some(path) if path == right_path => std::cmp::Ordering::Less, - _ => std::cmp::Ordering::Equal, - } - }) - }, - ) - .map(|(path, _, _)| path) -} - -fn sibling_node_path(command_path: &Path) -> Option { - #[cfg(target_os = "windows")] - let node_name = "node.exe"; - - #[cfg(not(target_os = "windows"))] - let node_name = "node"; - - let node_path = command_path.parent()?.join(node_name); - node_path.is_file().then_some(node_path) -} - -async fn read_binary_semver(path: &Path) -> Option<(u64, u64, u64)> { - let mut command = Command::new(path); - apply_windows_no_window(&mut command); - let output = command - .arg("--version") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .ok()?; - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - parse_semver(stdout.trim()).or_else(|| parse_semver(stderr.trim())) -} - -#[cfg_attr(not(test), allow(dead_code))] -fn select_best_semver_candidate( - candidates: Vec<(PathBuf, Option<(u64, u64, u64)>)>, -) -> Option { - core_select_best_semver_candidate(candidates, NODE_MIN_VERSION) -} - -fn select_preferred_path_candidate(candidates: Vec) -> Option { - core_select_preferred_path_candidate(candidates) -} - -async fn run_shell_command_with_progress( - app: &AppHandle, - command_line: &str, -) -> Result { - let mut child = spawn_shell_command(command_line)?; - - let stdout_task = child.stdout.take().map(|stdout| { - let app = app.clone(); - tokio::spawn(async move { - stream_reader_to_progress(app, stdout, "info").await; - }) - }); - - let stderr_task = child.stderr.take().map(|stderr| { - let app = app.clone(); - tokio::spawn(async move { - stream_reader_to_progress(app, stderr, "error").await; - }) - }); - - let status = child - .wait() - .await - .map_err(|e| format!("执行命令失败: {e}"))?; - - if let Some(task) = stdout_task { - let _ = task.await; - } - if let Some(task) = stderr_task { - let _ = task.await; - } - - if status.success() { - emit_install_progress(app, "命令执行成功。", "info"); - Ok(ActionResult { - success: true, - message: "操作成功完成。".to_string(), - }) - } else { - emit_install_progress( - app, - &format!("命令执行失败,退出码: {:?}", status.code()), - "error", - ); - Ok(ActionResult { - success: false, - message: format!("命令执行失败,退出码: {:?}", status.code()), - }) - } -} - -fn spawn_shell_command(command_line: &str) -> Result { - let mut command = if cfg!(target_os = "windows") { - let mut cmd = Command::new("cmd"); - cmd.arg("/C").arg(command_line); - cmd - } else if cfg!(target_os = "macos") { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - let mut cmd = Command::new("script"); - cmd.arg("-q") - .arg("/dev/null") - .arg(shell) - .arg("-lc") - .arg(command_line); - cmd - } else { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - let mut cmd = Command::new(shell); - cmd.arg("-lc").arg(command_line); - cmd - }; - - apply_windows_no_window(&mut command); - - command - .env("NO_COLOR", "1") - .env("CLICOLOR", "0") - .env("FORCE_COLOR", "0") - .env("npm_config_color", "false") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - command.spawn().map_err(|e| format!("启动命令失败: {e}")) -} - -async fn stream_reader_to_progress(app: AppHandle, mut reader: R, default_level: &'static str) -where - R: AsyncRead + Unpin, -{ - let mut buffer = [0_u8; 2048]; - let mut pending = String::new(); - - loop { - match reader.read(&mut buffer).await { - Ok(0) => break, - Ok(size) => { - pending.push_str(&String::from_utf8_lossy(&buffer[..size])); - flush_progress_chunks(&app, &mut pending, default_level); - } - Err(error) => { - emit_install_progress(&app, &format!("读取命令输出失败: {error}"), "warn"); - break; - } - } - } - - let tail = pending.trim(); - if !tail.is_empty() { - emit_install_progress(&app, tail, classify_progress_level(tail, default_level)); - } -} - -fn flush_progress_chunks(app: &AppHandle, pending: &mut String, default_level: &'static str) { - loop { - let next_break = pending.find(['\n', '\r']); - let Some(index) = next_break else { - break; - }; - - let mut line = pending[..index].trim().to_string(); - let mut consume_len = index + 1; - while pending - .get(consume_len..consume_len + 1) - .is_some_and(|ch| ch == "\n" || ch == "\r") - { - consume_len += 1; - } - - pending.drain(..consume_len); - - if line.is_empty() { - continue; - } - - line = sanitize_progress_line(&line); - if line.is_empty() { - continue; - } - - emit_install_progress(app, &line, classify_progress_level(&line, default_level)); - } - - if pending.len() > 4096 { - let line = sanitize_progress_line(pending.trim()); - if !line.is_empty() { - emit_install_progress(app, &line, classify_progress_level(&line, default_level)); - } - pending.clear(); - } -} - -fn sanitize_progress_line(value: &str) -> String { - value - .replace('\u{1b}', "") - .replace("[?25h", "") - .replace("[?25l", "") - .trim() - .to_string() -} - -fn classify_progress_level(message: &str, default_level: &'static str) -> &'static str { - let lower = message.to_ascii_lowercase(); - if lower.contains("error") || lower.contains("fatal") { - "error" - } else if lower.contains("warn") || lower.contains("warning") { - "warn" - } else { - default_level - } -} - -fn emit_install_progress(app: &AppHandle, message: &str, level: &str) { - if let Some(service_state) = app.try_state::() { - if let Ok(mut service) = service_state.0.try_lock() { - service.push_progress_log(message.to_string(), level.to_string()); - } - } - - let payload = InstallProgressEvent { - message: message.to_string(), - level: level.to_string(), - }; - let _ = app.emit(OPENCLAW_INSTALL_EVENT, payload); -} - -fn parse_semver(value: &str) -> Option<(u64, u64, u64)> { - let sanitized = value.trim().trim_start_matches('v'); - let core = sanitized.split(['-', '+']).next()?; - let mut parts = core.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next().unwrap_or("0").parse().ok()?; - let patch = parts.next().unwrap_or("0").parse().ok()?; - Some((major, minor, patch)) -} - -fn parse_semver_from_text(value: &str) -> Option<(u64, u64, u64)> { - parse_semver(value).or_else(|| { - value - .split(|ch: char| ch.is_whitespace() || ch == ',' || ch == '(' || ch == ')') - .find_map(parse_semver) - }) -} - -fn format_semver(version: (u64, u64, u64)) -> String { - format!("{}.{}.{}", version.0, version.1, version.2) -} - -#[cfg(test)] -mod tests { - use super::{ - apply_gateway_runtime_defaults, build_environment_status, build_openclaw_cleanup_command, - build_openclaw_install_command, build_openclaw_pnpm_install_command, - build_winget_install_command, command_bin_dir_for, determine_api_type, - extract_gateway_auth_token, find_installed_openclaw_package, - format_gateway_start_failure_message, format_openclaw_update_failure_message, - format_provider_base_url, gateway_start_args, has_api_version, - infer_openclaw_package_name_from_path, npm_global_command_dirs_for, - npm_global_node_modules_dirs_for, package_registry_for_package_spec, - parse_semver_from_text, resolve_openclaw_cli_entry_from_package_manifest, - resolve_openclaw_command_from_runtime_candidate, - resolve_openclaw_command_from_runtime_candidate_for, - resolve_windows_dependency_install_plan, runtime_candidate_matches_install_root, - sanitize_runtime_config, select_best_git_candidate, select_best_semver_candidate, - select_gateway_start_failure_detail, select_openclaw_update_failure_detail, - select_preferred_path_candidate, shell_command_escape_for, - shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for, - shell_path_assignment_for, trim_trailing_slash, windows_dependency_action_result, - windows_dependency_setup_message, windows_git_install_dir_variants, - windows_install_block_result, windows_manual_install_message, DependencyKind, - DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate, - ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN, - OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE, - }; - use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; - use chrono::Utc; - use serde_json::{json, Value}; - use std::ffi::OsString; - use std::fs; - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvider { - ApiKeyProvider { - id: "provider-1".to_string(), - name: "Provider 1".to_string(), - provider_type, - api_host: api_host.to_string(), - is_system: false, - group: ProviderGroup::Custom, - enabled: true, - sort_order: 0, - api_version: None, - project: None, - location: None, - region: None, - custom_models: Vec::new(), - created_at: Utc::now(), - updated_at: Utc::now(), - } - } - - fn build_unique_temp_dir(prefix: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("系统时间应晚于 Unix epoch") - .as_nanos(); - - std::env::temp_dir().join(format!( - "lime-openclaw-{prefix}-{}-{nanos}", - std::process::id() - )) - } - - #[test] - fn trims_trailing_slash() { - assert_eq!( - trim_trailing_slash("https://api.openai.com/"), - "https://api.openai.com" - ); - } - - #[test] - fn detects_version_segment() { - assert!(has_api_version("https://api.openai.com/v1")); - assert!(!has_api_version("https://api.openai.com")); - } - - #[test] - fn maps_api_type_correctly() { - assert_eq!( - determine_api_type(ApiProviderType::Openai).unwrap(), - "openai-completions" - ); - assert_eq!( - determine_api_type(ApiProviderType::OpenaiResponse).unwrap(), - "openai-responses" - ); - assert_eq!( - determine_api_type(ApiProviderType::Anthropic).unwrap(), - "anthropic-messages" - ); - } - - #[test] - fn formats_openai_url() { - let provider = build_provider(ApiProviderType::Openai, "https://api.openai.com"); - assert_eq!( - format_provider_base_url(&provider).unwrap(), - "https://api.openai.com/v1" - ); - } - - #[test] - fn keeps_existing_version_url() { - let provider = build_provider(ApiProviderType::Openai, "https://example.com/v2"); - assert_eq!( - format_provider_base_url(&provider).unwrap(), - "https://example.com/v2" - ); - } - - #[test] - fn formats_gemini_url() { - let provider = build_provider( - ApiProviderType::Gemini, - "https://generativelanguage.googleapis.com", - ); - assert_eq!( - format_provider_base_url(&provider).unwrap(), - "https://generativelanguage.googleapis.com/v1beta/openai" - ); - } - - #[test] - fn formats_gateway_url() { - let provider = build_provider( - ApiProviderType::Gateway, - "https://gateway.example.com/v1/ai", - ); - assert_eq!( - format_provider_base_url(&provider).unwrap(), - "https://gateway.example.com/v1" - ); - } - - #[test] - fn rejects_unsupported_provider_types() { - let provider = build_provider(ApiProviderType::AzureOpenai, "https://example.com"); - assert!(format_provider_base_url(&provider).is_err()); - } - - #[test] - fn extracts_gateway_auth_token_from_config() { - let config = json!({ - "gateway": { - "auth": { - "token": "lime-token" - } - } - }); - - assert_eq!( - extract_gateway_auth_token(&config).as_deref(), - Some("lime-token") - ); - } - - #[test] - fn ignores_empty_gateway_auth_token() { - let config = json!({ - "gateway": { - "auth": { - "token": " " - } - } - }); - - assert_eq!(extract_gateway_auth_token(&config), None); - } - - #[test] - fn applies_gateway_runtime_defaults_for_current_openclaw() { - let mut config = json!({}); - - apply_gateway_runtime_defaults(&mut config, 18790, "lime-token"); - - assert_eq!( - config.pointer("/gateway/mode").and_then(Value::as_str), - Some("local") - ); - assert_eq!( - config.pointer("/gateway/bind").and_then(Value::as_str), - Some("loopback") - ); - assert_eq!( - config.pointer("/gateway/auth/mode").and_then(Value::as_str), - Some("token") - ); - assert_eq!( - config - .pointer("/gateway/auth/token") - .and_then(Value::as_str), - Some("lime-token") - ); - assert_eq!( - config - .pointer("/gateway/remote/token") - .and_then(Value::as_str), - Some("lime-token") - ); - assert_eq!( - config.pointer("/gateway/port").and_then(Value::as_u64), - Some(18_790) - ); - } - - #[test] - fn gateway_start_args_include_new_runtime_guards() { - assert_eq!( - gateway_start_args(18790, "lime-token"), - vec![ - "gateway", - "--allow-unconfigured", - "--bind", - "loopback", - "--auth", - "token", - "--token", - "lime-token", - "--port", - "18790", - ] - ); - } - - #[test] - fn parses_lsof_listener_pid_output() { - assert_eq!( - super::parse_lsof_listener_pids("1201\n1202\nbad\n1201\n"), - vec![1201, 1202] - ); - } - - #[test] - fn parses_windows_netstat_listener_pid_output() { - let output = "\ - TCP 127.0.0.1:18790 0.0.0.0:0 LISTENING 31234\n\ - TCP [::]:18790 [::]:0 LISTENING 31235\n\ - TCP 127.0.0.1:18791 0.0.0.0:0 LISTENING 39999\n"; - - assert_eq!( - super::parse_windows_netstat_listener_pids(output, 18790), - vec![31234, 31235] - ); - } - - #[test] - fn detects_openclaw_process_from_node_command_line() { - let args = vec![ - OsString::from("/Users/demo/.nvm/versions/node/v23.4.0/bin/.openclaw-a1b2c3/openclaw"), - OsString::from("gateway"), - OsString::from("--port"), - OsString::from("18790"), - ]; - let node_path = PathBuf::from("/Users/demo/.nvm/versions/node/v23.4.0/bin/node"); - - assert!(super::process_looks_like_openclaw_process( - "node", - Some(node_path.as_path()), - &args, - )); - } - - #[test] - fn ignores_unrelated_listener_process() { - let args = vec![ - OsString::from("/usr/local/bin/python3"), - OsString::from("-m"), - OsString::from("http.server"), - OsString::from("18790"), - ]; - let python_path = PathBuf::from("/usr/local/bin/python3"); - - assert!(!super::process_looks_like_openclaw_process( - "python3", - Some(python_path.as_path()), - &args, - )); - } - - #[test] - fn formats_gateway_start_failure_for_missing_config() { - assert_eq!( - format_gateway_start_failure_message(Some( - "Missing config. Run `openclaw setup` or set gateway.mode=local." - )), - "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。" - ); - } - - #[test] - fn formats_gateway_start_failure_for_loopback_bind_error() { - assert_eq!( - format_gateway_start_failure_message(Some( - "gateway bind=loopback resolved to non-loopback host 0.0.0.0" - )), - "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。" - ); - } - - #[test] - fn sanitizes_null_context_window_from_runtime_config() { - let mut config = json!({ - "models": { - "providers": { - "lime-openai": { - "models": [ - { - "id": "gpt-5", - "name": "GPT-5", - "contextWindow": null - }, - { - "id": "gpt-5-mini", - "name": "GPT-5 mini", - "contextWindow": 400000 - } - ] - } - } - } - }); - - sanitize_runtime_config(&mut config); - - assert!(config - .pointer("/models/providers/lime-openai/models/0/contextWindow") - .is_none()); - assert_eq!( - config - .pointer("/models/providers/lime-openai/models/1/contextWindow") - .and_then(Value::as_u64), - Some(400_000) - ); - } - - #[test] - fn selects_specific_gateway_failure_detail_over_doctor_hint() { - let lines = vec![ - "Config invalid".to_string(), - "Run: openclaw doctor --fix".to_string(), - "Invalid config at /Users/demo/.openclaw/openclaw.lime.json:\\n- models.providers.lime-openai.models.0.contextWindow: Invalid input: expected number, received null".to_string(), - ]; - - assert_eq!( - select_gateway_start_failure_detail(&lines), - Some( - "Invalid config at /Users/demo/.openclaw/openclaw.lime.json:\\n- models.providers.lime-openai.models.0.contextWindow: Invalid input: expected number, received null" - ) - ); - } - - #[test] - fn formats_gateway_start_failure_for_invalid_context_window_config() { - assert_eq!( - format_gateway_start_failure_message(Some( - "Invalid config at /Users/demo/.openclaw/openclaw.lime.json:\\n- models.providers.lime-openai.models.0.contextWindow: Invalid input: expected number, received null" - )), - "Gateway 启动失败:当前 OpenClaw 配置包含空的 contextWindow 字段。Lime 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。" - ); - } - - #[test] - fn selects_update_failure_reason_from_json_payload() { - let payload = json!({ - "status": "error", - "reason": "not-openclaw-root", - "root": "/Users/demo/.nvm" - }); - - assert_eq!( - select_openclaw_update_failure_detail(Some(&payload), &[], &[]), - Some("not-openclaw-root (/Users/demo/.nvm)".to_string()) - ); - } - - #[test] - fn formats_openclaw_update_failure_for_invalid_root() { - assert_eq!( - format_openclaw_update_failure_message(Some( - "not-openclaw-root (/Users/demo/.nvm)" - )), - "OpenClaw 升级失败:未在 OpenClaw 安装根目录执行更新。Lime 会优先切换到安装目录;如仍失败,请重新检测安装状态后重试。" - ); - } - - #[test] - fn formats_openclaw_update_failure_for_node_version_requirement() { - assert_eq!( - format_openclaw_update_failure_message(Some( - "openclaw: Node.js v22.12+ is required (current: v18.20.2)." - )), - "OpenClaw 升级失败:当前用于执行 openclaw 的 Node.js 版本过低,需要 22.12.0+。请切换到满足要求的 Node.js 后重试。" - ); - } - - #[test] - fn parses_semver_from_git_version_text() { - assert_eq!( - parse_semver_from_text("git version 2.39.5 (Apple Git-154)"), - Some((2, 39, 5)) - ); - } - - #[test] - fn environment_status_prioritizes_missing_node() { - let env = build_environment_status( - DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "missing node".to_string(), - auto_install_supported: true, - }, - DependencyStatus { - status: "ok".to_string(), - version: Some("2.43.0".to_string()), - path: Some("/usr/bin/git".to_string()), - message: "git ok".to_string(), - auto_install_supported: true, - }, - DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "openclaw missing".to_string(), - auto_install_supported: false, - }, - EnvironmentDiagnostics::default(), - ); - - assert_eq!(env.recommended_action, "install_node"); - assert_eq!(env.openclaw.auto_install_supported, false); - } - - #[test] - fn environment_status_uses_reload_summary_when_openclaw_command_not_ready() { - let env = build_environment_status( - DependencyStatus { - status: "ok".to_string(), - version: Some("22.12.0".to_string()), - path: Some("/usr/local/bin/node".to_string()), - message: "node ok".to_string(), - auto_install_supported: true, - }, - DependencyStatus { - status: "ok".to_string(), - version: Some("2.44.0".to_string()), - path: Some("/usr/bin/git".to_string()), - message: "git ok".to_string(), - auto_install_supported: true, - }, - DependencyStatus { - status: "needs_reload".to_string(), - version: Some("0.3.0".to_string()), - path: Some("/mock/prefix".to_string()), - message: "reload openclaw".to_string(), - auto_install_supported: false, - }, - EnvironmentDiagnostics::default(), - ); - - assert_eq!(env.recommended_action, "refresh_openclaw_env"); - assert!(env.summary.contains("重新检测")); - } - - #[test] - fn semver_selection_prefers_windows_launcher_over_bare_file_when_versions_equal() { - let preferred = select_best_semver_candidate(vec![ - (PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), Some((23, 1, 0))), - ( - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), - Some((23, 1, 0)), - ), - ]); - - assert_eq!( - preferred, - Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd")) - ); - } - - #[test] - fn windows_command_bin_dir_supports_backslash_paths() { - assert_eq!( - command_bin_dir_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), - Some(r"C:\Program Files\nodejs".to_string()) - ); - } - - #[test] - fn windows_shell_command_escape_keeps_cmd_compatible_quotes() { - assert_eq!( - shell_command_escape_for(ShellPlatform::Windows, r#"C:\Program Files\nodejs\npm.cmd"#), - r#""C:\Program Files\nodejs\npm.cmd""# - ); - assert_eq!( - shell_command_escape_for(ShellPlatform::Windows, "C:\\demo\\na\"me\\npm.cmd"), - r#""C:\demo\na""me\npm.cmd""# - ); - } - - #[test] - fn windows_shell_npm_prefix_assignment_uses_set_syntax() { - assert_eq!( - shell_npm_prefix_assignment_for( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Roaming\npm" - ), - r#"set "NPM_CONFIG_PREFIX=C:\Users\demo\AppData\Roaming\npm" && "# - ); - } - - #[test] - fn windows_shell_path_assignment_prepends_binary_directory() { - assert_eq!( - shell_path_assignment_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), - r#"set "PATH=C:\Program Files\nodejs;%PATH%" && "# - ); - } - - #[test] - fn windows_cmd_scripts_use_call_invocation_prefix() { - assert_eq!( - shell_command_invocation_prefix_for( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd" - ), - "call " - ); - assert!(shell_command_invocation_prefix_for( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe" - ) - .is_empty()); - } - - #[test] - fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() { - let command = build_openclaw_cleanup_command( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd", - Some(r"C:\Users\demo\AppData\Roaming\npm"), - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" - ) - ); - assert!(!command.contains("|| true")); - } - - #[test] - fn windows_install_command_adds_registry_when_using_china_package() { - let command = build_openclaw_install_command( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd", - Some(r"C:\Users\demo\AppData\Roaming\npm"), - OPENCLAW_CN_PACKAGE, - Some(NPM_MIRROR_CN), - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", - "--registry=https://registry.npmmirror.com" - ) - ); - } - - #[test] - fn windows_install_command_omits_registry_for_default_package() { - let command = build_openclaw_install_command( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd", - None, - OPENCLAW_DEFAULT_PACKAGE, - None, - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" - ) - ); - assert!(!command.contains("--registry=")); - } - - #[test] - fn windows_pnpm_install_command_uses_global_add_syntax() { - let command = build_openclaw_pnpm_install_command( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Local\pnpm\pnpm.cmd", - "@qingchencloud/openclaw-zh@latest", - Some(NPM_MIRROR_CN), - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Users\\demo\\AppData\\Local\\pnpm;%PATH%\" && ", - "call \"C:\\Users\\demo\\AppData\\Local\\pnpm\\pnpm.cmd\" add -g \"@qingchencloud/openclaw-zh@latest\" ", - "--registry=\"https://registry.npmmirror.com\"" - ) - ); - } - - #[test] - fn infers_openclaw_package_name_from_manifest_path() { - assert_eq!( - infer_openclaw_package_name_from_path( - PathBuf::from( - "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/openclaw/package.json", - ) - .as_path() - ), - Some("openclaw") - ); - assert_eq!( - infer_openclaw_package_name_from_path(PathBuf::from( - "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/@qingchencloud/openclaw-zh/package.json", - ) - .as_path()), - Some("@qingchencloud/openclaw-zh") - ); - } - - #[test] - fn china_package_upgrade_uses_npmmirror_registry() { - assert_eq!( - package_registry_for_package_spec("@qingchencloud/openclaw-zh@latest"), - Some(NPM_MIRROR_CN) - ); - assert_eq!(package_registry_for_package_spec("openclaw@latest"), None); - } - - #[test] - fn runtime_candidate_prefers_install_root_match_over_current_binary_hint() { - let candidate = OpenClawRuntimeCandidate { - id: "/Users/demo/.nvm/versions/node/v23.4.0/bin".to_string(), - source: "nvm".to_string(), - bin_dir: "/Users/demo/.nvm/versions/node/v23.4.0/bin".to_string(), - node_path: "/Users/demo/.nvm/versions/node/v23.4.0/bin/node".to_string(), - node_version: Some("23.4.0".to_string()), - npm_path: Some("/Users/demo/.nvm/versions/node/v23.4.0/bin/npm".to_string()), - npm_global_prefix: Some("/Users/demo/.nvm/versions/node/v23.4.0".to_string()), - openclaw_path: Some("/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw".to_string()), - openclaw_version: Some("2026.3.8".to_string()), - openclaw_package_path: Some( - "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/@qingchencloud/openclaw-zh/package.json" - .to_string(), - ), - is_active: false, - is_preferred: false, - }; - - assert!(runtime_candidate_matches_install_root( - &candidate, - PathBuf::from("/Users/demo/.nvm").as_path() - )); - assert!(!runtime_candidate_matches_install_root( - &candidate, - PathBuf::from("/Users/demo/Library/PhpWebStudy").as_path() - )); - } - - #[test] - fn preferred_path_candidate_prioritizes_windows_executable_extensions() { - let preferred = select_preferred_path_candidate(vec![ - PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.bat"), - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe"), - ]); - - assert_eq!( - preferred, - Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe")) - ); - } - - #[test] - fn git_candidate_selection_prefers_executable_extension() { - let preferred = select_best_git_candidate(vec![ - PathBuf::from(r"C:\Program Files\Git\cmd\git.cmd"), - PathBuf::from(r"C:\Program Files\Git\cmd\git.exe"), - ]); - - assert_eq!( - preferred, - Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")) - ); - } - - #[test] - fn windows_git_install_dir_variants_cover_common_layouts() { - let git_root = build_unique_temp_dir("git-layout-root"); - let cmd_dir = git_root.join("cmd"); - let bin_dir = git_root.join("bin"); - fs::create_dir_all(&cmd_dir).unwrap(); - fs::create_dir_all(&bin_dir).unwrap(); - fs::write(cmd_dir.join("git.exe"), "").unwrap(); - fs::write(bin_dir.join("git.cmd"), "").unwrap(); - - let matches = super::find_all_commands_in_paths_for( - ShellPlatform::Windows, - "git", - &windows_git_install_dir_variants(git_root.clone()), - ); - - let _ = fs::remove_dir_all(&git_root); - - assert_eq!( - matches, - vec![cmd_dir.join("git.exe"), bin_dir.join("git.cmd")] - ); - } - - #[test] - fn windows_npm_global_command_dirs_use_prefix_root() { - assert_eq!( - npm_global_command_dirs_for( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Roaming\npm" - ), - vec![PathBuf::from(r"C:\Users\demo\AppData\Roaming\npm")] - ); - } - - #[test] - fn unix_npm_global_command_dirs_include_bin_directory() { - assert_eq!( - npm_global_command_dirs_for(ShellPlatform::Unix, "/Users/demo/.npm-global"), - vec![ - PathBuf::from("/Users/demo/.npm-global/bin"), - PathBuf::from("/Users/demo/.npm-global") - ] - ); - } - - #[test] - fn windows_npm_global_node_modules_dirs_use_prefix_node_modules() { - assert_eq!( - npm_global_node_modules_dirs_for( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Roaming\npm" - ), - vec![PathBuf::from(r"C:\Users\demo\AppData\Roaming\npm").join("node_modules")] - ); - } - - #[test] - fn finds_openclaw_package_from_global_npm_prefix() { - let temp_dir = - std::env::temp_dir().join(format!("lime-openclaw-test-{}", std::process::id())); - let package_dir = temp_dir.join("node_modules").join("openclaw"); - fs::create_dir_all(&package_dir).unwrap(); - fs::write( - package_dir.join("package.json"), - r#"{"name":"openclaw","version":"0.4.1"}"#, - ) - .unwrap(); - - let detected = find_installed_openclaw_package(temp_dir.to_str().unwrap()); - - fs::remove_dir_all(&temp_dir).unwrap(); - - assert_eq!(detected, Some(("openclaw", Some("0.4.1".to_string())))); - } - - #[test] - fn resolves_openclaw_cli_entry_from_dist_index_when_bin_target_missing() { - let temp_dir = build_unique_temp_dir("cli-entry"); - let package_dir = temp_dir - .join("node_modules") - .join("@qingchencloud/openclaw-zh"); - let dist_dir = package_dir.join("dist"); - fs::create_dir_all(&dist_dir).unwrap(); - fs::write( - package_dir.join("package.json"), - r#"{ - "name":"@qingchencloud/openclaw-zh", - "version":"2026.3.13-zh.1", - "bin":{"openclaw":"openclaw.mjs"} - }"#, - ) - .unwrap(); - fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); - - let resolved = - resolve_openclaw_cli_entry_from_package_manifest(&package_dir.join("package.json")); - - let _ = fs::remove_dir_all(&temp_dir); - - assert_eq!(resolved, Some(package_dir.join("dist").join("index.js"))); - } - - #[test] - fn resolves_openclaw_command_from_runtime_candidate_as_node_cli() { - let temp_dir = build_unique_temp_dir("runtime-candidate"); - let node_bin_dir = temp_dir.join("bin"); - let package_dir = temp_dir - .join("node_modules") - .join("@qingchencloud/openclaw-zh"); - let dist_dir = package_dir.join("dist"); - fs::create_dir_all(&node_bin_dir).unwrap(); - fs::create_dir_all(&dist_dir).unwrap(); - - let node_path = node_bin_dir.join("node"); - fs::write(&node_path, "").unwrap(); - fs::write( - package_dir.join("package.json"), - r#"{ - "name":"@qingchencloud/openclaw-zh", - "version":"2026.3.13-zh.1", - "bin":{"openclaw":"openclaw.mjs"} - }"#, - ) - .unwrap(); - fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); - - let candidate = OpenClawRuntimeCandidate { - id: temp_dir.display().to_string(), - source: "nvm".to_string(), - bin_dir: node_bin_dir.display().to_string(), - node_path: node_path.display().to_string(), - node_version: Some("23.4.0".to_string()), - npm_path: None, - npm_global_prefix: None, - openclaw_path: None, - openclaw_version: Some("2026.3.13-zh.1".to_string()), - openclaw_package_path: Some(package_dir.join("package.json").display().to_string()), - is_active: true, - is_preferred: true, - }; - - let resolved = resolve_openclaw_command_from_runtime_candidate(&candidate); - - let _ = fs::remove_dir_all(&temp_dir); - - assert_eq!( - resolved, - Some(ResolvedOpenClawCommand::NodeCli { - node_path, - cli_path: package_dir.join("dist").join("index.js"), - package_version: Some("2026.3.13-zh.1".to_string()), - }) - ); - } - - #[test] - fn windows_runtime_candidate_prefers_node_cli_over_cmd_shim() { - let temp_dir = build_unique_temp_dir("runtime-candidate-windows-shim"); - let node_bin_dir = temp_dir.join("nodejs"); - let package_dir = temp_dir - .join("node_modules") - .join("@qingchencloud") - .join("openclaw-zh"); - let dist_dir = package_dir.join("dist"); - fs::create_dir_all(&node_bin_dir).unwrap(); - fs::create_dir_all(&dist_dir).unwrap(); - - let node_path = node_bin_dir.join("node.exe"); - let openclaw_cmd = node_bin_dir.join("openclaw.cmd"); - fs::write(&node_path, "").unwrap(); - fs::write(&openclaw_cmd, "@echo off").unwrap(); - fs::write( - package_dir.join("package.json"), - r#"{ - "name":"@qingchencloud/openclaw-zh", - "version":"2026.3.13-zh.1", - "bin":{"openclaw":"openclaw.mjs"} - }"#, - ) - .unwrap(); - fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); - - let candidate = OpenClawRuntimeCandidate { - id: temp_dir.display().to_string(), - source: "system".to_string(), - bin_dir: node_bin_dir.display().to_string(), - node_path: node_path.display().to_string(), - node_version: Some("23.4.0".to_string()), - npm_path: Some(node_bin_dir.join("npm.cmd").display().to_string()), - npm_global_prefix: Some(temp_dir.display().to_string()), - openclaw_path: Some(openclaw_cmd.display().to_string()), - openclaw_version: Some("2026.3.13-zh.1".to_string()), - openclaw_package_path: Some(package_dir.join("package.json").display().to_string()), - is_active: true, - is_preferred: true, - }; - - let resolved = - resolve_openclaw_command_from_runtime_candidate_for(ShellPlatform::Windows, &candidate); - - let _ = fs::remove_dir_all(&temp_dir); - - assert_eq!( - resolved, - Some(ResolvedOpenClawCommand::NodeCli { - node_path, - cli_path: package_dir.join("dist").join("index.js"), - package_version: Some("2026.3.13-zh.1".to_string()), - }) - ); - } - - #[test] - fn windows_node_prefers_winget_when_available() { - assert_eq!( - resolve_windows_dependency_install_plan(DependencyKind::Node, true), - WindowsDependencyInstallPlan::Winget { - package_id: "OpenJS.NodeJS.LTS" - } - ); - } - - #[test] - fn windows_node_falls_back_to_official_installer_without_winget() { - assert_eq!( - resolve_windows_dependency_install_plan(DependencyKind::Node, false), - WindowsDependencyInstallPlan::OfficialInstaller - ); - } - - #[test] - fn windows_git_prefers_winget_when_available() { - assert_eq!( - resolve_windows_dependency_install_plan(DependencyKind::Git, true), - WindowsDependencyInstallPlan::Winget { - package_id: "Git.Git" - } - ); - } - - #[test] - fn windows_git_requires_manual_download_without_winget() { - assert_eq!( - resolve_windows_dependency_install_plan(DependencyKind::Git, false), - WindowsDependencyInstallPlan::ManualDownload - ); - assert_eq!( - windows_manual_install_message(DependencyKind::Git), - "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" - ); - } - - #[test] - fn windows_git_setup_message_points_to_manual_download() { - let message = windows_dependency_setup_message( - DependencyKind::Git, - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Git。".to_string(), - auto_install_supported: false, - }, - ); - - assert!(message.contains("git-scm.com")); - assert!(message.contains("加入 PATH")); - } - - #[test] - fn windows_node_setup_message_points_to_nodejs_download() { - let message = windows_dependency_setup_message( - DependencyKind::Node, - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Node.js,需要安装 22.12.0+。".to_string(), - auto_install_supported: false, - }, - ); - - assert!(message.contains("nodejs.org")); - assert!(message.contains("Node.js 22+")); - } - - #[test] - fn windows_dependency_action_result_returns_failure_message() { - let result = windows_dependency_action_result( - DependencyKind::Git, - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Git。".to_string(), - auto_install_supported: false, - }, - ); - - assert!(!result.success); - assert!(result.message.contains("git-scm.com")); - } - - #[test] - fn windows_install_block_result_prioritizes_node_before_git() { - let result = windows_install_block_result( - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Node.js,需要安装 22.12.0+。".to_string(), - auto_install_supported: false, - }, - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Git。".to_string(), - auto_install_supported: false, - }, - ) - .expect("应返回 Windows 阻断结果"); - - assert!(!result.success); - assert!(result.message.contains("nodejs.org")); - assert!(!result.message.contains("git-scm.com")); - } - - #[test] - fn windows_install_block_result_returns_none_when_dependencies_ready() { - let result = windows_install_block_result( - &DependencyStatus { - status: "ok".to_string(), - version: Some("22.12.0".to_string()), - path: Some("C:\\Program Files\\nodejs\\node.exe".to_string()), - message: "Node.js 已就绪:22.12.0".to_string(), - auto_install_supported: false, - }, - &DependencyStatus { - status: "ok".to_string(), - version: Some("2.44.0".to_string()), - path: Some("C:\\Program Files\\Git\\cmd\\git.exe".to_string()), - message: "Git 已就绪:2.44.0".to_string(), - auto_install_supported: false, - }, - ); - - assert!(result.is_none()); - } - - #[test] - fn winget_install_command_uses_expected_windows_flags() { - assert_eq!( - build_winget_install_command( - r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe", - "OpenJS.NodeJS.LTS" - ), - concat!( - "set \"PATH=C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps;%PATH%\" && ", - "\"C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe\" install ", - "--id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements" - ) - ); - } -} - -/// 从 Windows 注册表读取最新的 PATH 环境变量并刷新当前进程 -#[cfg(target_os = "windows")] -fn refresh_windows_path_from_registry() -> Result<(), String> { - unsafe { - let mut combined_path = String::new(); - - // 读取系统 PATH (HKEY_LOCAL_MACHINE) - if let Ok(system_path) = read_registry_path(HKEY_LOCAL_MACHINE) { - combined_path.push_str(&system_path); - } - - // 读取用户 PATH (HKEY_CURRENT_USER) - if let Ok(user_path) = read_registry_path(HKEY_CURRENT_USER) { - if !combined_path.is_empty() { - combined_path.push(';'); - } - combined_path.push_str(&user_path); - } - - if !combined_path.is_empty() { - std::env::set_var("PATH", combined_path); - } - } - - Ok(()) -} - -#[cfg(target_os = "windows")] -unsafe fn read_registry_path(root_key: HKEY) -> Result { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - use std::ptr; - - let subkey: Vec = OsStr::new("Environment") - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - - let value_name: Vec = OsStr::new("Path") - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - - let mut key: HKEY = ptr::null_mut(); - let result = RegOpenKeyExW( - root_key, - subkey.as_ptr(), - 0, - winapi::um::winnt::KEY_READ, - &mut key, - ); - - if result != ERROR_SUCCESS as i32 { - return Err(format!("无法打开注册表键: {}", result)); - } - - let mut buffer_size: DWORD = 0; - let result = RegQueryValueExW( - key, - value_name.as_ptr(), - ptr::null_mut(), - ptr::null_mut(), - ptr::null_mut(), - &mut buffer_size, - ); - - if result != ERROR_SUCCESS as i32 { - winapi::um::winreg::RegCloseKey(key); - return Err(format!("无法查询注册表值大小: {}", result)); - } - - let mut buffer: Vec = vec![0; (buffer_size / 2) as usize + 1]; - let result = RegQueryValueExW( - key, - value_name.as_ptr(), - ptr::null_mut(), - ptr::null_mut(), - buffer.as_mut_ptr() as *mut u8, - &mut buffer_size, - ); - - winapi::um::winreg::RegCloseKey(key); - - if result != ERROR_SUCCESS as i32 { - return Err(format!("无法读取注册表值: {}", result)); - } - - // 移除尾部的 null 字符 - if let Some(null_pos) = buffer.iter().position(|&c| c == 0) { - buffer.truncate(null_pos); - } - - Ok(String::from_utf16_lossy(&buffer)) -} - -#[cfg(not(target_os = "windows"))] -#[allow(dead_code)] -fn refresh_windows_path_from_registry() -> Result<(), String> { - Ok(()) -} diff --git a/src-tauri/src/services/openclaw_service/config.rs b/src-tauri/src/services/openclaw_service/config.rs new file mode 100644 index 000000000..476880854 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/config.rs @@ -0,0 +1,341 @@ +use super::*; + +impl OpenClawService { + pub fn sync_provider_config( + &mut self, + provider: &ApiKeyProvider, + api_key: &str, + primary_model_id: &str, + models: &[SyncModelEntry], + ) -> Result { + if api_key.trim().is_empty() && provider.provider_type != ApiProviderType::Ollama { + return Ok(ActionResult { + success: false, + message: "该 Provider 没有可用的 API Key。".to_string(), + }); + } + + let api_type = determine_api_type(provider.provider_type)?; + let base_url = format_provider_base_url(provider)?; + let provider_key = format!("lime-{}", provider.id); + + let normalized_models = if models.is_empty() { + vec![SyncModelEntry { + id: primary_model_id.to_string(), + name: primary_model_id.to_string(), + context_window: None, + }] + } else { + let mut items = models.to_vec(); + if !items.iter().any(|item| item.id == primary_model_id) { + items.insert( + 0, + SyncModelEntry { + id: primary_model_id.to_string(), + name: primary_model_id.to_string(), + context_window: None, + }, + ); + } + items + }; + + self.ensure_runtime_config( + Some(( + &provider_key, + json!({ + "baseUrl": base_url, + "apiKey": api_key, + "api": api_type, + "models": normalized_models + .iter() + .map(sync_model_entry_to_config_value) + .collect::>() + }), + )), + Some(format!("{provider_key}/{primary_model_id}")), + )?; + + Ok(ActionResult { + success: true, + message: format!("已同步 Provider“{}”到 OpenClaw。", provider.name), + }) + } + pub(crate) fn ensure_runtime_config( + &mut self, + provider_entry: Option<(&str, Value)>, + primary_model: Option, + ) -> Result<(), String> { + let config_dir = openclaw_config_dir(); + std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建配置目录失败: {e}"))?; + + let lime_config_path = openclaw_lime_config_path(); + let mut config = read_base_openclaw_config()?; + sanitize_runtime_config(&mut config); + + if self.gateway_auth_token.is_empty() { + self.gateway_auth_token = generate_auth_token(); + } + + apply_gateway_runtime_defaults(&mut config, self.gateway_port, &self.gateway_auth_token); + + if let Some((provider_key, provider_value)) = provider_entry { + set_json_path( + &mut config, + &["models", "mode"], + Value::String("merge".to_string()), + ); + set_json_path( + &mut config, + &["models", "providers", provider_key], + provider_value, + ); + } + + if let Some(primary) = primary_model { + set_json_path( + &mut config, + &["agents", "defaults", "model", "primary"], + Value::String(primary), + ); + } + + let content = + serde_json::to_string_pretty(&config).map_err(|e| format!("序列化配置失败: {e}"))?; + std::fs::write(lime_config_path, content).map_err(|e| format!("写入配置失败: {e}"))?; + Ok(()) + } +} + +pub(crate) fn openclaw_config_dir() -> PathBuf { + home_dir() + .or_else(data_dir) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openclaw") +} + +pub(crate) fn openclaw_original_config_path() -> PathBuf { + openclaw_config_dir().join("openclaw.json") +} + +pub(crate) fn openclaw_lime_config_path() -> PathBuf { + openclaw_config_dir().join("openclaw.lime.json") +} + +pub(crate) fn read_base_openclaw_config() -> Result { + let lime_path = openclaw_lime_config_path(); + if lime_path.exists() { + return read_json_file(&lime_path); + } + + let original_path = openclaw_original_config_path(); + if original_path.exists() { + return read_json_file(&original_path); + } + + Ok(json!({})) +} + +pub(crate) fn read_json_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("读取配置文件失败({}): {e}", path.display()))?; + serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败({}): {e}", path.display())) +} + +pub(crate) fn ensure_path_object<'a>( + root: &'a mut Value, + path: &[&str], +) -> &'a mut Map { + let mut current = root; + for segment in path { + let object = ensure_value_object(current); + current = object + .entry((*segment).to_string()) + .or_insert_with(|| Value::Object(Map::new())); + } + ensure_value_object(current) +} + +pub(crate) fn set_json_path(root: &mut Value, path: &[&str], value: Value) { + if path.is_empty() { + *root = value; + return; + } + + let parent = ensure_path_object(root, &path[..path.len() - 1]); + parent.insert(path[path.len() - 1].to_string(), value); +} + +pub(crate) fn sync_model_entry_to_config_value(model: &SyncModelEntry) -> Value { + let mut entry = Map::new(); + entry.insert("id".to_string(), Value::String(model.id.clone())); + entry.insert("name".to_string(), Value::String(model.name.clone())); + if let Some(context_window) = model.context_window { + entry.insert( + "contextWindow".to_string(), + Value::Number(context_window.into()), + ); + } + Value::Object(entry) +} + +pub(crate) fn sanitize_runtime_config(config: &mut Value) { + let Some(providers) = config + .get_mut("models") + .and_then(|models| models.get_mut("providers")) + .and_then(Value::as_object_mut) + else { + return; + }; + + for provider in providers.values_mut() { + let Some(models) = provider.get_mut("models").and_then(Value::as_array_mut) else { + continue; + }; + + for model in models { + let Some(entry) = model.as_object_mut() else { + continue; + }; + + if matches!(entry.get("contextWindow"), Some(Value::Null)) { + entry.remove("contextWindow"); + } + } + } +} + +pub(crate) fn apply_gateway_runtime_defaults( + config: &mut Value, + gateway_port: u16, + gateway_auth_token: &str, +) { + ensure_path_object(config, &["gateway"]); + set_json_path( + config, + &["gateway", "mode"], + Value::String("local".to_string()), + ); + set_json_path( + config, + &["gateway", "bind"], + Value::String("loopback".to_string()), + ); + set_json_path( + config, + &["gateway", "port"], + Value::Number(gateway_port.into()), + ); + set_json_path( + config, + &["gateway", "auth", "mode"], + Value::String("token".to_string()), + ); + set_json_path( + config, + &["gateway", "auth", "token"], + Value::String(gateway_auth_token.to_string()), + ); + set_json_path( + config, + &["gateway", "remote", "token"], + Value::String(gateway_auth_token.to_string()), + ); +} + +pub(crate) fn ensure_value_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + value.as_object_mut().expect("value should be object") +} + +pub(crate) fn extract_gateway_auth_token(config: &Value) -> Option { + config + .get("gateway") + .and_then(|gateway| { + gateway + .get("auth") + .and_then(|auth| auth.get("token")) + .or_else(|| gateway.get("remote").and_then(|remote| remote.get("token"))) + }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(ToString::to_string) +} + +pub(crate) fn determine_api_type(provider_type: ApiProviderType) -> Result<&'static str, String> { + match provider_type { + ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => { + Ok("anthropic-messages") + } + ApiProviderType::OpenaiResponse => Ok("openai-responses"), + ApiProviderType::Openai + | ApiProviderType::Codex + | ApiProviderType::Gemini + | ApiProviderType::Ollama + | ApiProviderType::Fal + | ApiProviderType::NewApi + | ApiProviderType::Gateway => Ok("openai-completions"), + ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => { + Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string()) + } + } +} + +pub(crate) fn format_provider_base_url(provider: &ApiKeyProvider) -> Result { + let api_host = trim_trailing_slash(&provider.api_host); + + match provider.provider_type { + ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => Ok(api_host), + ApiProviderType::Gemini => { + if api_host.contains("generativelanguage.googleapis.com") { + if api_host.ends_with("/v1beta/openai") { + Ok(api_host) + } else { + Ok(format!("{api_host}/v1beta/openai")) + } + } else if has_api_version(&api_host) { + Ok(api_host) + } else { + Ok(format!("{api_host}/v1")) + } + } + ApiProviderType::Gateway => { + if api_host.ends_with("/v1/ai") { + Ok(api_host.trim_end_matches("/ai").to_string()) + } else if has_api_version(&api_host) { + Ok(api_host) + } else { + Ok(format!("{api_host}/v1")) + } + } + ApiProviderType::Openai + | ApiProviderType::OpenaiResponse + | ApiProviderType::Codex + | ApiProviderType::Ollama + | ApiProviderType::Fal + | ApiProviderType::NewApi => { + if has_api_version(&api_host) { + Ok(api_host) + } else { + Ok(format!("{api_host}/v1")) + } + } + ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => { + Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string()) + } + } +} + +pub(crate) fn trim_trailing_slash(value: &str) -> String { + value.trim().trim_end_matches('/').to_string() +} + +pub(crate) fn has_api_version(url: &str) -> bool { + static VERSION_RE: OnceLock = OnceLock::new(); + VERSION_RE + .get_or_init(|| Regex::new(r"/v\d+(?:[./]|$)").expect("regex should compile")) + .is_match(url) +} diff --git a/src-tauri/src/services/openclaw_service/diagnostics.rs b/src-tauri/src/services/openclaw_service/diagnostics.rs new file mode 100644 index 000000000..cee6e9d65 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/diagnostics.rs @@ -0,0 +1,482 @@ +use super::*; + +impl OpenClawService { + pub async fn get_environment_status(&self) -> Result { + let node = inspect_node_dependency_status().await?; + let git = inspect_git_dependency_status().await?; + let openclaw = inspect_openclaw_dependency_status().await?; + let diagnostics = collect_environment_diagnostics().await; + + Ok(build_environment_status(node, git, openclaw, diagnostics)) + } + + pub async fn check_installed(&self) -> Result { + let openclaw = inspect_openclaw_dependency_status().await?; + Ok(BinaryInstallStatus { + installed: openclaw.status == "ok", + path: openclaw.path, + }) + } + + pub async fn check_git_available(&self) -> Result { + let git = inspect_git_dependency_status().await?; + Ok(BinaryAvailabilityStatus { + available: git.status == "ok", + path: git.path, + }) + } + + pub async fn check_node_version(&self) -> Result { + let node = inspect_node_dependency_status().await?; + Ok(NodeCheckResult { + status: match node.status.as_str() { + "missing" => "not_found".to_string(), + other => other.to_string(), + }, + version: node.version, + path: node.path, + }) + } + + pub fn get_node_download_url(&self) -> String { + if cfg!(target_os = "windows") { + "https://nodejs.org/en/download".to_string() + } else if cfg!(target_os = "macos") { + "https://nodejs.org/en/download".to_string() + } else if cfg!(target_os = "linux") { + "https://nodejs.org/en/download".to_string() + } else { + "https://nodejs.org/en/download".to_string() + } + } + + pub fn get_git_download_url(&self) -> String { + if cfg!(target_os = "windows") { + "https://git-scm.com/download/win".to_string() + } else if cfg!(target_os = "macos") { + "https://git-scm.com/download/mac".to_string() + } else if cfg!(target_os = "linux") { + "https://git-scm.com/download/linux".to_string() + } else { + "https://git-scm.com/downloads".to_string() + } + } +} + +#[cfg(any(target_os = "windows", test))] +pub(crate) fn windows_dependency_setup_message( + dependency: DependencyKind, + status: &DependencyStatus, +) -> String { + let guidance = match dependency { + DependencyKind::Node => format!( + "Windows 下请先从 nodejs.org 安装或升级 Node.js {}+,完成后点击“重新检测”,再安装 OpenClaw。", + NODE_MIN_VERSION.0 + ), + DependencyKind::Git => { + "Windows 下请先从 git-scm.com 安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。" + .to_string() + } + }; + + format!("{} {}", status.message, guidance) +} + +#[cfg(any(target_os = "windows", test))] +pub(crate) fn windows_dependency_action_result( + dependency: DependencyKind, + status: &DependencyStatus, +) -> ActionResult { + ActionResult { + success: false, + message: windows_dependency_setup_message(dependency, status), + } +} + +#[cfg(any(target_os = "windows", test))] +pub(crate) fn windows_install_block_result( + node_status: &DependencyStatus, + git_status: &DependencyStatus, +) -> Option { + if node_status.status != "ok" { + return Some(windows_dependency_action_result( + DependencyKind::Node, + node_status, + )); + } + + if git_status.status != "ok" { + return Some(windows_dependency_action_result( + DependencyKind::Git, + git_status, + )); + } + + None +} + +pub(crate) fn dependency_setup_summary(dependency: DependencyKind) -> String { + if cfg!(target_os = "windows") { + return match dependency { + DependencyKind::Node => format!( + "当前缺少可用的 Node.js {}+ 运行时,Windows 下请先手动安装 Node.js,完成后点击“重新检测”,再安装 OpenClaw。", + NODE_MIN_VERSION.0 + ), + DependencyKind::Git => { + "当前缺少可用的 Git,Windows 下请先手动安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。" + .to_string() + } + }; + } + + if cfg!(target_os = "macos") { + return match dependency { + DependencyKind::Node => format!( + "当前缺少可用的 Node.js {}+ 运行时,建议先一键安装或修复 Node.js。", + format_semver(NODE_MIN_VERSION) + ), + DependencyKind::Git => "当前缺少可用的 Git,建议先一键安装或修复 Git。".to_string(), + }; + } + + match dependency { + DependencyKind::Node => format!( + "当前缺少可用的 Node.js {}+ 运行时,请先手动安装后重新检测。", + format_semver(NODE_MIN_VERSION) + ), + DependencyKind::Git => "当前缺少可用的 Git,请先手动安装后重新检测。".to_string(), + } +} + +pub(crate) fn build_environment_status( + node: DependencyStatus, + git: DependencyStatus, + mut openclaw: DependencyStatus, + diagnostics: EnvironmentDiagnostics, +) -> EnvironmentStatus { + let node_ready = node.status == "ok"; + let git_ready = git.status == "ok"; + openclaw.auto_install_supported = node_ready && git_ready; + + let (recommended_action, summary) = if !node_ready { + ( + "install_node".to_string(), + dependency_setup_summary(DependencyKind::Node), + ) + } else if !git_ready { + ( + "install_git".to_string(), + dependency_setup_summary(DependencyKind::Git), + ) + } else if openclaw.status == "needs_reload" { + ( + "refresh_openclaw_env".to_string(), + "已检测到 OpenClaw 包,但命令尚未生效;请点击“重新检测”,必要时重启 Lime。".to_string(), + ) + } else if openclaw.status != "ok" { + ( + "install_openclaw".to_string(), + "运行环境已就绪,可以继续一键安装 OpenClaw。".to_string(), + ) + } else { + ( + "ready".to_string(), + "Node.js、Git 和 OpenClaw 均已就绪,可以继续配置与启动。".to_string(), + ) + }; + + EnvironmentStatus { + node, + git, + openclaw, + recommended_action, + summary, + diagnostics, + temp_artifacts: collect_temp_artifact_paths(None) + .into_iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect(), + } +} + +pub(crate) async fn inspect_node_dependency_status() -> Result { + let Some(path) = find_command_in_shell("node").await? else { + return Ok(DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: format!( + "未检测到 Node.js,需要安装 {}+。", + format_semver(NODE_MIN_VERSION) + ), + auto_install_supported: cfg!(target_os = "macos"), + }); + }; + + let version_text = read_command_version_text(&path, &["--version"]).await?; + let Some(version) = parse_semver_from_text(&version_text) else { + return Ok(DependencyStatus { + status: "version_low".to_string(), + version: Some(version_text.clone()), + path: Some(path), + message: format!( + "检测到 Node.js,但无法识别版本:{version_text}。请安装 {}+。", + format_semver(NODE_MIN_VERSION) + ), + auto_install_supported: cfg!(target_os = "macos"), + }); + }; + + let normalized = format_semver(version); + if version >= NODE_MIN_VERSION { + Ok(DependencyStatus { + status: "ok".to_string(), + version: Some(normalized.clone()), + path: Some(path), + message: format!("Node.js 已就绪:{normalized}"), + auto_install_supported: cfg!(target_os = "macos"), + }) + } else { + Ok(DependencyStatus { + status: "version_low".to_string(), + version: Some(normalized.clone()), + path: Some(path), + message: format!( + "Node.js 版本过低:{normalized},需要 {}+。", + format_semver(NODE_MIN_VERSION) + ), + auto_install_supported: cfg!(target_os = "macos"), + }) + } +} + +pub(crate) async fn inspect_git_dependency_status() -> Result { + let Some(path) = find_command_in_shell("git").await? else { + return Ok(DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "未检测到 Git。".to_string(), + auto_install_supported: git_auto_install_supported().await?, + }); + }; + + let version_text = read_command_version_text(&path, &["--version"]).await?; + let version = parse_semver_from_text(&version_text).map(format_semver); + let detail = version.clone().unwrap_or(version_text); + + Ok(DependencyStatus { + status: "ok".to_string(), + version, + path: Some(path), + message: format!("Git 已就绪:{detail}"), + auto_install_supported: git_auto_install_supported().await?, + }) +} + +pub(crate) async fn inspect_openclaw_dependency_status() -> Result { + let Some(command) = resolve_openclaw_command().await? else { + if let Some(status) = inspect_openclaw_package_reload_status().await? { + return Ok(status); + } + + return Ok(DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "未检测到 OpenClaw,可在环境就绪后一键安装。".to_string(), + auto_install_supported: false, + }); + }; + + let version_text = read_openclaw_version_from_command(&command) + .await? + .unwrap_or_default(); + Ok(DependencyStatus { + status: "ok".to_string(), + version: if version_text.is_empty() { + None + } else { + Some(version_text.clone()) + }, + path: Some(command.install_path_display()), + message: if matches!(command, ResolvedOpenClawCommand::NodeCli { .. }) { + if version_text.is_empty() { + "已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动。".to_string() + } else { + format!("已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动:{version_text}") + } + } else if version_text.is_empty() { + "已检测到 OpenClaw。".to_string() + } else { + format!("已检测到 OpenClaw:{version_text}") + }, + auto_install_supported: false, + }) +} + +pub(crate) async fn inspect_openclaw_package_reload_status( +) -> Result, String> { + let Some(npm_path) = find_command_in_standard_locations("npm").await? else { + return Ok(None); + }; + let Some(prefix) = detect_npm_global_prefix(&npm_path).await else { + return Ok(None); + }; + let Some(package) = find_installed_openclaw_package_details(&prefix) else { + return Ok(None); + }; + + let version_suffix = package + .version + .as_deref() + .map(|item| format!("({item})")) + .unwrap_or_default(); + + Ok(Some(DependencyStatus { + status: "needs_reload".to_string(), + version: package.version.clone(), + path: Some(prefix.clone()), + message: format!( + "已在 npm 全局目录检测到 {}{},但当前进程尚未解析到 openclaw 命令。请点击“重新检测”;若仍失败,请重启 Lime,或确认 {prefix} 已加入 PATH。", package.name, version_suffix + ), + auto_install_supported: false, + })) +} + +pub(crate) async fn git_auto_install_supported() -> Result { + #[cfg(target_os = "macos")] + { + Ok(true) + } + + #[cfg(not(target_os = "macos"))] + { + Ok(false) + } +} + +pub(crate) async fn read_command_version_text( + command_path: &str, + args: &[&str], +) -> Result { + let mut command = Command::new(command_path); + apply_binary_runtime_path(&mut command, command_path); + for arg in args { + command.arg(arg); + } + let output = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("执行命令失败({command_path}): {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !stdout.is_empty() { + Ok(stdout) + } else { + Ok(stderr) + } +} + +pub(crate) async fn collect_environment_diagnostics() -> EnvironmentDiagnostics { + let npm_path = find_command_in_standard_locations("npm") + .await + .ok() + .flatten(); + let npm_global_prefix = match npm_path.as_deref() { + Some(path) => detect_npm_global_prefix(path).await, + None => None, + }; + + #[cfg(target_os = "windows")] + let where_candidates = find_commands_via_where("openclaw") + .await + .unwrap_or_default() + .into_iter() + .map(|path| path.display().to_string()) + .collect(); + + #[cfg(not(target_os = "windows"))] + let where_candidates = Vec::new(); + + let supplemental_search_dirs = + collect_supplemental_openclaw_search_dirs(npm_global_prefix.as_deref()); + let supplemental_command_candidates = + find_all_commands_in_paths("openclaw", &supplemental_search_dirs) + .into_iter() + .map(|path| path.display().to_string()) + .collect(); + let openclaw_package_path = npm_global_prefix + .as_deref() + .and_then(find_installed_openclaw_package_details) + .map(|package| package.path.display().to_string()); + + #[cfg(target_os = "windows")] + let git_where_candidates = find_commands_via_where("git") + .await + .unwrap_or_default() + .into_iter() + .map(|path| path.display().to_string()) + .collect(); + + #[cfg(not(target_os = "windows"))] + let git_where_candidates = Vec::new(); + + let git_supplemental_search_dirs = collect_supplemental_git_search_dirs(); + let git_supplemental_command_candidates = + find_all_commands_in_paths("git", &git_supplemental_search_dirs) + .into_iter() + .map(|path| path.display().to_string()) + .collect(); + + EnvironmentDiagnostics { + npm_path, + npm_global_prefix, + openclaw_package_path, + where_candidates, + supplemental_search_dirs: supplemental_search_dirs + .into_iter() + .map(|path| path.display().to_string()) + .collect(), + supplemental_command_candidates, + git_where_candidates, + git_supplemental_search_dirs: git_supplemental_search_dirs + .into_iter() + .map(|path| path.display().to_string()) + .collect(), + git_supplemental_command_candidates, + } +} + +pub(crate) fn collect_supplemental_openclaw_search_dirs( + npm_global_prefix: Option<&str>, +) -> Vec { + let mut dirs = Vec::new(); + + #[cfg(target_os = "windows")] + { + dirs.extend(windows_known_command_dirs_from_env()); + } + + if let Some(prefix) = npm_global_prefix { + dirs.extend(npm_global_command_dirs(prefix)); + } + + collect_existing_unique_dirs(dirs) +} + +pub(crate) fn collect_supplemental_git_search_dirs() -> Vec { + #[cfg(target_os = "windows")] + { + return collect_existing_unique_dirs(windows_known_git_command_dirs_from_env()); + } + + #[cfg(not(target_os = "windows"))] + { + Vec::new() + } +} diff --git a/src-tauri/src/services/openclaw_service/gateway.rs b/src-tauri/src/services/openclaw_service/gateway.rs new file mode 100644 index 000000000..803b352be --- /dev/null +++ b/src-tauri/src/services/openclaw_service/gateway.rs @@ -0,0 +1,871 @@ +use super::*; + +impl OpenClawService { + pub async fn start_gateway( + &mut self, + app: Option<&AppHandle>, + port: Option, + ) -> Result { + if let Some(next_port) = port { + self.gateway_port = next_port.max(1); + } + + if let Some(app) = app { + emit_install_progress( + app, + &format!("准备启动 Gateway,目标端口 {}。", self.gateway_port), + "info", + ); + } + + self.ensure_runtime_config(None, None)?; + self.refresh_process_state().await?; + + if self.gateway_status == GatewayStatus::Running { + if let Some(app) = app { + emit_install_progress( + app, + &format!("检测到 Gateway 已在端口 {} 运行。", self.gateway_port), + "info", + ); + } + return Ok(ActionResult { + success: true, + message: format!("Gateway 已在端口 {} 运行", self.gateway_port), + }); + } + + let Some(openclaw_command) = resolve_openclaw_command().await? else { + self.gateway_status = GatewayStatus::Error; + if let Some(app) = app { + emit_install_progress(app, "未检测到 OpenClaw 可执行文件,请先安装。", "error"); + } + return Ok(ActionResult { + success: false, + message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(), + }); + }; + + self.gateway_status = GatewayStatus::Starting; + + let config_path = openclaw_lime_config_path(); + if let Some(app) = app { + emit_install_progress( + app, + &format!("使用配置文件启动 Gateway: {}", config_path.display()), + "info", + ); + } + let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token); + let mut command = openclaw_command.build_command_with_args(&start_args); + command + .env(OPENCLAW_CONFIG_ENV, &config_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command + .spawn() + .map_err(|e| format!("启动 Gateway 失败: {e}"))?; + + let gateway_error_lines = Arc::new(StdMutex::new(Vec::::new())); + + if let Some(stdout) = child.stdout.take() { + let app = app.cloned(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::info!(target: "openclaw", "Gateway stdout: {}", line); + if let Some(app) = app.as_ref() { + emit_install_progress(app, &line, classify_progress_level(&line, "info")); + } + } + }); + } + + if let Some(stderr) = child.stderr.take() { + let app = app.cloned(); + let gateway_error_lines = gateway_error_lines.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::warn!(target: "openclaw", "Gateway stderr: {}", line); + if let Ok(mut slot) = gateway_error_lines.lock() { + push_gateway_error_line(&mut slot, &line); + } + if let Some(app) = app.as_ref() { + emit_install_progress(app, &line, classify_progress_level(&line, "warn")); + } + } + }); + } + + self.gateway_process = Some(child); + self.gateway_started_at = Some(SystemTime::now()); + + if let Some(app) = app { + emit_install_progress(app, "Gateway 进程已拉起,等待服务就绪。", "info"); + } + + let start_at = tokio::time::Instant::now(); + while start_at.elapsed() < Duration::from_secs(30) { + sleep(Duration::from_millis(300)).await; + self.refresh_process_state().await?; + let gateway_error_lines = gateway_error_lines + .lock() + .ok() + .map(|slot| slot.clone()) + .unwrap_or_default(); + + if self.gateway_process.is_none() && self.gateway_status == GatewayStatus::Error { + let message = format_gateway_start_failure_message( + select_gateway_start_failure_detail(&gateway_error_lines), + ); + if let Some(app) = app { + emit_install_progress(app, &message, "error"); + } + return Ok(ActionResult { + success: false, + message, + }); + } + + if self.gateway_status == GatewayStatus::Running { + if let Some(app) = app { + emit_install_progress( + app, + &format!("Gateway 启动成功,监听端口 {}。", self.gateway_port), + "info", + ); + } + return Ok(ActionResult { + success: true, + message: format!("Gateway 已启动,端口 {}", self.gateway_port), + }); + } + + if self.check_port_open().await { + self.gateway_status = GatewayStatus::Running; + if let Some(app) = app { + emit_install_progress( + app, + &format!("Gateway 探测成功,监听端口 {}。", self.gateway_port), + "info", + ); + } + return Ok(ActionResult { + success: true, + message: format!("Gateway 已启动,端口 {}", self.gateway_port), + }); + } + } + + self.gateway_status = GatewayStatus::Error; + let gateway_error_lines = gateway_error_lines + .lock() + .ok() + .map(|slot| slot.clone()) + .unwrap_or_default(); + let message = format_gateway_start_failure_message(select_gateway_start_failure_detail( + &gateway_error_lines, + )); + if let Some(app) = app { + emit_install_progress(app, &message, "error"); + } + Ok(ActionResult { + success: false, + message, + }) + } + + pub async fn stop_gateway(&mut self, app: Option<&AppHandle>) -> Result { + if let Some(app) = app { + emit_install_progress(app, "准备停止 Gateway。", "info"); + } + + self.restore_auth_token_from_config(); + + if let Some(mut child) = self.gateway_process.take() { + if let Some(app) = app { + emit_install_progress(app, "正在终止当前托管的 Gateway 子进程。", "info"); + } + let _ = child.kill().await; + let _ = timeout(Duration::from_secs(3), child.wait()).await; + } + + if self + .wait_for_gateway_shutdown(Duration::from_secs(3)) + .await? + { + if let Some(app) = app { + emit_install_progress(app, "Gateway 已停止。", "info"); + } + return Ok(ActionResult { + success: true, + message: "Gateway 已停止。".to_string(), + }); + } + + let stop_commands = self.collect_gateway_stop_commands().await?; + if stop_commands.is_empty() { + if let Some(app) = app { + emit_install_progress( + app, + "未检测到可用的 OpenClaw 停止命令,将尝试按端口回收旧 Gateway。", + "warn", + ); + } + } else { + for command_spec in &stop_commands { + self.request_gateway_stop_via_command(command_spec, app) + .await; + if self + .wait_for_gateway_shutdown(Duration::from_secs(4)) + .await? + { + if let Some(app) = app { + emit_install_progress(app, "Gateway 已停止。", "info"); + } + return Ok(ActionResult { + success: true, + message: "Gateway 已停止。".to_string(), + }); + } + } + } + + let reclaimed_by_pid = self.force_stop_gateway_listener_processes(app).await?; + if reclaimed_by_pid + && self + .wait_for_gateway_shutdown(Duration::from_secs(5)) + .await? + { + if let Some(app) = app { + emit_install_progress(app, "Gateway 已停止。", "info"); + } + return Ok(ActionResult { + success: true, + message: "Gateway 已停止。".to_string(), + }); + } + + self.refresh_process_state().await?; + let message = if self.check_port_open().await { + format!( + "Gateway 停止失败:端口 {} 仍被旧进程占用,升级已中止。请使用“立即重启生效”或结束旧 OpenClaw 进程后重试。", + self.gateway_port + ) + } else { + "Gateway 停止流程已结束,但未能确认运行态完全退出,请重试。".to_string() + }; + + if let Some(app) = app { + emit_install_progress(app, &message, "error"); + } + + Ok(ActionResult { + success: false, + message, + }) + } + + pub async fn restart_gateway(&mut self, app: &AppHandle) -> Result { + emit_install_progress(app, "开始重启 Gateway。", "info"); + let stop_result = self.stop_gateway(Some(app)).await?; + if !stop_result.success { + return Ok(stop_result); + } + emit_install_progress(app, "Gateway 停止阶段结束,开始重新启动。", "info"); + self.start_gateway(Some(app), Some(self.gateway_port)).await + } + + pub async fn get_status(&mut self) -> Result { + self.refresh_process_state().await?; + Ok(GatewayStatusInfo { + status: self.gateway_status.clone(), + port: self.gateway_port, + }) + } + + pub async fn check_health(&mut self) -> Result { + self.refresh_process_state().await?; + + self.restore_auth_token_from_config(); + + let health_snapshot = self.fetch_authenticated_gateway_health_json().await; + let healthy = self.gateway_status == GatewayStatus::Running + && self.check_port_open().await + && health_snapshot + .as_ref() + .and_then(|value| value.get("ok").and_then(Value::as_bool)) + .unwrap_or(false); + let version = self.read_openclaw_version().await.ok().flatten(); + let uptime = self.gateway_started_at.and_then(|start| { + SystemTime::now() + .duration_since(start) + .ok() + .map(|elapsed| elapsed.as_secs()) + }); + + Ok(HealthInfo { + status: if healthy { "healthy" } else { "unhealthy" }.to_string(), + gateway_port: self.gateway_port, + uptime, + version, + }) + } + + pub fn get_dashboard_url(&mut self) -> String { + self.restore_auth_token_from_config(); + let mut url = format!("http://127.0.0.1:{}", self.gateway_port); + if !self.gateway_auth_token.is_empty() { + url.push_str(&format!( + "/#token={}", + urlencoding::encode(&self.gateway_auth_token) + )); + } + url + } + + pub async fn get_channels(&mut self) -> Result, String> { + self.refresh_process_state().await?; + if self.gateway_status != GatewayStatus::Running { + return Ok(Vec::new()); + } + + self.restore_auth_token_from_config(); + + let Some(body) = self.fetch_authenticated_gateway_health_json().await else { + return Ok(Vec::new()); + }; + + let channels_map = body + .get("channels") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let labels = body + .get("channelLabels") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let ordered_ids = body + .get("channelOrder") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let mut ordered = Vec::new(); + for channel_id in ordered_ids.iter().filter_map(Value::as_str) { + if let Some(entry) = channels_map.get(channel_id) { + ordered.push(build_channel_info( + channel_id, + entry, + labels.get(channel_id), + )); + } + } + + if ordered.is_empty() { + ordered = channels_map + .iter() + .map(|(channel_id, entry)| { + build_channel_info(channel_id, entry, labels.get(channel_id)) + }) + .collect(); + } + + Ok(ordered) + } + + async fn wait_for_gateway_shutdown(&mut self, max_wait: Duration) -> Result { + let start_at = tokio::time::Instant::now(); + while start_at.elapsed() < max_wait { + self.refresh_process_state().await?; + if !self.check_port_open().await { + self.clear_gateway_runtime_state(); + return Ok(true); + } + sleep(Duration::from_millis(250)).await; + } + + self.refresh_process_state().await?; + if !self.check_port_open().await { + self.clear_gateway_runtime_state(); + return Ok(true); + } + + Ok(false) + } + + async fn collect_gateway_stop_commands(&self) -> Result, String> { + let mut commands = Vec::new(); + + if let Some(command) = resolve_openclaw_command().await? { + commands.push(command); + } + + let mut runtime_candidates = list_openclaw_runtime_candidates().await?; + runtime_candidates.sort_by(compare_openclaw_runtime_candidates); + commands.extend( + runtime_candidates + .iter() + .filter_map(resolve_openclaw_command_from_runtime_candidate), + ); + + Ok(dedupe_openclaw_commands(commands)) + } + + async fn request_gateway_stop_via_command( + &self, + command_spec: &ResolvedOpenClawCommand, + app: Option<&AppHandle>, + ) { + let binary_label = command_spec.invocation_display(); + if let Some(app) = app { + emit_install_progress( + app, + &format!("尝试通过 {} 停止 Gateway。", binary_label), + "info", + ); + } + + let stop_args = vec![ + "gateway".to_string(), + "stop".to_string(), + "--url".to_string(), + self.gateway_ws_url(), + "--token".to_string(), + self.gateway_auth_token.clone(), + ]; + let mut command = command_spec.build_command_with_args(&stop_args); + let output = timeout( + Duration::from_secs(8), + command + .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(), + ) + .await; + + match output { + Ok(Ok(result)) => { + for line in command_output_lines(&result.stdout) { + if let Some(app) = app { + emit_install_progress(app, &line, classify_progress_level(&line, "info")); + } + } + for line in command_output_lines(&result.stderr) { + if let Some(app) = app { + emit_install_progress(app, &line, classify_progress_level(&line, "warn")); + } + } + + if let Some(app) = app { + if result.status.success() { + emit_install_progress(app, "已发送 Gateway 停止命令。", "info"); + } else { + emit_install_progress( + app, + &format!("Gateway 停止命令返回异常状态: {:?}", result.status.code()), + "warn", + ); + } + } + } + Ok(Err(error)) => { + if let Some(app) = app { + emit_install_progress( + app, + &format!("执行 Gateway 停止命令失败: {error}"), + "warn", + ); + } + } + Err(_) => { + if let Some(app) = app { + emit_install_progress( + app, + "Gateway 停止命令超时,继续尝试自动回收旧进程。", + "warn", + ); + } + } + } + } + + async fn force_stop_gateway_listener_processes( + &mut self, + app: Option<&AppHandle>, + ) -> Result { + let listener_pids = collect_listening_port_pids(self.gateway_port).await; + if listener_pids.is_empty() { + return Ok(false); + } + + let mut system = System::new_all(); + system.refresh_all(); + + let target_pids = collect_openclaw_process_family_pids(&system, &listener_pids); + if target_pids.is_empty() { + if let Some(app) = app { + emit_install_progress( + app, + &format!( + "检测到端口 {} 仍被占用,但监听进程不是 OpenClaw,未执行自动终止。", + self.gateway_port + ), + "warn", + ); + } + return Ok(false); + } + + if let Some(app) = app { + emit_install_progress( + app, + &format!( + "检测到旧 Gateway 仍占用端口 {},准备回收进程:{}。", + self.gateway_port, + target_pids + .iter() + .map(|pid| pid.as_u32().to_string()) + .collect::>() + .join(", ") + ), + "warn", + ); + } + + terminate_sysinfo_processes(&mut system, &target_pids).await; + Ok(true) + } + + pub(crate) async fn refresh_process_state(&mut self) -> Result<(), String> { + let mut process_exited = false; + + if let Some(child) = self.gateway_process.as_mut() { + match child.try_wait() { + Ok(Some(status)) => { + tracing::info!(target: "openclaw", "Gateway 进程已退出: {}", status); + process_exited = true; + } + Ok(None) => {} + Err(error) => { + tracing::warn!(target: "openclaw", "检查 Gateway 进程状态失败: {}", error); + process_exited = true; + } + } + } + + if process_exited { + self.gateway_process = None; + self.gateway_started_at = None; + } + + let openclaw_command = resolve_openclaw_command().await?; + let running = self.check_port_open().await + || self.check_gateway_status(openclaw_command.as_ref()).await?; + + self.gateway_status = if running { + GatewayStatus::Running + } else if self.gateway_status == GatewayStatus::Starting { + GatewayStatus::Error + } else { + GatewayStatus::Stopped + }; + + if !running { + self.gateway_process = None; + self.gateway_started_at = None; + } + + Ok(()) + } + + async fn check_port_open(&self) -> bool { + timeout( + Duration::from_secs(2), + TcpStream::connect(("127.0.0.1", self.gateway_port)), + ) + .await + .map(|result| result.is_ok()) + .unwrap_or(false) + } + + async fn check_gateway_status( + &self, + command_spec: Option<&ResolvedOpenClawCommand>, + ) -> Result { + let Some(command_spec) = command_spec else { + return Ok(false); + }; + + let status_args = vec![ + "gateway".to_string(), + "status".to_string(), + "--url".to_string(), + self.gateway_ws_url(), + "--token".to_string(), + self.gateway_auth_token.clone(), + ]; + let mut command = command_spec.build_command_with_args(&status_args); + let output = command + .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await; + + match output { + Ok(result) => { + let stdout = String::from_utf8_lossy(&result.stdout).to_lowercase(); + let stderr = String::from_utf8_lossy(&result.stderr).to_lowercase(); + Ok(result.status.success() + && (stdout.contains("listening") + || stdout.contains("running") + || stderr.contains("listening"))) + } + Err(_) => Ok(false), + } + } + + pub(crate) async fn read_openclaw_version(&self) -> Result, String> { + let Some(command_spec) = resolve_openclaw_command().await? else { + return Ok(None); + }; + + read_openclaw_version_from_command(&command_spec).await + } + + pub(crate) fn gateway_ws_url(&self) -> String { + format!("ws://127.0.0.1:{}", self.gateway_port) + } + + pub(crate) fn restore_auth_token_from_config(&mut self) { + if !self.gateway_auth_token.is_empty() { + return; + } + + match read_base_openclaw_config() + .ok() + .and_then(|config| extract_gateway_auth_token(&config)) + { + Some(token) => { + self.gateway_auth_token = token; + } + None => { + tracing::warn!( + target: "openclaw", + "未能从 OpenClaw 配置恢复 gateway token,Dashboard 访问可能鉴权失败" + ); + } + } + } + + async fn fetch_authenticated_gateway_health_json(&self) -> Option { + if self.gateway_auth_token.is_empty() { + return None; + } + + let Some(command_spec) = resolve_openclaw_command().await.ok().flatten() else { + return None; + }; + + let health_args = vec![ + "gateway".to_string(), + "health".to_string(), + "--url".to_string(), + self.gateway_ws_url(), + "--token".to_string(), + self.gateway_auth_token.clone(), + "--json".to_string(), + ]; + let mut command = command_spec.build_command_with_args(&health_args); + let output = command + .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await; + + match output { + Ok(output) if output.status.success() => { + serde_json::from_slice::(&output.stdout) + .map_err(|error| { + tracing::warn!( + target: "openclaw", + "解析 Gateway 官方健康检查结果失败: {}", + error + ); + error + }) + .ok() + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + target: "openclaw", + "Gateway 官方健康检查失败: {}", + stderr.trim() + ); + None + } + Err(error) => { + tracing::warn!(target: "openclaw", "执行 Gateway 官方健康检查失败: {}", error); + None + } + } + } +} + +pub(crate) fn gateway_start_args(gateway_port: u16, gateway_auth_token: &str) -> Vec { + vec![ + "gateway".to_string(), + "--allow-unconfigured".to_string(), + "--bind".to_string(), + "loopback".to_string(), + "--auth".to_string(), + "token".to_string(), + "--token".to_string(), + gateway_auth_token.to_string(), + "--port".to_string(), + gateway_port.to_string(), + ] +} + +pub(crate) fn push_gateway_error_line(lines: &mut Vec, line: &str) { + let trimmed = line.trim(); + if trimmed.is_empty() { + return; + } + + if lines.len() >= 32 { + lines.remove(0); + } + lines.push(trimmed.to_string()); +} + +pub(crate) fn select_gateway_start_failure_detail<'a>(lines: &'a [String]) -> Option<&'a str> { + lines + .iter() + .filter_map(|line| { + let trimmed = line.trim(); + let score = gateway_failure_line_score(trimmed); + (score > 0).then_some((score, trimmed)) + }) + .max_by_key(|(score, _)| *score) + .map(|(_, line)| line) +} + +pub(crate) fn gateway_failure_line_score(line: &str) -> u8 { + if line.is_empty() { + return 0; + } + + let normalized = line.to_ascii_lowercase(); + if normalized.starts_with("run: openclaw doctor") + || normalized == "config invalid" + || normalized.starts_with("file:") + || normalized == "problem:" + { + return 0; + } + + if normalized.contains("invalid config") { + return 100; + } + + if normalized.contains("contextwindow") && normalized.contains("received null") { + return 95; + } + + if normalized.contains("address already in use") + || normalized.contains("eaddrinuse") + || normalized.contains("resolved to non-loopback host") + { + return 90; + } + + if normalized.contains("missing config") + || normalized.contains("gateway.mode=local") + || normalized.contains("gateway.auth.mode") + { + return 85; + } + + if normalized.starts_with("- ") { + return 60; + } + + 20 +} + +pub(crate) fn format_gateway_start_failure_message(detail: Option<&str>) -> String { + let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else { + return "Gateway 启动超时,请检查配置或端口占用。".to_string(); + }; + + let normalized = detail.to_ascii_lowercase(); + if normalized.contains("invalid config") || normalized.contains("config invalid") { + if normalized.contains("contextwindow") && normalized.contains("received null") { + return "Gateway 启动失败:当前 OpenClaw 配置包含空的 contextWindow 字段。Lime 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。" + .to_string(); + } + return "Gateway 启动失败:OpenClaw 配置文件无效,请重新同步模型配置后再试。".to_string(); + } + + if normalized.contains("missing config") || normalized.contains("gateway.mode=local") { + return "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。" + .to_string(); + } + + if normalized.contains("gateway.auth.mode") { + return "Gateway 启动失败:缺少网关认证模式,已自动切换为 token 模式,请重试。".to_string(); + } + + if normalized.contains("address already in use") || normalized.contains("eaddrinuse") { + return "Gateway 启动失败:目标端口已被占用,请更换端口或停止占用进程。".to_string(); + } + + if normalized.contains("resolved to non-loopback host") { + return "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。".to_string(); + } + + if normalized.contains("allowedorigins") || normalized.contains("host-header origin fallback") { + return "Gateway 启动失败:当前绑定方式需要配置 Control UI 允许来源,请检查 gateway.controlUi.allowedOrigins。".to_string(); + } + + if normalized.contains("doctor --fix") { + return "Gateway 启动失败:OpenClaw 检测到本地环境或配置异常,请先在安装页执行“重新检测”或“修复环境”后再试。" + .to_string(); + } + + format!("Gateway 启动失败:{detail}") +} + +pub(crate) fn build_channel_info( + channel_id: &str, + entry: &Value, + label: Option<&Value>, +) -> ChannelInfo { + ChannelInfo { + id: channel_id.to_string(), + name: entry + .get("name") + .and_then(Value::as_str) + .or_else(|| label.and_then(Value::as_str)) + .unwrap_or("未命名通道") + .to_string(), + channel_type: entry + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + status: entry + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + } +} diff --git a/src-tauri/src/services/openclaw_service/install.rs b/src-tauri/src/services/openclaw_service/install.rs new file mode 100644 index 000000000..0db0ff2fa --- /dev/null +++ b/src-tauri/src/services/openclaw_service/install.rs @@ -0,0 +1,810 @@ +use super::*; + +impl OpenClawService { + pub async fn install(&mut self, app: &AppHandle) -> Result { + emit_install_progress(app, "开始准备 OpenClaw 环境。", "info"); + + #[cfg(target_os = "windows")] + { + let node_status = self.inspect_dependency_status(DependencyKind::Node).await?; + let git_status = self.inspect_dependency_status(DependencyKind::Git).await?; + if let Some(result) = windows_install_block_result(&node_status, &git_status) { + emit_install_progress(app, &result.message, "warn"); + return Ok(result); + } + } + + let node_result = self + .ensure_dependency_ready(app, DependencyKind::Node) + .await?; + if !node_result.success { + return Ok(node_result); + } + + let git_result = self + .ensure_dependency_ready(app, DependencyKind::Git) + .await?; + if !git_result.success { + return Ok(git_result); + } + + let (_, npm_path, npm_prefix, cleanup_command, install_command) = + self.resolve_install_commands(app).await?; + + emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); + if let Some(prefix) = npm_prefix { + emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info"); + } + emit_install_progress(app, "安装前先清理已有 OpenClaw 全局包。", "info"); + let cleanup_result = run_shell_command_with_progress(app, &cleanup_command).await?; + if !cleanup_result.success { + emit_install_progress( + app, + &format!( + "清理旧版 OpenClaw 失败,继续尝试安装:{}", + cleanup_result.message + ), + "warn", + ); + } + + emit_install_progress(app, &format!("执行安装命令: {install_command}"), "info"); + let result = run_shell_command_with_progress(app, &install_command).await?; + if !result.success { + return Ok(result); + } + + let installed = self.check_installed().await?; + if installed.installed { + emit_install_progress(app, "已检测到 OpenClaw 可执行文件。", "info"); + return Ok(ActionResult { + success: true, + message: installed + .path + .map(|path| format!("OpenClaw 安装完成:{path}")) + .unwrap_or_else(|| "OpenClaw 安装完成。".to_string()), + }); + } + + Ok(ActionResult { + success: false, + message: + "安装命令执行完成,但仍未检测到 OpenClaw 可执行文件,请检查 npm 全局目录或权限设置。" + .to_string(), + }) + } + + pub async fn install_dependency( + &mut self, + app: &AppHandle, + kind: &str, + ) -> Result { + let dependency = match kind { + "node" => DependencyKind::Node, + "git" => DependencyKind::Git, + _ => return Err(format!("不支持的依赖类型: {kind}")), + }; + + #[cfg(target_os = "windows")] + { + let status = self.inspect_dependency_status(dependency).await?; + if status.status == "ok" { + emit_install_progress( + app, + &format!( + "{} 已就绪{}。", + dependency.label(), + status + .version + .as_deref() + .map(|version| format!(" · {version}")) + .unwrap_or_default() + ), + "info", + ); + return Ok(ActionResult { + success: true, + message: format!("{} 已满足要求。", dependency.label()), + }); + } + + let result = windows_dependency_action_result(dependency, &status); + emit_install_progress(app, &result.message, "warn"); + return Ok(result); + } + + #[cfg(not(target_os = "windows"))] + { + self.ensure_dependency_ready(app, dependency).await + } + } + + pub async fn cleanup_temp_artifacts( + &mut self, + app: Option<&AppHandle>, + ) -> Result { + let mut removed = Vec::new(); + let mut failed = Vec::new(); + + for target in collect_temp_artifact_paths(app) { + if !target.exists() { + continue; + } + + let result = if target.is_dir() { + std::fs::remove_dir_all(&target) + } else { + std::fs::remove_file(&target) + }; + + match result { + Ok(_) => { + if let Some(app) = app { + emit_install_progress( + app, + &format!("已清理临时文件:{}", target.display()), + "info", + ); + } + removed.push(target.display().to_string()); + } + Err(error) => { + if let Some(app) = app { + emit_install_progress( + app, + &format!("清理临时文件失败({}): {error}", target.display()), + "warn", + ); + } + failed.push(format!("{}: {error}", target.display())); + } + } + } + + if failed.is_empty() { + Ok(ActionResult { + success: true, + message: if removed.is_empty() { + "未发现需要清理的 OpenClaw 临时文件。".to_string() + } else { + format!("已清理 {} 项临时文件。", removed.len()) + }, + }) + } else { + Ok(ActionResult { + success: false, + message: format!("部分临时文件清理失败:{}", failed.join(";")), + }) + } + } + + pub async fn uninstall(&mut self, app: &AppHandle) -> Result { + if self.gateway_status == GatewayStatus::Running || self.gateway_process.is_some() { + let _ = self.stop_gateway(None).await; + } + + let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?; + + emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); + if let Some(prefix) = npm_prefix { + emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info"); + } + emit_install_progress(app, &format!("执行卸载命令: {command}"), "info"); + run_shell_command_with_progress(app, &command).await + } + + async fn ensure_dependency_ready( + &mut self, + app: &AppHandle, + dependency: DependencyKind, + ) -> Result { + let status = self.inspect_dependency_status(dependency).await?; + if status.status == "ok" { + emit_install_progress( + app, + &format!( + "{} 已就绪{}。", + dependency.label(), + status + .version + .as_deref() + .map(|version| format!(" · {version}")) + .unwrap_or_default() + ), + "info", + ); + return Ok(ActionResult { + success: true, + message: format!("{} 已满足要求。", dependency.label()), + }); + } + + emit_install_progress( + app, + &format!("{},开始修复 {} 环境。", status.message, dependency.label()), + "warn", + ); + + match dependency { + DependencyKind::Node => self.install_node_runtime(app).await, + DependencyKind::Git => self.install_git_runtime(app).await, + } + } + + async fn inspect_dependency_status( + &self, + dependency: DependencyKind, + ) -> Result { + match dependency { + DependencyKind::Node => inspect_node_dependency_status().await, + DependencyKind::Git => inspect_git_dependency_status().await, + } + } + + async fn install_node_runtime(&mut self, app: &AppHandle) -> Result { + #[cfg(target_os = "windows")] + { + let winget_path = find_command_in_shell("winget").await?; + match resolve_windows_dependency_install_plan( + DependencyKind::Node, + winget_path.is_some(), + ) { + WindowsDependencyInstallPlan::Winget { package_id } => { + let winget_path = winget_path.expect("winget path should exist"); + emit_install_progress( + app, + "检测到 winget,准备通过 winget 安装 Node.js。", + "info", + ); + let command = build_winget_install_command(&winget_path, package_id); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Node) + .await; + } + WindowsDependencyInstallPlan::OfficialInstaller => { + emit_install_progress( + app, + "未检测到 winget,准备下载官方 Node.js 安装器。", + "warn", + ); + let asset = resolve_node_installer_asset().await?; + let installer_path = download_installer_asset(app, &asset).await?; + launch_installer(&installer_path)?; + return self + .wait_for_dependency_ready(app, DependencyKind::Node, 900) + .await; + } + WindowsDependencyInstallPlan::ManualDownload => { + unreachable!("Node.js 在 Windows 上不应返回手动下载计划") + } + } + } + + #[cfg(target_os = "macos")] + { + if let Some(brew_path) = find_command_in_shell("brew").await? { + emit_install_progress( + app, + "检测到 Homebrew,准备通过 Homebrew 安装 Node.js。", + "info", + ); + let brew_cmd = shell_command_escape(&brew_path); + let path_env = shell_path_assignment(&brew_path); + let command = format!( + "{path_env}{brew_cmd} install node || {path_env}{brew_cmd} upgrade node" + ); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Node) + .await; + } + + emit_install_progress( + app, + "未检测到 Homebrew,准备下载官方 Node.js 安装器。", + "warn", + ); + let asset = resolve_node_installer_asset().await?; + let installer_path = download_installer_asset(app, &asset).await?; + launch_installer(&installer_path)?; + return self + .wait_for_dependency_ready(app, DependencyKind::Node, 900) + .await; + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let message = "当前平台暂不支持应用内自动安装 Node.js,请手动安装 Node.js 22+ 后重试。" + .to_string(); + emit_install_progress(app, &message, "warn"); + Ok(ActionResult { + success: false, + message, + }) + } + } + + async fn install_git_runtime(&mut self, app: &AppHandle) -> Result { + #[cfg(target_os = "windows")] + { + let winget_path = find_command_in_shell("winget").await?; + match resolve_windows_dependency_install_plan( + DependencyKind::Git, + winget_path.is_some(), + ) { + WindowsDependencyInstallPlan::Winget { package_id } => { + let winget_path = winget_path.expect("winget path should exist"); + emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Git。", "info"); + let command = build_winget_install_command(&winget_path, package_id); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Git) + .await; + } + WindowsDependencyInstallPlan::OfficialInstaller => { + unreachable!("Git 在 Windows 上不应返回官方安装器计划") + } + WindowsDependencyInstallPlan::ManualDownload => { + let message = windows_manual_install_message(DependencyKind::Git).to_string(); + emit_install_progress(app, &message, "warn"); + return Ok(ActionResult { + success: false, + message, + }); + } + } + } + + #[cfg(target_os = "macos")] + { + if let Some(brew_path) = find_command_in_shell("brew").await? { + emit_install_progress(app, "检测到 Homebrew,准备通过 Homebrew 安装 Git。", "info"); + let brew_cmd = shell_command_escape(&brew_path); + let path_env = shell_path_assignment(&brew_path); + let command = + format!("{path_env}{brew_cmd} install git || {path_env}{brew_cmd} upgrade git"); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Git) + .await; + } + + emit_install_progress( + app, + "未检测到 Homebrew,准备拉起 macOS Command Line Tools 安装器。", + "warn", + ); + let trigger_result = trigger_macos_command_line_tools_install().await?; + emit_install_progress(app, &trigger_result, "info"); + return self + .wait_for_dependency_ready(app, DependencyKind::Git, 1200) + .await; + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let message = "当前平台暂不支持应用内自动安装 Git,请使用系统包管理器手动安装后重试。" + .to_string(); + emit_install_progress(app, &message, "warn"); + Ok(ActionResult { + success: false, + message, + }) + } + } + + async fn verify_dependency_after_install( + &self, + app: &AppHandle, + dependency: DependencyKind, + ) -> Result { + // 在 Windows 上刷新 PATH 环境变量 + #[cfg(target_os = "windows")] + { + if let Err(e) = refresh_windows_path_from_registry() { + emit_install_progress(app, &format!("刷新环境变量失败: {}", e), "warn"); + } else { + emit_install_progress(app, "已刷新系统环境变量。", "info"); + } + } + + let status = self.inspect_dependency_status(dependency).await?; + if status.status == "ok" { + emit_install_progress( + app, + &format!( + "{} 已准备完成{}。", + dependency.label(), + status + .version + .as_deref() + .map(|version| format!(" · {version}")) + .unwrap_or_default() + ), + "info", + ); + return Ok(ActionResult { + success: true, + message: format!("{} 已安装完成。", dependency.label()), + }); + } + + Ok(ActionResult { + success: false, + message: format!( + "{} 安装完成后仍未通过校验:{}", + dependency.label(), + status.message + ), + }) + } + + async fn wait_for_dependency_ready( + &self, + app: &AppHandle, + dependency: DependencyKind, + timeout_secs: u64, + ) -> Result { + emit_install_progress( + app, + &format!( + "已拉起 {} 安装器,正在等待安装完成(最长 {} 秒)。", + dependency.label(), + timeout_secs + ), + "info", + ); + + let start = tokio::time::Instant::now(); + let mut last_notice_at = 0_u64; + #[cfg(target_os = "windows")] + let mut last_refresh_at = 0_u64; + + while start.elapsed() < Duration::from_secs(timeout_secs) { + let elapsed = start.elapsed().as_secs(); + + // 每 10 秒刷新一次 Windows PATH(因为用户可能在安装过程中) + #[cfg(target_os = "windows")] + if elapsed >= last_refresh_at + 10 { + last_refresh_at = elapsed; + let _ = refresh_windows_path_from_registry(); + } + + if elapsed >= last_notice_at + 15 { + last_notice_at = elapsed; + emit_install_progress( + app, + &format!("正在等待 {} 安装完成…", dependency.label()), + "info", + ); + } + + sleep(Duration::from_secs(2)).await; + let status = self.inspect_dependency_status(dependency).await?; + if status.status == "ok" { + emit_install_progress( + app, + &format!( + "{} 已检测通过{}。", + dependency.label(), + status + .version + .as_deref() + .map(|version| format!(" · {version}")) + .unwrap_or_default() + ), + "info", + ); + return Ok(ActionResult { + success: true, + message: format!("{} 已安装完成。", dependency.label()), + }); + } + } + + Ok(ActionResult { + success: false, + message: format!( + "等待 {} 安装完成超时,请完成安装后重新点击重试。", + dependency.label() + ), + }) + } + pub(crate) async fn resolve_install_commands( + &self, + app: &AppHandle, + ) -> Result<(String, String, Option, String, String), String> { + let npm_path = find_command_in_shell("npm") + .await? + .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; + let npm_prefix = detect_npm_global_prefix(&npm_path).await; + let use_china_package = should_use_china_package(app).await; + let package = if use_china_package { + OPENCLAW_CN_PACKAGE + } else { + OPENCLAW_DEFAULT_PACKAGE + }; + let shell_platform = current_shell_platform(); + let cleanup_command = + build_openclaw_cleanup_command(shell_platform, &npm_path, npm_prefix.as_deref()); + let install_command = build_openclaw_install_command( + shell_platform, + &npm_path, + npm_prefix.as_deref(), + package, + use_china_package.then_some(NPM_MIRROR_CN), + ); + Ok(( + package.to_string(), + npm_path, + npm_prefix, + cleanup_command, + install_command, + )) + } + + pub(crate) async fn resolve_uninstall_command( + &self, + ) -> Result<(String, Option, String), String> { + let npm_path = find_command_in_shell("npm") + .await? + .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; + let npm_prefix = detect_npm_global_prefix(&npm_path).await; + let command = build_openclaw_cleanup_command( + current_shell_platform(), + &npm_path, + npm_prefix.as_deref(), + ); + Ok((npm_path, npm_prefix, command)) + } +} + +pub(crate) fn openclaw_installer_download_dir(app: &AppHandle) -> Result { + let _ = app; + let app_data_dir = lime_core::app_paths::preferred_data_dir() + .map_err(|e| format!("无法获取应用数据目录: {e}"))?; + let dir = app_data_dir.join("downloads").join("openclaw-installers"); + std::fs::create_dir_all(&dir).map_err(|e| format!("创建 OpenClaw 下载目录失败: {e}"))?; + Ok(dir) +} + +pub(crate) fn collect_temp_artifact_paths(app: Option<&AppHandle>) -> Vec { + let mut targets = Vec::new(); + + #[cfg(not(target_os = "windows"))] + { + targets.push(PathBuf::from(OPENCLAW_TEMP_CARGO_CHECK_DIR)); + } + + if let Some(app) = app { + if let Ok(dir) = openclaw_installer_download_dir(app) { + targets.push(dir); + } + } + + targets +} + +pub(crate) async fn resolve_node_installer_asset() -> Result { + let client = reqwest::Client::new(); + let response = client + .get("https://nodejs.org/dist/index.json") + .header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT) + .send() + .await + .map_err(|e| format!("请求 Node.js 版本列表失败: {e}"))?; + + if !response.status().is_success() { + return Err(format!( + "获取 Node.js 版本列表失败: HTTP {}", + response.status() + )); + } + + let releases: Vec = response + .json() + .await + .map_err(|e| format!("解析 Node.js 版本列表失败: {e}"))?; + + let select_version = |only_lts: bool| -> Option { + releases.iter().find_map(|release| { + let version = release.get("version")?.as_str()?; + let parsed = parse_semver(version)?; + let is_lts = release + .get("lts") + .map(|value| match value { + Value::Bool(flag) => *flag, + Value::String(text) => !text.trim().is_empty() && text != "false", + _ => false, + }) + .unwrap_or(false); + if parsed >= NODE_MIN_VERSION && (!only_lts || is_lts) { + Some(version.to_string()) + } else { + None + } + }) + }; + + let version = select_version(true) + .or_else(|| select_version(false)) + .ok_or_else(|| "未找到满足要求的 Node.js 官方安装包版本。".to_string())?; + + #[cfg(target_os = "windows")] + let filename = { + #[cfg(target_arch = "aarch64")] + { + format!("node-{version}-arm64.msi") + } + #[cfg(not(target_arch = "aarch64"))] + { + format!("node-{version}-x64.msi") + } + }; + + #[cfg(target_os = "macos")] + let filename = format!("node-{version}.pkg"); + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let filename = String::new(); + + if filename.is_empty() { + return Err("当前平台暂不支持自动下载官方 Node.js 安装器。".to_string()); + } + + Ok(InstallerAsset { + download_url: format!("https://nodejs.org/dist/{version}/{filename}"), + filename, + }) +} + +pub(crate) async fn download_installer_asset( + app: &AppHandle, + asset: &InstallerAsset, +) -> Result { + let download_dir = openclaw_installer_download_dir(app)?; + let installer_path = download_dir.join(&asset.filename); + if installer_path.exists() { + let _ = std::fs::remove_file(&installer_path); + } + + emit_install_progress( + app, + &format!("开始下载安装器:{}", asset.download_url), + "info", + ); + + let client = reqwest::Client::new(); + let response = client + .get(&asset.download_url) + .header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT) + .send() + .await + .map_err(|e| format!("下载官方安装器失败: {e}"))?; + + if !response.status().is_success() { + return Err(format!("下载安装器失败: HTTP {}", response.status())); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("读取安装器文件失败: {e}"))?; + std::fs::write(&installer_path, bytes) + .map_err(|e| format!("保存安装器失败({}): {e}", installer_path.display()))?; + + emit_install_progress( + app, + &format!("安装器已保存到:{}", installer_path.display()), + "info", + ); + + Ok(installer_path) +} + +pub(crate) fn launch_installer(file_path: &Path) -> Result<(), String> { + let extension = file_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + match extension.as_str() { + "exe" => { + #[cfg(target_os = "windows")] + { + std::process::Command::new(file_path) + .spawn() + .map_err(|e| format!("启动安装程序失败: {e}"))?; + } + + #[cfg(not(target_os = "windows"))] + { + return Err("EXE 安装器只能在 Windows 上运行。".to_string()); + } + } + "msi" => { + #[cfg(target_os = "windows")] + { + std::process::Command::new("msiexec") + .arg("/i") + .arg(file_path) + .spawn() + .map_err(|e| format!("启动 MSI 安装程序失败: {e}"))?; + } + + #[cfg(not(target_os = "windows"))] + { + return Err("MSI 安装器只能在 Windows 上运行。".to_string()); + } + } + "pkg" | "dmg" => { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(file_path) + .spawn() + .map_err(|e| format!("打开 macOS 安装器失败: {e}"))?; + } + + #[cfg(not(target_os = "macos"))] + { + return Err("该安装器只能在 macOS 上运行。".to_string()); + } + } + _ => return Err(format!("不支持的安装器文件类型: {extension}")), + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +pub(crate) async fn trigger_macos_command_line_tools_install() -> Result { + let output = Command::new("/usr/bin/xcode-select") + .arg("--install") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("拉起 macOS 开发者工具安装器失败: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let combined = if !stderr.is_empty() { stderr } else { stdout }; + let lower = combined.to_ascii_lowercase(); + + if output.status.success() + || lower.contains("install requested") + || lower.contains("already been requested") + { + return Ok("已拉起 macOS 开发者工具安装器。".to_string()); + } + + if lower.contains("already installed") { + return Err( + "系统提示 Command Line Tools 已安装,但当前仍未检测到 Git,请先执行系统更新或安装 Homebrew 后重试。" + .to_string(), + ); + } + + Err(format!("拉起 macOS 开发者工具安装器失败: {combined}")) +} + +#[cfg(not(target_os = "macos"))] +#[allow(dead_code)] +pub(crate) async fn trigger_macos_command_line_tools_install() -> Result { + Err("当前平台不支持拉起 macOS 开发者工具安装器。".to_string()) +} diff --git a/src-tauri/src/services/openclaw_service/mod.rs b/src-tauri/src/services/openclaw_service/mod.rs new file mode 100644 index 000000000..36208e698 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/mod.rs @@ -0,0 +1,155 @@ +use crate::app::AppState; +use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType}; +use dirs::{data_dir, home_dir}; +use lime_core::openclaw_install::{ + build_openclaw_cleanup_command as core_build_openclaw_cleanup_command, + build_openclaw_install_command as core_build_openclaw_install_command, + build_winget_install_command as core_build_winget_install_command, + command_bin_dir_for as core_command_bin_dir_for, + resolve_windows_dependency_install_plan as core_resolve_windows_dependency_install_plan, + select_best_semver_candidate as core_select_best_semver_candidate, + select_preferred_path_candidate as core_select_preferred_path_candidate, + shell_command_escape_for as core_shell_command_escape_for, + shell_command_invocation_prefix_for as core_shell_command_invocation_prefix_for, + shell_npm_prefix_assignment_for as core_shell_npm_prefix_assignment_for, + shell_path_assignment_for as core_shell_path_assignment_for, + windows_manual_install_message as core_windows_manual_install_message, + OpenClawInstallDependencyKind, ShellPlatform, WindowsDependencyInstallPlan, +}; +use rand::{distributions::Alphanumeric, Rng}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::cmp::Ordering; +use std::collections::{HashSet, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::time::SystemTime; +use sysinfo::{Pid, Signal, System}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader}; +use tokio::net::TcpStream; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; +use tokio::time::{sleep, timeout, Duration}; +#[cfg(target_os = "windows")] +use winapi::shared::minwindef::{DWORD, HKEY}; +#[cfg(target_os = "windows")] +use winapi::shared::winerror::ERROR_SUCCESS; +#[cfg(target_os = "windows")] +use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + +const DEFAULT_GATEWAY_PORT: u16 = 18790; +const OPENCLAW_INSTALL_EVENT: &str = "openclaw:install-progress"; +const OPENCLAW_CONFIG_ENV: &str = "OPENCLAW_CONFIG_PATH"; + +fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} +const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest"; +const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest"; +const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com"; +const NODE_MIN_VERSION: (u64, u64, u64) = (22, 12, 0); +const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400; +const OPENCLAW_INSTALLER_USER_AGENT: &str = "Lime-OpenClaw"; +#[cfg(not(target_os = "windows"))] +const OPENCLAW_TEMP_CARGO_CHECK_DIR: &str = "/tmp/lime-cargo-check"; +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +static OPENCLAW_PREFERRED_RUNTIME_BIN_DIR: OnceLock>> = OnceLock::new(); + +mod config; +mod diagnostics; +mod gateway; +mod install; +mod platform; +mod preview; +mod process; +mod progress; +mod runtime; +#[cfg(test)] +mod tests; +mod types; +mod update; + +pub(crate) use self::config::*; +pub(crate) use self::diagnostics::*; +pub(crate) use self::gateway::*; +pub(crate) use self::install::*; +pub(crate) use self::platform::*; +pub(crate) use self::process::*; +pub(crate) use self::progress::*; +pub(crate) use self::runtime::*; +pub use self::types::{ + ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus, ChannelInfo, CommandPreview, + DependencyStatus, EnvironmentDiagnostics, EnvironmentStatus, GatewayStatus, GatewayStatusInfo, + HealthInfo, InstallProgressEvent, NodeCheckResult, OpenClawRuntimeCandidate, SyncModelEntry, + UpdateInfo, +}; +pub(crate) use self::types::{ + DependencyKind, InstallerAsset, OpenClawDirectUpgradePlan, OpenClawDirectUpgradeResult, + OpenClawUpdateExecutionContext, ResolvedOpenClawCommand, +}; +#[cfg(test)] +pub(crate) use self::update::*; + +#[derive(Debug)] +pub struct OpenClawService { + gateway_process: Option, + gateway_status: GatewayStatus, + gateway_port: u16, + gateway_auth_token: String, + gateway_started_at: Option, + progress_logs: VecDeque, +} + +impl Default for OpenClawService { + fn default() -> Self { + Self { + gateway_process: None, + gateway_status: GatewayStatus::Stopped, + gateway_port: DEFAULT_GATEWAY_PORT, + gateway_auth_token: String::new(), + gateway_started_at: None, + progress_logs: VecDeque::new(), + } + } +} + +pub struct OpenClawServiceState(pub std::sync::Arc>); + +impl Default for OpenClawServiceState { + fn default() -> Self { + Self(std::sync::Arc::new(Mutex::new(OpenClawService::default()))) + } +} + +impl OpenClawService { + pub fn clear_progress_logs(&mut self) { + self.progress_logs.clear(); + } + + pub fn get_progress_logs(&self) -> Vec { + self.progress_logs.iter().cloned().collect() + } + + fn clear_gateway_runtime_state(&mut self) { + self.gateway_process = None; + self.gateway_started_at = None; + self.gateway_status = GatewayStatus::Stopped; + } + fn push_progress_log(&mut self, message: String, level: String) { + if self.progress_logs.len() >= OPENCLAW_PROGRESS_LOG_LIMIT { + self.progress_logs.pop_front(); + } + self.progress_logs + .push_back(InstallProgressEvent { message, level }); + } +} + +pub fn openclaw_install_event_name() -> &'static str { + OPENCLAW_INSTALL_EVENT +} diff --git a/src-tauri/src/services/openclaw_service/platform.rs b/src-tauri/src/services/openclaw_service/platform.rs new file mode 100644 index 000000000..14a842440 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/platform.rs @@ -0,0 +1,292 @@ +use super::*; + +pub(crate) fn generate_auth_token() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(48) + .map(char::from) + .collect() +} + +pub(crate) async fn should_use_china_package(app: &AppHandle) -> bool { + if let Some(app_state) = app.try_state::() { + let language = { + let state = app_state.read().await; + state.config.language.clone() + }; + + if language.starts_with("zh") { + return true; + } + } + + let locale = std::env::var("LC_ALL") + .ok() + .or_else(|| std::env::var("LANG").ok()) + .unwrap_or_default() + .to_lowercase(); + let timezone = std::env::var("TZ").unwrap_or_default().to_lowercase(); + locale.contains("zh_cn") || locale.contains("zh-hans") || timezone.contains("shanghai") +} + +pub(crate) async fn detect_npm_global_prefix(npm_path: &str) -> Option { + let mut command = Command::new(npm_path); + apply_binary_runtime_path(&mut command, npm_path); + let output = command + .arg("config") + .arg("get") + .arg("prefix") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + + let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if prefix.is_empty() || prefix.eq_ignore_ascii_case("undefined") { + None + } else { + Some(prefix) + } +} + +pub(crate) fn current_shell_platform() -> ShellPlatform { + if cfg!(target_os = "windows") { + ShellPlatform::Windows + } else { + ShellPlatform::Unix + } +} + +#[allow(dead_code)] +pub(crate) fn command_bin_dir_for(platform: ShellPlatform, binary_path: &str) -> Option { + core_command_bin_dir_for(platform, binary_path) +} + +pub(crate) fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String { + core_shell_command_escape_for(platform, value) +} + +pub(crate) fn shell_command_invocation_prefix_for( + platform: ShellPlatform, + binary_path: &str, +) -> String { + core_shell_command_invocation_prefix_for(platform, binary_path) +} + +#[cfg_attr(target_os = "windows", allow(dead_code))] +pub(crate) fn shell_command_escape(value: &str) -> String { + shell_command_escape_for(current_shell_platform(), value) +} + +#[allow(dead_code)] +pub(crate) fn shell_npm_prefix_assignment_for(platform: ShellPlatform, value: &str) -> String { + core_shell_npm_prefix_assignment_for(platform, value) +} + +pub(crate) fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> String { + core_shell_path_assignment_for(platform, binary_path) +} + +#[cfg_attr(target_os = "windows", allow(dead_code))] +pub(crate) fn shell_path_assignment(binary_path: &str) -> String { + shell_path_assignment_for(current_shell_platform(), binary_path) +} + +pub(crate) fn build_openclaw_cleanup_command( + platform: ShellPlatform, + npm_path: &str, + npm_prefix: Option<&str>, +) -> String { + core_build_openclaw_cleanup_command(platform, npm_path, npm_prefix) +} + +pub(crate) fn build_openclaw_install_command( + platform: ShellPlatform, + npm_path: &str, + npm_prefix: Option<&str>, + package: &str, + registry: Option<&str>, +) -> String { + core_build_openclaw_install_command(platform, npm_path, npm_prefix, package, registry) +} + +pub(crate) fn build_openclaw_pnpm_install_command( + platform: ShellPlatform, + pnpm_path: &str, + package: &str, + registry: Option<&str>, +) -> String { + let mut command = format!( + "{}{}{} add -g {}", + shell_path_assignment_for(platform, pnpm_path), + shell_command_invocation_prefix_for(platform, pnpm_path), + shell_command_escape_for(platform, pnpm_path), + shell_command_escape_for(platform, package), + ); + + if let Some(registry) = registry { + command.push(' '); + command.push_str("--registry="); + command.push_str(&shell_command_escape_for(platform, registry)); + } + + command +} + +#[allow(dead_code)] +pub(crate) fn resolve_windows_dependency_install_plan( + dependency: DependencyKind, + has_winget: bool, +) -> WindowsDependencyInstallPlan { + core_resolve_windows_dependency_install_plan( + match dependency { + DependencyKind::Node => OpenClawInstallDependencyKind::Node, + DependencyKind::Git => OpenClawInstallDependencyKind::Git, + }, + has_winget, + ) +} + +#[allow(dead_code)] +pub(crate) fn build_winget_install_command(winget_path: &str, package_id: &str) -> String { + core_build_winget_install_command(winget_path, package_id) +} + +#[allow(dead_code)] +pub(crate) fn windows_manual_install_message(dependency: DependencyKind) -> &'static str { + core_windows_manual_install_message(match dependency { + DependencyKind::Node => OpenClawInstallDependencyKind::Node, + DependencyKind::Git => OpenClawInstallDependencyKind::Git, + }) +} + +pub(crate) fn prepend_path(dir: &Path) -> Option { + let mut paths = vec![dir.to_path_buf()]; + if let Some(current) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(¤t)); + } + std::env::join_paths(paths).ok() +} + +pub(crate) fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) { + apply_windows_no_window(command); + + let Some(bin_dir) = Path::new(binary_path).parent() else { + return; + }; + if let Some(path) = prepend_path(bin_dir) { + command.env("PATH", path); + } +} + +pub(crate) fn apply_windows_no_window(_command: &mut Command) { + #[cfg(target_os = "windows")] + { + _command.creation_flags(CREATE_NO_WINDOW); + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn refresh_windows_path_from_registry() -> Result<(), String> { + unsafe { + let mut combined_path = String::new(); + + // 读取系统 PATH (HKEY_LOCAL_MACHINE) + if let Ok(system_path) = read_registry_path(HKEY_LOCAL_MACHINE) { + combined_path.push_str(&system_path); + } + + // 读取用户 PATH (HKEY_CURRENT_USER) + if let Ok(user_path) = read_registry_path(HKEY_CURRENT_USER) { + if !combined_path.is_empty() { + combined_path.push(';'); + } + combined_path.push_str(&user_path); + } + + if !combined_path.is_empty() { + std::env::set_var("PATH", combined_path); + } + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +pub(crate) unsafe fn read_registry_path(root_key: HKEY) -> Result { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::ptr; + + let subkey: Vec = OsStr::new("Environment") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let value_name: Vec = OsStr::new("Path") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut key: HKEY = ptr::null_mut(); + let result = RegOpenKeyExW( + root_key, + subkey.as_ptr(), + 0, + winapi::um::winnt::KEY_READ, + &mut key, + ); + + if result != ERROR_SUCCESS as i32 { + return Err(format!("无法打开注册表键: {}", result)); + } + + let mut buffer_size: DWORD = 0; + let result = RegQueryValueExW( + key, + value_name.as_ptr(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + &mut buffer_size, + ); + + if result != ERROR_SUCCESS as i32 { + winapi::um::winreg::RegCloseKey(key); + return Err(format!("无法查询注册表值大小: {}", result)); + } + + let mut buffer: Vec = vec![0; (buffer_size / 2) as usize + 1]; + let result = RegQueryValueExW( + key, + value_name.as_ptr(), + ptr::null_mut(), + ptr::null_mut(), + buffer.as_mut_ptr() as *mut u8, + &mut buffer_size, + ); + + winapi::um::winreg::RegCloseKey(key); + + if result != ERROR_SUCCESS as i32 { + return Err(format!("无法读取注册表值: {}", result)); + } + + // 移除尾部的 null 字符 + if let Some(null_pos) = buffer.iter().position(|&c| c == 0) { + buffer.truncate(null_pos); + } + + Ok(String::from_utf16_lossy(&buffer)) +} + +#[cfg(not(target_os = "windows"))] +#[allow(dead_code)] +pub(crate) fn refresh_windows_path_from_registry() -> Result<(), String> { + Ok(()) +} diff --git a/src-tauri/src/services/openclaw_service/preview.rs b/src-tauri/src/services/openclaw_service/preview.rs new file mode 100644 index 000000000..d2a928270 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/preview.rs @@ -0,0 +1,117 @@ +use super::*; + +impl OpenClawService { + pub async fn get_command_preview( + &mut self, + app: &AppHandle, + operation: &str, + port: Option, + ) -> Result { + match operation { + "install" => self.build_install_command_preview(app).await, + "uninstall" => self.build_uninstall_command_preview().await, + "restart" => self.build_restart_command_preview(port).await, + "start" => self.build_start_command_preview(port).await, + "stop" => self.build_stop_command_preview(port).await, + _ => Err(format!("不支持的 OpenClaw 操作预览: {operation}")), + } + } + + async fn build_install_command_preview( + &self, + app: &AppHandle, + ) -> Result { + let (package, npm_path, npm_prefix, cleanup_command, install_command) = + self.resolve_install_commands(app).await?; + let prefix_note = npm_prefix + .map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n")) + .unwrap_or_else(|| format!("npm: {npm_path}\n")); + Ok(CommandPreview { + title: format!("安装 {package}"), + command: format!("{prefix_note}{cleanup_command}\n{install_command}"), + }) + } + + async fn build_uninstall_command_preview(&self) -> Result { + let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?; + let prefix_note = npm_prefix + .map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n")) + .unwrap_or_else(|| format!("npm: {npm_path}\n")); + Ok(CommandPreview { + title: "卸载 OpenClaw".to_string(), + command: format!("{prefix_note}{command}"), + }) + } + + async fn build_start_command_preview( + &mut self, + port: Option, + ) -> Result { + if let Some(next_port) = port { + self.gateway_port = next_port.max(1); + } + self.restore_auth_token_from_config(); + if self.gateway_auth_token.is_empty() { + self.gateway_auth_token = generate_auth_token(); + } + let openclaw_command = resolve_openclaw_command() + .await? + .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; + let config_path = openclaw_lime_config_path(); + let command = gateway_start_args(self.gateway_port, &self.gateway_auth_token) + .into_iter() + .map(|arg| shell_escape(&arg)) + .collect::>() + .join(" "); + Ok(CommandPreview { + title: "启动 Gateway".to_string(), + command: format!( + "{}OPENCLAW_CONFIG_PATH={} {} {}", + if cfg!(target_os = "windows") { + "set " + } else { + "" + }, + shell_escape(config_path.to_string_lossy().as_ref()), + openclaw_command.preview_invocation(), + command + ), + }) + } + + async fn build_stop_command_preview( + &mut self, + port: Option, + ) -> Result { + if let Some(next_port) = port { + self.gateway_port = next_port.max(1); + } + self.restore_auth_token_from_config(); + let openclaw_command = resolve_openclaw_command() + .await? + .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; + let config_path = openclaw_lime_config_path(); + Ok(CommandPreview { + title: "停止 Gateway".to_string(), + command: format!( + "OPENCLAW_CONFIG_PATH={} {} gateway stop --url {} --token {}", + shell_escape(config_path.to_string_lossy().as_ref()), + openclaw_command.preview_invocation(), + self.gateway_ws_url(), + shell_escape(&self.gateway_auth_token) + ), + }) + } + + async fn build_restart_command_preview( + &mut self, + port: Option, + ) -> Result { + let stop = self.build_stop_command_preview(port).await?; + let start = self.build_start_command_preview(port).await?; + Ok(CommandPreview { + title: "重启 Gateway".to_string(), + command: format!("{}\n{}", stop.command, start.command), + }) + } +} diff --git a/src-tauri/src/services/openclaw_service/process.rs b/src-tauri/src/services/openclaw_service/process.rs new file mode 100644 index 000000000..73af52210 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/process.rs @@ -0,0 +1,196 @@ +use super::*; + +pub(crate) fn command_output_lines(output: &[u8]) -> Vec { + String::from_utf8_lossy(output) + .lines() + .map(sanitize_progress_line) + .filter(|line| !line.is_empty()) + .collect() +} + +pub(crate) fn normalize_process_probe_text(value: &str) -> String { + value.replace('\\', "/").to_ascii_lowercase() +} + +pub(crate) fn process_looks_like_openclaw_process( + process_name: &str, + exe_path: Option<&Path>, + command_args: &[OsString], +) -> bool { + let process_name = normalize_process_probe_text(process_name); + let exe_path = exe_path + .map(|path| normalize_process_probe_text(&path.display().to_string())) + .unwrap_or_default(); + let command_line = normalize_process_probe_text( + &command_args + .iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "), + ); + + process_name.contains("openclaw") + || exe_path.contains("openclaw") + || command_line.contains("openclaw") +} + +pub(crate) fn collect_openclaw_process_family_pids( + system: &System, + listener_pids: &[u32], +) -> Vec { + let mut target_pids = HashSet::new(); + + for listener_pid in listener_pids { + let pid = Pid::from_u32(*listener_pid); + let Some(process) = system.process(pid) else { + continue; + }; + if !process_looks_like_openclaw_process( + &process.name().to_string_lossy(), + process.exe(), + process.cmd(), + ) { + continue; + } + + target_pids.insert(pid); + let mut parent_pid = process.parent(); + while let Some(next_parent) = parent_pid { + let Some(parent_process) = system.process(next_parent) else { + break; + }; + if !process_looks_like_openclaw_process( + &parent_process.name().to_string_lossy(), + parent_process.exe(), + parent_process.cmd(), + ) { + break; + } + + target_pids.insert(next_parent); + parent_pid = parent_process.parent(); + } + } + + let mut target_pids = target_pids.into_iter().collect::>(); + target_pids.sort_by_key(|pid| pid.as_u32()); + target_pids +} + +pub(crate) async fn terminate_sysinfo_processes(system: &mut System, target_pids: &[Pid]) { + for pid in target_pids { + if let Some(process) = system.process(*pid) { + let terminated = process.kill_with(Signal::Term).unwrap_or(false); + if !terminated { + let _ = process.kill(); + } + } + } + + sleep(Duration::from_millis(900)).await; + system.refresh_all(); + + for pid in target_pids { + if let Some(process) = system.process(*pid) { + let _ = process.kill(); + } + } +} + +#[cfg_attr(target_os = "windows", allow(dead_code))] +pub(crate) fn parse_lsof_listener_pids(output: &str) -> Vec { + let mut pids = output + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect::>(); + pids.sort_unstable(); + pids.dedup(); + pids +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) fn parse_windows_netstat_listener_pids(output: &str, port: u16) -> Vec { + let mut pids = output + .lines() + .filter_map(|line| { + let columns = line.split_whitespace().collect::>(); + if columns.len() < 5 { + return None; + } + + let local_address = columns.get(1).copied().unwrap_or_default(); + let state = columns.get(3).copied().unwrap_or_default(); + if !state.eq_ignore_ascii_case("LISTENING") + || !local_address.ends_with(&format!(":{port}")) + { + return None; + } + + columns.last().and_then(|value| value.parse::().ok()) + }) + .collect::>(); + pids.sort_unstable(); + pids.dedup(); + pids +} + +pub(crate) async fn collect_listening_port_pids(port: u16) -> Vec { + #[cfg(target_os = "windows")] + { + collect_listening_port_pids_windows(port).await + } + + #[cfg(not(target_os = "windows"))] + { + collect_listening_port_pids_unix(port).await + } +} + +#[cfg(not(target_os = "windows"))] +pub(crate) async fn collect_listening_port_pids_unix(port: u16) -> Vec { + let mut command = Command::new("lsof"); + apply_windows_no_window(&mut command); + let output = timeout( + Duration::from_secs(3), + command + .arg("-nP") + .arg(format!("-iTCP:{port}")) + .arg("-sTCP:LISTEN") + .arg("-t") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; + + match output { + Ok(Ok(result)) if result.status.success() => { + parse_lsof_listener_pids(&String::from_utf8_lossy(&result.stdout)) + } + _ => Vec::new(), + } +} + +#[cfg(target_os = "windows")] +pub(crate) async fn collect_listening_port_pids_windows(port: u16) -> Vec { + let mut command = Command::new("netstat"); + apply_windows_no_window(&mut command); + let output = timeout( + Duration::from_secs(3), + command + .arg("-ano") + .arg("-p") + .arg("tcp") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; + + match output { + Ok(Ok(result)) if result.status.success() => { + parse_windows_netstat_listener_pids(&String::from_utf8_lossy(&result.stdout), port) + } + _ => Vec::new(), + } +} diff --git a/src-tauri/src/services/openclaw_service/progress.rs b/src-tauri/src/services/openclaw_service/progress.rs new file mode 100644 index 000000000..b6255c9ab --- /dev/null +++ b/src-tauri/src/services/openclaw_service/progress.rs @@ -0,0 +1,214 @@ +use super::*; + +pub(crate) async fn run_shell_command_with_progress( + app: &AppHandle, + command_line: &str, +) -> Result { + let mut child = spawn_shell_command(command_line)?; + + let stdout_task = child.stdout.take().map(|stdout| { + let app = app.clone(); + tokio::spawn(async move { + stream_reader_to_progress(app, stdout, "info").await; + }) + }); + + let stderr_task = child.stderr.take().map(|stderr| { + let app = app.clone(); + tokio::spawn(async move { + stream_reader_to_progress(app, stderr, "error").await; + }) + }); + + let status = child + .wait() + .await + .map_err(|e| format!("执行命令失败: {e}"))?; + + if let Some(task) = stdout_task { + let _ = task.await; + } + if let Some(task) = stderr_task { + let _ = task.await; + } + + if status.success() { + emit_install_progress(app, "命令执行成功。", "info"); + Ok(ActionResult { + success: true, + message: "操作成功完成。".to_string(), + }) + } else { + emit_install_progress( + app, + &format!("命令执行失败,退出码: {:?}", status.code()), + "error", + ); + Ok(ActionResult { + success: false, + message: format!("命令执行失败,退出码: {:?}", status.code()), + }) + } +} + +pub(crate) fn spawn_shell_command(command_line: &str) -> Result { + let mut command = if cfg!(target_os = "windows") { + let mut cmd = Command::new("cmd"); + cmd.arg("/C").arg(command_line); + cmd + } else if cfg!(target_os = "macos") { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + let mut cmd = Command::new("script"); + cmd.arg("-q") + .arg("/dev/null") + .arg(shell) + .arg("-lc") + .arg(command_line); + cmd + } else { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let mut cmd = Command::new(shell); + cmd.arg("-lc").arg(command_line); + cmd + }; + + apply_windows_no_window(&mut command); + + command + .env("NO_COLOR", "1") + .env("CLICOLOR", "0") + .env("FORCE_COLOR", "0") + .env("npm_config_color", "false") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command.spawn().map_err(|e| format!("启动命令失败: {e}")) +} + +pub(crate) async fn stream_reader_to_progress( + app: AppHandle, + mut reader: R, + default_level: &'static str, +) where + R: AsyncRead + Unpin, +{ + let mut buffer = [0_u8; 2048]; + let mut pending = String::new(); + + loop { + match reader.read(&mut buffer).await { + Ok(0) => break, + Ok(size) => { + pending.push_str(&String::from_utf8_lossy(&buffer[..size])); + flush_progress_chunks(&app, &mut pending, default_level); + } + Err(error) => { + emit_install_progress(&app, &format!("读取命令输出失败: {error}"), "warn"); + break; + } + } + } + + let tail = pending.trim(); + if !tail.is_empty() { + emit_install_progress(&app, tail, classify_progress_level(tail, default_level)); + } +} + +pub(crate) fn flush_progress_chunks( + app: &AppHandle, + pending: &mut String, + default_level: &'static str, +) { + loop { + let next_break = pending.find(['\n', '\r']); + let Some(index) = next_break else { + break; + }; + + let mut line = pending[..index].trim().to_string(); + let mut consume_len = index + 1; + while pending + .get(consume_len..consume_len + 1) + .is_some_and(|ch| ch == "\n" || ch == "\r") + { + consume_len += 1; + } + + pending.drain(..consume_len); + + if line.is_empty() { + continue; + } + + line = sanitize_progress_line(&line); + if line.is_empty() { + continue; + } + + emit_install_progress(app, &line, classify_progress_level(&line, default_level)); + } + + if pending.len() > 4096 { + let line = sanitize_progress_line(pending.trim()); + if !line.is_empty() { + emit_install_progress(app, &line, classify_progress_level(&line, default_level)); + } + pending.clear(); + } +} + +pub(crate) fn sanitize_progress_line(value: &str) -> String { + value + .replace('\u{1b}', "") + .replace("[?25h", "") + .replace("[?25l", "") + .trim() + .to_string() +} + +pub(crate) fn classify_progress_level(message: &str, default_level: &'static str) -> &'static str { + let lower = message.to_ascii_lowercase(); + if lower.contains("error") || lower.contains("fatal") { + "error" + } else if lower.contains("warn") || lower.contains("warning") { + "warn" + } else { + default_level + } +} + +pub(crate) fn emit_install_progress(app: &AppHandle, message: &str, level: &str) { + if let Some(service_state) = app.try_state::() { + if let Ok(mut service) = service_state.0.try_lock() { + service.push_progress_log(message.to_string(), level.to_string()); + } + } + + let payload = InstallProgressEvent { + message: message.to_string(), + level: level.to_string(), + }; + let _ = app.emit(OPENCLAW_INSTALL_EVENT, payload); +} + +pub(crate) fn parse_semver(value: &str) -> Option<(u64, u64, u64)> { + let sanitized = value.trim().trim_start_matches('v'); + let core = sanitized.split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some((major, minor, patch)) +} + +pub(crate) fn parse_semver_from_text(value: &str) -> Option<(u64, u64, u64)> { + parse_semver(value).or_else(|| { + value + .split(|ch: char| ch.is_whitespace() || ch == ',' || ch == '(' || ch == ')') + .find_map(parse_semver) + }) +} + +pub(crate) fn format_semver(version: (u64, u64, u64)) -> String { + format!("{}.{}.{}", version.0, version.1, version.2) +} diff --git a/src-tauri/src/services/openclaw_service/runtime.rs b/src-tauri/src/services/openclaw_service/runtime.rs new file mode 100644 index 000000000..a1db1f210 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/runtime.rs @@ -0,0 +1,1096 @@ +use super::*; + +impl OpenClawService { + pub async fn list_runtime_candidates(&self) -> Result, String> { + list_openclaw_runtime_candidates().await + } + + pub async fn set_preferred_runtime( + &self, + runtime_id: Option<&str>, + ) -> Result { + let normalized = runtime_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + + let Some(runtime_bin_dir) = normalized else { + set_preferred_runtime_bin_dir(None); + return Ok(ActionResult { + success: true, + message: "已切换为自动选择执行环境。".to_string(), + }); + }; + + let candidates = list_openclaw_runtime_candidates().await?; + let runtime_id = runtime_bin_dir.display().to_string(); + let Some(candidate) = candidates.iter().find(|item| item.id == runtime_id) else { + return Ok(ActionResult { + success: false, + message: "未找到指定的 OpenClaw 执行环境,请重新检测后再试。".to_string(), + }); + }; + + set_preferred_runtime_bin_dir(Some(runtime_bin_dir)); + Ok(ActionResult { + success: true, + message: format!( + "已固定使用执行环境:{}{}。", + candidate.source, + candidate + .node_version + .as_deref() + .map(|version| format!(" · Node {version}")) + .unwrap_or_default() + ), + }) + } +} + +pub(crate) fn preferred_runtime_bin_dir_store() -> &'static StdMutex> { + OPENCLAW_PREFERRED_RUNTIME_BIN_DIR.get_or_init(|| StdMutex::new(None)) +} + +pub(crate) fn get_preferred_runtime_bin_dir() -> Option { + preferred_runtime_bin_dir_store() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() +} + +pub(crate) fn set_preferred_runtime_bin_dir(next: Option) { + let mut guard = preferred_runtime_bin_dir_store() + .lock() + .unwrap_or_else(|error| error.into_inner()); + *guard = next.filter(|path| !path.as_os_str().is_empty()); +} + +pub(crate) fn command_uses_node_runtime(command_name: &str) -> bool { + matches!(command_name, "node" | "npm" | "npx" | "openclaw") +} + +pub(crate) fn find_command_in_bin_dir(command_name: &str, bin_dir: &Path) -> Option { + select_preferred_path_candidate(find_all_commands_in_paths( + command_name, + &[bin_dir.to_path_buf()], + )) +} + +pub(crate) fn collect_existing_unique_dirs(candidates: I) -> Vec +where + I: IntoIterator, +{ + let mut dirs = Vec::new(); + let mut seen = HashSet::new(); + + for dir in candidates { + if dir.as_os_str().is_empty() || !dir.exists() { + continue; + } + if seen.insert(dir.clone()) { + dirs.push(dir); + } + } + + dirs +} + +pub(crate) async fn collect_preferred_runtime_command_dirs( + command_name: &str, + preferred_bin_dir: &Path, +) -> Result, String> { + let mut candidate_dirs = vec![preferred_bin_dir.to_path_buf()]; + + if command_name == "openclaw" { + if let Some(npm_path) = find_command_in_bin_dir("npm", preferred_bin_dir) + .and_then(|path| path.to_str().map(str::to_string)) + { + if let Some(prefix) = detect_npm_global_prefix(&npm_path).await { + for dir in npm_global_command_dirs(&prefix) { + candidate_dirs.push(dir); + } + } + } + } + + Ok(collect_existing_unique_dirs(candidate_dirs)) +} + +pub(crate) async fn collect_preferred_runtime_command_candidates( + command_name: &str, +) -> Result, String> { + if !command_uses_node_runtime(command_name) { + return Ok(Vec::new()); + } + + let Some(preferred_bin_dir) = get_preferred_runtime_bin_dir() else { + return Ok(Vec::new()); + }; + + let search_dirs = + collect_preferred_runtime_command_dirs(command_name, &preferred_bin_dir).await?; + Ok(find_all_commands_in_paths(command_name, &search_dirs)) +} + +pub(crate) async fn find_command_in_shell(command_name: &str) -> Result, String> { + let mut candidates = collect_standard_command_candidates(command_name).await?; + + if command_name == "openclaw" { + candidates.extend(find_commands_via_npm_global_prefix(command_name).await?); + } + + Ok(select_command_path(command_name, candidates) + .await? + .map(|path| path.to_string_lossy().to_string())) +} + +pub(crate) async fn find_command_in_standard_locations( + command_name: &str, +) -> Result, String> { + Ok(select_command_path( + command_name, + collect_standard_command_candidates(command_name).await?, + ) + .await? + .map(|path| path.to_string_lossy().to_string())) +} + +pub(crate) async fn collect_standard_command_candidates( + command_name: &str, +) -> Result, String> { + collect_standard_command_candidates_with_preference(command_name, true).await +} + +pub(crate) async fn collect_standard_command_candidates_without_preference( + command_name: &str, +) -> Result, String> { + collect_standard_command_candidates_with_preference(command_name, false).await +} + +pub(crate) async fn collect_standard_command_candidates_with_preference( + command_name: &str, + include_preferred_runtime: bool, +) -> Result, String> { + #[cfg(target_os = "windows")] + { + let _ = refresh_windows_path_from_registry(); + } + + let mut candidates = Vec::new(); + + if include_preferred_runtime { + candidates.extend(collect_preferred_runtime_command_candidates(command_name).await?); + } + + #[cfg(target_os = "windows")] + { + candidates.extend(find_commands_via_where(command_name).await?); + } + + candidates.extend(find_all_commands_in_known_locations(command_name)); + + Ok(candidates) +} + +pub(crate) async fn select_command_path( + command_name: &str, + candidates: Vec, +) -> Result, String> { + select_command_candidate(command_name, dedupe_paths(candidates)).await +} + +#[cfg(target_os = "windows")] +pub(crate) async fn find_commands_via_where(command_name: &str) -> Result, String> { + let mut command = Command::new("cmd"); + apply_windows_no_window(&mut command); + let output = command + .arg("/C") + .arg("where") + .arg(command_name) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await + .map_err(|e| format!("查找命令失败: {e}"))?; + + if !output.status.success() { + return Ok(Vec::new()); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect()) +} + +pub(crate) async fn select_command_candidate( + command_name: &str, + candidates: Vec, +) -> Result, String> { + if candidates.is_empty() { + return Ok(None); + } + + let has_preferred_runtime = + command_uses_node_runtime(command_name) && get_preferred_runtime_bin_dir().is_some(); + if let Some(candidate) = select_preferred_runtime_candidate(command_name, &candidates).await? { + return Ok(Some(candidate)); + } + if has_preferred_runtime { + return Ok(None); + } + + if command_name == "node" { + return select_best_node_candidate(candidates).await; + } + + if matches!(command_name, "npm" | "npx" | "openclaw") { + return select_node_runtime_candidate(candidates).await; + } + + if command_name == "git" { + return Ok(select_best_git_candidate(candidates)); + } + + Ok(candidates.into_iter().next()) +} + +pub(crate) fn select_best_git_candidate(candidates: Vec) -> Option { + select_preferred_path_candidate(candidates.clone()).or_else(|| candidates.into_iter().next()) +} + +pub(crate) async fn select_preferred_runtime_candidate( + command_name: &str, + candidates: &[PathBuf], +) -> Result, String> { + if !command_uses_node_runtime(command_name) { + return Ok(None); + } + + let Some(preferred_bin_dir) = get_preferred_runtime_bin_dir() else { + return Ok(None); + }; + + let preferred_dirs = + collect_preferred_runtime_command_dirs(command_name, &preferred_bin_dir).await?; + if preferred_dirs.is_empty() { + return Ok(None); + } + + Ok(select_preferred_path_candidate( + candidates + .iter() + .filter(|candidate| { + candidate + .parent() + .is_some_and(|parent| preferred_dirs.iter().any(|dir| dir.as_path() == parent)) + }) + .cloned() + .collect(), + )) +} + +pub(crate) fn find_all_commands_in_known_locations(command_name: &str) -> Vec { + let search_dirs = collect_known_command_search_dirs(command_name); + find_all_commands_in_paths(command_name, &search_dirs) +} + +pub(crate) fn collect_known_command_search_dirs(_command_name: &str) -> Vec { + let mut search_dirs = Vec::new(); + + if let Some(path_var) = std::env::var_os("PATH") { + search_dirs.extend(std::env::split_paths(&path_var)); + } + + if let Some(home) = home_dir() { + search_dirs.extend([ + home.join(".npm-global/bin"), + home.join(".local/bin"), + home.join(".bun/bin"), + home.join(".volta/bin"), + home.join(".asdf/shims"), + home.join(".local/share/mise/shims"), + home.join("Library/PhpWebStudy/env/node/bin"), + ]); + + let nvm_versions = home.join(".nvm/versions/node"); + if let Ok(entries) = std::fs::read_dir(nvm_versions) { + for entry in entries.flatten() { + search_dirs.push(entry.path().join("bin")); + } + } + + let fnm_versions = home.join(".fnm/node-versions"); + if let Ok(entries) = std::fs::read_dir(fnm_versions) { + for entry in entries.flatten() { + search_dirs.push(entry.path().join("installation/bin")); + } + } + } + + #[cfg(target_os = "windows")] + { + search_dirs.extend(windows_known_command_dirs_from_env()); + if _command_name == "git" { + search_dirs.extend(windows_known_git_command_dirs_from_env()); + } + } + + if cfg!(target_os = "macos") { + search_dirs.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/bin"), + ]); + } + + collect_existing_unique_dirs(search_dirs) +} + +#[cfg(target_os = "windows")] +pub(crate) fn windows_known_command_dirs_from_env() -> Vec { + let mut dirs = Vec::new(); + + if let Some(appdata) = std::env::var_os("APPDATA") { + dirs.push(PathBuf::from(appdata).join("npm")); + } + + if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") { + let localappdata = PathBuf::from(localappdata); + dirs.push(localappdata.join("Programs").join("nodejs")); + dirs.push(localappdata.join("Volta").join("bin")); + } + + if let Some(program_files) = std::env::var_os("ProgramFiles") { + dirs.push(PathBuf::from(program_files).join("nodejs")); + } + + if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { + dirs.push(PathBuf::from(program_files_x86).join("nodejs")); + } + + if let Some(home) = home_dir() { + dirs.push(home.join("AppData").join("Roaming").join("npm")); + dirs.push( + home.join("AppData") + .join("Local") + .join("Programs") + .join("nodejs"), + ); + } + + dirs +} + +#[cfg(any(target_os = "windows", test))] +pub(crate) fn windows_git_install_dir_variants(root: PathBuf) -> Vec { + vec![ + root.join("cmd"), + root.join("bin"), + root.join("mingw64").join("bin"), + ] +} + +#[cfg(target_os = "windows")] +pub(crate) fn windows_known_git_command_dirs_from_env() -> Vec { + let mut dirs = Vec::new(); + + if let Some(program_files) = std::env::var_os("ProgramFiles") { + dirs.extend(windows_git_install_dir_variants( + PathBuf::from(program_files).join("Git"), + )); + } + + if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { + dirs.extend(windows_git_install_dir_variants( + PathBuf::from(program_files_x86).join("Git"), + )); + } + + if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") { + dirs.extend(windows_git_install_dir_variants( + PathBuf::from(localappdata).join("Programs").join("Git"), + )); + } + + if let Some(home) = home_dir() { + dirs.extend(windows_git_install_dir_variants( + home.join("scoop").join("apps").join("git").join("current"), + )); + } + + dirs +} + +pub(crate) fn find_all_commands_in_paths( + command_name: &str, + search_dirs: &[PathBuf], +) -> Vec { + find_all_commands_in_paths_for(current_shell_platform(), command_name, search_dirs) +} + +pub(crate) fn find_all_commands_in_paths_for( + platform: ShellPlatform, + command_name: &str, + search_dirs: &[PathBuf], +) -> Vec { + let candidates = match platform { + ShellPlatform::Windows => vec![ + format!("{command_name}.exe"), + format!("{command_name}.cmd"), + format!("{command_name}.bat"), + command_name.to_string(), + ], + ShellPlatform::Unix => vec![command_name.to_string()], + }; + + let mut matches = Vec::new(); + let mut seen = HashSet::new(); + for dir in search_dirs { + for candidate in &candidates { + let path = dir.join(candidate); + if path.is_file() && seen.insert(path.clone()) { + matches.push(path); + } + } + } + + matches +} + +pub(crate) async fn find_commands_via_npm_global_prefix( + command_name: &str, +) -> Result, String> { + let Some(npm_path) = find_command_in_standard_locations("npm").await? else { + return Ok(Vec::new()); + }; + let Some(prefix) = detect_npm_global_prefix(&npm_path).await else { + return Ok(Vec::new()); + }; + + Ok(find_all_commands_in_paths( + command_name, + &npm_global_command_dirs(&prefix), + )) +} + +pub(crate) fn npm_global_command_dirs(prefix: &str) -> Vec { + npm_global_command_dirs_for(current_shell_platform(), prefix) +} + +pub(crate) fn npm_global_command_dirs_for(platform: ShellPlatform, prefix: &str) -> Vec { + let prefix_path = PathBuf::from(prefix); + + match platform { + ShellPlatform::Windows => vec![prefix_path], + ShellPlatform::Unix => vec![prefix_path.join("bin"), prefix_path], + } +} + +pub(crate) fn npm_global_node_modules_dirs_for( + platform: ShellPlatform, + prefix: &str, +) -> Vec { + let prefix_path = PathBuf::from(prefix); + + match platform { + ShellPlatform::Windows => vec![prefix_path.join("node_modules")], + ShellPlatform::Unix => vec![ + prefix_path.join("lib").join("node_modules"), + prefix_path.join("node_modules"), + ], + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InstalledOpenClawPackage { + pub(crate) name: &'static str, + pub(crate) version: Option, + pub(crate) path: PathBuf, +} + +#[cfg(test)] +pub(crate) fn find_installed_openclaw_package( + prefix: &str, +) -> Option<(&'static str, Option)> { + find_installed_openclaw_package_details(prefix).map(|package| (package.name, package.version)) +} + +pub(crate) fn find_installed_openclaw_package_details( + prefix: &str, +) -> Option { + for node_modules_dir in npm_global_node_modules_dirs_for(current_shell_platform(), prefix) { + let openclaw_manifest = node_modules_dir.join("openclaw").join("package.json"); + if openclaw_manifest.is_file() { + return Some(InstalledOpenClawPackage { + name: "openclaw", + version: read_package_version(&openclaw_manifest), + path: openclaw_manifest, + }); + } + + let zh_manifest = node_modules_dir + .join("@qingchencloud") + .join("openclaw-zh") + .join("package.json"); + if zh_manifest.is_file() { + return Some(InstalledOpenClawPackage { + name: "@qingchencloud/openclaw-zh", + version: read_package_version(&zh_manifest), + path: zh_manifest, + }); + } + } + + None +} + +pub(crate) fn read_package_version(manifest_path: &Path) -> Option { + #[derive(Deserialize)] + struct PackageManifest { + version: Option, + } + + let content = std::fs::read_to_string(manifest_path).ok()?; + let manifest = serde_json::from_str::(&content).ok()?; + manifest.version.filter(|item| !item.trim().is_empty()) +} + +pub(crate) fn resolve_openclaw_cli_entry_from_package_manifest( + manifest_path: &Path, +) -> Option { + let package_root = manifest_path.parent()?; + let content = std::fs::read_to_string(manifest_path).ok()?; + let manifest = serde_json::from_str::(&content).ok()?; + + let mut candidates = Vec::new(); + + if let Some(bin_value) = manifest.get("bin") { + let bin_entry = match bin_value { + Value::String(value) => Some(value.as_str()), + Value::Object(entries) => entries + .get("openclaw") + .and_then(Value::as_str) + .or_else(|| entries.values().find_map(Value::as_str)), + _ => None, + }; + + if let Some(entry) = bin_entry { + candidates.push(package_root.join(entry)); + } + } + + candidates.push(package_root.join("dist").join("index.js")); + candidates.push(package_root.join("dist").join("index.mjs")); + candidates.push(package_root.join("dist").join("entry.js")); + candidates.push(package_root.join("dist").join("entry.mjs")); + + candidates.into_iter().find(|path| path.is_file()) +} + +pub(crate) fn prefers_node_cli_for_openclaw_path( + platform: ShellPlatform, + openclaw_path: &Path, +) -> bool { + matches!(platform, ShellPlatform::Windows) + && openclaw_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "cmd" | "bat")) + .unwrap_or(false) +} + +pub(crate) fn resolve_openclaw_command_from_runtime_candidate_for( + platform: ShellPlatform, + candidate: &OpenClawRuntimeCandidate, +) -> Option { + let openclaw_path = candidate + .openclaw_path + .as_deref() + .map(PathBuf::from) + .filter(|path| path.is_file()); + + if let Some(openclaw_path) = openclaw_path.as_ref() { + if !prefers_node_cli_for_openclaw_path(platform, openclaw_path) { + return Some(ResolvedOpenClawCommand::Binary { + binary_path: openclaw_path.clone(), + }); + } + } + + let node_path = PathBuf::from(candidate.node_path.as_str()); + let manifest_path = candidate + .openclaw_package_path + .as_deref() + .map(PathBuf::from); + + if node_path.is_file() { + if let Some(manifest_path) = manifest_path { + if let Some(cli_path) = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path) + { + return Some(ResolvedOpenClawCommand::NodeCli { + node_path, + cli_path, + package_version: read_package_version(&manifest_path), + }); + } + } + } + + openclaw_path.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path }) +} + +pub(crate) fn resolve_openclaw_command_from_runtime_candidate( + candidate: &OpenClawRuntimeCandidate, +) -> Option { + resolve_openclaw_command_from_runtime_candidate_for(current_shell_platform(), candidate) +} + +pub(crate) fn runtime_candidate_matches_openclaw_path( + candidate: &OpenClawRuntimeCandidate, + openclaw_path: &Path, +) -> bool { + candidate + .openclaw_path + .as_deref() + .map(Path::new) + .is_some_and(|candidate_path| candidate_path == openclaw_path) + || openclaw_path + .parent() + .is_some_and(|parent| Path::new(&candidate.bin_dir) == parent) + || candidate + .npm_global_prefix + .as_deref() + .map(Path::new) + .is_some_and(|prefix| openclaw_path.starts_with(prefix)) +} + +pub(crate) fn dedupe_openclaw_commands( + commands: Vec, +) -> Vec { + let mut deduped = Vec::with_capacity(commands.len()); + let mut seen = HashSet::new(); + for command in commands { + if seen.insert(command.dedupe_key()) { + deduped.push(command); + } + } + deduped +} + +pub(crate) fn dedupe_paths(candidates: Vec) -> Vec { + let mut deduped = Vec::with_capacity(candidates.len()); + let mut seen = HashSet::new(); + for candidate in candidates { + if seen.insert(candidate.clone()) { + deduped.push(candidate); + } + } + deduped +} + +pub(crate) async fn resolve_openclaw_command() -> Result, String> { + let shell_platform = current_shell_platform(); + let shell_binary = find_command_in_shell("openclaw").await?.map(PathBuf::from); + let mut runtime_candidates = list_openclaw_runtime_candidates().await?; + runtime_candidates.sort_by(compare_openclaw_runtime_candidates); + + if let Some(shell_binary) = shell_binary.as_ref() { + if !prefers_node_cli_for_openclaw_path(shell_platform, shell_binary) { + return Ok(Some(ResolvedOpenClawCommand::Binary { + binary_path: shell_binary.clone(), + })); + } + + if let Some(command) = runtime_candidates + .iter() + .filter(|candidate| runtime_candidate_matches_openclaw_path(candidate, shell_binary)) + .find_map(|candidate| { + resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate) + }) + { + return Ok(Some(command)); + } + } + + if let Some(command) = runtime_candidates.iter().find_map(|candidate| { + resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate) + }) { + return Ok(Some(command)); + } + + Ok(shell_binary.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path })) +} + +pub(crate) async fn read_openclaw_version_from_command( + command_spec: &ResolvedOpenClawCommand, +) -> Result, String> { + if let Some(version) = command_spec.fallback_version() { + return Ok(Some(version)); + } + + let output = command_spec + .build_command_with_args(["--version"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + Ok(None) + } else { + Ok(Some(stdout)) + } +} + +pub(crate) async fn list_openclaw_runtime_candidates( +) -> Result, String> { + let node_candidates = + dedupe_paths(collect_standard_command_candidates_without_preference("node").await?); + let preferred_bin_dir = get_preferred_runtime_bin_dir().filter(|dir| dir.is_dir()); + let auto_selected_node = select_best_node_candidate(node_candidates.clone()).await?; + let active_bin_dir = preferred_bin_dir.clone().or_else(|| { + auto_selected_node + .as_ref() + .and_then(|path| path.parent().map(Path::to_path_buf)) + }); + + let mut runtimes = Vec::new(); + let mut seen_bin_dirs = HashSet::new(); + for node_path in node_candidates { + let Some(bin_dir) = node_path.parent().map(Path::to_path_buf) else { + continue; + }; + if !seen_bin_dirs.insert(bin_dir.clone()) { + continue; + } + + runtimes.push( + inspect_openclaw_runtime_candidate( + &bin_dir, + preferred_bin_dir.as_deref(), + active_bin_dir.as_deref(), + ) + .await, + ); + } + + runtimes.sort_by(compare_openclaw_runtime_candidates); + Ok(runtimes) +} + +pub(crate) async fn inspect_openclaw_runtime_candidate( + bin_dir: &Path, + preferred_bin_dir: Option<&Path>, + active_bin_dir: Option<&Path>, +) -> OpenClawRuntimeCandidate { + let node_path = find_command_in_bin_dir("node", bin_dir).unwrap_or_else(|| { + #[cfg(target_os = "windows")] + let node_name = "node.exe"; + #[cfg(not(target_os = "windows"))] + let node_name = "node"; + + bin_dir.join(node_name) + }); + + let npm_path = find_command_in_bin_dir("npm", bin_dir); + let npm_path_string = npm_path.as_ref().map(|path| path.display().to_string()); + let npm_global_prefix = match npm_path_string.as_deref() { + Some(path) => detect_npm_global_prefix(path).await, + None => None, + }; + let openclaw_path = resolve_runtime_openclaw_path(bin_dir, npm_global_prefix.as_deref()); + let openclaw_package_path = npm_global_prefix + .as_deref() + .and_then(find_installed_openclaw_package_details) + .map(|package| package.path.display().to_string()); + + OpenClawRuntimeCandidate { + id: bin_dir.display().to_string(), + source: infer_openclaw_runtime_source(bin_dir), + bin_dir: bin_dir.display().to_string(), + node_path: node_path.display().to_string(), + node_version: read_display_version_text(&node_path).await, + npm_path: npm_path_string, + npm_global_prefix, + openclaw_path: openclaw_path + .as_ref() + .map(|path| path.display().to_string()), + openclaw_version: match openclaw_path.as_deref() { + Some(path) => read_display_version_text(path).await, + None => None, + }, + openclaw_package_path, + is_active: active_bin_dir.is_some_and(|path| path == bin_dir), + is_preferred: preferred_bin_dir.is_some_and(|path| path == bin_dir), + } +} + +pub(crate) fn resolve_runtime_openclaw_path( + bin_dir: &Path, + npm_global_prefix: Option<&str>, +) -> Option { + find_command_in_bin_dir("openclaw", bin_dir).or_else(|| { + npm_global_prefix.and_then(|prefix| { + select_preferred_path_candidate(find_all_commands_in_paths( + "openclaw", + &npm_global_command_dirs(prefix), + )) + }) + }) +} + +pub(crate) async fn read_display_version_text(binary_path: &Path) -> Option { + let path = binary_path.to_str()?; + let version_text = read_command_version_text(path, &["--version"]).await.ok()?; + let version_text = version_text.trim(); + if version_text.is_empty() { + return None; + } + + Some( + parse_semver_from_text(version_text) + .map(format_semver) + .unwrap_or_else(|| version_text.to_string()), + ) +} + +pub(crate) fn infer_openclaw_runtime_source(bin_dir: &Path) -> String { + let normalized = bin_dir + .display() + .to_string() + .replace('\\', "/") + .to_lowercase(); + + if normalized.contains("/.nvm/") { + return "nvm".to_string(); + } + if normalized.contains("/phpwebstudy/") { + return "PhpWebStudy".to_string(); + } + if normalized.contains("/.volta/") { + return "Volta".to_string(); + } + if normalized.contains("/.fnm/") { + return "fnm".to_string(); + } + if normalized.contains("/.asdf/") { + return "asdf".to_string(); + } + if normalized.contains("/mise/") { + return "mise".to_string(); + } + if normalized.contains("/opt/homebrew/") { + return "Homebrew".to_string(); + } + if normalized.ends_with("/usr/local/bin") + || normalized.ends_with("/usr/bin") + || normalized.ends_with("/bin") + || normalized.contains("/program files/nodejs") + || normalized.contains("/program files (x86)/nodejs") + { + return "系统".to_string(); + } + + "PATH".to_string() +} + +pub(crate) fn compare_openclaw_runtime_candidates( + left: &OpenClawRuntimeCandidate, + right: &OpenClawRuntimeCandidate, +) -> Ordering { + right + .is_active + .cmp(&left.is_active) + .then_with(|| right.is_preferred.cmp(&left.is_preferred)) + .then_with(|| { + right + .openclaw_path + .is_some() + .cmp(&left.openclaw_path.is_some()) + }) + .then_with(|| { + compare_optional_semver_desc( + left.node_version.as_deref(), + right.node_version.as_deref(), + ) + }) + .then_with(|| left.bin_dir.cmp(&right.bin_dir)) +} + +pub(crate) fn compare_optional_semver_desc(left: Option<&str>, right: Option<&str>) -> Ordering { + match ( + left.and_then(parse_semver_from_text), + right.and_then(parse_semver_from_text), + ) { + (Some(left), Some(right)) => right.cmp(&left), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + +pub(crate) async fn select_best_node_candidate( + candidates: Vec, +) -> Result, String> { + let mut versioned = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let openclaw_signal = match candidate.parent() { + Some(bin_dir) => inspect_node_runtime_openclaw_signal(bin_dir).await, + None => 0, + }; + let version = read_binary_semver(&candidate).await; + versioned.push((candidate, openclaw_signal, version)); + } + Ok(select_best_node_runtime_candidate(versioned)) +} + +pub(crate) async fn select_node_runtime_candidate( + candidates: Vec, +) -> Result, String> { + let mut runtime_ranked = Vec::with_capacity(candidates.len()); + for candidate in &candidates { + let openclaw_signal = match candidate.parent() { + Some(bin_dir) => inspect_node_runtime_openclaw_signal(bin_dir).await, + None => 0, + }; + let version = match sibling_node_path(candidate) { + Some(node_path) => read_binary_semver(&node_path).await, + None => None, + }; + runtime_ranked.push((candidate.clone(), openclaw_signal, version)); + } + + if let Some(candidate) = select_best_node_runtime_candidate(runtime_ranked.clone()) { + let candidate_signal = runtime_ranked + .iter() + .find(|(path, _, _)| path == &candidate) + .map(|(_, signal, _)| *signal) + .unwrap_or(0); + if candidate_signal > 0 { + return Ok(Some(candidate)); + } + } + + let preferred_node = + select_best_node_candidate(find_all_commands_in_known_locations("node")).await?; + if let Some(preferred_bin_dir) = preferred_node.as_deref().and_then(Path::parent) { + if let Some(candidate) = select_preferred_path_candidate( + candidates + .iter() + .filter(|candidate| candidate.parent() == Some(preferred_bin_dir)) + .cloned() + .collect(), + ) { + return Ok(Some(candidate)); + } + } + + Ok( + select_best_node_runtime_candidate(runtime_ranked) + .or_else(|| candidates.into_iter().next()), + ) +} + +pub(crate) async fn inspect_node_runtime_openclaw_signal(bin_dir: &Path) -> u8 { + if find_command_in_bin_dir("openclaw", bin_dir).is_some() { + return 3; + } + + let Some(npm_path) = + find_command_in_bin_dir("npm", bin_dir).and_then(|path| path.to_str().map(str::to_string)) + else { + return 0; + }; + + let Some(prefix) = detect_npm_global_prefix(&npm_path).await else { + return 0; + }; + + if select_preferred_path_candidate(find_all_commands_in_paths( + "openclaw", + &npm_global_command_dirs(&prefix), + )) + .is_some() + { + return 3; + } + + if find_installed_openclaw_package_details(&prefix).is_some() { + return 2; + } + + 0 +} + +pub(crate) fn select_best_node_runtime_candidate( + candidates: Vec<(PathBuf, u8, Option<(u64, u64, u64)>)>, +) -> Option { + candidates + .into_iter() + .max_by( + |(left_path, left_signal, left_version), (right_path, right_signal, right_version)| { + left_signal + .cmp(right_signal) + .then_with(|| left_version.cmp(right_version)) + .then_with(|| { + let preferred = select_preferred_path_candidate(vec![ + left_path.clone(), + right_path.clone(), + ]); + match preferred.as_ref() { + Some(path) if path == left_path => std::cmp::Ordering::Greater, + Some(path) if path == right_path => std::cmp::Ordering::Less, + _ => std::cmp::Ordering::Equal, + } + }) + }, + ) + .map(|(path, _, _)| path) +} + +pub(crate) fn sibling_node_path(command_path: &Path) -> Option { + #[cfg(target_os = "windows")] + let node_name = "node.exe"; + + #[cfg(not(target_os = "windows"))] + let node_name = "node"; + + let node_path = command_path.parent()?.join(node_name); + node_path.is_file().then_some(node_path) +} + +pub(crate) async fn read_binary_semver(path: &Path) -> Option<(u64, u64, u64)> { + let mut command = Command::new(path); + apply_windows_no_window(&mut command); + let output = command + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + parse_semver(stdout.trim()).or_else(|| parse_semver(stderr.trim())) +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn select_best_semver_candidate( + candidates: Vec<(PathBuf, Option<(u64, u64, u64)>)>, +) -> Option { + core_select_best_semver_candidate(candidates, NODE_MIN_VERSION) +} + +pub(crate) fn select_preferred_path_candidate(candidates: Vec) -> Option { + core_select_preferred_path_candidate(candidates) +} diff --git a/src-tauri/src/services/openclaw_service/tests.rs b/src-tauri/src/services/openclaw_service/tests.rs new file mode 100644 index 000000000..673a25249 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/tests.rs @@ -0,0 +1,1063 @@ +use super::{ + apply_gateway_runtime_defaults, build_environment_status, build_openclaw_cleanup_command, + build_openclaw_install_command, build_openclaw_pnpm_install_command, + build_winget_install_command, command_bin_dir_for, determine_api_type, + extract_gateway_auth_token, find_installed_openclaw_package, + format_gateway_start_failure_message, format_openclaw_update_failure_message, + format_provider_base_url, gateway_start_args, has_api_version, + infer_openclaw_package_name_from_path, npm_global_command_dirs_for, + npm_global_node_modules_dirs_for, package_registry_for_package_spec, parse_semver_from_text, + resolve_openclaw_cli_entry_from_package_manifest, + resolve_openclaw_command_from_runtime_candidate, + resolve_openclaw_command_from_runtime_candidate_for, resolve_windows_dependency_install_plan, + runtime_candidate_matches_install_root, sanitize_runtime_config, select_best_git_candidate, + select_best_semver_candidate, select_gateway_start_failure_detail, + select_openclaw_update_failure_detail, select_preferred_path_candidate, + shell_command_escape_for, shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for, + shell_path_assignment_for, trim_trailing_slash, windows_dependency_action_result, + windows_dependency_setup_message, windows_git_install_dir_variants, + windows_install_block_result, windows_manual_install_message, DependencyKind, DependencyStatus, + EnvironmentDiagnostics, OpenClawRuntimeCandidate, ResolvedOpenClawCommand, ShellPlatform, + WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE, +}; +use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; +use chrono::Utc; +use serde_json::{json, Value}; +use std::ffi::OsString; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvider { + ApiKeyProvider { + id: "provider-1".to_string(), + name: "Provider 1".to_string(), + provider_type, + api_host: api_host.to_string(), + is_system: false, + group: ProviderGroup::Custom, + enabled: true, + sort_order: 0, + api_version: None, + project: None, + location: None, + region: None, + custom_models: Vec::new(), + created_at: Utc::now(), + updated_at: Utc::now(), + } +} + +fn build_unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("系统时间应晚于 Unix epoch") + .as_nanos(); + + std::env::temp_dir().join(format!( + "lime-openclaw-{prefix}-{}-{nanos}", + std::process::id() + )) +} + +#[test] +fn trims_trailing_slash() { + assert_eq!( + trim_trailing_slash("https://api.openai.com/"), + "https://api.openai.com" + ); +} + +#[test] +fn detects_version_segment() { + assert!(has_api_version("https://api.openai.com/v1")); + assert!(!has_api_version("https://api.openai.com")); +} + +#[test] +fn maps_api_type_correctly() { + assert_eq!( + determine_api_type(ApiProviderType::Openai).unwrap(), + "openai-completions" + ); + assert_eq!( + determine_api_type(ApiProviderType::OpenaiResponse).unwrap(), + "openai-responses" + ); + assert_eq!( + determine_api_type(ApiProviderType::Anthropic).unwrap(), + "anthropic-messages" + ); +} + +#[test] +fn formats_openai_url() { + let provider = build_provider(ApiProviderType::Openai, "https://api.openai.com"); + assert_eq!( + format_provider_base_url(&provider).unwrap(), + "https://api.openai.com/v1" + ); +} + +#[test] +fn keeps_existing_version_url() { + let provider = build_provider(ApiProviderType::Openai, "https://example.com/v2"); + assert_eq!( + format_provider_base_url(&provider).unwrap(), + "https://example.com/v2" + ); +} + +#[test] +fn formats_gemini_url() { + let provider = build_provider( + ApiProviderType::Gemini, + "https://generativelanguage.googleapis.com", + ); + assert_eq!( + format_provider_base_url(&provider).unwrap(), + "https://generativelanguage.googleapis.com/v1beta/openai" + ); +} + +#[test] +fn formats_gateway_url() { + let provider = build_provider( + ApiProviderType::Gateway, + "https://gateway.example.com/v1/ai", + ); + assert_eq!( + format_provider_base_url(&provider).unwrap(), + "https://gateway.example.com/v1" + ); +} + +#[test] +fn rejects_unsupported_provider_types() { + let provider = build_provider(ApiProviderType::AzureOpenai, "https://example.com"); + assert!(format_provider_base_url(&provider).is_err()); +} + +#[test] +fn extracts_gateway_auth_token_from_config() { + let config = json!({ + "gateway": { + "auth": { + "token": "lime-token" + } + } + }); + + assert_eq!( + extract_gateway_auth_token(&config).as_deref(), + Some("lime-token") + ); +} + +#[test] +fn ignores_empty_gateway_auth_token() { + let config = json!({ + "gateway": { + "auth": { + "token": " " + } + } + }); + + assert_eq!(extract_gateway_auth_token(&config), None); +} + +#[test] +fn applies_gateway_runtime_defaults_for_current_openclaw() { + let mut config = json!({}); + + apply_gateway_runtime_defaults(&mut config, 18790, "lime-token"); + + assert_eq!( + config.pointer("/gateway/mode").and_then(Value::as_str), + Some("local") + ); + assert_eq!( + config.pointer("/gateway/bind").and_then(Value::as_str), + Some("loopback") + ); + assert_eq!( + config.pointer("/gateway/auth/mode").and_then(Value::as_str), + Some("token") + ); + assert_eq!( + config + .pointer("/gateway/auth/token") + .and_then(Value::as_str), + Some("lime-token") + ); + assert_eq!( + config + .pointer("/gateway/remote/token") + .and_then(Value::as_str), + Some("lime-token") + ); + assert_eq!( + config.pointer("/gateway/port").and_then(Value::as_u64), + Some(18_790) + ); +} + +#[test] +fn gateway_start_args_include_new_runtime_guards() { + assert_eq!( + gateway_start_args(18790, "lime-token"), + vec![ + "gateway", + "--allow-unconfigured", + "--bind", + "loopback", + "--auth", + "token", + "--token", + "lime-token", + "--port", + "18790", + ] + ); +} + +#[test] +fn parses_lsof_listener_pid_output() { + assert_eq!( + super::parse_lsof_listener_pids("1201\n1202\nbad\n1201\n"), + vec![1201, 1202] + ); +} + +#[test] +fn parses_windows_netstat_listener_pid_output() { + let output = "\ + TCP 127.0.0.1:18790 0.0.0.0:0 LISTENING 31234\n\ + TCP [::]:18790 [::]:0 LISTENING 31235\n\ + TCP 127.0.0.1:18791 0.0.0.0:0 LISTENING 39999\n"; + + assert_eq!( + super::parse_windows_netstat_listener_pids(output, 18790), + vec![31234, 31235] + ); +} + +#[test] +fn detects_openclaw_process_from_node_command_line() { + let args = vec![ + OsString::from("/Users/demo/.nvm/versions/node/v23.4.0/bin/.openclaw-a1b2c3/openclaw"), + OsString::from("gateway"), + OsString::from("--port"), + OsString::from("18790"), + ]; + let node_path = PathBuf::from("/Users/demo/.nvm/versions/node/v23.4.0/bin/node"); + + assert!(super::process_looks_like_openclaw_process( + "node", + Some(node_path.as_path()), + &args, + )); +} + +#[test] +fn ignores_unrelated_listener_process() { + let args = vec![ + OsString::from("/usr/local/bin/python3"), + OsString::from("-m"), + OsString::from("http.server"), + OsString::from("18790"), + ]; + let python_path = PathBuf::from("/usr/local/bin/python3"); + + assert!(!super::process_looks_like_openclaw_process( + "python3", + Some(python_path.as_path()), + &args, + )); +} + +#[test] +fn formats_gateway_start_failure_for_missing_config() { + assert_eq!( + format_gateway_start_failure_message(Some( + "Missing config. Run `openclaw setup` or set gateway.mode=local." + )), + "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。" + ); +} + +#[test] +fn formats_gateway_start_failure_for_loopback_bind_error() { + assert_eq!( + format_gateway_start_failure_message(Some( + "gateway bind=loopback resolved to non-loopback host 0.0.0.0" + )), + "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。" + ); +} + +#[test] +fn sanitizes_null_context_window_from_runtime_config() { + let mut config = json!({ + "models": { + "providers": { + "lime-openai": { + "models": [ + { + "id": "gpt-5", + "name": "GPT-5", + "contextWindow": null + }, + { + "id": "gpt-5-mini", + "name": "GPT-5 mini", + "contextWindow": 400000 + } + ] + } + } + } + }); + + sanitize_runtime_config(&mut config); + + assert!(config + .pointer("/models/providers/lime-openai/models/0/contextWindow") + .is_none()); + assert_eq!( + config + .pointer("/models/providers/lime-openai/models/1/contextWindow") + .and_then(Value::as_u64), + Some(400_000) + ); +} + +#[test] +fn selects_specific_gateway_failure_detail_over_doctor_hint() { + let lines = vec![ + "Config invalid".to_string(), + "Run: openclaw doctor --fix".to_string(), + "Invalid config at /Users/demo/.openclaw/openclaw.lime.json:\\n- models.providers.lime-openai.models.0.contextWindow: Invalid input: expected number, received null".to_string(), + ]; + + assert_eq!( + select_gateway_start_failure_detail(&lines), + Some( + "Invalid config at /Users/demo/.openclaw/openclaw.lime.json:\\n- models.providers.lime-openai.models.0.contextWindow: Invalid input: expected number, received null" + ) + ); +} + +#[test] +fn formats_gateway_start_failure_for_invalid_context_window_config() { + assert_eq!( + format_gateway_start_failure_message(Some( + "Invalid config at /Users/demo/.openclaw/openclaw.lime.json:\\n- models.providers.lime-openai.models.0.contextWindow: Invalid input: expected number, received null" + )), + "Gateway 启动失败:当前 OpenClaw 配置包含空的 contextWindow 字段。Lime 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。" + ); +} + +#[test] +fn selects_update_failure_reason_from_json_payload() { + let payload = json!({ + "status": "error", + "reason": "not-openclaw-root", + "root": "/Users/demo/.nvm" + }); + + assert_eq!( + select_openclaw_update_failure_detail(Some(&payload), &[], &[]), + Some("not-openclaw-root (/Users/demo/.nvm)".to_string()) + ); +} + +#[test] +fn formats_openclaw_update_failure_for_invalid_root() { + assert_eq!( + format_openclaw_update_failure_message(Some( + "not-openclaw-root (/Users/demo/.nvm)" + )), + "OpenClaw 升级失败:未在 OpenClaw 安装根目录执行更新。Lime 会优先切换到安装目录;如仍失败,请重新检测安装状态后重试。" + ); +} + +#[test] +fn formats_openclaw_update_failure_for_node_version_requirement() { + assert_eq!( + format_openclaw_update_failure_message(Some( + "openclaw: Node.js v22.12+ is required (current: v18.20.2)." + )), + "OpenClaw 升级失败:当前用于执行 openclaw 的 Node.js 版本过低,需要 22.12.0+。请切换到满足要求的 Node.js 后重试。" + ); +} + +#[test] +fn parses_semver_from_git_version_text() { + assert_eq!( + parse_semver_from_text("git version 2.39.5 (Apple Git-154)"), + Some((2, 39, 5)) + ); +} + +#[test] +fn environment_status_prioritizes_missing_node() { + let env = build_environment_status( + DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "missing node".to_string(), + auto_install_supported: true, + }, + DependencyStatus { + status: "ok".to_string(), + version: Some("2.43.0".to_string()), + path: Some("/usr/bin/git".to_string()), + message: "git ok".to_string(), + auto_install_supported: true, + }, + DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "openclaw missing".to_string(), + auto_install_supported: false, + }, + EnvironmentDiagnostics::default(), + ); + + assert_eq!(env.recommended_action, "install_node"); + assert_eq!(env.openclaw.auto_install_supported, false); +} + +#[test] +fn environment_status_uses_reload_summary_when_openclaw_command_not_ready() { + let env = build_environment_status( + DependencyStatus { + status: "ok".to_string(), + version: Some("22.12.0".to_string()), + path: Some("/usr/local/bin/node".to_string()), + message: "node ok".to_string(), + auto_install_supported: true, + }, + DependencyStatus { + status: "ok".to_string(), + version: Some("2.44.0".to_string()), + path: Some("/usr/bin/git".to_string()), + message: "git ok".to_string(), + auto_install_supported: true, + }, + DependencyStatus { + status: "needs_reload".to_string(), + version: Some("0.3.0".to_string()), + path: Some("/mock/prefix".to_string()), + message: "reload openclaw".to_string(), + auto_install_supported: false, + }, + EnvironmentDiagnostics::default(), + ); + + assert_eq!(env.recommended_action, "refresh_openclaw_env"); + assert!(env.summary.contains("重新检测")); +} + +#[test] +fn semver_selection_prefers_windows_launcher_over_bare_file_when_versions_equal() { + let preferred = select_best_semver_candidate(vec![ + (PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), Some((23, 1, 0))), + ( + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), + Some((23, 1, 0)), + ), + ]); + + assert_eq!( + preferred, + Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd")) + ); +} + +#[test] +fn windows_command_bin_dir_supports_backslash_paths() { + assert_eq!( + command_bin_dir_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), + Some(r"C:\Program Files\nodejs".to_string()) + ); +} + +#[test] +fn windows_shell_command_escape_keeps_cmd_compatible_quotes() { + assert_eq!( + shell_command_escape_for(ShellPlatform::Windows, r#"C:\Program Files\nodejs\npm.cmd"#), + r#""C:\Program Files\nodejs\npm.cmd""# + ); + assert_eq!( + shell_command_escape_for(ShellPlatform::Windows, "C:\\demo\\na\"me\\npm.cmd"), + r#""C:\demo\na""me\npm.cmd""# + ); +} + +#[test] +fn windows_shell_npm_prefix_assignment_uses_set_syntax() { + assert_eq!( + shell_npm_prefix_assignment_for( + ShellPlatform::Windows, + r"C:\Users\demo\AppData\Roaming\npm" + ), + r#"set "NPM_CONFIG_PREFIX=C:\Users\demo\AppData\Roaming\npm" && "# + ); +} + +#[test] +fn windows_shell_path_assignment_prepends_binary_directory() { + assert_eq!( + shell_path_assignment_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), + r#"set "PATH=C:\Program Files\nodejs;%PATH%" && "# + ); +} + +#[test] +fn windows_cmd_scripts_use_call_invocation_prefix() { + assert_eq!( + shell_command_invocation_prefix_for( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd" + ), + "call " + ); + assert!(shell_command_invocation_prefix_for( + ShellPlatform::Windows, + r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe" + ) + .is_empty()); +} + +#[test] +fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() { + let command = build_openclaw_cleanup_command( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd", + Some(r"C:\Users\demo\AppData\Roaming\npm"), + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", + "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", + "call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" + ) + ); + assert!(!command.contains("|| true")); +} + +#[test] +fn windows_install_command_adds_registry_when_using_china_package() { + let command = build_openclaw_install_command( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd", + Some(r"C:\Users\demo\AppData\Roaming\npm"), + OPENCLAW_CN_PACKAGE, + Some(NPM_MIRROR_CN), + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", + "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", + "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", + "--registry=https://registry.npmmirror.com" + ) + ); +} + +#[test] +fn windows_install_command_omits_registry_for_default_package() { + let command = build_openclaw_install_command( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd", + None, + OPENCLAW_DEFAULT_PACKAGE, + None, + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", + "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" + ) + ); + assert!(!command.contains("--registry=")); +} + +#[test] +fn windows_pnpm_install_command_uses_global_add_syntax() { + let command = build_openclaw_pnpm_install_command( + ShellPlatform::Windows, + r"C:\Users\demo\AppData\Local\pnpm\pnpm.cmd", + "@qingchencloud/openclaw-zh@latest", + Some(NPM_MIRROR_CN), + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Users\\demo\\AppData\\Local\\pnpm;%PATH%\" && ", + "call \"C:\\Users\\demo\\AppData\\Local\\pnpm\\pnpm.cmd\" add -g \"@qingchencloud/openclaw-zh@latest\" ", + "--registry=\"https://registry.npmmirror.com\"" + ) + ); +} + +#[test] +fn infers_openclaw_package_name_from_manifest_path() { + assert_eq!( + infer_openclaw_package_name_from_path( + PathBuf::from( + "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/openclaw/package.json", + ) + .as_path() + ), + Some("openclaw") + ); + assert_eq!( + infer_openclaw_package_name_from_path(PathBuf::from( + "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/@qingchencloud/openclaw-zh/package.json", + ) + .as_path()), + Some("@qingchencloud/openclaw-zh") + ); +} + +#[test] +fn china_package_upgrade_uses_npmmirror_registry() { + assert_eq!( + package_registry_for_package_spec("@qingchencloud/openclaw-zh@latest"), + Some(NPM_MIRROR_CN) + ); + assert_eq!(package_registry_for_package_spec("openclaw@latest"), None); +} + +#[test] +fn runtime_candidate_prefers_install_root_match_over_current_binary_hint() { + let candidate = OpenClawRuntimeCandidate { + id: "/Users/demo/.nvm/versions/node/v23.4.0/bin".to_string(), + source: "nvm".to_string(), + bin_dir: "/Users/demo/.nvm/versions/node/v23.4.0/bin".to_string(), + node_path: "/Users/demo/.nvm/versions/node/v23.4.0/bin/node".to_string(), + node_version: Some("23.4.0".to_string()), + npm_path: Some("/Users/demo/.nvm/versions/node/v23.4.0/bin/npm".to_string()), + npm_global_prefix: Some("/Users/demo/.nvm/versions/node/v23.4.0".to_string()), + openclaw_path: Some("/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw".to_string()), + openclaw_version: Some("2026.3.8".to_string()), + openclaw_package_path: Some( + "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/@qingchencloud/openclaw-zh/package.json" + .to_string(), + ), + is_active: false, + is_preferred: false, + }; + + assert!(runtime_candidate_matches_install_root( + &candidate, + PathBuf::from("/Users/demo/.nvm").as_path() + )); + assert!(!runtime_candidate_matches_install_root( + &candidate, + PathBuf::from("/Users/demo/Library/PhpWebStudy").as_path() + )); +} + +#[test] +fn preferred_path_candidate_prioritizes_windows_executable_extensions() { + let preferred = select_preferred_path_candidate(vec![ + PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.bat"), + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe"), + ]); + + assert_eq!( + preferred, + Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe")) + ); +} + +#[test] +fn git_candidate_selection_prefers_executable_extension() { + let preferred = select_best_git_candidate(vec![ + PathBuf::from(r"C:\Program Files\Git\cmd\git.cmd"), + PathBuf::from(r"C:\Program Files\Git\cmd\git.exe"), + ]); + + assert_eq!( + preferred, + Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")) + ); +} + +#[test] +fn windows_git_install_dir_variants_cover_common_layouts() { + let git_root = build_unique_temp_dir("git-layout-root"); + let cmd_dir = git_root.join("cmd"); + let bin_dir = git_root.join("bin"); + fs::create_dir_all(&cmd_dir).unwrap(); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(cmd_dir.join("git.exe"), "").unwrap(); + fs::write(bin_dir.join("git.cmd"), "").unwrap(); + + let matches = super::find_all_commands_in_paths_for( + ShellPlatform::Windows, + "git", + &windows_git_install_dir_variants(git_root.clone()), + ); + + let _ = fs::remove_dir_all(&git_root); + + assert_eq!( + matches, + vec![cmd_dir.join("git.exe"), bin_dir.join("git.cmd")] + ); +} + +#[test] +fn windows_npm_global_command_dirs_use_prefix_root() { + assert_eq!( + npm_global_command_dirs_for(ShellPlatform::Windows, r"C:\Users\demo\AppData\Roaming\npm"), + vec![PathBuf::from(r"C:\Users\demo\AppData\Roaming\npm")] + ); +} + +#[test] +fn unix_npm_global_command_dirs_include_bin_directory() { + assert_eq!( + npm_global_command_dirs_for(ShellPlatform::Unix, "/Users/demo/.npm-global"), + vec![ + PathBuf::from("/Users/demo/.npm-global/bin"), + PathBuf::from("/Users/demo/.npm-global") + ] + ); +} + +#[test] +fn windows_npm_global_node_modules_dirs_use_prefix_node_modules() { + assert_eq!( + npm_global_node_modules_dirs_for( + ShellPlatform::Windows, + r"C:\Users\demo\AppData\Roaming\npm" + ), + vec![PathBuf::from(r"C:\Users\demo\AppData\Roaming\npm").join("node_modules")] + ); +} + +#[test] +fn finds_openclaw_package_from_global_npm_prefix() { + let temp_dir = std::env::temp_dir().join(format!("lime-openclaw-test-{}", std::process::id())); + let package_dir = temp_dir.join("node_modules").join("openclaw"); + fs::create_dir_all(&package_dir).unwrap(); + fs::write( + package_dir.join("package.json"), + r#"{"name":"openclaw","version":"0.4.1"}"#, + ) + .unwrap(); + + let detected = find_installed_openclaw_package(temp_dir.to_str().unwrap()); + + fs::remove_dir_all(&temp_dir).unwrap(); + + assert_eq!(detected, Some(("openclaw", Some("0.4.1".to_string())))); +} + +#[test] +fn resolves_openclaw_cli_entry_from_dist_index_when_bin_target_missing() { + let temp_dir = build_unique_temp_dir("cli-entry"); + let package_dir = temp_dir + .join("node_modules") + .join("@qingchencloud/openclaw-zh"); + let dist_dir = package_dir.join("dist"); + fs::create_dir_all(&dist_dir).unwrap(); + fs::write( + package_dir.join("package.json"), + r#"{ + "name":"@qingchencloud/openclaw-zh", + "version":"2026.3.13-zh.1", + "bin":{"openclaw":"openclaw.mjs"} + }"#, + ) + .unwrap(); + fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); + + let resolved = + resolve_openclaw_cli_entry_from_package_manifest(&package_dir.join("package.json")); + + let _ = fs::remove_dir_all(&temp_dir); + + assert_eq!(resolved, Some(package_dir.join("dist").join("index.js"))); +} + +#[test] +fn resolves_openclaw_command_from_runtime_candidate_as_node_cli() { + let temp_dir = build_unique_temp_dir("runtime-candidate"); + let node_bin_dir = temp_dir.join("bin"); + let package_dir = temp_dir + .join("node_modules") + .join("@qingchencloud/openclaw-zh"); + let dist_dir = package_dir.join("dist"); + fs::create_dir_all(&node_bin_dir).unwrap(); + fs::create_dir_all(&dist_dir).unwrap(); + + let node_path = node_bin_dir.join("node"); + fs::write(&node_path, "").unwrap(); + fs::write( + package_dir.join("package.json"), + r#"{ + "name":"@qingchencloud/openclaw-zh", + "version":"2026.3.13-zh.1", + "bin":{"openclaw":"openclaw.mjs"} + }"#, + ) + .unwrap(); + fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); + + let candidate = OpenClawRuntimeCandidate { + id: temp_dir.display().to_string(), + source: "nvm".to_string(), + bin_dir: node_bin_dir.display().to_string(), + node_path: node_path.display().to_string(), + node_version: Some("23.4.0".to_string()), + npm_path: None, + npm_global_prefix: None, + openclaw_path: None, + openclaw_version: Some("2026.3.13-zh.1".to_string()), + openclaw_package_path: Some(package_dir.join("package.json").display().to_string()), + is_active: true, + is_preferred: true, + }; + + let resolved = resolve_openclaw_command_from_runtime_candidate(&candidate); + + let _ = fs::remove_dir_all(&temp_dir); + + assert_eq!( + resolved, + Some(ResolvedOpenClawCommand::NodeCli { + node_path, + cli_path: package_dir.join("dist").join("index.js"), + package_version: Some("2026.3.13-zh.1".to_string()), + }) + ); +} + +#[test] +fn windows_runtime_candidate_prefers_node_cli_over_cmd_shim() { + let temp_dir = build_unique_temp_dir("runtime-candidate-windows-shim"); + let node_bin_dir = temp_dir.join("nodejs"); + let package_dir = temp_dir + .join("node_modules") + .join("@qingchencloud") + .join("openclaw-zh"); + let dist_dir = package_dir.join("dist"); + fs::create_dir_all(&node_bin_dir).unwrap(); + fs::create_dir_all(&dist_dir).unwrap(); + + let node_path = node_bin_dir.join("node.exe"); + let openclaw_cmd = node_bin_dir.join("openclaw.cmd"); + fs::write(&node_path, "").unwrap(); + fs::write(&openclaw_cmd, "@echo off").unwrap(); + fs::write( + package_dir.join("package.json"), + r#"{ + "name":"@qingchencloud/openclaw-zh", + "version":"2026.3.13-zh.1", + "bin":{"openclaw":"openclaw.mjs"} + }"#, + ) + .unwrap(); + fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); + + let candidate = OpenClawRuntimeCandidate { + id: temp_dir.display().to_string(), + source: "system".to_string(), + bin_dir: node_bin_dir.display().to_string(), + node_path: node_path.display().to_string(), + node_version: Some("23.4.0".to_string()), + npm_path: Some(node_bin_dir.join("npm.cmd").display().to_string()), + npm_global_prefix: Some(temp_dir.display().to_string()), + openclaw_path: Some(openclaw_cmd.display().to_string()), + openclaw_version: Some("2026.3.13-zh.1".to_string()), + openclaw_package_path: Some(package_dir.join("package.json").display().to_string()), + is_active: true, + is_preferred: true, + }; + + let resolved = + resolve_openclaw_command_from_runtime_candidate_for(ShellPlatform::Windows, &candidate); + + let _ = fs::remove_dir_all(&temp_dir); + + assert_eq!( + resolved, + Some(ResolvedOpenClawCommand::NodeCli { + node_path, + cli_path: package_dir.join("dist").join("index.js"), + package_version: Some("2026.3.13-zh.1".to_string()), + }) + ); +} + +#[test] +fn windows_node_prefers_winget_when_available() { + assert_eq!( + resolve_windows_dependency_install_plan(DependencyKind::Node, true), + WindowsDependencyInstallPlan::Winget { + package_id: "OpenJS.NodeJS.LTS" + } + ); +} + +#[test] +fn windows_node_falls_back_to_official_installer_without_winget() { + assert_eq!( + resolve_windows_dependency_install_plan(DependencyKind::Node, false), + WindowsDependencyInstallPlan::OfficialInstaller + ); +} + +#[test] +fn windows_git_prefers_winget_when_available() { + assert_eq!( + resolve_windows_dependency_install_plan(DependencyKind::Git, true), + WindowsDependencyInstallPlan::Winget { + package_id: "Git.Git" + } + ); +} + +#[test] +fn windows_git_requires_manual_download_without_winget() { + assert_eq!( + resolve_windows_dependency_install_plan(DependencyKind::Git, false), + WindowsDependencyInstallPlan::ManualDownload + ); + assert_eq!( + windows_manual_install_message(DependencyKind::Git), + "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" + ); +} + +#[test] +fn windows_git_setup_message_points_to_manual_download() { + let message = windows_dependency_setup_message( + DependencyKind::Git, + &DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "未检测到 Git。".to_string(), + auto_install_supported: false, + }, + ); + + assert!(message.contains("git-scm.com")); + assert!(message.contains("加入 PATH")); +} + +#[test] +fn windows_node_setup_message_points_to_nodejs_download() { + let message = windows_dependency_setup_message( + DependencyKind::Node, + &DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "未检测到 Node.js,需要安装 22.12.0+。".to_string(), + auto_install_supported: false, + }, + ); + + assert!(message.contains("nodejs.org")); + assert!(message.contains("Node.js 22+")); +} + +#[test] +fn windows_dependency_action_result_returns_failure_message() { + let result = windows_dependency_action_result( + DependencyKind::Git, + &DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "未检测到 Git。".to_string(), + auto_install_supported: false, + }, + ); + + assert!(!result.success); + assert!(result.message.contains("git-scm.com")); +} + +#[test] +fn windows_install_block_result_prioritizes_node_before_git() { + let result = windows_install_block_result( + &DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "未检测到 Node.js,需要安装 22.12.0+。".to_string(), + auto_install_supported: false, + }, + &DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "未检测到 Git。".to_string(), + auto_install_supported: false, + }, + ) + .expect("应返回 Windows 阻断结果"); + + assert!(!result.success); + assert!(result.message.contains("nodejs.org")); + assert!(!result.message.contains("git-scm.com")); +} + +#[test] +fn windows_install_block_result_returns_none_when_dependencies_ready() { + let result = windows_install_block_result( + &DependencyStatus { + status: "ok".to_string(), + version: Some("22.12.0".to_string()), + path: Some("C:\\Program Files\\nodejs\\node.exe".to_string()), + message: "Node.js 已就绪:22.12.0".to_string(), + auto_install_supported: false, + }, + &DependencyStatus { + status: "ok".to_string(), + version: Some("2.44.0".to_string()), + path: Some("C:\\Program Files\\Git\\cmd\\git.exe".to_string()), + message: "Git 已就绪:2.44.0".to_string(), + auto_install_supported: false, + }, + ); + + assert!(result.is_none()); +} + +#[test] +fn winget_install_command_uses_expected_windows_flags() { + assert_eq!( + build_winget_install_command( + r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe", + "OpenJS.NodeJS.LTS" + ), + concat!( + "set \"PATH=C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps;%PATH%\" && ", + "\"C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe\" install ", + "--id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements" + ) + ); +} diff --git a/src-tauri/src/services/openclaw_service/types.rs b/src-tauri/src/services/openclaw_service/types.rs new file mode 100644 index 000000000..a79e70d57 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/types.rs @@ -0,0 +1,299 @@ +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BinaryInstallStatus { + pub installed: bool, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BinaryAvailabilityStatus { + pub available: bool, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeCheckResult { + pub status: String, + pub version: Option, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionResult { + pub success: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DependencyStatus { + pub status: String, + pub version: Option, + pub path: Option, + pub message: String, + pub auto_install_supported: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentStatus { + pub node: DependencyStatus, + pub git: DependencyStatus, + pub openclaw: DependencyStatus, + pub recommended_action: String, + pub summary: String, + #[serde(default)] + pub diagnostics: EnvironmentDiagnostics, + #[serde(default)] + pub temp_artifacts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentDiagnostics { + pub npm_path: Option, + pub npm_global_prefix: Option, + pub openclaw_package_path: Option, + #[serde(default)] + pub where_candidates: Vec, + #[serde(default)] + pub supplemental_search_dirs: Vec, + #[serde(default)] + pub supplemental_command_candidates: Vec, + #[serde(default)] + pub git_where_candidates: Vec, + #[serde(default)] + pub git_supplemental_search_dirs: Vec, + #[serde(default)] + pub git_supplemental_command_candidates: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandPreview { + pub title: String, + pub command: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayStatusInfo { + pub status: GatewayStatus, + pub port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum GatewayStatus { + Stopped, + Starting, + Running, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthInfo { + pub status: String, + pub gateway_port: u16, + pub uptime: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + pub has_update: bool, + pub current_version: Option, + pub latest_version: Option, + pub channel: Option, + pub install_kind: Option, + pub package_manager: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenClawRuntimeCandidate { + pub id: String, + pub source: String, + pub bin_dir: String, + pub node_path: String, + pub node_version: Option, + pub npm_path: Option, + pub npm_global_prefix: Option, + pub openclaw_path: Option, + pub openclaw_version: Option, + pub openclaw_package_path: Option, + pub is_active: bool, + pub is_preferred: bool, +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct OpenClawUpdateExecutionContext { + pub(crate) root: Option, + pub(crate) install_kind: Option, + pub(crate) package_manager: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct OpenClawDirectUpgradePlan { + pub(crate) runtime_source: String, + pub(crate) runtime_bin_dir: PathBuf, + pub(crate) package_manager: String, + pub(crate) package_spec: String, + pub(crate) command_line: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct OpenClawDirectUpgradeResult { + pub(crate) runtime_source: String, + pub(crate) runtime_bin_dir: PathBuf, + pub(crate) package_manager: String, + pub(crate) package_spec: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ResolvedOpenClawCommand { + Binary { + binary_path: PathBuf, + }, + NodeCli { + node_path: PathBuf, + cli_path: PathBuf, + package_version: Option, + }, +} + +impl ResolvedOpenClawCommand { + pub(crate) fn build_command_with_args(&self, args: I) -> Command + where + I: IntoIterator, + S: AsRef, + { + let command_path = self.command_path(); + let command_path_string = command_path.to_string_lossy().to_string(); + let mut command = Command::new(command_path); + apply_binary_runtime_path(&mut command, &command_path_string); + + if let Self::NodeCli { cli_path, .. } = self { + command.arg(cli_path); + } + + command.args(args); + + command + } + + pub(crate) fn command_path(&self) -> &Path { + match self { + Self::Binary { binary_path } => binary_path.as_path(), + Self::NodeCli { node_path, .. } => node_path.as_path(), + } + } + + pub(crate) fn install_path_display(&self) -> String { + match self { + Self::Binary { binary_path } => binary_path.display().to_string(), + Self::NodeCli { cli_path, .. } => cli_path.display().to_string(), + } + } + + pub(crate) fn invocation_display(&self) -> String { + match self { + Self::Binary { binary_path } => binary_path.display().to_string(), + Self::NodeCli { + node_path, + cli_path, + .. + } => { + format!("{} {}", node_path.display(), cli_path.display()) + } + } + } + + pub(crate) fn preview_invocation(&self) -> String { + match self { + Self::Binary { binary_path } => shell_escape(binary_path.to_string_lossy().as_ref()), + Self::NodeCli { + node_path, + cli_path, + .. + } => format!( + "{} {}", + shell_escape(node_path.to_string_lossy().as_ref()), + shell_escape(cli_path.to_string_lossy().as_ref()) + ), + } + } + + pub(crate) fn fallback_version(&self) -> Option { + match self { + Self::Binary { .. } => None, + Self::NodeCli { + package_version, .. + } => package_version.clone(), + } + } + + pub(crate) fn dedupe_key(&self) -> String { + match self { + Self::Binary { binary_path } => format!("binary:{}", binary_path.display()), + Self::NodeCli { + node_path, + cli_path, + .. + } => format!("node:{}:{}", node_path.display(), cli_path.display()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelInfo { + pub id: String, + pub name: String, + pub channel_type: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallProgressEvent { + pub message: String, + pub level: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncModelEntry { + pub id: String, + pub name: String, + pub context_window: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DependencyKind { + Node, + Git, +} + +impl DependencyKind { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Node => "Node.js", + Self::Git => "Git", + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct InstallerAsset { + pub(crate) filename: String, + pub(crate) download_url: String, +} diff --git a/src-tauri/src/services/openclaw_service/update.rs b/src-tauri/src/services/openclaw_service/update.rs new file mode 100644 index 000000000..a2e2069f2 --- /dev/null +++ b/src-tauri/src/services/openclaw_service/update.rs @@ -0,0 +1,747 @@ +use super::*; + +impl OpenClawService { + pub async fn check_update(&self) -> Result { + let Some(openclaw_command) = resolve_openclaw_command().await? else { + return Ok(UpdateInfo { + has_update: false, + current_version: None, + latest_version: None, + channel: None, + install_kind: None, + package_manager: None, + message: Some("未检测到 OpenClaw 可执行文件,请先安装。".to_string()), + }); + }; + + let current_version = self + .read_openclaw_version() + .await? + .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))); + + let payload = match read_openclaw_update_status_payload(&openclaw_command).await { + Ok(payload) => payload, + Err(message) => { + return Ok(UpdateInfo { + has_update: false, + current_version, + latest_version: None, + channel: None, + install_kind: None, + package_manager: None, + message: Some(message), + }); + } + }; + + Ok(UpdateInfo { + has_update: payload + .pointer("/availability/available") + .and_then(Value::as_bool) + .unwrap_or(false), + current_version, + latest_version: payload + .pointer("/availability/latestVersion") + .and_then(Value::as_str) + .map(str::to_string), + channel: payload + .pointer("/channel/label") + .or_else(|| payload.pointer("/channel/value")) + .and_then(Value::as_str) + .map(str::to_string), + install_kind: payload + .pointer("/update/installKind") + .and_then(Value::as_str) + .map(str::to_string), + package_manager: payload + .pointer("/update/packageManager") + .and_then(Value::as_str) + .map(str::to_string), + message: payload + .pointer("/update/registry/error") + .and_then(Value::as_str) + .map(str::to_string), + }) + } + + pub async fn perform_update(&mut self, app: &AppHandle) -> Result { + emit_install_progress(app, "开始执行 OpenClaw 升级。", "info"); + + let Some(openclaw_command) = resolve_openclaw_command().await? else { + return Ok(ActionResult { + success: false, + message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(), + }); + }; + let current_runtime_bin_dir = openclaw_command + .command_path() + .parent() + .map(Path::to_path_buf); + + self.refresh_process_state().await?; + let gateway_was_running = self.gateway_status == GatewayStatus::Running; + if self.gateway_status == GatewayStatus::Running { + emit_install_progress( + app, + "升级前先停止 Gateway,避免占用正在运行的 OpenClaw。", + "info", + ); + let stop_result = self.stop_gateway(Some(app)).await?; + if !stop_result.success { + return Ok(stop_result); + } + } + + if let Some(current_version) = self + .read_openclaw_version() + .await + .ok() + .flatten() + .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))) + { + emit_install_progress( + app, + &format!("当前版本 {current_version},开始执行升级命令。"), + "info", + ); + } + + let update_status_payload = + match read_openclaw_update_status_payload(&openclaw_command).await { + Ok(payload) => payload, + Err(message) => { + emit_install_progress(app, &message, "warn"); + match attempt_direct_openclaw_package_upgrade( + app, + current_runtime_bin_dir.as_deref(), + None, + None, + ) + .await + { + Ok(result) => { + set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone())); + emit_install_progress( + app, + &format!( + "已自动切换后续执行环境到 {}。", + result.runtime_bin_dir.display() + ), + "info", + ); + return self + .finalize_successful_openclaw_update( + app, + gateway_was_running, + Some(format!( + "OpenClaw 已通过 {} 的 {} 全局升级完成({})。", + result.runtime_source, + result.package_manager, + result.package_spec + )), + ) + .await; + } + Err(fallback_error) => { + emit_install_progress(app, &fallback_error, "error"); + return Ok(ActionResult { + success: false, + message, + }); + } + } + } + }; + let update_context = extract_openclaw_update_execution_context(&update_status_payload); + if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) { + emit_install_progress( + app, + &format!("已切换到 OpenClaw 安装根目录执行升级:{}", root.display()), + "info", + ); + } + if let Some(install_kind) = update_context.install_kind.as_deref() { + let package_manager = update_context + .package_manager + .as_deref() + .unwrap_or("默认包管理器"); + emit_install_progress( + app, + &format!("检测到安装方式:{install_kind}(包管理器:{package_manager})。"), + "info", + ); + } + + let mut command = openclaw_command.build_command_with_args(["update", "--yes", "--json"]); + if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) { + command.current_dir(root); + } + let output = command + .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("执行 OpenClaw 升级失败: {e}"))?; + + let stdout_lines = command_output_lines(&output.stdout); + for line in &stdout_lines { + emit_install_progress(app, &line, classify_progress_level(&line, "info")); + } + let stderr_lines = command_output_lines(&output.stderr); + for line in &stderr_lines { + emit_install_progress(app, line, classify_progress_level(line, "warn")); + } + let stdout_payload = serde_json::from_slice::(&output.stdout).ok(); + + if !output.status.success() { + let failure_detail = select_openclaw_update_failure_detail( + stdout_payload.as_ref(), + &stderr_lines, + &stdout_lines, + ); + let message = format_openclaw_update_failure_message(failure_detail.as_deref()); + emit_install_progress(app, &message, "warn"); + + match attempt_direct_openclaw_package_upgrade( + app, + current_runtime_bin_dir.as_deref(), + update_context.root.as_deref(), + update_context.package_manager.as_deref(), + ) + .await + { + Ok(result) => { + set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone())); + emit_install_progress( + app, + &format!( + "已自动切换后续执行环境到 {}。", + result.runtime_bin_dir.display() + ), + "info", + ); + return self + .finalize_successful_openclaw_update( + app, + gateway_was_running, + Some(format!( + "OpenClaw 已通过 {} 的 {} 全局升级完成({})。", + result.runtime_source, result.package_manager, result.package_spec + )), + ) + .await; + } + Err(fallback_error) => { + emit_install_progress(app, &fallback_error, "error"); + return Ok(ActionResult { + success: false, + message, + }); + } + } + } + + self.finalize_successful_openclaw_update(app, gateway_was_running, None) + .await + } + + async fn finalize_successful_openclaw_update( + &mut self, + app: &AppHandle, + gateway_was_running: bool, + success_message_override: Option, + ) -> Result { + self.refresh_process_state().await?; + let updated_version = self + .read_openclaw_version() + .await + .ok() + .flatten() + .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))); + + if gateway_was_running { + emit_install_progress(app, "升级前 Gateway 处于运行态,开始自动恢复服务。", "info"); + let restart_result = self + .start_gateway(Some(app), Some(self.gateway_port)) + .await?; + if !restart_result.success { + return Ok(restart_result); + } + } + + let message = success_message_override.unwrap_or_else(|| { + updated_version + .as_ref() + .map(|version| format!("OpenClaw 已升级完成,当前版本 {version}。")) + .unwrap_or_else(|| "OpenClaw 已升级完成。".to_string()) + }); + emit_install_progress(app, &message, "info"); + Ok(ActionResult { + success: true, + message, + }) + } +} + +pub(crate) fn parse_openclaw_release_version(value: &str) -> Option { + static VERSION_RE: OnceLock = OnceLock::new(); + VERSION_RE + .get_or_init(|| Regex::new(r"(?i)openclaw\s+([0-9]+(?:\.[0-9]+)+)").expect("valid regex")) + .captures(value) + .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())) +} + +pub(crate) async fn read_openclaw_update_status_payload( + command_spec: &ResolvedOpenClawCommand, +) -> Result { + let mut command = command_spec.build_command_with_args(["update", "status", "--json"]); + let output = command + .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("检查 OpenClaw 更新失败: {e}"))?; + + let stdout_lines = command_output_lines(&output.stdout); + let stderr_lines = command_output_lines(&output.stderr); + let payload = serde_json::from_slice::(&output.stdout).map_err(|error| { + let detail = select_openclaw_update_failure_detail(None, &stderr_lines, &stdout_lines) + .unwrap_or_else(|| format!("解析更新状态失败: {error}")); + format_openclaw_update_failure_message(Some(detail.as_str())) + })?; + + if !output.status.success() { + let detail = + select_openclaw_update_failure_detail(Some(&payload), &stderr_lines, &stdout_lines); + return Err(format_openclaw_update_failure_message(detail.as_deref())); + } + + Ok(payload) +} + +pub(crate) fn extract_openclaw_update_execution_context( + payload: &Value, +) -> OpenClawUpdateExecutionContext { + OpenClawUpdateExecutionContext { + root: payload + .pointer("/update/root") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(PathBuf::from), + install_kind: payload + .pointer("/update/installKind") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + package_manager: payload + .pointer("/update/packageManager") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + } +} + +pub(crate) fn select_openclaw_update_failure_detail( + payload: Option<&Value>, + stderr_lines: &[String], + stdout_lines: &[String], +) -> Option { + if let Some(payload) = payload { + if let Some(reason) = payload.get("reason").and_then(Value::as_str) { + let root_suffix = payload + .get("root") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|root| format!(" ({root})")) + .unwrap_or_default(); + return Some(format!("{reason}{root_suffix}")); + } + + if let Some(message) = payload.get("message").and_then(Value::as_str) { + let trimmed = message.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + if let Some(message) = payload + .pointer("/update/registry/error") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(message.to_string()); + } + } + + stderr_lines + .iter() + .chain(stdout_lines.iter()) + .filter_map(|line| { + let trimmed = line.trim(); + let score = openclaw_update_failure_line_score(trimmed); + (score > 0).then_some((score, trimmed)) + }) + .max_by_key(|(score, _)| *score) + .map(|(_, line)| line.to_string()) +} + +pub(crate) fn openclaw_update_failure_line_score(line: &str) -> u8 { + if line.is_empty() { + return 0; + } + + let normalized = line.to_ascii_lowercase(); + if normalized.starts_with("updating openclaw") + || normalized.starts_with("update result") + || normalized.starts_with("total time") + || normalized.starts_with("root:") + { + return 0; + } + + if normalized.contains("not-openclaw-root") { + return 100; + } + + if normalized.contains("node.js") && normalized.contains("required") { + return 95; + } + + if normalized.contains("fetch failed") || normalized.contains("network") { + return 90; + } + + if normalized.contains("pnpm") && normalized.contains("not found") + || normalized.contains("npm") && normalized.contains("not found") + { + return 85; + } + + if normalized.contains("already up to date") || normalized.contains("not modified") { + return 80; + } + + if normalized.starts_with("reason:") { + return 70; + } + + 20 +} + +pub(crate) fn format_openclaw_update_failure_message(detail: Option<&str>) -> String { + let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else { + return "OpenClaw 升级失败,请查看日志输出。".to_string(); + }; + + let normalized = detail.to_ascii_lowercase(); + if normalized.contains("not-openclaw-root") { + return "OpenClaw 升级失败:未在 OpenClaw 安装根目录执行更新。Lime 会优先切换到安装目录;如仍失败,请重新检测安装状态后重试。" + .to_string(); + } + + if normalized.contains("node.js") && normalized.contains("required") { + return format!( + "OpenClaw 升级失败:当前用于执行 openclaw 的 Node.js 版本过低,需要 {}+。请切换到满足要求的 Node.js 后重试。", + format_semver(NODE_MIN_VERSION) + ); + } + + if normalized.contains("fetch failed") || normalized.contains("network") { + return "OpenClaw 升级失败:当前无法访问更新源,请检查网络或代理设置后重试。".to_string(); + } + + if normalized.contains("not modified") || normalized.contains("already up to date") { + return "OpenClaw 当前已经是最新版本,无需升级。".to_string(); + } + + if normalized.contains("pnpm") && normalized.contains("not found") { + return "OpenClaw 升级失败:当前安装方式依赖 pnpm,但系统未找到 pnpm。请先修复 Node.js / pnpm 环境后重试。" + .to_string(); + } + + if normalized.contains("npm") && normalized.contains("not found") { + return "OpenClaw 升级失败:当前安装方式依赖 npm,但系统未找到 npm。请先修复 Node.js / npm 环境后重试。" + .to_string(); + } + + format!("OpenClaw 升级失败:{detail}") +} + +pub(crate) async fn attempt_direct_openclaw_package_upgrade( + app: &AppHandle, + runtime_bin_dir_hint: Option<&Path>, + install_root_hint: Option<&Path>, + package_manager_hint: Option<&str>, +) -> Result { + let mut runtime_candidates = list_openclaw_runtime_candidates().await?; + runtime_candidates.sort_by(|left, right| { + let left_matches_root = install_root_hint + .map(|root| runtime_candidate_matches_install_root(left, root)) + .unwrap_or(false); + let right_matches_root = install_root_hint + .map(|root| runtime_candidate_matches_install_root(right, root)) + .unwrap_or(false); + let left_matches_bin = runtime_bin_dir_hint + .map(|hint| Path::new(&left.bin_dir) == hint) + .unwrap_or(false); + let right_matches_bin = runtime_bin_dir_hint + .map(|hint| Path::new(&right.bin_dir) == hint) + .unwrap_or(false); + + right_matches_root + .cmp(&left_matches_root) + .then_with(|| right_matches_bin.cmp(&left_matches_bin)) + .then_with(|| compare_openclaw_runtime_candidates(left, right)) + }); + let mut last_error = None; + let mut attempted = 0usize; + + if let Some(install_root_hint) = install_root_hint { + emit_install_progress( + app, + &format!( + "官方 updater 报告的安装根目录为 {},将优先匹配该安装来源执行兜底升级。", + install_root_hint.display() + ), + "info", + ); + } + + for candidate in runtime_candidates { + let Some(plan) = resolve_direct_openclaw_upgrade_plan( + app, + &candidate, + runtime_bin_dir_hint, + install_root_hint, + package_manager_hint, + ) + .await? + else { + continue; + }; + + attempted += 1; + emit_install_progress( + app, + &format!( + "官方自更新未能完成,开始尝试全局包升级兜底:{} · {}。", + plan.runtime_source, plan.package_manager + ), + "warn", + ); + emit_install_progress( + app, + &format!("目标执行环境:{}", plan.runtime_bin_dir.display()), + "info", + ); + emit_install_progress(app, &format!("升级包:{}", plan.package_spec), "info"); + + let result = run_shell_command_with_progress(app, &plan.command_line).await?; + if result.success { + emit_install_progress( + app, + &format!( + "已通过 {} 的 {} 全局安装方式完成兜底升级。", + plan.runtime_source, plan.package_manager + ), + "info", + ); + return Ok(OpenClawDirectUpgradeResult { + runtime_source: plan.runtime_source, + runtime_bin_dir: plan.runtime_bin_dir, + package_manager: plan.package_manager, + package_spec: plan.package_spec, + }); + } + + emit_install_progress( + app, + &format!( + "全局包升级兜底失败:{} · {}。", + plan.runtime_source, result.message + ), + "warn", + ); + last_error = Some(result.message); + } + + if attempted == 0 { + return Err("未检测到可用于全局升级的 OpenClaw 安装来源。".to_string()); + } + + Err(last_error.unwrap_or_else(|| "已自动尝试全局升级兜底,但仍未成功。".to_string())) +} + +pub(crate) async fn resolve_direct_openclaw_upgrade_plan( + app: &AppHandle, + candidate: &OpenClawRuntimeCandidate, + runtime_bin_dir_hint: Option<&Path>, + install_root_hint: Option<&Path>, + package_manager_hint: Option<&str>, +) -> Result, String> { + let runtime_bin_dir = PathBuf::from(&candidate.bin_dir); + let runtime_matches_hint = runtime_bin_dir_hint + .map(|hint| hint == runtime_bin_dir.as_path()) + .unwrap_or(false); + let runtime_matches_install_root = install_root_hint + .map(|root| runtime_candidate_matches_install_root(candidate, root)) + .unwrap_or(false); + + if runtime_bin_dir_hint.is_some() + && !runtime_matches_hint + && !runtime_matches_install_root + && candidate.openclaw_path.is_none() + && candidate.openclaw_package_path.is_none() + { + return Ok(None); + } + + if candidate.openclaw_path.is_none() && candidate.openclaw_package_path.is_none() { + return Ok(None); + } + + let package_spec = resolve_openclaw_upgrade_package_spec(app, candidate).await?; + let registry = package_registry_for_package_spec(&package_spec); + let package_manager_hint = package_manager_hint + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()); + let npm_path = candidate + .npm_path + .as_deref() + .map(PathBuf::from) + .or_else(|| find_command_in_bin_dir("npm", &runtime_bin_dir)); + let pnpm_path = find_command_in_bin_dir("pnpm", &runtime_bin_dir); + let shell_platform = current_shell_platform(); + + let (package_manager, command_line) = match package_manager_hint.as_deref() { + Some("pnpm") => { + if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) { + ( + "pnpm".to_string(), + build_openclaw_pnpm_install_command( + shell_platform, + pnpm_path, + &package_spec, + registry, + ), + ) + } else if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) { + ( + "npm".to_string(), + build_openclaw_install_command( + shell_platform, + npm_path, + candidate.npm_global_prefix.as_deref(), + &package_spec, + registry, + ), + ) + } else { + return Ok(None); + } + } + _ => { + if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) { + ( + "npm".to_string(), + build_openclaw_install_command( + shell_platform, + npm_path, + candidate.npm_global_prefix.as_deref(), + &package_spec, + registry, + ), + ) + } else if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) { + ( + "pnpm".to_string(), + build_openclaw_pnpm_install_command( + shell_platform, + pnpm_path, + &package_spec, + registry, + ), + ) + } else { + return Ok(None); + } + } + }; + + Ok(Some(OpenClawDirectUpgradePlan { + runtime_source: candidate.source.clone(), + runtime_bin_dir, + package_manager, + package_spec, + command_line, + })) +} + +pub(crate) async fn resolve_openclaw_upgrade_package_spec( + app: &AppHandle, + candidate: &OpenClawRuntimeCandidate, +) -> Result { + if let Some(prefix) = candidate.npm_global_prefix.as_deref() { + if let Some(package) = find_installed_openclaw_package_details(prefix) { + return Ok(format!("{}@latest", package.name)); + } + } + + if let Some(package_path) = candidate.openclaw_package_path.as_deref() { + if let Some(package_name) = infer_openclaw_package_name_from_path(Path::new(package_path)) { + return Ok(format!("{package_name}@latest")); + } + } + + Ok(if should_use_china_package(app).await { + OPENCLAW_CN_PACKAGE.to_string() + } else { + OPENCLAW_DEFAULT_PACKAGE.to_string() + }) +} + +pub(crate) fn infer_openclaw_package_name_from_path(path: &Path) -> Option<&'static str> { + let normalized = path + .display() + .to_string() + .replace('\\', "/") + .to_ascii_lowercase(); + if normalized.contains("@qingchencloud/openclaw-zh") { + return Some("@qingchencloud/openclaw-zh"); + } + if normalized.contains("/openclaw/package.json") { + return Some("openclaw"); + } + None +} + +pub(crate) fn package_registry_for_package_spec(package_spec: &str) -> Option<&'static str> { + package_spec + .starts_with("@qingchencloud/openclaw-zh@") + .then_some(NPM_MIRROR_CN) +} + +pub(crate) fn runtime_candidate_matches_install_root( + candidate: &OpenClawRuntimeCandidate, + install_root_hint: &Path, +) -> bool { + [ + Some(candidate.bin_dir.as_str()), + Some(candidate.node_path.as_str()), + candidate.npm_path.as_deref(), + candidate.npm_global_prefix.as_deref(), + candidate.openclaw_path.as_deref(), + candidate.openclaw_package_path.as_deref(), + ] + .into_iter() + .flatten() + .map(PathBuf::from) + .any(|path| path.starts_with(install_root_hint)) +} diff --git a/src-tauri/src/services/runtime_agents_template_service.rs b/src-tauri/src/services/runtime_agents_template_service.rs index 81d6869a8..baaddae95 100644 --- a/src-tauri/src/services/runtime_agents_template_service.rs +++ b/src-tauri/src/services/runtime_agents_template_service.rs @@ -61,7 +61,10 @@ 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") + ensure_gitignore_entry( + &gitignore_path, + app_paths::WORKSPACE_LOCAL_RUNTIME_AGENTS_GITIGNORE_ENTRY, + ) } fn resolve_runtime_agents_template_path( @@ -73,12 +76,16 @@ fn resolve_runtime_agents_template_path( RuntimeAgentsTemplateTarget::Workspace => { let working_dir = working_dir.ok_or_else(|| "生成 Workspace 模板时缺少 working_dir".to_string())?; - Ok(working_dir.join(".lime").join("AGENTS.md")) + Ok(app_paths::resolve_workspace_runtime_agents_path( + working_dir, + )) } RuntimeAgentsTemplateTarget::WorkspaceLocal => { let working_dir = working_dir .ok_or_else(|| "生成 Workspace 本机模板时缺少 working_dir".to_string())?; - Ok(working_dir.join(".lime").join("AGENTS.local.md")) + Ok(app_paths::resolve_workspace_local_runtime_agents_path( + working_dir, + )) } } } @@ -252,7 +259,7 @@ mod tests { #[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 path = app_paths::resolve_workspace_runtime_agents_path(tmp.path()); let result = scaffold_runtime_agents_template_at_path( RuntimeAgentsTemplateTarget::Workspace, @@ -270,7 +277,7 @@ mod tests { #[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"); + let path = app_paths::resolve_workspace_runtime_agents_path(tmp.path()); fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); fs::write(&path, "custom content").expect("write custom"); @@ -289,7 +296,7 @@ mod tests { #[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"); + let path = app_paths::resolve_workspace_local_runtime_agents_path(tmp.path()); fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); fs::write(&path, "custom content").expect("write custom"); @@ -317,7 +324,13 @@ mod tests { 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"); + assert_eq!( + content, + format!( + "{}\n", + app_paths::WORKSPACE_LOCAL_RUNTIME_AGENTS_GITIGNORE_ENTRY + ) + ); } #[test] @@ -334,6 +347,11 @@ mod tests { 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); + assert_eq!( + content + .matches(app_paths::WORKSPACE_LOCAL_RUNTIME_AGENTS_GITIGNORE_ENTRY) + .count(), + 1 + ); } } diff --git a/src-tauri/src/services/thread_reliability_projection_service.rs b/src-tauri/src/services/thread_reliability_projection_service.rs new file mode 100644 index 000000000..f99db6b6f --- /dev/null +++ b/src-tauri/src/services/thread_reliability_projection_service.rs @@ -0,0 +1,277 @@ +use crate::commands::aster_agent_cmd::{ + build_incidents, build_last_outcome, build_pending_requests, AgentRuntimeIncidentView, + AgentRuntimeOutcomeView, AgentRuntimeRequestView, +}; +use lime_agent::SessionDetail; +use lime_core::database::dao::agent_thread_incident::{ + AgentThreadIncidentDao, AgentThreadIncidentRecord, +}; +use lime_core::database::dao::agent_turn_outcome::{AgentTurnOutcomeDao, AgentTurnOutcomeRecord}; +use lime_core::database::{lock_db, DbConnection}; +use rusqlite::Connection; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +pub struct ThreadReliabilityProjection { + pub pending_requests: Vec, + pub last_outcome: Option, + pub incidents: Vec, +} + +pub fn sync_thread_reliability_projection( + db: &DbConnection, + detail: &SessionDetail, +) -> Result { + let conn = lock_db(db)?; + sync_thread_reliability_projection_with_conn(&conn, detail) +} + +fn sync_thread_reliability_projection_with_conn( + conn: &Connection, + detail: &SessionDetail, +) -> Result { + let pending_requests = build_pending_requests(detail); + let derived_outcome = build_last_outcome(detail); + let derived_incidents = build_incidents(detail, &pending_requests); + + if let Some(outcome) = derived_outcome.as_ref() { + let record = outcome_record_from_view(outcome); + AgentTurnOutcomeDao::upsert(conn, &record) + .map_err(|error| format!("写入 turn outcome 失败: {error}"))?; + } + + let persisted_outcome = match (detail.turns.last(), derived_outcome.as_ref()) { + (Some(turn), Some(_)) => AgentTurnOutcomeDao::get_by_turn(conn, &turn.id) + .map_err(|error| format!("读取 turn outcome 失败: {error}"))? + .map(outcome_view_from_record), + _ => None, + }; + + let now = chrono::Utc::now().to_rfc3339(); + let active_incident_ids = derived_incidents + .iter() + .map(|incident| incident.id.clone()) + .collect::>(); + + for incident in &derived_incidents { + let record = incident_record_from_view(incident); + AgentThreadIncidentDao::upsert_active(conn, &record) + .map_err(|error| format!("写入 thread incident 失败: {error}"))?; + } + + for existing in AgentThreadIncidentDao::list_active_by_thread(conn, &detail.thread_id) + .map_err(|error| format!("读取 active incidents 失败: {error}"))? + { + if active_incident_ids.contains(&existing.id) { + continue; + } + AgentThreadIncidentDao::clear(conn, &existing.id, &now, &now) + .map_err(|error| format!("清理过期 incident 失败: {error}"))?; + } + + let persisted_incidents = + AgentThreadIncidentDao::list_active_by_thread(conn, &detail.thread_id) + .map_err(|error| format!("读取同步后的 active incidents 失败: {error}"))? + .into_iter() + .map(incident_view_from_record) + .collect(); + + Ok(ThreadReliabilityProjection { + pending_requests, + last_outcome: persisted_outcome, + incidents: persisted_incidents, + }) +} + +fn outcome_record_from_view(view: &AgentRuntimeOutcomeView) -> AgentTurnOutcomeRecord { + let now = chrono::Utc::now().to_rfc3339(); + AgentTurnOutcomeRecord { + turn_id: view.turn_id.clone().unwrap_or_default(), + thread_id: view.thread_id.clone(), + outcome_type: view.outcome_type.clone(), + summary: view.summary.clone().unwrap_or_default(), + primary_cause: view.primary_cause.clone(), + retryable: view.retryable, + details_json: None, + ended_at: view.ended_at.clone().unwrap_or_else(|| now.clone()), + created_at: now.clone(), + updated_at: now, + } +} + +fn outcome_view_from_record(record: AgentTurnOutcomeRecord) -> AgentRuntimeOutcomeView { + AgentRuntimeOutcomeView { + thread_id: record.thread_id, + turn_id: Some(record.turn_id), + outcome_type: record.outcome_type, + summary: Some(record.summary), + primary_cause: record.primary_cause, + retryable: record.retryable, + ended_at: Some(record.ended_at), + } +} + +fn incident_record_from_view(view: &AgentRuntimeIncidentView) -> AgentThreadIncidentRecord { + let now = chrono::Utc::now().to_rfc3339(); + AgentThreadIncidentRecord { + id: view.id.clone(), + thread_id: view.thread_id.clone(), + turn_id: view.turn_id.clone(), + item_id: view.item_id.clone(), + incident_type: view.incident_type.clone(), + severity: view.severity.clone(), + status: view.status.clone(), + title: view.title.clone(), + details_json: view + .details + .as_ref() + .map(|value: &serde_json::Value| value.to_string()), + detected_at: view.detected_at.clone().unwrap_or_else(|| now.clone()), + cleared_at: view.cleared_at.clone(), + created_at: now.clone(), + updated_at: now, + } +} + +fn incident_view_from_record(record: AgentThreadIncidentRecord) -> AgentRuntimeIncidentView { + AgentRuntimeIncidentView { + id: record.id, + thread_id: record.thread_id, + turn_id: record.turn_id, + item_id: record.item_id, + incident_type: record.incident_type, + severity: record.severity, + status: record.status, + title: record.title, + details: record + .details_json + .as_deref() + .and_then(|value| serde_json::from_str(value).ok()), + detected_at: Some(record.detected_at), + cleared_at: record.cleared_at, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lime_core::database::dao::agent_thread_incident::AgentThreadIncidentDao; + use lime_core::database::dao::agent_timeline::{ + AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn, + AgentThreadTurnStatus, + }; + use lime_core::database::dao::agent_turn_outcome::AgentTurnOutcomeDao; + use lime_core::database::schema::create_tables; + 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)) + } + + fn base_detail() -> SessionDetail { + SessionDetail { + id: "session-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::new(), + execution_strategy: None, + turns: Vec::new(), + items: Vec::new(), + todo_items: Vec::new(), + child_subagent_sessions: Vec::new(), + subagent_parent_context: None, + } + } + + #[test] + fn should_persist_failed_outcome_and_clear_stale_incident() { + let db = setup_db(); + + let mut failed_detail = base_detail(); + failed_detail.turns.push(AgentThreadTurn { + id: "turn-failed".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续生成周报".to_string(), + status: AgentThreadTurnStatus::Failed, + started_at: "2026-03-23T09:55:00Z".to_string(), + completed_at: Some("2026-03-23T09:56:00Z".to_string()), + error_message: Some("provider rate limit".to_string()), + created_at: "2026-03-23T09:55:00Z".to_string(), + updated_at: "2026-03-23T09:56:00Z".to_string(), + }); + failed_detail.items.push(AgentThreadItem { + id: "item-tool-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-failed".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Failed, + started_at: "2026-03-23T09:55:10Z".to_string(), + completed_at: Some("2026-03-23T09:55:20Z".to_string()), + updated_at: "2026-03-23T09:55:20Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "web_search".to_string(), + arguments: None, + output: None, + success: Some(false), + error: Some("rate limit".to_string()), + metadata: None, + }, + }); + + let projection = sync_thread_reliability_projection(&db, &failed_detail) + .expect("failed projection 应成功"); + assert_eq!( + projection + .last_outcome + .as_ref() + .map(|value| value.outcome_type.as_str()), + Some("failed_tool") + ); + assert_eq!(projection.incidents.len(), 1); + + let conn = lock_db(&db).expect("获取数据库连接失败"); + assert!(AgentTurnOutcomeDao::get_by_turn(&conn, "turn-failed") + .expect("读取 outcome 应成功") + .is_some()); + assert_eq!( + AgentThreadIncidentDao::list_active_by_thread(&conn, "thread-1") + .expect("读取 active incident 应成功") + .len(), + 1 + ); + drop(conn); + + let mut recovered_detail = base_detail(); + recovered_detail.turns.push(AgentThreadTurn { + id: "turn-running".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "重新执行".to_string(), + status: AgentThreadTurnStatus::Running, + started_at: chrono::Utc::now().to_rfc3339(), + completed_at: None, + error_message: None, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }); + + let recovered_projection = sync_thread_reliability_projection(&db, &recovered_detail) + .expect("recovered projection 应成功"); + assert!(recovered_projection.last_outcome.is_none()); + assert!(recovered_projection.incidents.is_empty()); + + let conn = lock_db(&db).expect("获取数据库连接失败"); + assert!( + AgentThreadIncidentDao::list_active_by_thread(&conn, "thread-1") + .expect("读取 active incident 应成功") + .is_empty() + ); + } +} diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 00261c962..b9c20e409 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.94.0", + "version": "0.95.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8fe6deec1..64e6cecf8 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.94.0", + "version": "0.95.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/agent/chat/AgentChatHomeShell.tsx b/src/components/agent/chat/AgentChatHomeShell.tsx index 555273b1c..c5e93abf3 100644 --- a/src/components/agent/chat/AgentChatHomeShell.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.tsx @@ -4,7 +4,6 @@ import { toast } from "sonner"; 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 { @@ -30,24 +29,13 @@ import { import { useClawSolutions } from "./claw-solutions/useClawSolutions"; import { ClawHomeSolutionsPanel } from "./claw-solutions/ClawHomeSolutionsPanel"; import type { ClawSolutionHomeItem } from "./claw-solutions/types"; +import { normalizeInitialTheme } from "./agentChatWorkspaceShared"; import { type AgentChatWorkspaceBootstrap, resolveHomeShellWorkspaceEntry, type HomeShellEnterWorkspacePayload, } from "./homeShellEntry"; -const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ - "general", - "social-media", - "poster", - "music", - "knowledge", - "planning", - "document", - "video", - "novel", -]; - const PageContainer = styled.div<{ $compact?: boolean }>` display: flex; height: 100%; @@ -144,14 +132,6 @@ const ThemeWorkbenchLayoutShell = styled.div<{ $bottomInset: string }>` transition: padding-bottom 0.2s ease; `; -function normalizeInitialTheme(value?: string): ThemeType { - if (!value) return "general"; - if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) { - return value as ThemeType; - } - return "general"; -} - function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 1392a3147..584f1ad58 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -13,129 +13,33 @@ import { useMemo, useEffect, useRef, - memo, - type ReactNode, } from "react"; import { toast } from "sonner"; -import styled from "styled-components"; -import { - AlertTriangle, - CheckCircle2, - Info, - Loader2, - type LucideIcon, - PanelLeftOpen, -} from "lucide-react"; -import { open as openDialog } from "@tauri-apps/plugin-dialog"; -import { safeListen } from "@/lib/dev-bridge"; -import { readFilePreview } from "@/lib/api/fileBrowser"; -import { - openPathWithDefaultApp, - revealPathInFinder, -} from "@/lib/api/fileSystem"; -import { - importDocument, - resolveFilePath as resolveSessionFilePath, -} from "@/lib/api/session-files"; import { useAgentChatUnified, - useArtifactAutoPreviewSync, useCompatSubagentRuntime, - useTeamWorkspaceRuntime, - useThemeContextWorkspace, - useTopicBranchBoard, } from "./hooks"; import { - buildLiveTaskSnapshot, - type AssistantDraftState, type TaskStatusReason, } from "./hooks/agentChatShared"; import { settleLiveArtifactAfterStreamStops, useArtifactDisplayState, } from "./hooks/useArtifactDisplayState"; -import type { SidebarActivityLog } from "./hooks/useThemeContextWorkspace"; import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard"; import { useSessionFiles } from "./hooks/useSessionFiles"; -import { useContentSync, type SyncStatus } from "./hooks/useContentSync"; +import { useContentSync } from "./hooks/useContentSync"; import { useGlobalMediaGenerationDefaults } from "@/hooks/useGlobalMediaGenerationDefaults"; -import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt"; import { useTrayModelShortcuts } from "./hooks/useTrayModelShortcuts"; -import { - isTeamWorkspaceTerminalStatus, - resolveTeamWorkspaceRuntimeStatusLabel, - summarizeTeamWorkspaceExecution, - type TeamWorkspaceControlSummary, - type TeamWorkspaceRuntimeFormationState, - 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"; -import type { ThemeWorkbenchCreationTaskEvent } from "./components/themeWorkbenchWorkflowData"; -import { AgentRuntimeStrip } from "./components/AgentRuntimeStrip"; -import { HarnessStatusPanel } from "./components/HarnessStatusPanel"; -import { SocialMediaHarnessCard } from "./components/SocialMediaHarnessCard"; -import { TeamWorkspaceDock } from "./components/TeamWorkspaceDock"; -import { TeamWorkspaceBoard } from "./components/TeamWorkspaceBoard"; -import { TeamWorkbenchSummaryPanel } from "./components/TeamWorkbenchSummaryPanel"; -import { ThemeWorkbenchEntryPromptAccessory } from "./components/ThemeWorkbenchEntryPromptAccessory"; -import { - ImageWorkbenchCanvas, - type ImageWorkbenchOutputView, - type ImageWorkbenchTaskMode, - type ImageWorkbenchTaskStatus, - type ImageWorkbenchTaskView, - type ImageWorkbenchViewport, -} from "./components/ImageWorkbenchCanvas"; -import { MessageList } from "./components/MessageList"; -import { Inputbar } from "./components/Inputbar"; -import { RuntimeStyleControlBar } from "./components/RuntimeStyleControlBar"; -import { EmptyState } from "./components/EmptyState"; -import { - CanvasWorkbenchLayout, - type CanvasWorkbenchDefaultPreview, - type CanvasWorkbenchLayoutMode, - type CanvasWorkbenchPreviewTarget, -} from "./components/CanvasWorkbenchLayout"; -import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { type CanvasWorkbenchLayoutMode } from "./components/CanvasWorkbenchLayout"; import type { CreationMode } from "./components/types"; import { type TaskFile } from "./components/TaskFiles"; -import { LayoutTransition } from "@/components/content-creator/core/LayoutTransition/LayoutTransition"; -import { StepProgress } from "@/components/content-creator/core/StepGuide/StepProgress"; import { useWorkflow } from "@/components/content-creator/hooks/useWorkflow"; -import { CanvasFactory } from "@/components/content-creator/canvas/CanvasFactory"; import { createInitialCanvasState, type CanvasStateUnion, } from "@/components/content-creator/canvas/canvasUtils"; import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; -import { - COVER_IMAGE_WORKBENCH_REQUEST_EVENT, - COVER_IMAGE_REPLACED_EVENT, - type CoverImageWorkbenchRequestDetail, - type CoverImageReplacedDetail, -} from "@/components/content-creator/canvas/document/platforms/CoverImagePlaceholder"; -import type { - AutoContinueRunPayload, - ContentReviewRunPayload, - DocumentVersion, - PlatformType, - TextStylizeRunPayload, -} from "@/components/content-creator/canvas/document/types"; -import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; -import { - buildActionRequestA2UI, - buildActionRequestSubmissionPayload, - isActionRequestA2UICompatible, - summarizeActionRequestSubmission, -} from "./utils/actionRequestA2UI"; -import { - buildLegacyQuestionnaireSubmissionPayload, - buildLegacyQuestionnaireA2UI, -} from "./utils/legacyQuestionnaireA2UI"; -import { CanvasPanel as GeneralCanvasPanel } from "@/components/general-chat/bridge"; import { type CanvasState as GeneralCanvasState, DEFAULT_CANVAS_STATE, @@ -145,38 +49,19 @@ import { selectedArtifactAtom, selectedArtifactIdAtom, } from "@/lib/artifact/store"; -import { - ArtifactCanvasOverlay, - ArtifactRenderer, - ArtifactToolbar, -} from "@/components/artifact"; import type { Artifact } from "@/lib/artifact/types"; import { useAtomValue, useSetAtom } from "jotai"; -import { createInitialMusicState } from "@/components/content-creator/canvas/music/types"; -import { - createInitialNovelState, - countWords as countNovelWords, -} from "@/components/content-creator/canvas/novel/types"; -import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser"; import { generateContentCreationPrompt, isContentCreationTheme, } from "@/components/content-creator/utils/systemPrompt"; -import { activityLogger } from "@/components/content-creator/utils/activityLogger"; import { generateProjectMemoryPrompt } from "@/components/content-creator/utils/projectPrompt"; -import { resolveSocialMediaArtifactDescriptor } from "@/components/content-creator/utils/socialMediaHarness"; import { getProject, - getDefaultProject, - getOrCreateDefaultProject, getContent, getThemeWorkbenchDocumentState, ensureWorkspaceReady, - updateProject as updateProjectById, - updateContent, type Project, - type ProjectType, - type ThemeWorkbenchDocumentState, } from "@/lib/api/project"; import { getProjectMemory, @@ -184,101 +69,28 @@ import { type Character, } from "@/lib/api/memory"; import { logAgentDebug } from "@/lib/agentDebug"; -import { browserExecuteAction, launchBrowserSession } from "@/lib/webview-api"; -import type { Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; -import { buildHomeAgentParams } from "@/lib/workspace/navigation"; -import { loadConfiguredProviders } from "@/hooks/useConfiguredProviders"; -import { - executionRunGet, - executionRunGetThemeWorkbenchState, - executionRunListThemeWorkbenchHistory, - type AgentRun, - type ThemeWorkbenchRunTodoItem, - type ThemeWorkbenchRunTerminalItem, - type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState, -} from "@/lib/api/executionRun"; -import { - contentWorkflowApi, -} from "@/lib/api/content-workflow"; import { setActiveContentTarget } from "@/lib/activeContentTarget"; import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; -import { listMaterials, uploadMaterial } from "@/lib/api/materials"; -import { setStoredResourceProjectId } from "@/lib/resourceProjectSelection"; import { - IMAGE_GENERATION_CANCELED_MESSAGE, useImageGen, } from "@/components/image-gen/useImageGen"; import { resolveMediaGenerationPreference } from "@/lib/mediaGeneration"; -import { resolveProviderModelCompatibility } from "./utils/providerModelCompatibility"; -import { loadProviderModels } from "@/hooks/useProviderModels"; -import { - isReasoningModel, - resolveBaseModelOnThinkingOff, - resolveThinkingModel, -} from "@/lib/model/thinkingModelResolver"; -import { resolveVisionModel } from "@/lib/model/visionModelResolver"; -import { - loadRememberedBaseModel, - saveRememberedBaseModel, -} from "@/lib/model/thinkingBaseModelMemory"; -import type { - AgentRuntimeToolInventory, - AsterSubagentSessionInfo, - AutoContinueRequestPayload, -} from "@/lib/api/agentRuntime"; -import { - closeAgentRuntimeSubagent, - getAgentRuntimeToolInventory, - resumeAgentRuntimeSubagent, - sendAgentRuntimeSubagentInput, - waitAgentRuntimeSubagents, -} from "@/lib/api/agentRuntime"; -import type { ToolCallState } from "@/lib/api/agentStream"; -import { - skillExecutionApi, - type SkillDetailInfo, -} from "@/lib/api/skill-execution"; import type { - AgentRuntimeStatus, - BrowserAssistSessionState, Message, - MessageImage, WriteArtifactContext, } from "./types"; import type { ThemeType, LayoutMode, - StepStatus, } from "@/components/content-creator/types"; -import type { A2UIFormData } from "@/components/content-creator/a2ui/types"; -import { getFileToStepMap } from "./utils/workflowMapping"; import { normalizeProjectId } from "./utils/topicProjectResolution"; -import { - buildGeneralChatResourceDescription, - buildGeneralChatResourceHash, - buildGeneralChatResourceTags, - extractGeneralChatResourceHash, - inferGeneralChatResourceMaterialType, -} from "./utils/generalResourceSync"; -import { - extractStyleActionContent, - resolveStyleActionFileName, -} from "./utils/styleRuntime"; -import { resolveTopicSwitchProject } from "./utils/topicProjectSwitch"; -import { - saveChatToolPreferences, -} from "./utils/chatToolPreferences"; -import { parseImageWorkbenchCommand } from "./utils/imageWorkbenchCommand"; import { buildHarnessRequestMetadata, - extractExistingHarnessMetadata, } from "./utils/harnessRequestMetadata"; -import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations"; import { deriveHarnessSessionState } from "./utils/harnessState"; import { - buildArtifactFromWrite, mergeArtifacts, resolveDefaultArtifactViewMode, } from "./utils/messageArtifacts"; @@ -286,12 +98,6 @@ import { buildRealSubagentTimelineItems, buildSyntheticSubagentTimelineItems, } from "./utils/subagentTimeline"; -import { resolveThemeWorkbenchLayoutBottomSpacing } from "./utils/themeWorkbenchLayout"; -import { - resolveCanvasTaskFileTarget, - shouldDeferCanvasSyncWhileEditing, -} from "./utils/taskFileCanvasSync"; -import { parseSkillSlashCommand } from "./hooks/skillCommand"; import { buildGeneralAgentSystemPrompt, resolveAgentChatMode, @@ -301,2611 +107,94 @@ 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 { - useBootstrapDispatchPreview, -} from "./hooks/useBootstrapDispatchPreview"; -import { - shouldPrepareRuntimeTeamBeforeSend, useRuntimeTeamFormation, } from "./hooks/useRuntimeTeamFormation"; import { useThemeWorkbenchEntryPrompt } from "./hooks/useThemeWorkbenchEntryPrompt"; import { useThemeWorkbenchEntryPromptActions } from "./hooks/useThemeWorkbenchEntryPromptActions"; -import { - useThemeWorkbenchSendBoundary, -} from "./hooks/useThemeWorkbenchSendBoundary"; +import { useThemeWorkbenchSendBoundary } from "./hooks/useThemeWorkbenchSendBoundary"; import type { BrowserTaskPreflight, - HandleSendOptions, } from "./hooks/handleSendTypes"; -import { - areBrowserAssistSessionStatesEqual, - clearBrowserAssistSessionState, - createBrowserAssistSessionState, - extractBrowserAssistSessionFromArtifact, - findLatestBrowserAssistSessionInMessages, - loadBrowserAssistSessionState, - mergeBrowserAssistSessionStates, - resolveBrowserAssistSessionScopeKey, - saveBrowserAssistSessionState, -} from "./utils/browserAssistSession"; -import { - extractExplicitUrlFromText, - resolveBrowserAssistLaunchUrl, -} from "./utils/browserAssistIntent"; -import { preheatBrowserAssistInBackground } from "./utils/browserAssistPreheat"; import { mergeThreadItems } from "./utils/threadTimelineView"; -import { subscribeDocumentEditorFocus } from "@/lib/documentEditorFocusEvents"; -import { - emitCanvasImageInsertRequest, - type CanvasImageInsertAnchorHint, - type CanvasImageTargetType, -} from "@/lib/canvasImageInsertBus"; -import { - getImageModelIdsForProvider, - pickImageModelBySelection, - findImageProviderForSelection, - type ImageModelPreset, -} from "@/lib/imageGeneration"; -import { - onImageWorkbenchFocus, - onImageWorkbenchRequest, - type ImageWorkbenchFocusDetail, - type ImageWorkbenchExternalRequestDetail, -} from "@/lib/imageWorkbenchEvents"; import { DEFAULT_STYLE_PROFILE, buildRuntimeStyleOverridePrompt, - buildStyleAuditPrompt, - buildStyleRewritePrompt, getStyleProfileFromGuide, type RuntimeStyleSelection, } from "@/lib/style-guide"; import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; -import { collectConversationSkillNames } from "./utils/harnessSkills"; - -const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ - "general", - "social-media", - "poster", - "music", - "knowledge", - "planning", - "document", - "video", - "novel", -]; +import { + isResumableBrowserTaskReason, + mergeMessageArtifactsIntoStore, +} from "./workspace/browserAssistArtifact"; +import { useWorkspaceBrowserAssistRuntime } from "./workspace/useWorkspaceBrowserAssistRuntime"; +import { useWorkspaceBrowserPreflightRuntime } from "./workspace/useWorkspaceBrowserPreflightRuntime"; +import { useWorkspaceA2UISubmitActions } from "./workspace/useWorkspaceA2UISubmitActions"; +import { useWorkspaceContextHarnessRuntime } from "./workspace/useWorkspaceContextHarnessRuntime"; +import { useWorkspaceHarnessInventoryRuntime } from "./workspace/useWorkspaceHarnessInventoryRuntime"; +import { useWorkspaceCanvasWorkflowActions } from "./workspace/useWorkspaceCanvasWorkflowActions"; +import { useWorkspaceCanvasSceneRuntime } from "./workspace/useWorkspaceCanvasSceneRuntime"; +import { useWorkspaceCanvasMessageSyncRuntime } from "./workspace/useWorkspaceCanvasMessageSyncRuntime"; +import { useWorkspaceConversationShellSceneRuntime } from "./workspace/useWorkspaceConversationShellSceneRuntime"; +import { useWorkspaceDisplayMessagesRuntime } from "./workspace/useWorkspaceDisplayMessagesRuntime"; +import { useWorkspaceInputbarSceneRuntime } from "./workspace/useWorkspaceInputbarSceneRuntime"; +import { useWorkspaceNavigationActions } from "./workspace/useWorkspaceNavigationActions"; +import { useWorkspaceShellChromeRuntime } from "./workspace/useWorkspaceShellChromeRuntime"; +import { useWorkspaceWriteFileAction } from "./workspace/useWorkspaceWriteFileAction"; +import { useWorkspaceArtifactPreviewActions } from "./workspace/useWorkspaceArtifactPreviewActions"; +import { useWorkspaceWorkflowProgressSync } from "./workspace/useWorkspaceWorkflowProgressSync"; +import { useWorkspaceCanvasLayoutRuntime } from "./workspace/useWorkspaceCanvasLayoutRuntime"; +import { useWorkspaceCanvasTaskFileSync } from "./workspace/useWorkspaceCanvasTaskFileSync"; +import { useWorkspaceGeneralResourceSync } from "./workspace/useWorkspaceGeneralResourceSync"; +import { useWorkspaceImageWorkbenchActionRuntime } from "./workspace/useWorkspaceImageWorkbenchActionRuntime"; +import { useWorkspaceImageWorkbenchEventRuntime } from "./workspace/useWorkspaceImageWorkbenchEventRuntime"; +import { useWorkspaceRuntimeTeamDispatchPreviewRuntime } from "./workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime"; +import { useWorkspaceSessionRestore } from "./workspace/useWorkspaceSessionRestore"; +import { useWorkspaceResetRuntime } from "./workspace/useWorkspaceResetRuntime"; +import { useWorkspaceSendActions } from "./workspace/useWorkspaceSendActions"; +import { useWorkspaceTeamSessionControlRuntime } from "./workspace/useWorkspaceTeamSessionControlRuntime"; +import { useWorkspaceTeamWorkbenchAutoOpenRuntime } from "./workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime"; +import { useWorkspaceThemeWorkbenchScaffoldRuntime } from "./workspace/useWorkspaceThemeWorkbenchScaffoldRuntime"; +import { useWorkspaceThemeWorkbenchVersionStatusRuntime } from "./workspace/useWorkspaceThemeWorkbenchVersionStatusRuntime"; +import { useWorkspaceTopicSwitch } from "./workspace/useWorkspaceTopicSwitch"; +import { useWorkspaceA2UIRuntime } from "./workspace/useWorkspaceA2UIRuntime"; +import { useWorkspaceAutoGuideRuntime } from "./workspace/useWorkspaceAutoGuideRuntime"; +import { useWorkspaceThemeWorkbenchSidebarRuntime } from "./workspace/useWorkspaceThemeWorkbenchSidebarRuntime"; +import { useWorkspaceThemeWorkbenchRuntime } from "./workspace/useWorkspaceThemeWorkbenchRuntime"; +import { useWorkspaceThemeWorkbenchShellRuntime } from "./workspace/useWorkspaceThemeWorkbenchShellRuntime"; +import { useWorkspaceContextDetailActions } from "./workspace/useWorkspaceContextDetailActions"; +import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime"; +import { useWorkspaceThemeWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime"; +import { + createInitialSessionImageWorkbenchState, + type SessionImageWorkbenchState, +} from "./workspace/imageWorkbenchHelpers"; +import { + SOCIAL_ARTICLE_SKILL_KEY, + THEME_WORKBENCH_HISTORY_PAGE_SIZE, + applyBackendThemeWorkbenchDocumentState, + isCorruptedThemeWorkbenchDocumentContent, + isSyncContentEmpty, + readPersistedThemeWorkbenchDocument, + serializeCanvasStateForSync, +} from "./workspace/themeWorkbenchHelpers"; +import { + normalizeInitialTheme, + projectTypeToTheme, +} from "./agentChatWorkspaceShared"; +import type { + AgentChatWorkspaceProps, +} from "./agentChatWorkspaceContract"; const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; -const GENERAL_BROWSER_ASSIST_ARTIFACT_ID = "browser-assist:general"; - -function isResumableBrowserTaskReason( - statusReason?: TaskStatusReason, -): boolean { - return ( - statusReason === "browser_launching" || - statusReason === "browser_awaiting_user" || - statusReason === "browser_failed" - ); -} - -interface HarnessFilePreviewResult { - path: string; - content: string | null; - isBinary: boolean; - size: number; - error: string | null; -} - -function extractFileNameFromPath(path: string): string { - const normalized = path.replace(/\\/g, "/"); - const segments = normalized.split("/"); - return segments[segments.length - 1] || path; -} - -function normalizeInitialTheme(value?: string): ThemeType { - if (!value) return "general"; - if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) { - return value as ThemeType; - } - return "general"; -} - -function shouldPreserveGeneralArtifact(artifact: Artifact): boolean { - return artifact.meta.persistOutsideMessages === true; -} - -function deriveCurrentSessionRuntimeStatus(params: { - isSending: boolean; - queuedTurnCount: number; - turns: Array<{ status: string }>; -}): AsterSubagentSessionInfo["runtime_status"] | undefined { - if ( - params.isSending || - params.turns.some((turn) => turn.status === "running") - ) { - return "running"; - } - if (params.queuedTurnCount > 0) { - return "queued"; - } - - const latestStatus = params.turns[params.turns.length - 1]?.status; - switch (latestStatus) { - case "completed": - return "completed"; - case "failed": - return "failed"; - case "aborted": - return "aborted"; - default: - return undefined; - } -} - -function deriveLatestTurnRuntimeStatus( - turns: Array<{ status: string }>, -): AsterSubagentSessionInfo["runtime_status"] | undefined { - switch (turns[turns.length - 1]?.status) { - case "queued": - return "queued"; - case "running": - return "running"; - case "completed": - return "completed"; - case "failed": - return "failed"; - case "aborted": - return "aborted"; - default: - return undefined; - } -} - -function normalizeUniqueSessionIds(ids: string[]): string[] { - return Array.from( - new Set(ids.map((sessionId) => sessionId.trim()).filter(Boolean)), - ); -} - -function buildTeamControlSummary(params: { - action: TeamWorkspaceControlSummary["action"]; - requestedSessionIds: string[]; - cascadeSessionIds?: string[]; - affectedSessionIds?: string[]; -}): TeamWorkspaceControlSummary { - return { - action: params.action, - requestedSessionIds: normalizeUniqueSessionIds(params.requestedSessionIds), - cascadeSessionIds: normalizeUniqueSessionIds( - params.cascadeSessionIds ?? [], - ), - affectedSessionIds: normalizeUniqueSessionIds( - params.affectedSessionIds ?? [], - ), - updatedAt: Date.now(), - }; -} - -function buildBrowserAssistArtifact(params: { - scopeKey: string; - profileKey: string; - browserSessionId: string; - url: string; - title?: string; - targetId?: string; - transportKind?: string; - lifecycleState?: string; - controlMode?: string; -}): Artifact { - const now = Date.now(); - - return { - id: GENERAL_BROWSER_ASSIST_ARTIFACT_ID, - type: "browser_assist", - title: params.title?.trim() || "浏览器协助", - content: "", - status: "complete", - error: undefined, - meta: { - persistOutsideMessages: true, - browserAssistScopeKey: params.scopeKey, - profileKey: params.profileKey, - sessionId: params.browserSessionId, - url: params.url, - launchState: "ready", - launchHint: undefined, - launchError: undefined, - ...(params.targetId ? { targetId: params.targetId } : {}), - ...(params.transportKind ? { transportKind: params.transportKind } : {}), - ...(params.lifecycleState - ? { lifecycleState: params.lifecycleState } - : {}), - ...(params.controlMode ? { controlMode: params.controlMode } : {}), - }, - position: { start: 0, end: 0 }, - createdAt: now, - updatedAt: now, - }; -} - -function buildPendingBrowserAssistArtifact(params: { - scopeKey: string; - profileKey: string; - url: string; - title?: string; -}): Artifact { - const now = Date.now(); - - return { - id: GENERAL_BROWSER_ASSIST_ARTIFACT_ID, - type: "browser_assist", - title: params.title?.trim() || "浏览器协助", - content: "", - status: "pending", - error: undefined, - meta: { - persistOutsideMessages: true, - browserAssistScopeKey: params.scopeKey, - profileKey: params.profileKey, - url: params.url, - launchState: "launching", - launchHint: - "正在启动 Chrome、连接调试通道并等待首帧画面,通常需要 3–8 秒。", - launchError: undefined, - }, - position: { start: 0, end: 0 }, - createdAt: now, - updatedAt: now, - }; -} - -function buildFailedBrowserAssistArtifact(params: { - scopeKey: string; - profileKey: string; - url: string; - title?: string; - error: string; -}): Artifact { - const now = Date.now(); - - return { - id: GENERAL_BROWSER_ASSIST_ARTIFACT_ID, - type: "browser_assist", - title: params.title?.trim() || "浏览器协助", - content: "", - status: "error", - error: params.error, - meta: { - persistOutsideMessages: true, - browserAssistScopeKey: params.scopeKey, - profileKey: params.profileKey, - url: params.url, - launchState: "failed", - launchHint: undefined, - launchError: params.error, - }, - position: { start: 0, end: 0 }, - createdAt: now, - updatedAt: now, - }; -} - -function asRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - - return value as Record; -} - -function readFirstString( - candidates: Array | null | undefined>, - keys: string[], -): string | undefined { - for (const candidate of candidates) { - if (!candidate) { - continue; - } - for (const key of keys) { - const value = candidate[key]; - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - } - } - - return undefined; -} - -function resolveBrowserAssistArtifactScopeKey( - artifact: Pick | null | undefined, -): string | null { - if (!artifact || artifact.type !== "browser_assist") { - return null; - } - - const meta = asRecord(artifact.meta); - return ( - readFirstString(meta ? [meta] : [], [ - "browserAssistScopeKey", - "browser_assist_scope_key", - ]) || null - ); -} -function resolveArtifactFilePath( - artifact: Pick, -): string { - if ( - typeof artifact.meta.filePath === "string" && - artifact.meta.filePath.trim() - ) { - return artifact.meta.filePath.trim(); - } - if ( - typeof artifact.meta.filename === "string" && - artifact.meta.filename.trim() - ) { - return artifact.meta.filename.trim(); - } - return artifact.title; -} - -function resolveAbsoluteWorkspacePath( - workspaceRoot: string | null | undefined, - filePath: string | null | undefined, -): string | undefined { - const normalizedFilePath = filePath?.trim(); - if (!normalizedFilePath) { - return undefined; - } - - if ( - normalizedFilePath.startsWith("/") || - normalizedFilePath.startsWith("~/") || - normalizedFilePath.startsWith("\\\\") || - /^[A-Za-z]:[\\/]/.test(normalizedFilePath) - ) { - return normalizedFilePath; - } - - const normalizedWorkspaceRoot = workspaceRoot?.trim(); - if (!normalizedWorkspaceRoot) { - return normalizedFilePath; - } - - return `${normalizedWorkspaceRoot.replace(/[\\/]+$/, "")}/${normalizedFilePath.replace(/^[\\/]+/, "")}`; -} - -function resolvePreviousDocumentVersionContent( - version: DocumentVersion | null | undefined, - versions: DocumentVersion[], -): string | null { - if (!version) { - return null; - } - - const parentVersionId = version.metadata?.parentVersionId?.trim(); - if (parentVersionId) { - const parentVersion = versions.find((item) => item.id === parentVersionId); - if (parentVersion) { - return parentVersion.content; - } - } - - const currentIndex = versions.findIndex((item) => item.id === version.id); - if (currentIndex > 0) { - return versions[currentIndex - 1]?.content || null; - } - - return null; -} - -function wrapPreviewWithWorkbenchTrigger( - preview: ReactNode, - stackedWorkbenchTrigger?: ReactNode, -) { - if (!stackedWorkbenchTrigger) { - return preview; - } - - return ( -
- {preview} -
-
{stackedWorkbenchTrigger}
-
-
- ); -} - -function mergeMessageArtifactsIntoStore( - messageArtifacts: Artifact[], - currentArtifacts: Artifact[], - browserAssistScopeKey: string | null, -): Artifact[] { - const preservedArtifacts = currentArtifacts.filter( - (artifact) => - shouldPreserveGeneralArtifact(artifact) && - (artifact.type !== "browser_assist" || - resolveBrowserAssistArtifactScopeKey(artifact) === - browserAssistScopeKey), - ); - - if (messageArtifacts.length === 0) { - return mergeArtifacts(preservedArtifacts); - } - - const currentArtifactsById = new Map( - currentArtifacts.map((artifact) => [artifact.id, artifact]), - ); - - return mergeArtifacts([ - ...messageArtifacts.map((artifact) => { - const existing = currentArtifactsById.get(artifact.id); - if (!existing) { - return artifact; - } - - const shouldReuseExistingContent = - existing.content.length > 0 && - (artifact.content.length === 0 || - (artifact.status === "streaming" && - artifact.content.length < existing.content.length && - existing.content.startsWith(artifact.content))); - - return { - ...existing, - ...artifact, - content: shouldReuseExistingContent - ? existing.content - : artifact.content, - meta: { - ...existing.meta, - ...artifact.meta, - }, - createdAt: Math.min(existing.createdAt, artifact.createdAt), - updatedAt: Math.max(existing.updatedAt, artifact.updatedAt), - }; - }), - ...preservedArtifacts, - ]); -} - -const PageContainer = styled.div<{ $compact?: boolean }>` - display: flex; - height: 100%; - width: 100%; - position: relative; - min-height: 0; - gap: ${({ $compact }) => ($compact ? "8px" : "14px")}; - padding: ${({ $compact }) => ($compact ? "8px" : "14px")}; - box-sizing: border-box; - overflow: hidden; - isolation: isolate; - background: - radial-gradient( - circle at 14% 18%, - rgba(56, 189, 248, 0.1), - transparent 30% - ), - radial-gradient( - circle at 86% 14%, - rgba(16, 185, 129, 0.08), - transparent 28% - ), - radial-gradient( - circle at 72% 84%, - rgba(245, 158, 11, 0.06), - transparent 24% - ), - linear-gradient( - 180deg, - rgba(248, 250, 252, 0.98) 0%, - rgba(248, 250, 252, 0.96) 42%, - rgba(242, 251, 247, 0.94) 100% - ); - - > * { - position: relative; - z-index: 1; - } -`; - -const MainArea = styled.div<{ $compact?: boolean }>` - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; - min-height: 0; - overflow: hidden; - position: relative; - border: 1px solid rgba(226, 232, 240, 0.88); - border-radius: ${({ $compact }) => ($compact ? "24px" : "32px")}; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.96) 0%, - rgba(248, 250, 252, 0.94) 56%, - rgba(248, 250, 252, 0.88) 100% - ); - box-shadow: - 0 24px 72px -36px rgba(15, 23, 42, 0.18), - 0 16px 28px -24px rgba(15, 23, 42, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.76); - backdrop-filter: blur(18px); -`; - -function resolveContentSyncTone(status: SyncStatus): { - text: string; - background: string; - border: string; -} { - switch (status) { - case "syncing": - return { - text: "#475569", - background: - "linear-gradient(180deg, rgba(255,255,255,0.94) 0%, rgba(248,250,252,0.92) 100%)", - border: "rgba(226, 232, 240, 0.9)", - }; - case "success": - return { - text: "#047857", - background: - "linear-gradient(180deg, rgba(236,253,245,0.98) 0%, rgba(220,252,231,0.92) 100%)", - border: "rgba(167, 243, 208, 0.95)", - }; - case "error": - return { - text: "#be123c", - background: - "linear-gradient(180deg, rgba(255,241,242,0.98) 0%, rgba(255,228,230,0.92) 100%)", - border: "rgba(254, 205, 211, 0.95)", - }; - case "idle": - default: - return { - text: "#475569", - background: - "linear-gradient(180deg, rgba(255,255,255,0.94) 0%, rgba(248,250,252,0.9) 100%)", - border: "rgba(226, 232, 240, 0.88)", - }; - } -} - -const ContentSyncNotice = styled.div<{ $status: SyncStatus }>` - ${({ $status }) => { - const tone = resolveContentSyncTone($status); - return ` - display: flex; - align-items: center; - gap: 8px; - margin: -2px 14px 10px; - padding: 8px 12px; - border: 1px solid ${tone.border}; - border-radius: 14px; - background: ${tone.background}; - color: ${tone.text}; - box-shadow: 0 10px 24px hsl(var(--foreground) / 0.03); - `; - }} -`; - -const ContentSyncNoticeText = styled.span` - font-size: 12px; - font-weight: 500; - line-height: 1.4; -`; - -const ChatContainer = styled.div` - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - height: 100%; -`; - -const ChatContainerInner = styled.div` - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - height: 100%; - overflow: hidden; - background: linear-gradient( - 180deg, - rgba(248, 250, 252, 0.78) 0%, - rgba(255, 255, 255, 0.12) 18%, - rgba(255, 255, 255, 0) 100% - ); -`; - -const EntryBanner = styled.div` - display: flex; - align-items: center; - gap: 8px; - margin: 8px 12px 0; - padding: 10px 12px; - border-radius: 18px; - border: 1px solid rgba(191, 219, 254, 0.9); - background: linear-gradient( - 180deg, - rgba(239, 246, 255, 0.96) 0%, - rgba(248, 250, 252, 0.92) 100% - ); - color: #0f172a; - font-size: 13px; - box-shadow: 0 10px 22px -20px rgba(15, 23, 42, 0.16); -`; - -const EntryBannerClose = styled.button` - margin-left: auto; - border: none; - background: transparent; - color: #64748b; - cursor: pointer; - font-size: 13px; -`; - -const ChatContent = styled.div<{ $compact?: boolean }>` - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - padding: ${({ $compact }) => ($compact ? "0 6px 6px" : "0 10px 10px")}; - overflow: hidden; - height: 100%; - position: relative; -`; - -const MessageViewport = styled.div<{ $bottomPadding?: string }>` - flex: 1; - min-height: 0; - overflow: hidden; - padding-bottom: ${({ $bottomPadding }) => $bottomPadding || "128px"}; -`; - -const ThemeWorkbenchInputOverlay = styled.div<{ - $hasPendingA2UIForm?: boolean; -}>` - position: absolute; - left: 24px; - right: 24px; - bottom: 20px; - z-index: 25; - pointer-events: none; - display: flex; - justify-content: center; - box-sizing: border-box; - - > * { - pointer-events: auto; - width: ${({ $hasPendingA2UIForm }) => - $hasPendingA2UIForm - ? "min(calc(100% - 24px), 880px)" - : "min(calc(100% - 16px), 480px)"}; - max-width: 100%; - } -`; - -const ThemeWorkbenchLayoutShell = styled.div<{ $bottomInset: string }>` - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; - box-sizing: border-box; - padding-bottom: ${({ $bottomInset }) => $bottomInset}; - transition: padding-bottom 0.2s ease; -`; - -const ThemeWorkbenchCanvasHost = styled.div` - flex: 1; - min-height: 0; - - > * { - height: 100%; - } -`; - -interface LayoutTransitionRenderGateProps { - mode: LayoutMode; - chatContent: ReactNode; - canvasContent: ReactNode; - chatPanelWidth?: string; - chatPanelMinWidth?: string; -} - -const LayoutTransitionRenderGate = memo( - ({ - mode, - chatContent, - canvasContent, - chatPanelWidth, - chatPanelMinWidth, - }: LayoutTransitionRenderGateProps) => ( - - - - ), - (previous, next) => - previous.mode === next.mode && - previous.chatContent === next.chatContent && - previous.canvasContent === next.canvasContent && - previous.chatPanelWidth === next.chatPanelWidth && - previous.chatPanelMinWidth === next.chatPanelMinWidth, -); -LayoutTransitionRenderGate.displayName = "LayoutTransitionRenderGate"; - -const TEAM_PRIMARY_CHAT_PANEL_WIDTH = "min(100%, clamp(420px, 34%, 560px))"; -const TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH = "400px"; - -interface RuntimeTeamDispatchPreviewSnapshot { - key: string; - prompt: string; - images: MessageImage[]; - baseMessageCount: number; - status: "forming" | "formed" | "failed"; - formationState?: TeamWorkspaceRuntimeFormationState | null; - failureMessage?: string | null; -} - -function buildRuntimeTeamMemberPlanLines( - state: TeamWorkspaceRuntimeFormationState, -): string[] { - const members = state.members.slice(0, 3); - const lines = members.map((member, index) => { - const label = member.label.trim() || `成员 ${index + 1}`; - const summary = member.summary.trim() || "负责分担当前任务中的一部分工作。"; - return `${index + 1}. ${label}:${summary}`; - }); - - if (state.members.length > members.length) { - lines.push(`另外还有 ${state.members.length - members.length} 位成员会继续配合处理。`); - } - - return lines; -} - -function buildRuntimeTeamAssistantDraft( - state: TeamWorkspaceRuntimeFormationState | null | undefined, -): AssistantDraftState | undefined { - if (!state || state.status !== "formed") { - return undefined; - } - - const teamLabel = - state.label?.trim() || state.blueprint?.label?.trim() || "当前协作方案"; - const summary = - state.summary?.trim() || state.blueprint?.summary?.trim() || ""; - const planLines = buildRuntimeTeamMemberPlanLines(state); - const contentSections = [ - `我已经为这项任务准备了「${teamLabel}」。`, - summary ? `会先按“${summary}”来推进。` : null, - planLines.length > 0 ? `分工如下:\n${planLines.join("\n")}` : null, - "接下来我会让他们分别处理,再把关键进展、风险和需要你确认的事项汇总给你。", - ].filter(Boolean); - - const initialRuntimeStatus: AgentRuntimeStatus = { - phase: "routing", - title: "协作分工已准备好", - detail: - summary || "已整理好当前任务的分工,接下来会分别展开处理并同步结果。", - checkpoints: [ - `当前方案:${teamLabel}`, - `已安排 ${Math.max(state.members.length, 1)} 位协作成员`, - "主对话会持续同步关键进展", - ], - }; - - const waitingRuntimeStatus: AgentRuntimeStatus = { - phase: "routing", - title: "协作成员开始接手", - detail: - summary || "分工已经确认,协作成员会按各自职责继续处理并回传关键结果。", - checkpoints: [ - `当前方案:${teamLabel}`, - planLines[0] || "成员会分别接手自己的部分", - "主对话会持续同步关键进展", - ], - }; - - return { - content: contentSections.join("\n\n"), - initialRuntimeStatus, - waitingRuntimeStatus, - }; -} - -function buildRuntimeTeamDispatchPreviewMessages( - snapshot: RuntimeTeamDispatchPreviewSnapshot, -): Message[] { - const normalizedPrompt = snapshot.prompt.trim(); - const timestamp = new Date(); - const formedAssistantDraft = - snapshot.status === "formed" - ? buildRuntimeTeamAssistantDraft(snapshot.formationState) - : undefined; - const formedTeamLabel = - snapshot.formationState?.label?.trim() || - snapshot.formationState?.blueprint?.label?.trim() || - "当前协作方案"; - const formedSummary = - snapshot.formationState?.summary?.trim() || - snapshot.formationState?.blueprint?.summary?.trim() || - ""; - const assistantRuntimeStatus = - snapshot.status === "failed" - ? { - phase: "failed" as const, - title: "Team 调度准备失败", - detail: - snapshot.failureMessage?.trim() || - "这次 Team 组建失败,已回退到普通对话发送。", - } - : snapshot.status === "formed" - ? formedAssistantDraft?.initialRuntimeStatus || { - phase: "routing" as const, - title: "协作分工已准备好", - detail: - formedSummary || - "已整理好当前任务的分工,接下来会分别展开处理并同步结果。", - checkpoints: [ - `当前方案:${formedTeamLabel}`, - "协作成员会按分工开始接手", - "主对话会持续同步关键进展", - ], - } - : { - phase: "routing" as const, - title: "正在组建 Team", - detail: - "系统正在根据当前任务安排分工,会先接入合适的成员,再把关键进展持续汇总回主对话。", - checkpoints: ["确认当前任务目标", "安排协作分工", "等待成员接手处理"], - }; - - return [ - { - id: `runtime-team-dispatch:${snapshot.key}:user`, - role: "user", - content: normalizedPrompt, - images: snapshot.images.length > 0 ? snapshot.images : undefined, - timestamp, - }, - { - id: `runtime-team-dispatch:${snapshot.key}:assistant`, - role: "assistant", - content: - snapshot.status === "failed" - ? "这次 Team 调度准备失败,已回退到普通执行。" - : snapshot.status === "formed" - ? formedAssistantDraft?.content || - `我已经为这项任务准备了「${formedTeamLabel}」。\n\n接下来我会让他们分别处理,再把关键进展和结果汇总给你。` - : "我会先安排协作分工,再把关键进展和结果汇总给你。", - timestamp: new Date(timestamp.getTime() + 1), - isThinking: snapshot.status === "forming", - runtimeStatus: assistantRuntimeStatus, - }, - ]; -} - -interface ImageWorkbenchTask extends ImageWorkbenchTaskView { - sessionId: string; - hookImageIds: string[]; - applyTarget: ImageWorkbenchApplyTarget | null; -} - -interface ImageWorkbenchOutput extends ImageWorkbenchOutputView { - hookImageId: string; - applyTarget: ImageWorkbenchApplyTarget | null; -} - -type ImageWorkbenchApplyTarget = - | { - kind: "canvas-insert"; - canvasType: CanvasImageTargetType; - anchorHint?: CanvasImageInsertAnchorHint; - projectId?: string | null; - contentId?: string | null; - actionLabel: string; - dispatchLabel: string; - } - | { - kind: "document-cover"; - placeholder: string; - actionLabel: string; - successLabel: string; - }; - -interface SessionImageWorkbenchState { - active: boolean; - viewport: ImageWorkbenchViewport; - tasks: ImageWorkbenchTask[]; - outputs: ImageWorkbenchOutput[]; - selectedOutputId: string | null; - nextOutputIndex: number; -} - -function createInitialSessionImageWorkbenchState(): SessionImageWorkbenchState { - return { - active: false, - viewport: { x: 0, y: 0, scale: 1 }, - tasks: [], - outputs: [], - selectedOutputId: null, - nextOutputIndex: 1, - }; -} - -function buildImageWorkbenchDispatchMessages(params: { - rawText: string; - images: MessageImage[]; - taskId: string; - prompt: string; - mode: ImageWorkbenchTaskMode; - count: number; -}): Message[] { - const timestamp = new Date(); - const modeLabel = - params.mode === "edit" - ? "图片编辑" - : params.mode === "variation" - ? "图片变体" - : "图片生成"; - - return [ - { - id: `image-workbench:${params.taskId}:user`, - role: "user", - content: params.rawText, - images: params.images.length > 0 ? params.images : undefined, - timestamp, - }, - { - id: `image-workbench:${params.taskId}:assistant`, - role: "assistant", - content: `已创建${modeLabel}任务,正在准备 ${params.count} 张结果。`, - timestamp: new Date(timestamp.getTime() + 1), - runtimeStatus: { - phase: "routing", - title: `${modeLabel}已进入工作台`, - detail: params.prompt.trim() - ? `主画布已接管当前任务:${params.prompt.trim()}` - : "主画布已接管当前任务,正在准备图片服务与结果卡片。", - checkpoints: ["记录当前调度", "创建画布任务卡", "等待结果回填"], - }, - }, - ]; -} - -function buildImageWorkbenchCompletionMessage(params: { - taskId: string; - successCount: number; - failedCount: number; - mode: ImageWorkbenchTaskMode; -}): Message { - const timestamp = new Date(); - const modeLabel = - params.mode === "edit" - ? "图片编辑" - : params.mode === "variation" - ? "图片变体" - : "图片生成"; - const detail = - params.failedCount > 0 - ? `${modeLabel}完成 ${params.successCount} 张,失败 ${params.failedCount} 张。` - : `${modeLabel}已完成,共生成 ${params.successCount} 张。`; - - return { - id: `image-workbench:${params.taskId}:complete`, - role: "assistant", - content: detail, - timestamp, - }; -} - -function collapseWhitespace(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} - -function extractImagePromptSnippet(content: string, maxLength = 120): string { - const normalized = collapseWhitespace( - content - .replace(/```[\s\S]*?```/g, " ") - .replace(/!\[[^\]]*]\([^)]*\)/g, " ") - .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") - .replace(/[#>*`~\-|]/g, " ") - .replace(/\d+\.\s+/g, " ") - .replace(/[^\S\r\n]+/g, " "), - ); - - if (normalized.length <= maxLength) { - return normalized; - } - - return `${normalized.slice(0, maxLength).trim()}...`; -} - -function resolveDocumentPlatformLabel(platform: PlatformType): string { - switch (platform) { - case "wechat": - return "微信"; - case "xiaohongshu": - return "小红书"; - case "zhihu": - return "知乎"; - case "markdown": - default: - return "文稿"; - } -} - -function resolveCoverAspectRatio(platform?: PlatformType): string { - if (platform === "xiaohongshu") { - return "1:1"; - } - return "16:9"; -} - -function resolveClosestImageAspectRatio( - width: number, - height: number, -): string | undefined { - if (width <= 0 || height <= 0) { - return undefined; - } - - const currentRatio = width / height; - const candidates: Array<[string, number]> = [ - ["1:1", 1], - ["16:9", 16 / 9], - ["9:16", 9 / 16], - ["4:3", 4 / 3], - ["3:4", 3 / 4], - ["3:2", 3 / 2], - ["2:3", 2 / 3], - ["21:9", 21 / 9], - ["4:5", 4 / 5], - ["5:4", 5 / 4], - ]; - - return candidates.reduce((closest, candidate) => { - if (!closest) { - return candidate; - } - return Math.abs(candidate[1] - currentRatio) < Math.abs(closest[1] - currentRatio) - ? candidate - : closest; - }, null as [string, number] | null)?.[0]; -} - -function buildImageWorkbenchCommandText( - prompt: string, - options?: { - aspectRatio?: string; - count?: number; - }, -): string { - const normalizedPrompt = collapseWhitespace(prompt) || "生成一张主题配图"; - const ratioSuffix = options?.aspectRatio?.trim() - ? `,${options.aspectRatio.trim()}` - : ""; - const countSuffix = - options?.count && options.count > 1 ? `,出 ${Math.trunc(options.count)} 张` : ""; - return `@配图 生成 ${normalizedPrompt}${ratioSuffix}${countSuffix}`; -} - -function buildDocumentImageWorkbenchPrompt(params: { - projectName?: string | null; - platform: PlatformType; - content: string; -}): string { - const platformLabel = resolveDocumentPlatformLabel(params.platform); - const subject = - extractImagePromptSnippet(params.content) || - collapseWhitespace(params.projectName || "") || - "当前主题"; - return `为当前${platformLabel}文稿补一张主视觉配图,重点内容:${subject}`; -} - -function buildPosterImageWorkbenchPrompt(params: { - projectName?: string | null; - width: number; - height: number; -}): string { - const subject = collapseWhitespace(params.projectName || "") || "当前海报主题"; - return `为当前海报生成一张主视觉图片,主题:${subject},画布尺寸约 ${params.width}x${params.height}`; -} - -function findDocumentCoverPlaceholder(content: string): string | null { - const match = content.match( - /!\[[^\]]*]\((pending-cover:\/\/[^)\s]+|【img:[^】]+】|cover-generation-failed)\)/, - ); - return match?.[1]?.trim() || null; -} - -function buildDefaultCanvasImageApplyTarget(params: { - canvasState: CanvasStateUnion | null; - projectId?: string | null; - contentId?: string | null; -}): ImageWorkbenchApplyTarget | null { - if (!params.canvasState) { - return null; - } - - if (params.canvasState.type === "document") { - return { - kind: "canvas-insert", - canvasType: "document", - anchorHint: "section_end", - projectId: params.projectId ?? null, - contentId: params.contentId ?? null, - actionLabel: "插入文稿", - dispatchLabel: "已切回文稿,正在插入图片", - }; - } - - if (params.canvasState.type === "poster") { - return { - kind: "canvas-insert", - canvasType: "poster", - anchorHint: "poster_center", - projectId: params.projectId ?? null, - contentId: params.contentId ?? null, - actionLabel: "插入海报", - dispatchLabel: "已切回海报,正在插入图片", - }; - } - - return null; -} - -function resolveScopedImageWorkbenchApplyTarget(params: { - canvasState: CanvasStateUnion | null; - projectId?: string | null; - contentId?: string | null; - requestedTarget?: "generate" | "cover"; -}): ImageWorkbenchApplyTarget | null { - if ( - params.requestedTarget === "cover" && - params.canvasState?.type === "document" - ) { - const placeholder = findDocumentCoverPlaceholder(params.canvasState.content); - if (placeholder) { - return { - kind: "document-cover", - placeholder, - actionLabel: "设为封面", - successLabel: "已设为封面", - }; - } - } - - return buildDefaultCanvasImageApplyTarget(params); -} - -function resolveImageWorkbenchActionLabel( - target: ImageWorkbenchApplyTarget | null | undefined, -): string { - if (!target) { - return "应用到画布"; - } - return target.actionLabel; -} - -const ThemeWorkbenchLeftExpandButton = styled.button` - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - width: 24px; - height: 78px; - border: 1px solid rgba(226, 232, 240, 0.92); - border-radius: 14px; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.94) 0%, - rgba(248, 250, 252, 0.9) 100% - ); - color: #64748b; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - z-index: 30; - box-shadow: 0 14px 28px -24px rgba(15, 23, 42, 0.2); - - &:hover { - color: #0f172a; - border-color: rgba(148, 163, 184, 0.84); - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.98) 0%, - rgba(241, 245, 249, 0.92) 100% - ); - } -`; - -function resolveContentSyncNotice(status: Exclude): { - label: string; - Icon: LucideIcon; - animated?: boolean; -} { - switch (status) { - case "syncing": - return { - label: "正在同步到当前内容…", - Icon: Loader2, - animated: true, - }; - case "success": - return { - label: "内容已同步", - Icon: CheckCircle2, - }; - case "error": - default: - return { - label: "同步失败,将自动重试", - Icon: AlertTriangle, - }; - } -} - -/** - * 将 ProjectType 转换为 ThemeType - * 由于类型已统一,大部分情况下直接返回即可 - */ -function projectTypeToTheme(projectType: ProjectType): ThemeType { - // ProjectType 和 ThemeType 现在是统一的 - // 系统类型 persistent/temporary 映射到 general - if (projectType === "persistent" || projectType === "temporary") { - return "general"; - } - return projectType as ThemeType; -} const TOPIC_PROJECT_KEY_PREFIX = "agent_session_workspace_"; -const THEME_WORKBENCH_DOCUMENT_META_KEY = "theme_workbench_document_v1"; -const MAX_PERSISTED_DOCUMENT_VERSIONS = 40; -const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; -const THEME_WORKBENCH_CREATION_TASK_EVENT_NAME = - "lime://creation_task_submitted"; -const MAX_THEME_WORKBENCH_CREATION_TASK_EVENTS = 120; - -interface CreationTaskSubmittedPayload { - task_id?: string; - task_type?: string; - path?: string; - absolute_path?: string; -} - -function normalizeThemeWorkbenchCreationTaskEvent( - payload: CreationTaskSubmittedPayload, -): ThemeWorkbenchCreationTaskEvent | null { - const taskId = payload.task_id?.trim(); - const taskType = payload.task_type?.trim(); - const path = payload.path?.trim(); - if (!taskId || !taskType || !path) { - return null; - } - const createdAt = Date.now(); - return { - taskId, - taskType, - path, - absolutePath: payload.absolute_path?.trim() || undefined, - createdAt, - timeLabel: new Date(createdAt).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }), - }; -} - -function hasActiveBrowserAssistSession( - sessionState: BrowserAssistSessionState | null, -): boolean { - if (!sessionState) { - return false; - } - - if (!sessionState.sessionId && !sessionState.profileKey) { - return false; - } - - const lifecycleState = sessionState.lifecycleState?.trim().toLowerCase(); - return !["failed", "closed", "terminated"].includes(lifecycleState || ""); -} - -function buildBrowserPreflightMessages( - preflight: BrowserTaskPreflight, -): Message[] { - const timestamp = new Date(preflight.createdAt); - const actionRequired = { - requestId: preflight.requestId, - actionType: "ask_user" as const, - uiKind: "browser_preflight" as const, - browserRequirement: preflight.requirement, - browserPrepState: preflight.phase, - prompt: preflight.reason, - detail: preflight.detail, - allowCapabilityFallback: false, - }; - - return [ - { - id: `${preflight.requestId}:user`, - role: "user", - content: preflight.sourceText, - images: preflight.images.length > 0 ? preflight.images : undefined, - timestamp, - }, - { - id: `${preflight.requestId}:assistant`, - role: "assistant", - content: "", - timestamp: new Date(preflight.createdAt + 1), - actionRequests: [actionRequired], - contentParts: [{ type: "action_required", actionRequired }], - }, - ]; -} - -function isLegacyQuestionnaireSummaryMessage(message?: Message): boolean { - return ( - message?.role === "user" && message.content.trim().startsWith("我的选择:") - ); -} - -function collapseLegacyQuestionnaireMessages(messages: Message[]): Message[] { - let mutated = false; - const collapsedMessages = messages.map((message, index) => { - if (message.role !== "assistant") { - return message; - } - - if ((message.actionRequests || []).length > 0) { - return message; - } - - const legacyForm = buildLegacyQuestionnaireA2UI(message.content || ""); - if (!legacyForm) { - return message; - } - - const nextMessage = messages[index + 1]; - const isPendingQuestionnaire = index === messages.length - 1; - const hasSubmittedSummary = - isLegacyQuestionnaireSummaryMessage(nextMessage); - - if (!isPendingQuestionnaire && !hasSubmittedSummary) { - return message; - } - - mutated = true; - return { - ...message, - content: hasSubmittedSummary - ? "补充信息表单已提交。" - : "已整理为补充信息表单,请在输入区完成填写。", - }; - }); - - return mutated ? collapsedMessages : messages; -} - -function resolveThemeWorkbenchRunStepStatus( - status: "queued" | "running" | "success" | "error" | "canceled" | "timeout", -): StepStatus { - if (status === "running") { - return "active"; - } - if (status === "queued") { - return "pending"; - } - if (status === "success") { - return "completed"; - } - return "error"; -} - -function parseThemeWorkbenchToolArguments( - argumentsJson?: string, -): Record { - if (!argumentsJson) { - return {}; - } - - try { - const parsed = JSON.parse(argumentsJson); - return parsed && typeof parsed === "object" - ? (parsed as Record) - : {}; - } catch { - return {}; - } -} - -function truncateThemeWorkbenchLabel(value: string, limit = 28): string { - return value.length > limit ? `${value.slice(0, limit)}…` : value; -} - -function resolveThemeWorkbenchTextArg( - args: Record, - keys: string[], -): string { - for (const key of keys) { - const value = args[key]; - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - if (Array.isArray(value)) { - const firstString = value.find( - (item): item is string => - typeof item === "string" && item.trim().length > 0, - ); - if (firstString) { - return firstString.trim(); - } - } - } - return ""; -} - -function getThemeWorkbenchFileLabel(pathValue: string): string { - const normalized = pathValue.trim(); - if (!normalized) { - return "主稿文件"; - } - const segments = normalized.split(/[/\\]/).filter(Boolean); - if (segments.length >= 2) { - return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`; - } - return segments[0] || normalized; -} - -function resolveThemeWorkbenchToolTaskTitle(toolCall: ToolCallState): string { - const normalized = toolCall.name.trim().toLowerCase(); - const args = parseThemeWorkbenchToolArguments(toolCall.arguments); - const queryValue = resolveThemeWorkbenchTextArg(args, [ - "query", - "q", - "keyword", - "pattern", - "text", - ]); - const urlValue = resolveThemeWorkbenchTextArg(args, ["url", "href"]); - const elementValue = resolveThemeWorkbenchTextArg(args, [ - "element", - "name", - "label", - "ref", - ]); - - if (normalized.includes("social_generate_cover_image")) { - const size = resolveThemeWorkbenchTextArg(args, ["size"]); - return size ? `生成封面图(${size})` : "生成封面图"; - } - if (normalized.includes("write_file") || normalized.includes("create_file")) { - const pathValue = resolveThemeWorkbenchTextArg(args, [ - "path", - "file_path", - "filePath", - ]); - return pathValue - ? `写入 ${getThemeWorkbenchFileLabel(pathValue)}` - : "写入主稿文件"; - } - if (normalized.includes("websearch")) { - return queryValue - ? `检索 ${truncateThemeWorkbenchLabel(queryValue)}` - : "检索参考资料"; - } - if ( - normalized.includes("browser_navigate") || - (normalized.includes("navigate") && urlValue) - ) { - return urlValue - ? `打开 ${truncateThemeWorkbenchLabel(urlValue, 36)}` - : "打开网页"; - } - if (normalized.includes("browser_click") || normalized === "click") { - return elementValue - ? `点击「${truncateThemeWorkbenchLabel(elementValue, 20)}」` - : "点击页面元素"; - } - if (normalized.includes("browser_hover") || normalized === "hover") { - return elementValue - ? `定位「${truncateThemeWorkbenchLabel(elementValue, 20)}」` - : "定位页面元素"; - } - if (normalized.includes("browser_type") || normalized === "type") { - return elementValue - ? `填写「${truncateThemeWorkbenchLabel(elementValue, 20)}」` - : queryValue - ? `填写 ${truncateThemeWorkbenchLabel(queryValue, 18)}` - : "填写页面内容"; - } - if ( - normalized.includes("browser_select_option") || - normalized.includes("select_option") - ) { - const value = resolveThemeWorkbenchTextArg(args, [ - "value", - "values", - "option", - ]); - return value - ? `选择 ${truncateThemeWorkbenchLabel(value, 20)}` - : elementValue - ? `选择「${truncateThemeWorkbenchLabel(elementValue, 20)}」` - : "选择页面选项"; - } - if ( - normalized.includes("browser_press_key") || - normalized.includes("press_key") - ) { - const keyValue = resolveThemeWorkbenchTextArg(args, ["key"]); - return keyValue ? `触发按键 ${keyValue}` : "触发页面快捷键"; - } - if (normalized.includes("browser_drag") || normalized.includes("drag")) { - const endValue = resolveThemeWorkbenchTextArg(args, [ - "endElement", - "endRef", - ]); - return endValue - ? `拖拽到「${truncateThemeWorkbenchLabel(endValue, 18)}」` - : "拖拽页面元素"; - } - if ( - normalized.includes("browser_snapshot") || - normalized.includes("screenshot") - ) { - return elementValue - ? `分析页面区域:${truncateThemeWorkbenchLabel(elementValue, 20)}` - : urlValue - ? `分析页面 ${truncateThemeWorkbenchLabel(urlValue, 30)}` - : "分析页面内容"; - } - if (normalized.includes("bash") || normalized.includes("shell")) { - const commandValue = resolveThemeWorkbenchTextArg(args, ["command", "cmd"]); - const commandProbe = commandValue.toLowerCase(); - if (commandProbe.includes("ffmpeg")) { - return "处理音视频素材"; - } - if (commandProbe.includes("curl") || commandProbe.includes("wget")) { - return "下载远程资源"; - } - if ( - commandProbe.includes("python") || - commandProbe.includes("node") || - commandProbe.includes("tsx") || - commandProbe.includes("npm") - ) { - return "执行自动化脚本"; - } - return commandValue - ? `执行命令:${truncateThemeWorkbenchLabel(commandValue, 22)}` - : "执行终端命令"; - } - if (normalized.includes("browser")) { - return urlValue - ? `采集 ${truncateThemeWorkbenchLabel(urlValue, 36)}` - : elementValue - ? `处理页面元素:${truncateThemeWorkbenchLabel(elementValue, 20)}` - : "采集网页信息"; - } - return toolCall.name.replace(/[_-]+/g, " ").trim() || "执行工具"; -} - -function resolveThemeWorkbenchPrimaryTaskTitle( - skillName: string, - detail?: SkillDetailInfo | null, -): string { - if (skillName === SOCIAL_ARTICLE_SKILL_KEY) { - return "生成社媒主稿"; - } - - const displayName = detail?.display_name?.trim(); - if (displayName) { - return displayName; - } - - return skillName.replace(/[_-]+/g, " ").trim() || "执行任务"; -} - -function extractThemeWorkbenchWorkflowMarkerIndex( - content: string, -): number | null { - const matches = [...content.matchAll(/\*\*步骤\s+(\d+)\/(\d+):/g)]; - if (matches.length === 0) { - return null; - } - const last = matches[matches.length - 1]; - const value = Number(last[1]); - if (!Number.isFinite(value) || value <= 0) { - return null; - } - return value - 1; -} - -function findLatestThemeWorkbenchExecution(messages: Message[]): { - assistantMessage: Message; - skillName: string | null; -} | null { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message.role !== "assistant") { - continue; - } - - const hasToolCalls = (message.toolCalls?.length || 0) > 0; - const hasPendingAction = - message.actionRequests?.some( - (request) => request.status !== "submitted", - ) || false; - if (!message.isThinking && !hasToolCalls && !hasPendingAction) { - continue; - } - - let skillName: string | null = null; - for (let userIndex = index - 1; userIndex >= 0; userIndex -= 1) { - const candidate = messages[userIndex]; - if (candidate.role !== "user") { - continue; - } - skillName = parseSkillSlashCommand(candidate.content)?.skillName || null; - break; - } - - return { - assistantMessage: message, - skillName, - }; - } - - return null; -} - -function buildThemeWorkbenchLiveWorkflowSteps( - messages: Message[], - skillDetailMap: Record, - isSending: boolean, -): Array<{ id: string; title: string; status: StepStatus }> { - const activeExecution = findLatestThemeWorkbenchExecution(messages); - if (!activeExecution) { - return []; - } - - const { assistantMessage, skillName } = activeExecution; - if (!skillName) { - return []; - } - - const skillDetail = skillDetailMap[skillName] || null; - const workflowSteps = skillDetail?.workflow_steps || []; - if (workflowSteps.length > 0) { - const latestAssistantContent = - messages - .slice() - .reverse() - .find((m) => m.role === "assistant")?.content || ""; - const activeIndex = - extractThemeWorkbenchWorkflowMarkerIndex(latestAssistantContent) ?? 0; - return workflowSteps.map((step, index) => ({ - id: step.id, - title: step.name, - status: - index < activeIndex - ? ("completed" as StepStatus) - : index == activeIndex - ? ("active" as StepStatus) - : ("pending" as StepStatus), - })); - } - - const toolCalls = assistantMessage.toolCalls || []; - const steps: Array<{ id: string; title: string; status: StepStatus }> = []; - const primaryTaskTitle = resolveThemeWorkbenchPrimaryTaskTitle( - skillName, - skillDetail, - ); - const hasRunningTool = toolCalls.some( - (toolCall) => toolCall.status === "running", - ); - const hasFailedTool = toolCalls.some( - (toolCall) => toolCall.status === "failed", - ); - const hasCompletedPrimaryWrite = toolCalls.some((toolCall) => { - if (toolCall.status !== "completed") { - return false; - } - const normalizedName = toolCall.name.trim().toLowerCase(); - return ( - normalizedName.includes("write_file") || - normalizedName.includes("create_file") - ); - }); - - steps.push({ - id: `${skillName}:primary`, - title: primaryTaskTitle, - status: hasCompletedPrimaryWrite - ? ("completed" as StepStatus) - : hasFailedTool - ? ("error" as StepStatus) - : toolCalls.length > 0 - ? ("completed" as StepStatus) - : assistantMessage.isThinking || isSending - ? ("active" as StepStatus) - : ("pending" as StepStatus), - }); - - toolCalls.forEach((toolCall, index) => { - steps.push({ - id: toolCall.id || `${skillName}:tool:${index}`, - title: resolveThemeWorkbenchToolTaskTitle(toolCall), - status: - toolCall.status === "running" - ? ("active" as StepStatus) - : toolCall.status === "completed" - ? ("completed" as StepStatus) - : ("error" as StepStatus), - }); - }); - - if (isSending && toolCalls.length > 0 && !hasRunningTool) { - steps.push({ - id: `${skillName}:finalize`, - title: "整理最终结果", - status: "active", - }); - } - - return steps; -} - -function resolveThemeWorkbenchQueueItemTitle( - item: ThemeWorkbenchRunTodoItem, - skillDetailMap: Record, -): string { - const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); - if (sourceRef) { - return resolveThemeWorkbenchPrimaryTaskTitle( - sourceRef, - skillDetailMap[sourceRef], - ); - } - return item.title?.trim() || "执行任务"; -} -const THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS = 45 * 1000; -const THEME_WORKBENCH_HISTORY_PAGE_SIZE = 20; - -function resolveThemeWorkbenchSkillSourceRef( - item: - | ThemeWorkbenchRunTodoItem - | ThemeWorkbenchRunTerminalItem - | { source?: string | null; source_ref?: string | null }, -): string | null { - if ((item.source || "").trim() !== "skill") { - return null; - } - const sourceRef = item.source_ref?.trim(); - return sourceRef || null; -} - -interface PersistedThemeWorkbenchDocument { - versions: DocumentVersion[]; - currentVersionId: string; - versionStatusMap: Record; -} - -function isTopicBranchStatus(value: unknown): value is TopicBranchStatus { - return ( - value === "in_progress" || - value === "pending" || - value === "merged" || - value === "candidate" - ); -} - -function normalizeDocumentVersion(value: unknown): DocumentVersion | null { - if (!value || typeof value !== "object") { - return null; - } - const candidate = value as Record; - const id = typeof candidate.id === "string" ? candidate.id.trim() : ""; - const content = - typeof candidate.content === "string" ? candidate.content : ""; - const createdAt = - typeof candidate.createdAt === "number" - ? candidate.createdAt - : typeof candidate.created_at === "number" - ? candidate.created_at - : NaN; - const description = - typeof candidate.description === "string" - ? candidate.description - : undefined; - const metadata = - candidate.metadata && typeof candidate.metadata === "object" - ? (candidate.metadata as DocumentVersion["metadata"]) - : undefined; - - if (!id || Number.isNaN(createdAt)) { - return null; - } - - return { - id, - content, - createdAt, - description, - metadata, - }; -} - -function buildPersistedThemeWorkbenchDocument( - state: CanvasStateUnion, - statusMap: Record, -): PersistedThemeWorkbenchDocument | null { - if (state.type !== "document" || state.versions.length === 0) { - return null; - } - - const normalizedVersions = state.versions - .map((version) => normalizeDocumentVersion(version)) - .filter((version): version is DocumentVersion => !!version); - - if (normalizedVersions.length === 0) { - return null; - } - - const latestVersions = normalizedVersions.slice( - -MAX_PERSISTED_DOCUMENT_VERSIONS, - ); - const versionIdSet = new Set(latestVersions.map((version) => version.id)); - let currentVersionId = state.currentVersionId; - - if (!versionIdSet.has(currentVersionId)) { - currentVersionId = - latestVersions[latestVersions.length - 1]?.id || latestVersions[0].id; - } - - const persistedVersions = latestVersions.map((version) => - version.id === currentVersionId ? { ...version, content: "" } : version, - ); - - const versionStatusMap = Object.fromEntries( - Object.entries(statusMap).filter( - ([versionId, status]) => - versionIdSet.has(versionId) && isTopicBranchStatus(status), - ), - ) as Record; - - return { - versions: persistedVersions, - currentVersionId, - versionStatusMap, - }; -} - -function readPersistedThemeWorkbenchDocument( - metadata?: Record, -): PersistedThemeWorkbenchDocument | null { - const raw = metadata?.[THEME_WORKBENCH_DOCUMENT_META_KEY]; - if (!raw || typeof raw !== "object") { - return null; - } - const candidate = raw as Record; - const versionsRaw = Array.isArray(candidate.versions) - ? candidate.versions - : []; - const versions = versionsRaw - .map((version) => normalizeDocumentVersion(version)) - .filter((version): version is DocumentVersion => !!version) - .slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); - if (versions.length === 0) { - return null; - } - - const versionIdSet = new Set(versions.map((version) => version.id)); - const currentVersionIdRaw = candidate.currentVersionId; - const currentVersionId = - typeof currentVersionIdRaw === "string" && - versionIdSet.has(currentVersionIdRaw) - ? currentVersionIdRaw - : versions[versions.length - 1]?.id || versions[0].id; - - const statusRaw = candidate.versionStatusMap; - const statusEntries = - statusRaw && typeof statusRaw === "object" ? statusRaw : {}; - const versionStatusMap = Object.fromEntries( - Object.entries(statusEntries).filter( - ([versionId, status]) => - versionIdSet.has(versionId) && isTopicBranchStatus(status), - ), - ) as Record; - - return { - versions, - currentVersionId, - versionStatusMap, - }; -} - -function applyBackendThemeWorkbenchDocumentState( - state: CanvasStateUnion, - backendState: ThemeWorkbenchDocumentState, - currentBody: string, -): { - state: CanvasStateUnion; - statusMap: Record; -} | null { - if (state.type !== "document" || backendState.versions.length === 0) { - return null; - } - - const versions = backendState.versions - .map((version, index) => ({ - id: version.id, - content: version.is_current ? currentBody : "", - createdAt: version.created_at, - description: version.description?.trim() || `版本 ${index + 1}`, - })) - .slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); - - if (versions.length === 0) { - return null; - } - - const currentVersion = - versions.find( - (version) => version.id === backendState.current_version_id, - ) || versions[versions.length - 1]; - - const statusMap = Object.fromEntries( - backendState.versions - .filter( - ( - version, - ): version is ThemeWorkbenchDocumentState["versions"][number] & { - status: TopicBranchStatus; - } => isTopicBranchStatus(version.status), - ) - .map((version) => [version.id, version.status]), - ) as Record; - - return { - state: { - ...state, - versions, - currentVersionId: currentVersion.id, - content: currentVersion.content, - }, - statusMap, - }; -} - -function inferThemeWorkbenchGateFromQueueItem( - queueItem: ThemeWorkbenchRunTodoItem | null, -): { - key: "topic_select" | "write_mode" | "publish_confirm"; - title: string; - description: string; -} { - const gateKey = queueItem?.gate_key; - if (gateKey === "publish_confirm") { - return { - key: "publish_confirm", - title: "发布闸门", - description: queueItem?.title || "正在准备发布前检查与平台适配结果。", - }; - } - if (gateKey === "topic_select") { - return { - key: "topic_select", - title: "选题闸门", - description: queueItem?.title || "正在整理选题方向并生成可确认方案。", - }; - } - if (gateKey === "write_mode") { - return { - key: "write_mode", - title: "写作闸门", - description: queueItem?.title || "正在执行主稿写作与插图生成流程。", - }; - } - - if (!queueItem) { - return { - key: "topic_select", - title: "选题闸门", - description: "正在整理选题方向并生成可确认方案。", - }; - } - - const probe = - `${queueItem.title} ${queueItem.source_ref || ""} ${queueItem.source}`.toLowerCase(); - const looksLikePublish = - /publish|adapt|distribution|release|发布|分发|平台适配/.test(probe); - if (looksLikePublish) { - return { - key: "publish_confirm", - title: "发布闸门", - description: queueItem.title || "正在准备发布前检查与平台适配结果。", - }; - } - - const looksLikeTopic = /topic|research|trend|idea|选题|方向|调研|洞察/.test( - probe, - ); - if (looksLikeTopic) { - return { - key: "topic_select", - title: "选题闸门", - description: queueItem.title || "正在整理选题方向并生成可确认方案。", - }; - } - - return { - key: "write_mode", - title: "写作闸门", - description: queueItem.title || "正在执行主稿写作与插图生成流程。", - }; -} - -function resolveThemeWorkbenchGateByKey( - gateKey: "topic_select" | "write_mode" | "publish_confirm", - fallbackTitle?: string, -): { - key: "topic_select" | "write_mode" | "publish_confirm"; - title: string; - description: string; -} { - if (gateKey === "publish_confirm") { - return { - key: "publish_confirm", - title: "发布闸门", - description: fallbackTitle || "正在准备发布前检查与平台适配结果。", - }; - } - if (gateKey === "topic_select") { - return { - key: "topic_select", - title: "选题闸门", - description: fallbackTitle || "正在整理选题方向并生成可确认方案。", - }; - } - return { - key: "write_mode", - title: "写作闸门", - description: fallbackTitle || "正在执行主稿写作与插图生成流程。", - }; -} - -function formatThemeWorkbenchRunTimeLabel( - raw: string | null | undefined, -): string { - if (!raw) { - return "--:--"; - } - const parsed = new Date(raw); - if (Number.isNaN(parsed.getTime())) { - return "--:--"; - } - return parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} - -function formatThemeWorkbenchRunDurationLabel( - startedAt: string | null | undefined, - finishedAt: string | null | undefined, -): string | undefined { - if (!startedAt || !finishedAt) { - return undefined; - } - - const started = new Date(startedAt); - const finished = new Date(finishedAt); - if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) { - return undefined; - } - - const durationMs = finished.getTime() - started.getTime(); - if (durationMs < 0) { - return undefined; - } - if (durationMs < 1000) { - return `${durationMs}ms`; - } - if (durationMs < 60000) { - return `${(durationMs / 1000).toFixed(1)}s`; - } - return `${Math.floor(durationMs / 60000)}m${Math.round( - (durationMs % 60000) / 1000, - )}s`; -} - -function resolveThemeWorkbenchApplyTargetByGateKey( - gateKey: "topic_select" | "write_mode" | "publish_confirm" | "idle", -): string { - if (gateKey === "topic_select") { - return "选题池"; - } - if (gateKey === "publish_confirm") { - return "发布产物"; - } - if (gateKey === "write_mode") { - return "版本主稿"; - } - return "主稿内容"; -} - -function extractExecutionIdFromSocialToolId(toolCallId: string): string | null { - const normalized = toolCallId.trim(); - if (!normalized.startsWith("social-write-")) { - return null; - } - const match = normalized.match(/^social-write-(.+)-[0-9a-f]{8}$/i); - const executionId = match?.[1]?.trim(); - if (!executionId) { - return null; - } - return executionId; -} - -function resolveExecutionIdCandidatesForActivityLog( - log: SidebarActivityLog, -): string[] { - const candidates: string[] = []; - const pushCandidate = (value?: string | null) => { - const normalized = value?.trim(); - if (!normalized) { - return; - } - if (!candidates.includes(normalized)) { - candidates.push(normalized); - } - }; - - pushCandidate(log.executionId); - pushCandidate(log.messageId); - - const normalizedLogId = log.id.trim(); - if (normalizedLogId) { - let toolCallIdProbe = normalizedLogId; - if (log.messageId) { - const messagePrefix = `${log.messageId}-`; - if (normalizedLogId.startsWith(messagePrefix)) { - toolCallIdProbe = normalizedLogId.slice(messagePrefix.length); - } - } - pushCandidate(extractExecutionIdFromSocialToolId(toolCallIdProbe)); - } - - return candidates; -} - -function isThemeWorkbenchPrimaryDocumentArtifact(fileName: string): boolean { - const normalized = fileName.trim().toLowerCase(); - if (!normalized) { - return false; - } - return normalized.endsWith(".md") || normalized.endsWith(".markdown"); -} - -function inferTaskFileType(fileName: string): TaskFile["type"] { - const normalized = fileName.trim().toLowerCase(); - const extension = normalized.split(".").pop() || ""; - - if (extension === "md" || extension === "markdown" || extension === "txt") { - return "document"; - } - if ( - ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "ico"].includes( - extension, - ) - ) { - return "image"; - } - if ( - ["mp3", "wav", "aac", "flac", "m4a", "ogg", "mid", "midi"].includes( - extension, - ) - ) { - return "audio"; - } - if (["mp4", "mov", "avi", "mkv", "webm"].includes(extension)) { - return "video"; - } - return "other"; -} - -function looksLikeSocialPublishPayload(content: string): boolean { - const trimmed = content.trim(); - if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { - return false; - } - - try { - const parsed = JSON.parse(trimmed) as Record; - return ( - typeof parsed.article_path === "string" || - typeof parsed.cover_meta_path === "string" || - Array.isArray(parsed.pipeline) || - Array.isArray(parsed.recommended_channels) - ); - } catch { - return false; - } -} - -function looksLikeThemeWorkbenchErrorPayload(content: string): boolean { - const normalized = content.trim().toLowerCase(); - if (!normalized) { - return false; - } - - return ( - normalized.startsWith("ran into this error:") || - normalized.startsWith("request failed:") || - normalized.includes( - "please retry if you think this is a transient or recoverable error.", - ) || - normalized.includes("api key not valid") - ); -} - -function isCorruptedThemeWorkbenchDocumentContent( - content?: string | null, -): boolean { - if (typeof content !== "string") { - return false; - } - - return ( - looksLikeSocialPublishPayload(content) || - looksLikeThemeWorkbenchErrorPayload(content) - ); -} - -function resolveTaskFileType( - fileName: string, - content?: string | null, -): TaskFile["type"] { - const inferredType = inferTaskFileType(fileName); - if ( - inferredType === "document" && - isCorruptedThemeWorkbenchDocumentContent(content) - ) { - return "other"; - } - return inferredType; -} - -function normalizeSessionTaskFileType( - fileType: string, - fileName: string, - content?: string | null, -): TaskFile["type"] { - const normalized = fileType.trim().toLowerCase(); - if ( - normalized === "document" || - normalized === "image" || - normalized === "audio" || - normalized === "video" || - normalized === "other" - ) { - const resolvedByContent = resolveTaskFileType(fileName, content); - if (normalized === "document" && resolvedByContent !== "document") { - return resolvedByContent; - } - return normalized; - } - return resolveTaskFileType(fileName, content); -} - -function isRenderableTaskFile( - file: Pick, - isThemeWorkbench: boolean, -): boolean { - if (file.type !== "document") { - return false; - } - if (!isThemeWorkbench) { - return true; - } - return isThemeWorkbenchPrimaryDocumentArtifact(file.name); -} - -function buildThemeWorkbenchWorkflowSteps( - messages: Message[], - backendRunState: BackendThemeWorkbenchRunState | null, - isSending: boolean, - skillDetailMap: Record, -): Array<{ id: string; title: string; status: StepStatus }> { - const liveSteps = buildThemeWorkbenchLiveWorkflowSteps( - messages, - skillDetailMap, - isSending, - ); - if (liveSteps.length > 0) { - return liveSteps; - } - - const queueItems = backendRunState?.queue_items || []; - if (queueItems.length > 0) { - if (queueItems.length === 1) { - const item = queueItems[0]; - const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); - const workflowSteps = sourceRef - ? skillDetailMap[sourceRef]?.workflow_steps || [] - : []; - if (workflowSteps.length > 0) { - const latestAssistantContent = - messages - .slice() - .reverse() - .find((m) => m.role === "assistant")?.content || ""; - const activeIndex = - extractThemeWorkbenchWorkflowMarkerIndex(latestAssistantContent) ?? 0; - return workflowSteps.map((step, index) => ({ - id: `${item.run_id}-${step.id}`, - title: step.name, - status: - index < activeIndex - ? ("completed" as StepStatus) - : index === activeIndex - ? ("active" as StepStatus) - : ("pending" as StepStatus), - })); - } - } - return queueItems.map((item) => ({ - id: item.run_id, - title: resolveThemeWorkbenchQueueItemTitle(item, skillDetailMap), - status: resolveThemeWorkbenchRunStepStatus(item.status), - })); - } - - const latestTerminal = backendRunState?.latest_terminal; - if (latestTerminal && backendRunState?.run_state !== "auto_running") { - return [ - { - id: latestTerminal.run_id, - title: resolveThemeWorkbenchQueueItemTitle( - latestTerminal, - skillDetailMap, - ), - status: resolveThemeWorkbenchRunStepStatus(latestTerminal.status), - }, - ]; - } - - return []; -} - -function loadPersistedBoolean(key: string, fallback = false): boolean { - try { - const stored = localStorage.getItem(key); - if (stored == null) { - return fallback; - } - - try { - const parsed = JSON.parse(stored); - return typeof parsed === "boolean" ? parsed : fallback; - } catch { - return stored === "true"; - } - } catch { - return fallback; - } -} - -function savePersistedBoolean(key: string, value: boolean) { - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch { - // ignore write errors - } -} - -export interface WorkflowProgressSnapshot { - steps: Array<{ - id: string; - title: string; - status: StepStatus; - }>; - currentIndex: number; -} - -export interface AgentChatWorkspaceProps { - onNavigate?: (page: Page, params?: PageParams) => void; - projectId?: string; - contentId?: string; - agentEntry?: "new-task" | "claw"; - immersiveHome?: boolean; - theme?: string; - initialCreationMode?: CreationMode; - lockTheme?: boolean; - fromResources?: boolean; - hideHistoryToggle?: boolean; - showChatPanel?: boolean; - hideTopBar?: boolean; - topBarChrome?: "full" | "workspace-compact"; - onBackToProjectManagement?: () => void; - hideInlineStepProgress?: boolean; - onWorkflowProgressChange?: ( - snapshot: WorkflowProgressSnapshot | null, - ) => void; - initialUserPrompt?: string; - initialUserImages?: MessageImage[]; - initialSessionName?: string; - entryBannerMessage?: string; - onInitialUserPromptConsumed?: () => void; - newChatAt?: number; - onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; - onHasMessagesChange?: (hasMessages: boolean) => void; - onSessionChange?: (sessionId: string | null) => void; - preferContentReviewInRightRail?: boolean; - openBrowserAssistOnMount?: boolean; -} - -/** - * 判断画布状态是否为空 - * 用于决定是否自动触发 AI 引导 - */ -const HARNESS_PANEL_VISIBILITY_KEY = "lime.chat.harness-panel.visible.v1"; - -function isCanvasStateEmpty(state: CanvasStateUnion | null): boolean { - if (!state) return true; - - switch (state.type) { - case "document": - // 文档画布:检查 content 是否为空 - return !state.content || state.content.trim() === ""; - case "novel": - // 小说画布:检查第一章内容是否为空 - return ( - state.chapters.length === 0 || - !state.chapters[0].content || - state.chapters[0].content.trim() === "" - ); - case "script": - // 剧本画布:检查场景是否有实际内容 - return ( - state.scenes.length === 0 || - (state.scenes.length === 1 && - state.scenes[0].dialogues.length === 0 && - !state.scenes[0].description) - ); - case "music": - // 音乐画布:检查 sections 是否为空 - return !state.sections || state.sections.length === 0; - case "poster": - // 海报画布:检查页面中是否有图层 - return ( - state.pages.length === 0 || - (state.pages.length === 1 && state.pages[0].layers.length === 0) - ); - default: - return true; - } -} - -function serializeCanvasStateForSync(state: CanvasStateUnion): string { - switch (state.type) { - case "document": - return state.content || ""; - case "novel": - return JSON.stringify(state.chapters); - case "script": - return JSON.stringify(state.scenes); - case "music": - return JSON.stringify(state.sections); - case "poster": - return JSON.stringify(state.pages); - default: - return JSON.stringify(state); - } -} - -function isSyncContentEmpty(content: string): boolean { - return !content || content === "[]" || content === "{}"; -} - -function resolveThemeWorkbenchRecentTerminals( - state: BackendThemeWorkbenchRunState | null, -): ThemeWorkbenchRunTerminalItem[] { - if (!state) { - return []; - } - - const rawTerminals = - Array.isArray(state.recent_terminals) && state.recent_terminals.length > 0 - ? state.recent_terminals - : state.latest_terminal - ? [state.latest_terminal] - : []; - - const seenRunIds = new Set(); - return rawTerminals.filter((item) => { - const runId = item.run_id?.trim(); - if (!runId || seenRunIds.has(runId)) { - return false; - } - seenRunIds.add(runId); - return true; - }); -} - -function mergeThemeWorkbenchTerminalItems( - ...groups: ThemeWorkbenchRunTerminalItem[][] -): ThemeWorkbenchRunTerminalItem[] { - const merged: ThemeWorkbenchRunTerminalItem[] = []; - const seenRunIds = new Set(); - - groups.forEach((items) => { - items.forEach((item) => { - const runId = item.run_id?.trim(); - if (!runId || seenRunIds.has(runId)) { - return; - } - seenRunIds.add(runId); - merged.push(item); - }); - }); - - return merged; -} - -function buildThemeWorkbenchRunStateSignature( - state: BackendThemeWorkbenchRunState | null, -): string { - if (!state) { - return "null"; - } - - const queueSignature = (state.queue_items || []) - .map((item) => - [ - item.run_id, - item.execution_id || "", - item.status, - item.gate_key || "", - item.source || "", - item.source_ref || "", - ].join(":"), - ) - .join("|"); - - const terminalSignature = resolveThemeWorkbenchRecentTerminals(state) - .map((item) => - [ - item.run_id, - item.execution_id || "", - item.status, - item.gate_key || "", - item.source || "", - item.source_ref || "", - ].join(":"), - ) - .join("|"); - - return [ - state.run_state, - state.current_gate_key || "", - queueSignature, - terminalSignature, - ].join("||"); -} +export type { + AgentChatWorkspaceProps, + WorkflowProgressSnapshot, +} from "./agentChatWorkspaceContract"; export function AgentChatWorkspace({ onNavigate: _onNavigate, @@ -2943,8 +232,6 @@ export function AgentChatWorkspace({ const [showSidebar, setShowSidebar] = useState( () => defaultTopicSidebarVisible, ); - const [themeWorkbenchSidebarCollapsed, setThemeWorkbenchSidebarCollapsed] = - useState(false); const [input, setInput] = useState(""); const [selectedText, setSelectedText] = useState(""); const [entryBannerVisible, setEntryBannerVisible] = useState( @@ -3002,7 +289,6 @@ export function AgentChatWorkspace({ setEntryBannerVisible(Boolean(entryBannerMessage)); }, [entryBannerMessage]); - const openBrowserAssistOnMountHandledRef = useRef(false); const pageMountedAtRef = useRef(Date.now()); useEffect(() => { @@ -3059,33 +345,8 @@ export function AgentChatWorkspace({ contentId: string; body: string; } | null>(null); - const themeWorkbenchRunStateSignatureRef = useRef(""); const [novelChapterListCollapsed, setNovelChapterListCollapsed] = useState(false); - const [themeWorkbenchBackendRunState, setThemeWorkbenchBackendRunState] = - useState(null); - const [themeWorkbenchHistoryTerminals, setThemeWorkbenchHistoryTerminals] = - useState([]); - const [themeWorkbenchHistoryHasMore, setThemeWorkbenchHistoryHasMore] = - useState(false); - const [themeWorkbenchHistoryNextOffset, setThemeWorkbenchHistoryNextOffset] = - useState(null); - const [themeWorkbenchHistoryLoading, setThemeWorkbenchHistoryLoading] = - useState(false); - const [themeWorkbenchSkillDetailMap, setThemeWorkbenchSkillDetailMap] = - useState>({}); - const [selectedThemeWorkbenchRunId, setSelectedThemeWorkbenchRunId] = - useState(null); - const themeWorkbenchHistoryLoadingRef = useRef(false); - const [selectedThemeWorkbenchRunDetail, setSelectedThemeWorkbenchRunDetail] = - useState(null); - const [themeWorkbenchRunDetailLoading, setThemeWorkbenchRunDetailLoading] = - useState(false); - const [ - themeWorkbenchCreationTaskEvents, - setThemeWorkbenchCreationTaskEvents, - ] = useState([]); - const documentEditorFocusedRef = useRef(false); const { selectedTeam, setSelectedTeam: handleSelectTeam, @@ -3121,8 +382,6 @@ export function AgentChatWorkspace({ const [selectedFileId, setSelectedFileId] = useState(); const taskFilesRef = useRef([]); const socialStageLogRef = useRef>({}); - const generalResourceHashesRef = useRef>>(new Map()); - const generalResourceSyncInFlightRef = useRef>(new Set()); // 项目上下文状态 const [project, setProject] = useState(null); @@ -3148,28 +407,26 @@ export function AgentChatWorkspace({ sourceProfile: null, }); + const imageWorkbenchGenerationRuntime = useImageGen({ + preferredProviderId: effectiveImageWorkbenchPreference.preferredProviderId, + preferredModelId: effectiveImageWorkbenchPreference.preferredModelId, + allowFallback: effectiveImageWorkbenchPreference.allowFallback, + }); const { availableProviders: imageWorkbenchProviders, selectedProvider: imageWorkbenchSelectedProvider, selectedProviderId: imageWorkbenchSelectedProviderId, setSelectedProviderId: setImageWorkbenchSelectedProviderId, - availableModels: imageWorkbenchModels, selectedModel: imageWorkbenchSelectedModel, selectedModelId: imageWorkbenchSelectedModelId, setSelectedModelId: setImageWorkbenchSelectedModelId, selectedSize: imageWorkbenchSelectedSize, setSelectedSize: setImageWorkbenchSelectedSize, - generating: imageWorkbenchGenerating, - savingToResource: imageWorkbenchSavingToResource, preferredProviderUnavailable: imageWorkbenchPreferredProviderUnavailable, generateImage: runImageWorkbenchGeneration, cancelGeneration: cancelImageWorkbenchGeneration, saveImagesToResource: saveImageWorkbenchImagesToResource, - } = useImageGen({ - preferredProviderId: effectiveImageWorkbenchPreference.preferredProviderId, - preferredModelId: effectiveImageWorkbenchPreference.preferredModelId, - allowFallback: effectiveImageWorkbenchPreference.allowFallback, - }); + } = imageWorkbenchGenerationRuntime; const imageWorkbenchPreferenceSourceLabel = useMemo(() => { switch (effectiveImageWorkbenchPreference.source) { case "project": @@ -3310,6 +567,14 @@ export function AgentChatWorkspace({ const selectedArtifact = useAtomValue(selectedArtifactAtom); const setArtifacts = useSetAtom(artifactsAtom); const setSelectedArtifactId = useSetAtom(selectedArtifactIdAtom); + const upsertGeneralArtifact = useCallback( + (artifact: Artifact) => { + setArtifacts((currentArtifacts) => + mergeArtifacts([...currentArtifacts, artifact]), + ); + }, + [setArtifacts], + ); const liveArtifact = useMemo( () => selectedArtifact || @@ -3326,124 +591,9 @@ export function AgentChatWorkspace({ >("desktop"); const [canvasWorkbenchLayoutMode, setCanvasWorkbenchLayoutMode] = useState("split"); - const [browserAssistLaunching, setBrowserAssistLaunching] = useState(false); - const [browserAssistSessionState, setBrowserAssistSessionState] = - useState(null); const [browserTaskPreflight, setBrowserTaskPreflight] = useState(null); - const autoOpenedBrowserAssistSessionIdRef = useRef(""); - const autoLaunchingBrowserAssistKeyRef = useRef(""); - const browserAssistLaunchRequestIdRef = useRef(0); - const browserTaskPreflightLaunchIdRef = useRef(""); const autoCollapsedTopicSidebarRef = useRef(false); - const browserAssistAutoOpenDismissedScopeRef = useRef(null); - const browserAssistScopeTrackerRef = useRef(null); - - // 当有新的 artifact 时,自动打开画布 - useEffect(() => { - if (activeTheme !== "general") return; - if (artifacts.length === 0) return; - const hasNonBrowserAssistArtifact = artifacts.some( - (artifact) => artifact.type !== "browser_assist", - ); - const hasBoundBrowserAssistSession = Boolean( - browserAssistSessionState?.sessionId || - browserAssistSessionState?.profileKey, - ); - if (!hasNonBrowserAssistArtifact && !hasBoundBrowserAssistSession) { - return; - } - - if ( - !hasNonBrowserAssistArtifact && - browserAssistAutoOpenDismissedScopeRef.current - ) { - return; - } - - // 自动打开画布显示 artifact - setLayoutMode("chat-canvas"); - }, [ - activeTheme, - artifacts, - browserAssistSessionState?.profileKey, - browserAssistSessionState?.sessionId, - ]); - - const isBrowserAssistReady = useMemo( - () => hasActiveBrowserAssistSession(browserAssistSessionState), - [browserAssistSessionState], - ); - const browserAssistEntryLabel = useMemo(() => { - if (browserTaskPreflight?.phase === "launching" || browserAssistLaunching) { - return "浏览器启动中"; - } - if ( - browserTaskPreflight?.phase === "awaiting_user" || - browserTaskPreflight?.phase === "ready_to_resume" - ) { - return "等待登录"; - } - if (browserTaskPreflight?.phase === "failed") { - return "浏览器未连接"; - } - if (isBrowserAssistReady) { - return "浏览器已就绪"; - } - return "浏览器协助"; - }, [ - browserAssistLaunching, - browserTaskPreflight?.phase, - isBrowserAssistReady, - ]); - const browserAssistAttentionLevel = useMemo(() => { - if (browserTaskPreflight?.phase === "launching" || browserAssistLaunching) { - return "info" as const; - } - - if ( - browserTaskPreflight?.phase === "awaiting_user" || - browserTaskPreflight?.phase === "ready_to_resume" || - browserTaskPreflight?.phase === "failed" - ) { - return "warning" as const; - } - - return "idle" as const; - }, [browserAssistLaunching, browserTaskPreflight?.phase]); - - 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") { @@ -3883,9 +1033,13 @@ export function AgentChatWorkspace({ childSubagentSessions = [], subagentParentContext = null, queuedTurns = [], + threadRead = null, isSending, sendMessage, + compactSession = async () => undefined, stopSending, + resumeThread = async () => false, + replayPendingAction = async () => false, promoteQueuedTurn = async () => false, removeQueuedTurn = async () => false, clearMessages, @@ -3893,6 +1047,7 @@ export function AgentChatWorkspace({ editMessage, handlePermissionResponse, pendingActions = [], + submittedActionsInFlight = [], triggerAIGuide, topics = [], sessionId, @@ -3928,10 +1083,6 @@ export function AgentChatWorkspace({ }, [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, @@ -3978,369 +1129,33 @@ export function AgentChatWorkspace({ }, [setChatMessages], ); - const handleCloseSubagentSession = useCallback( - async (subagentSessionId: string) => { - try { - const response = await closeAgentRuntimeSubagent({ - id: subagentSessionId, - }); - const summary = buildTeamControlSummary({ - action: "close", - requestedSessionIds: [subagentSessionId], - cascadeSessionIds: response.cascade_session_ids, - affectedSessionIds: response.changed_session_ids, - }); - if (summary.affectedSessionIds.length > 0) { - setTeamControlSummary(summary); - } - - if (summary.affectedSessionIds.length > 1) { - toast.success( - `已级联关闭 ${summary.affectedSessionIds.length} 位协作成员`, - ); - } else if (summary.affectedSessionIds.length === 1) { - toast.success("协作成员已关闭"); - } else { - toast.info( - `当前成员状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.previous_status.kind)},未发生新的关闭变更`, - ); - } - } catch (error) { - const message = - error instanceof Error ? error.message : "关闭协作成员失败"; - toast.error(message); - throw error; - } - }, - [], - ); - const handleResumeSubagentSession = useCallback( - async (subagentSessionId: string) => { - try { - const response = await resumeAgentRuntimeSubagent({ - id: subagentSessionId, - }); - const summary = buildTeamControlSummary({ - action: "resume", - requestedSessionIds: [subagentSessionId], - cascadeSessionIds: response.cascade_session_ids, - affectedSessionIds: response.changed_session_ids, - }); - if (summary.affectedSessionIds.length > 0) { - setTeamControlSummary(summary); - } - - if (summary.affectedSessionIds.length > 1) { - toast.success( - `已级联恢复 ${summary.affectedSessionIds.length} 位协作成员`, - ); - } else if (summary.affectedSessionIds.length === 1) { - toast.success("协作成员已恢复"); - } else { - toast.info( - `当前成员状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.status.kind)},未发生新的恢复变更`, - ); - } - } catch (error) { - const message = - error instanceof Error ? error.message : "恢复协作成员失败"; - toast.error(message); - throw error; - } - }, - [], - ); - const handleWaitSubagentSession = useCallback( - async (subagentSessionId: string, timeoutMs = 30_000) => { - try { - const response = await waitAgentRuntimeSubagents({ - ids: [subagentSessionId], - timeout_ms: timeoutMs, - }); - if (response.timed_out) { - toast.info("等待超时,该成员仍未进入最终状态"); - return; - } - - const status = response.status[subagentSessionId]; - toast.success( - `该成员已进入${resolveTeamWorkspaceRuntimeStatusLabel(status?.kind)}状态`, - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "等待成员失败"; - toast.error(message); - throw error; - } - }, - [], - ); - const handleWaitActiveTeamSessions = useCallback( - async (subagentSessionIds: string[], timeoutMs = 30_000) => { - const normalizedSessionIds = - normalizeUniqueSessionIds(subagentSessionIds); - - if (normalizedSessionIds.length === 0) { - const error = new Error("没有可等待的活跃成员"); - toast.error(error.message); - throw error; - } - - try { - const response = await waitAgentRuntimeSubagents({ - ids: normalizedSessionIds, - timeout_ms: timeoutMs, - }); - if (response.timed_out) { - setTeamWaitSummary({ - awaitedSessionIds: normalizedSessionIds, - timedOut: true, - updatedAt: Date.now(), - }); - toast.info("等待超时,团队内活跃成员仍未进入最终状态"); - return; - } - - const resolvedSessionId = - normalizedSessionIds.find((sessionId) => - isTeamWorkspaceTerminalStatus(response.status[sessionId]?.kind), - ) ?? normalizedSessionIds[0]; - const resolvedStatus = resolvedSessionId - ? response.status[resolvedSessionId]?.kind - : undefined; - - setTeamWaitSummary({ - awaitedSessionIds: normalizedSessionIds, - timedOut: false, - resolvedSessionId, - resolvedStatus, - updatedAt: Date.now(), - }); - toast.success( - `团队成员已进入${resolveTeamWorkspaceRuntimeStatusLabel(resolvedStatus)}状态`, - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "等待团队成员失败"; - toast.error(message); - throw error; - } - }, - [], - ); - const handleCloseCompletedTeamSessions = useCallback( - async (subagentSessionIds: string[]) => { - const normalizedSessionIds = - normalizeUniqueSessionIds(subagentSessionIds); - - if (normalizedSessionIds.length === 0) { - const error = new Error("没有可关闭的已完成成员"); - toast.error(error.message); - throw error; - } - - const results = await Promise.allSettled( - normalizedSessionIds.map((sessionId) => - closeAgentRuntimeSubagent({ id: sessionId }), - ), - ); - const successfulResponses = results - .filter( - ( - result, - ): result is PromiseFulfilledResult< - Awaited> - > => result.status === "fulfilled", - ) - .map((result) => result.value); - const succeededCount = results.filter( - (result) => result.status === "fulfilled", - ).length; - const affectedSessionIds = normalizeUniqueSessionIds( - successfulResponses.flatMap((response) => response.changed_session_ids), - ); - const cascadeSessionIds = normalizeUniqueSessionIds( - successfulResponses.flatMap((response) => response.cascade_session_ids), - ); - const failedResults = results.filter( - (result): result is PromiseRejectedResult => - result.status === "rejected", - ); - - if (successfulResponses.length > 0) { - setTeamControlSummary( - buildTeamControlSummary({ - action: "close_completed", - requestedSessionIds: normalizedSessionIds, - cascadeSessionIds, - affectedSessionIds, - }), - ); - } - - if (succeededCount > 0) { - toast.success( - affectedSessionIds.length > 0 - ? `已级联关闭 ${affectedSessionIds.length} 个会话` - : `已关闭 ${succeededCount} 位已完成成员`, - ); - } - - if (failedResults.length > 0) { - const firstFailure = failedResults[0]?.reason; - const message = - firstFailure instanceof Error - ? firstFailure.message - : "部分已完成成员关闭失败"; - toast.error(message); - if (succeededCount === 0) { - throw firstFailure instanceof Error - ? firstFailure - : new Error(message); - } - } - }, - [], - ); - const handleSendSubagentInput = useCallback( - async ( - subagentSessionId: string, - message: string, - options?: { interrupt?: boolean }, - ) => { - const normalizedMessage = message.trim(); - if (!normalizedMessage) { - const error = new Error("请输入要发送给成员的内容"); - toast.error(error.message); - throw error; - } - - try { - await sendAgentRuntimeSubagentInput({ - id: subagentSessionId, - message: normalizedMessage, - interrupt: options?.interrupt === true, - }); - toast.success( - options?.interrupt === true - ? "已中断当前执行并发送新说明" - : "已向成员发送补充说明", - ); - } catch (error) { - const messageText = - error instanceof Error ? error.message : "发送成员说明失败"; - toast.error(messageText); - throw error; - } - }, - [], - ); - const currentSessionTitle = useMemo( - () => topics.find((topic) => topic.id === sessionId)?.title ?? null, - [sessionId, topics], - ); - const showTeamWorkspaceBoard = - chatToolPreferences.subagent || - hasRealTeamGraph || - Boolean(runtimeTeamState); - const currentSessionRuntimeStatus = useMemo( - () => - deriveCurrentSessionRuntimeStatus({ - isSending, - queuedTurnCount: queuedTurns.length, - turns, - }), - [isSending, queuedTurns.length, turns], - ); - const currentSessionLatestTurnStatus = useMemo( - () => deriveLatestTurnRuntimeStatus(turns), - [turns], - ); - const { - liveRuntimeBySessionId: teamLiveRuntimeBySessionId, - liveActivityBySessionId: teamLiveActivityBySessionId, - activityRefreshVersionBySessionId: teamActivityRefreshVersionBySessionId, - } = useTeamWorkspaceRuntime({ - currentSessionId: sessionId, - currentSessionRuntimeStatus, - currentSessionLatestTurnStatus, - currentSessionQueuedTurnCount: queuedTurns.length, + const teamSessionRuntime = useWorkspaceTeamSessionRuntime({ + sessionId, + topics, + turns, + queuedTurnCount: queuedTurns.length, + isSending, + subagentEnabled: chatToolPreferences.subagent, + runtimeTeamState, childSubagentSessions, subagentParentContext, }); - const [teamWorkbenchAutoFocusToken, setTeamWorkbenchAutoFocusToken] = - useState(0); - const previousTeamWorkbenchSessionIdRef = useRef( - sessionId ?? null, - ); - const previousRealTeamGraphRef = useRef(hasRealTeamGraph); - const previousTeamWorkbenchLayoutModeRef = useRef(layoutMode); - const lastAutoActivatedRuntimeTeamRequestIdRef = useRef(null); - const dismissedRuntimeTeamRequestIdRef = useRef(null); - const handleActivateTeamWorkbench = useCallback(() => { - setTeamWorkbenchAutoFocusToken((current) => current + 1); - setLayoutMode((current) => (current === "chat" ? "chat-canvas" : current)); - }, []); - useEffect(() => { - const previousLayoutMode = previousTeamWorkbenchLayoutModeRef.current; - const activeRuntimeTeamRequestId = runtimeTeamState?.requestId ?? null; - - if ( - activeRuntimeTeamRequestId && - previousLayoutMode !== "chat" && - layoutMode === "chat" - ) { - dismissedRuntimeTeamRequestIdRef.current = activeRuntimeTeamRequestId; - } - - previousTeamWorkbenchLayoutModeRef.current = layoutMode; - }, [layoutMode, runtimeTeamState?.requestId]); - useEffect(() => { - const normalizedSessionId = sessionId ?? null; - const activeRuntimeTeamRequestId = runtimeTeamState?.requestId ?? null; - const shouldAutoActivateRuntimeTeam = - Boolean(activeRuntimeTeamRequestId) && - runtimeTeamState?.status !== "failed" && - dismissedRuntimeTeamRequestIdRef.current !== activeRuntimeTeamRequestId && - lastAutoActivatedRuntimeTeamRequestIdRef.current !== - activeRuntimeTeamRequestId; - - if (previousTeamWorkbenchSessionIdRef.current !== normalizedSessionId) { - previousTeamWorkbenchSessionIdRef.current = normalizedSessionId; - previousRealTeamGraphRef.current = hasRealTeamGraph; - previousTeamWorkbenchLayoutModeRef.current = layoutMode; - lastAutoActivatedRuntimeTeamRequestIdRef.current = null; - dismissedRuntimeTeamRequestIdRef.current = null; - return; - } - - if (shouldAutoActivateRuntimeTeam && activeRuntimeTeamRequestId) { - lastAutoActivatedRuntimeTeamRequestIdRef.current = - activeRuntimeTeamRequestId; - handleActivateTeamWorkbench(); - } - - const shouldSkipGraphAutoActivate = - activeRuntimeTeamRequestId !== null && - dismissedRuntimeTeamRequestIdRef.current === activeRuntimeTeamRequestId; - - if ( - hasRealTeamGraph && - !previousRealTeamGraphRef.current && - !shouldSkipGraphAutoActivate - ) { - handleActivateTeamWorkbench(); - } - - previousRealTeamGraphRef.current = hasRealTeamGraph; - }, [ + const teamSessionControlRuntime = useWorkspaceTeamSessionControlRuntime({ + childSubagentSessions, + liveRuntimeBySessionId: teamSessionRuntime.liveRuntimeBySessionId, + stopSending, + }); + const { + teamWorkbenchAutoFocusToken, + dismissActiveTeamWorkbenchAutoOpen, handleActivateTeamWorkbench, - hasRealTeamGraph, + } = useWorkspaceTeamWorkbenchAutoOpenRuntime({ + hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph, layoutMode, - runtimeTeamState?.requestId, - runtimeTeamState?.status, + runtimeTeamRequestId: runtimeTeamState?.requestId ?? null, sessionId, - ]); + setLayoutMode, + }); useEffect(() => { logAgentDebug( "AgentChatPage", @@ -4424,64 +1239,31 @@ export function AgentChatWorkspace({ ); const currentCanvasArtifact = artifactDisplayState.liveArtifact; const displayedCanvasArtifact = artifactDisplayState.displayArtifact; - const currentBrowserAssistScopeKey = useMemo( - () => - activeTheme === "general" - ? resolveBrowserAssistSessionScopeKey(projectId, sessionId) - : null, - [activeTheme, projectId, sessionId], - ); - const browserAssistArtifact = useMemo( - () => - artifacts.find( - (artifact) => - artifact.id === GENERAL_BROWSER_ASSIST_ARTIFACT_ID && - artifact.type === "browser_assist" && - resolveBrowserAssistArtifactScopeKey(artifact) === - currentBrowserAssistScopeKey, - ) || null, - [artifacts, currentBrowserAssistScopeKey], - ); - const latestBrowserAssistSessionFromMessages = useMemo( - () => findLatestBrowserAssistSessionInMessages(messages), - [messages], - ); - const browserAssistSessionFromArtifact = useMemo( - () => extractBrowserAssistSessionFromArtifact(browserAssistArtifact), - [browserAssistArtifact], - ); - const browserAssistStorageKey = useMemo( - () => - activeTheme === "general" - ? `${projectId || "global"}:${sessionId || "active"}` - : null, - [activeTheme, projectId, sessionId], - ); - const isBrowserAssistCanvasVisible = - activeTheme === "general" && - layoutMode !== "chat" && - currentCanvasArtifact?.type === "browser_assist"; - - 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 { + browserAssistLaunching, + isBrowserAssistReady, + isBrowserAssistCanvasVisible, + currentBrowserAssistScopeKey, + ensureBrowserAssistCanvas, + handleOpenBrowserAssistInCanvas, + suppressBrowserAssistCanvasAutoOpen, + suppressGeneralCanvasArtifactAutoOpen, + } = useWorkspaceBrowserAssistRuntime({ + activeTheme, + projectId, + sessionId, + input, + initialUserPrompt, + openBrowserAssistOnMount, + artifacts, + messages, + currentCanvasArtifact, + layoutMode, + setLayoutMode, + setSelectedArtifactId, + upsertGeneralArtifact, + generalBrowserAssistProfileKey: GENERAL_BROWSER_ASSIST_PROFILE_KEY, + }); const compatSubagentRuntime = useCompatSubagentRuntime(sessionId); const realSubagentTimelineItems = useMemo( @@ -4523,44 +1305,6 @@ export function AgentChatWorkspace({ ), [effectiveThreadItems, messages, pendingActions, todoItems], ); - const activeRuntimeStatusTitle = useMemo(() => { - if (!isSending) { - return null; - } - - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message.role === "assistant" && message.runtimeStatus?.title) { - return message.runtimeStatus.title; - } - } - - return "正在准备处理"; - }, [isSending, messages]); - const [harnessPanelVisible, setHarnessPanelVisible] = useState(() => - loadPersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, false), - ); - const [toolInventory, setToolInventory] = - useState(null); - const [toolInventoryLoading, setToolInventoryLoading] = useState(false); - const [toolInventoryError, setToolInventoryError] = useState( - null, - ); - const toolInventoryRequestIdRef = useRef(0); - const thinkingVariantWarnedRef = useRef>(new Set()); - const resolveSendProviderContext = useCallback(async () => { - const configuredProviders = await loadConfiguredProviders(); - const selectedProvider = - configuredProviders.find((provider) => provider.key === providerType) || - null; - const providerModels = await loadProviderModels(selectedProvider); - - return { - selectedProvider, - providerModels, - }; - }, [providerType]); - useEffect(() => { onSessionChange?.(sessionId ?? null); }, [onSessionChange, sessionId]); @@ -4618,1073 +1362,92 @@ export function AgentChatWorkspace({ ); }, [activeTheme, displayedCanvasArtifact]); - useEffect(() => { - savePersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, harnessPanelVisible); - }, [harnessPanelVisible]); - - const contextWorkspace = useThemeContextWorkspace({ + const contextHarnessRuntime = useWorkspaceContextHarnessRuntime({ projectId, activeTheme, messages, providerType, model, - }); - const isThemeWorkbench = contextWorkspace.enabled; - const harnessSkillNames = useMemo( - () => collectConversationSkillNames(messages), - [messages], - ); - const harnessPendingCount = harnessState.pendingApprovals.length; - const shouldAlwaysShowHarnessToggle = - contextWorkspace.enabled && mappedTheme === "social-media"; - const shouldAlwaysShowGeneralWorkbenchToggle = - chatMode === "general" && !contextWorkspace.enabled; - const hasHarnessActivity = - harnessPanelVisible || - harnessState.hasSignals || - compatSubagentRuntime.isRunning; - const showHarnessToggle = - shouldAlwaysShowHarnessToggle || - shouldAlwaysShowGeneralWorkbenchToggle || - hasHarnessActivity; - const harnessAttentionLevel = - harnessPendingCount > 0 - ? "warning" - : hasHarnessActivity - ? "active" - : "idle"; - const navbarHarnessPanelVisible = harnessPanelVisible; - const visibleContextItems = useMemo(() => { - const activeItems = contextWorkspace.sidebarContextItems.filter( - (item) => item.active, - ); - return activeItems.length > 0 - ? activeItems - : contextWorkspace.sidebarContextItems; - }, [contextWorkspace.sidebarContextItems]); - const harnessEnvironment = useMemo( - () => ({ - skillsCount: harnessSkillNames.length, - skillNames: harnessSkillNames.slice(0, 4), - memorySignals: [ - projectMemory?.characters.length ? "角色" : null, - projectMemory?.world_building ? "世界观" : null, - projectMemory?.style_guide ? "风格" : null, - projectMemory?.outline.length ? "大纲" : null, - ].filter((item): item is string => item !== null), - contextItemsCount: contextWorkspace.sidebarContextItems.length, - activeContextCount: contextWorkspace.sidebarContextItems.filter( - (item) => item.active, - ).length, - contextItemNames: visibleContextItems - .map((item) => item.name) - .filter((name) => !!name.trim()) - .slice(0, 4), - contextEnabled: contextWorkspace.enabled, - }), - [ - contextWorkspace.enabled, - contextWorkspace.sidebarContextItems, - harnessSkillNames, - projectMemory?.characters.length, - projectMemory?.outline.length, - projectMemory?.style_guide, - projectMemory?.world_building, - visibleContextItems, - ], - ); - const shouldUseCompactThemeWorkbench = - isThemeWorkbench && (mappedTheme === "video" || mappedTheme === "poster"); - const shouldSkipThemeWorkbenchAutoGuideWithoutPrompt = - isThemeWorkbench && - (shouldUseCompactThemeWorkbench || mappedTheme === "novel"); - const enableThemeWorkbenchPanelCollapse = - isThemeWorkbench && mappedTheme === "social-media"; - const handleToggleHarnessPanel = useCallback(() => { - setHarnessPanelVisible((current) => !current); - }, []); - - // 主题工作台模式:同步 skills 状态到 store - // 注意:不再设置 themeSkillsRailState,避免"操作面板"覆盖默认 Skills Rail - // 默认 Skills Rail 已包含完整的技能分类(文字多搜索、视觉生成、音频生成等) - useEffect(() => { - if (!isThemeWorkbench) { - clearThemeSkillsRailState(); - } - }, [isThemeWorkbench, clearThemeSkillsRailState]); - - // 组件卸载时清理 store 状态 - useEffect(() => { - return () => { - clearThemeSkillsRailState(); - }; - }, [clearThemeSkillsRailState]); - - useEffect(() => { - if (!isThemeWorkbench) { - setThemeWorkbenchCreationTaskEvents([]); - } - }, [isThemeWorkbench]); - - useEffect(() => { - if (!isThemeWorkbench || !sessionId) { - return; - } - - setThemeWorkbenchCreationTaskEvents([]); - - let cancelled = false; - let unlisten: (() => void) | null = null; - - safeListen( - THEME_WORKBENCH_CREATION_TASK_EVENT_NAME, - (event) => { - if (cancelled) { - return; - } - const normalized = normalizeThemeWorkbenchCreationTaskEvent( - event.payload || {}, - ); - if (!normalized) { - return; - } - setThemeWorkbenchCreationTaskEvents((previous) => { - const deduplicated = previous.filter( - (item) => - item.taskId !== normalized.taskId && - item.path !== normalized.path, - ); - return [normalized, ...deduplicated].slice( - 0, - MAX_THEME_WORKBENCH_CREATION_TASK_EVENTS, - ); - }); - }, - ) - .then((dispose) => { - if (cancelled) { - void dispose(); - return; - } - unlisten = dispose; - }) - .catch((error) => { - console.warn("[AgentChatPage] 监听任务提交事件失败:", error); - }); - - return () => { - cancelled = true; - if (unlisten) { - unlisten(); - } - }; - }, [isThemeWorkbench, sessionId]); - - useEffect(() => { - if (!isThemeWorkbench || canvasState) { - return; - } - - const initialThemeWorkbenchCanvas = - createInitialCanvasState(mappedTheme, "") || - createInitialDocumentState(""); - if (!initialThemeWorkbenchCanvas) { - return; - } - - setCanvasState(initialThemeWorkbenchCanvas); - setLayoutMode((previous) => (previous === "chat" ? "canvas" : previous)); - }, [canvasState, isThemeWorkbench, mappedTheme]); - - useEffect(() => { - if (enableThemeWorkbenchPanelCollapse) { - return; - } - setThemeWorkbenchSidebarCollapsed(false); - }, [enableThemeWorkbenchPanelCollapse]); - const versionTopics = useMemo(() => { - if (!isThemeWorkbench || !canvasState || canvasState.type !== "document") { - return []; - } - return canvasState.versions.map((version, index) => ({ - id: version.id, - title: version.description?.trim() || `版本 ${index + 1}`, - messagesCount: version.content.trim() ? 2 : 0, - })); - }, [canvasState, isThemeWorkbench]); - const currentVersionId = - isThemeWorkbench && canvasState?.type === "document" - ? canvasState.currentVersionId - : null; - const { branchItems, setTopicStatus } = useTopicBranchBoard({ - enabled: isThemeWorkbench && canvasState?.type === "document", - projectId, - currentTopicId: currentVersionId, - topics: versionTopics, - externalStatusMap: documentVersionStatusMap, - onStatusMapChange: setDocumentVersionStatusMap, - }); - - useEffect(() => { - if ( - !isThemeWorkbench || - !contentId || - !canvasState || - canvasState.type !== "document" - ) { - return; - } - - const persisted = buildPersistedThemeWorkbenchDocument( - canvasState, - documentVersionStatusMap, - ); - if (!persisted) { - return; - } - - const snapshot = JSON.stringify(persisted); - if (snapshot === persistedWorkbenchSnapshotRef.current) { - return; - } - - const nextMetadata = { - ...(contentMetadataRef.current || {}), - [THEME_WORKBENCH_DOCUMENT_META_KEY]: persisted, - }; - - const timer = setTimeout(() => { - updateContent(contentId, { - metadata: nextMetadata, - }) - .then((updated) => { - contentMetadataRef.current = updated.metadata || nextMetadata; - persistedWorkbenchSnapshotRef.current = snapshot; - }) - .catch((error) => { - console.warn("[AgentChatPage] 保存文稿版本状态失败:", error); - }); - }, 1000); - - return () => clearTimeout(timer); - }, [canvasState, contentId, documentVersionStatusMap, isThemeWorkbench]); - - const pendingActionRequest = useMemo(() => { - const latestPendingMessage = [...messages] - .reverse() - .find((message) => - message.actionRequests?.some((request) => request.status === "pending"), - ); - - if (!latestPendingMessage?.actionRequests) { - return null; - } - - return ( - [...latestPendingMessage.actionRequests] - .reverse() - .find((request) => request.status === "pending") || null - ); - }, [messages]); - - // 提取最新的 A2UI Form(从最后一条 assistant 消息的 content 解析) - const pendingMessageA2UIForm = useMemo(() => { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - - if (msg.role === "user") { - return null; - } - - if (msg.role === "assistant" && msg.content) { - try { - const parsed = parseAIResponse(msg.content, false); - if (parsed.hasA2UI) { - for (let j = parsed.parts.length - 1; j >= 0; j--) { - const part = parsed.parts[j]; - if (part.type === "a2ui" && typeof part.content !== "string") { - return part.content; - } - } - } - } catch { - // 解析失败,忽略 - } - } - } - return null; - }, [messages]); - - const pendingPromotedA2UIActionRequest = useMemo(() => { - if (pendingMessageA2UIForm) { - return null; - } - - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - const pendingRequest = [...(message.actionRequests || [])] - .reverse() - .find( - (request) => - request.status === "pending" && - isActionRequestA2UICompatible(request), - ); - - if (pendingRequest) { - return pendingRequest; - } - } - - return null; - }, [messages, pendingMessageA2UIForm]); - - const pendingLegacyQuestionnaireA2UIForm = useMemo(() => { - if (pendingMessageA2UIForm || pendingActionRequest) { - return null; - } - - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - - if (message.role === "user") { - return null; - } - - if (message.role !== "assistant") { - continue; - } - - if ((message.actionRequests || []).length > 0) { - return null; - } - - return buildLegacyQuestionnaireA2UI(message.content || ""); - } - - return null; - }, [messages, pendingActionRequest, pendingMessageA2UIForm]); - - const pendingA2UIForm = useMemo(() => { - if (pendingMessageA2UIForm) { - return pendingMessageA2UIForm; - } - - if (pendingPromotedA2UIActionRequest) { - return buildActionRequestA2UI(pendingPromotedA2UIActionRequest); - } - - return pendingLegacyQuestionnaireA2UIForm; - }, [ - pendingLegacyQuestionnaireA2UIForm, - pendingMessageA2UIForm, - pendingPromotedA2UIActionRequest, - ]); - - const a2uiSubmissionNotice = useMemo(() => { - if (pendingA2UIForm) { - return null; - } - - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.role === "assistant") { - const submittedActionRequest = [...(msg.actionRequests || [])] - .reverse() - .find( - (request) => - request.status === "submitted" && - isActionRequestA2UICompatible(request), - ); - - if (submittedActionRequest) { - const summary = summarizeActionRequestSubmission( - submittedActionRequest, - ); - return { - title: "补充信息已确认", - summary: summary || "已收到你的补充信息,正在继续推进下一步。", - }; - } - - continue; - } - - if (msg.role !== "user") { - continue; - } - - const content = msg.content.trim(); - if (!content.startsWith("我的选择:")) { - return null; - } - - const summary = content - .split("\n") - .slice(1) - .map((line) => line.replace(/^[-•]\s*/, "").trim()) - .filter(Boolean) - .slice(0, 3) - .join(" · "); - - return { - title: "需求已确认", - summary: summary || "已收到你的补充信息,正在继续推进下一步。", - }; - } - - return null; - }, [messages, pendingA2UIForm]); - - useEffect(() => { - if ( - !pendingActionRequest || - pendingA2UIForm || - !isActionRequestA2UICompatible(pendingActionRequest) - ) { - return; - } - - console.warn("[AgentChatPage] 待处理 action_required 未生成输入区 A2UI", { - requestId: pendingActionRequest.requestId, - actionType: pendingActionRequest.actionType, - prompt: pendingActionRequest.prompt, - scope: pendingActionRequest.scope, - }); - }, [pendingA2UIForm, pendingActionRequest]); - - useEffect(() => { - const unsubscribe = subscribeDocumentEditorFocus((focused) => { - documentEditorFocusedRef.current = focused; - }); - return unsubscribe; - }, []); - - useEffect(() => { - if (!isThemeWorkbench || !sessionId) { - themeWorkbenchRunStateSignatureRef.current = ""; - setThemeWorkbenchBackendRunState(null); - return; - } - - let disposed = false; - let inFlight = false; - let timer: number | null = null; - const activePollIntervalMs = isSending ? 1000 : 3000; - const idlePollIntervalMs = isSending ? 1000 : 10000; - const focusedPollIntervalMs = isSending ? 1000 : 15000; - - const scheduleNext = (delayMs: number) => { - if (disposed) { - return; - } - timer = window.setTimeout(() => { - void fetchRunState(); - }, delayMs); - }; - - const fetchRunState = async () => { - if (disposed || inFlight) { - return; - } - - inFlight = true; - try { - const state = await executionRunGetThemeWorkbenchState(sessionId, 3); - if (!disposed) { - const nextSignature = buildThemeWorkbenchRunStateSignature(state); - if (themeWorkbenchRunStateSignatureRef.current !== nextSignature) { - themeWorkbenchRunStateSignatureRef.current = nextSignature; - setThemeWorkbenchBackendRunState(state); - } - - const hasFreshRunningQueueItem = (state.queue_items || []).some( - (item) => { - if (item.status !== "running") { - return false; - } - const startedAt = new Date(item.started_at); - if (Number.isNaN(startedAt.getTime())) { - return false; - } - return ( - Date.now() - startedAt.getTime() <= - THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS - ); - }, - ); - - const latestTerminalRunning = - state.latest_terminal?.status === "running"; - const hasActiveBackendRun = - state.run_state === "auto_running" || - hasFreshRunningQueueItem || - latestTerminalRunning; - const isEditorFocused = documentEditorFocusedRef.current; - scheduleNext( - hasActiveBackendRun - ? activePollIntervalMs - : isEditorFocused - ? focusedPollIntervalMs - : idlePollIntervalMs, - ); - } - } catch (error) { - if (!disposed) { - console.warn("[AgentChatPage] 拉取主题工作台运行状态失败:", error); - if (themeWorkbenchRunStateSignatureRef.current !== "null") { - themeWorkbenchRunStateSignatureRef.current = "null"; - setThemeWorkbenchBackendRunState(null); - } - scheduleNext( - documentEditorFocusedRef.current - ? focusedPollIntervalMs - : activePollIntervalMs, - ); - } - } finally { - inFlight = false; - } - }; - - void fetchRunState(); - - return () => { - disposed = true; - if (timer !== null) { - window.clearTimeout(timer); - } - }; - }, [isSending, isThemeWorkbench, sessionId]); - - const loadThemeWorkbenchHistory = useCallback( - async (offset: number, replace: boolean) => { - if ( - !isThemeWorkbench || - !sessionId || - themeWorkbenchHistoryLoadingRef.current - ) { - return; - } - - themeWorkbenchHistoryLoadingRef.current = true; - setThemeWorkbenchHistoryLoading(true); - try { - const page = await executionRunListThemeWorkbenchHistory( - sessionId, - THEME_WORKBENCH_HISTORY_PAGE_SIZE, - offset, - ); - setThemeWorkbenchHistoryTerminals((previous) => - replace - ? mergeThemeWorkbenchTerminalItems(page.items || []) - : mergeThemeWorkbenchTerminalItems(previous, page.items || []), - ); - setThemeWorkbenchHistoryHasMore(Boolean(page.has_more)); - setThemeWorkbenchHistoryNextOffset(page.next_offset ?? null); - } catch (error) { - console.warn("[AgentChatPage] 拉取主题工作台历史日志失败:", error); - if (replace) { - setThemeWorkbenchHistoryTerminals([]); - setThemeWorkbenchHistoryHasMore(false); - setThemeWorkbenchHistoryNextOffset(null); - } - } finally { - themeWorkbenchHistoryLoadingRef.current = false; - setThemeWorkbenchHistoryLoading(false); - } - }, - [isThemeWorkbench, sessionId], - ); - - useEffect(() => { - if (!isThemeWorkbench || !sessionId) { - themeWorkbenchHistoryLoadingRef.current = false; - setThemeWorkbenchHistoryTerminals([]); - setThemeWorkbenchHistoryHasMore(false); - setThemeWorkbenchHistoryNextOffset(null); - setThemeWorkbenchHistoryLoading(false); - return; - } - - void loadThemeWorkbenchHistory(0, true); - }, [isThemeWorkbench, loadThemeWorkbenchHistory, sessionId]); - - const themeWorkbenchRequiredSkillNames = useMemo(() => { - if (!isThemeWorkbench) { - return [] as string[]; - } - - const requiredSkillNames = new Set(); - messages.forEach((message) => { - if (message.role !== "user") { - return; - } - const skillName = parseSkillSlashCommand(message.content)?.skillName; - if (skillName) { - requiredSkillNames.add(skillName); - } - }); - (themeWorkbenchBackendRunState?.queue_items || []).forEach((item) => { - const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); - if (sourceRef) { - requiredSkillNames.add(sourceRef); - } - }); - const terminalSourceRef = resolveThemeWorkbenchSkillSourceRef( - themeWorkbenchBackendRunState?.latest_terminal || {}, - ); - if (terminalSourceRef) { - requiredSkillNames.add(terminalSourceRef); - } - - return [...requiredSkillNames].sort(); - }, [ - isThemeWorkbench, - messages, - themeWorkbenchBackendRunState?.latest_terminal, - themeWorkbenchBackendRunState?.queue_items, - ]); - - useEffect(() => { - if (!isThemeWorkbench) { - setThemeWorkbenchSkillDetailMap((prev) => - Object.keys(prev).length === 0 ? prev : {}, - ); - return; - } - - const missingSkillNames = themeWorkbenchRequiredSkillNames.filter( - (skillName) => !(skillName in themeWorkbenchSkillDetailMap), - ); - if (missingSkillNames.length === 0) { - return; - } - - let disposed = false; - Promise.all( - missingSkillNames.map(async (skillName) => { - try { - const detail = await skillExecutionApi.getSkillDetail(skillName); - return [skillName, detail] as const; - } catch (error) { - console.warn( - "[AgentChatPage] 加载 Skill 详情失败:", - skillName, - error, - ); - return [skillName, null] as const; - } - }), - ).then((entries) => { - if (disposed) { - return; - } - setThemeWorkbenchSkillDetailMap((prev) => { - const next = { ...prev }; - entries.forEach(([skillName, detail]) => { - next[skillName] = detail; - }); - return next; - }); - }); - - return () => { - disposed = true; - }; - }, [ - isThemeWorkbench, - themeWorkbenchRequiredSkillNames, - themeWorkbenchSkillDetailMap, - ]); - - const themeWorkbenchWorkflowSteps = useMemo( - () => - buildThemeWorkbenchWorkflowSteps( - messages, - themeWorkbenchBackendRunState, - isSending, - themeWorkbenchSkillDetailMap, - ), - [ - isSending, - messages, - themeWorkbenchBackendRunState, - themeWorkbenchSkillDetailMap, - ], - ); - - const themeWorkbenchActiveQueueItem = useMemo(() => { - const queueItems = themeWorkbenchBackendRunState?.queue_items || []; - return ( - queueItems.find((item) => item.status === "running") || - queueItems[0] || - null - ); - }, [themeWorkbenchBackendRunState?.queue_items]); - - const themeWorkbenchMergedTerminals = useMemo( - () => - mergeThemeWorkbenchTerminalItems( - resolveThemeWorkbenchRecentTerminals(themeWorkbenchBackendRunState), - themeWorkbenchHistoryTerminals, - ), - [themeWorkbenchBackendRunState, themeWorkbenchHistoryTerminals], - ); - - const themeWorkbenchExecutionRunMap = useMemo(() => { - const map = new Map(); - if (!isThemeWorkbench || !themeWorkbenchBackendRunState) { - return map; - } - - const register = (executionId?: string | null, runId?: string | null) => { - const normalizedExecutionId = executionId?.trim(); - const normalizedRunId = runId?.trim(); - if (!normalizedExecutionId || !normalizedRunId) { - return; - } - map.set(normalizedExecutionId, normalizedRunId); - }; - - (themeWorkbenchBackendRunState.queue_items || []).forEach((item) => { - register(item.execution_id, item.run_id); - }); - themeWorkbenchMergedTerminals.forEach((item) => { - register(item.execution_id, item.run_id); - }); - - return map; - }, [ - isThemeWorkbench, - themeWorkbenchBackendRunState, - themeWorkbenchMergedTerminals, - ]); - - const themeWorkbenchBackendActivityLogs = useMemo< - SidebarActivityLog[] - >(() => { - if (!isThemeWorkbench || !themeWorkbenchBackendRunState) { - return []; - } - - const runningLogs = (themeWorkbenchBackendRunState.queue_items || []).map( - (item) => { - const gateKey = - item.gate_key || inferThemeWorkbenchGateFromQueueItem(item).key; - return { - id: `run-queue-${item.run_id}`, - name: item.title || "执行主题工作台编排", - status: "running" as const, - timeLabel: formatThemeWorkbenchRunTimeLabel(item.started_at), - applyTarget: resolveThemeWorkbenchApplyTargetByGateKey(gateKey), - runId: item.run_id, - executionId: item.execution_id || undefined, - sessionId: item.session_id || undefined, - artifactPaths: - Array.isArray(item.artifact_paths) && item.artifact_paths.length > 0 - ? item.artifact_paths - : undefined, - gateKey, - source: item.source, - sourceRef: item.source_ref || undefined, - }; - }, - ); - - const terminalLogs: SidebarActivityLog[] = - themeWorkbenchMergedTerminals.map((terminal) => ({ - id: `run-terminal-${terminal.run_id}`, - name: terminal.title || "执行主题工作台编排", - status: terminal.status === "success" ? "completed" : "failed", - timeLabel: formatThemeWorkbenchRunTimeLabel( - terminal.finished_at || terminal.started_at, - ), - durationLabel: formatThemeWorkbenchRunDurationLabel( - terminal.started_at, - terminal.finished_at, - ), - applyTarget: resolveThemeWorkbenchApplyTargetByGateKey( - terminal.gate_key || "idle", - ), - runId: terminal.run_id, - executionId: terminal.execution_id || undefined, - sessionId: terminal.session_id || undefined, - artifactPaths: - Array.isArray(terminal.artifact_paths) && - terminal.artifact_paths.length > 0 - ? terminal.artifact_paths - : undefined, - gateKey: terminal.gate_key || "idle", - source: terminal.source, - sourceRef: terminal.source_ref || undefined, - })); - - return [...runningLogs, ...terminalLogs]; - }, [ - isThemeWorkbench, - themeWorkbenchBackendRunState, - themeWorkbenchMergedTerminals, - ]); - - const handleLoadMoreThemeWorkbenchHistory = useCallback(() => { - const nextOffset = - themeWorkbenchHistoryNextOffset ?? themeWorkbenchHistoryTerminals.length; - void loadThemeWorkbenchHistory(nextOffset, false); - }, [ - loadThemeWorkbenchHistory, - themeWorkbenchHistoryNextOffset, - themeWorkbenchHistoryTerminals.length, - ]); - - const themeWorkbenchActivityLogs = useMemo(() => { - if (!isThemeWorkbench) { - return contextWorkspace.activityLogs; - } - const enrichedContextLogs = contextWorkspace.activityLogs.map((log) => { - const normalizedRunId = log.runId?.trim(); - if (normalizedRunId) { - return { - ...log, - runId: normalizedRunId, - }; - } - - const candidateExecutionIds = - resolveExecutionIdCandidatesForActivityLog(log); - for (const executionId of candidateExecutionIds) { - const mappedRunId = themeWorkbenchExecutionRunMap.get(executionId); - if (!mappedRunId) { - continue; - } - return { - ...log, - executionId, - runId: mappedRunId, - }; - } - - return log; - }); - - return [...themeWorkbenchBackendActivityLogs, ...enrichedContextLogs]; - }, [ - contextWorkspace.activityLogs, - isThemeWorkbench, - themeWorkbenchBackendActivityLogs, - themeWorkbenchExecutionRunMap, - ]); - - const handleViewThemeWorkbenchRunDetail = useCallback((runId: string) => { - const normalizedRunId = runId.trim(); - if (!normalizedRunId) { - return; - } - setSelectedThemeWorkbenchRunId(normalizedRunId); - }, []); - - const handleViewContextDetail = useCallback( - (contextId: string) => { - const detail = contextWorkspace.getContextDetail(contextId); - if (!detail) { - toast.error("无法找到上下文详情"); - return; - } - - // 显示上下文详情 - const sourceLabel = - detail.source === "material" - ? "素材库" - : detail.source === "content" - ? "历史内容" - : "搜索结果"; - - toast.info( -
-
- {detail.name} -
-
- 来源: {sourceLabel} · 约 {detail.estimatedTokens} tokens -
-
- {detail.bodyText || detail.previewText} -
-
, - { duration: 10000 }, - ); - }, - [contextWorkspace], - ); - - useEffect(() => { - if (!isThemeWorkbench || !selectedThemeWorkbenchRunId) { - setThemeWorkbenchRunDetailLoading(false); - setSelectedThemeWorkbenchRunDetail(null); - return; - } - - let cancelled = false; - setThemeWorkbenchRunDetailLoading(true); - executionRunGet(selectedThemeWorkbenchRunId) - .then((detail) => { - if (!cancelled) { - setSelectedThemeWorkbenchRunDetail(detail); - } - }) - .catch((error) => { - if (cancelled) { - return; - } - setSelectedThemeWorkbenchRunDetail(null); - console.warn("[AgentChatPage] 加载运行详情失败:", error); - }) - .finally(() => { - if (!cancelled) { - setThemeWorkbenchRunDetailLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [isThemeWorkbench, selectedThemeWorkbenchRunId]); - - const currentGateBase = useMemo(() => { - if (!isThemeWorkbench) { - return { - key: "idle", - title: "编排待启动", - requiresUserDecision: false, - description: "输入目标后将自动进入编排执行。", - }; - } - - if (pendingActionRequest) { - const prompt = - pendingActionRequest.prompt || - pendingActionRequest.questions?.[0]?.question || - "等待你的决策以继续执行后续节点。"; - return { - key: pendingActionRequest.actionType, - title: "人工闸门", - requiresUserDecision: true, - description: prompt, - }; - } - - if (themeWorkbenchBackendRunState?.run_state === "auto_running") { - const backendGateKey = themeWorkbenchBackendRunState.current_gate_key; - if ( - backendGateKey === "topic_select" || - backendGateKey === "write_mode" || - backendGateKey === "publish_confirm" - ) { - const backendGate = resolveThemeWorkbenchGateByKey( - backendGateKey, - themeWorkbenchActiveQueueItem?.title, - ); - return { - key: backendGate.key, - title: backendGate.title, - requiresUserDecision: false, - description: backendGate.description, - }; - } - const backendGate = inferThemeWorkbenchGateFromQueueItem( - themeWorkbenchActiveQueueItem, - ); - return { - key: backendGate.key, - title: backendGate.title, - requiresUserDecision: false, - description: backendGate.description, - }; - } - - return { - key: "idle", - title: "编排待启动", - requiresUserDecision: false, - description: "输入目标后将自动进入编排执行。", - }; - }, [ - isThemeWorkbench, - pendingActionRequest, - themeWorkbenchActiveQueueItem, - themeWorkbenchBackendRunState?.current_gate_key, - themeWorkbenchBackendRunState?.run_state, - ]); - - const themeWorkbenchRunState = useMemo< - "idle" | "auto_running" | "await_user_decision" - >(() => { - if (!isThemeWorkbench) { - return "idle"; - } - if (currentGateBase.requiresUserDecision) { - return "await_user_decision"; - } - if (themeWorkbenchBackendRunState) { - if (themeWorkbenchBackendRunState.run_state !== "auto_running") { - return "idle"; - } - - const hasFreshRunningQueueItem = ( - themeWorkbenchBackendRunState.queue_items || [] - ).some((item) => { - if (item.status !== "running") { - return false; - } - const startedAt = new Date(item.started_at); - if (Number.isNaN(startedAt.getTime())) { - return false; - } - return ( - Date.now() - startedAt.getTime() <= - THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS - ); - }); - - if (hasFreshRunningQueueItem || isSending) { - return "auto_running"; - } - return "idle"; - } - return isSending ? "auto_running" : "idle"; - }, [ - currentGateBase.requiresUserDecision, - isThemeWorkbench, - themeWorkbenchBackendRunState, + mappedTheme, + chatMode, isSending, - ]); + projectMemory, + harnessState, + compatSubagentRuntime, + }); + const { + contextWorkspace, + isThemeWorkbench, + harnessPanelVisible, + harnessPendingCount, + showHarnessToggle, + harnessAttentionLevel, + navbarHarnessPanelVisible, + } = contextHarnessRuntime; + const themeWorkbenchScaffoldRuntime = useWorkspaceThemeWorkbenchScaffoldRuntime({ + isThemeWorkbench, + mappedTheme, + sessionId, + projectId, + canvasState, + documentVersionStatusMap, + setDocumentVersionStatusMap, + clearThemeSkillsRailState, + setCanvasState, + setLayoutMode, + }); + const { + shouldUseCompactThemeWorkbench, + shouldSkipThemeWorkbenchAutoGuideWithoutPrompt, + setTopicStatus, + } = themeWorkbenchScaffoldRuntime; - const currentGate = useMemo(() => { - const status = currentGateBase.requiresUserDecision - ? ("waiting" as const) - : themeWorkbenchRunState === "auto_running" - ? ("running" as const) - : ("idle" as const); + useWorkspaceThemeWorkbenchDocumentPersistenceRuntime({ + isThemeWorkbench, + contentId, + canvasState, + documentVersionStatusMap, + contentMetadataRef, + persistedWorkbenchSnapshotRef, + }); + + const { + a2uiSubmissionNotice, + pendingA2UIForm, + pendingActionRequest, + pendingLegacyQuestionnaireA2UIForm, + pendingPromotedA2UIActionRequest, + } = useWorkspaceA2UIRuntime({ + messages, + }); + + const { + currentGate, + documentEditorFocusedRef, + themeWorkbenchActiveQueueItem, + themeWorkbenchBackendRunState, + themeWorkbenchRunState, + } = useWorkspaceThemeWorkbenchRuntime({ + isThemeWorkbench, + sessionId, + isSending, + pendingActionRequest, + }); + + const themeWorkbenchSidebarRuntime = useWorkspaceThemeWorkbenchSidebarRuntime({ + isThemeWorkbench, + sessionId, + messages, + isSending, + themeWorkbenchBackendRunState, + contextActivityLogs: contextWorkspace.activityLogs, + historyPageSize: THEME_WORKBENCH_HISTORY_PAGE_SIZE, + }); + + const { handleViewContextDetail } = useWorkspaceContextDetailActions({ + contextWorkspace, + }); - return { - key: currentGateBase.key, - title: currentGateBase.title, - description: currentGateBase.description, - status, - }; - }, [currentGateBase, themeWorkbenchRunState]); const harnessRequestMetadata = useMemo( () => buildHarnessRequestMetadata({ @@ -5730,135 +1493,26 @@ export function AgentChatWorkspace({ themeWorkbenchActiveQueueItem?.title, ], ); - const refreshToolInventory = useCallback(async () => { - const requestId = toolInventoryRequestIdRef.current + 1; - toolInventoryRequestIdRef.current = requestId; - setToolInventoryLoading(true); - setToolInventoryError(null); - - try { - const nextInventory = await getAgentRuntimeToolInventory({ - caller: "assistant", - creator: chatMode === "creator", - browserAssist: mappedTheme === "general", - metadata: { - harness: harnessRequestMetadata, - }, - }); - - if (toolInventoryRequestIdRef.current !== requestId) { - return; - } - - setToolInventory(nextInventory); - } catch (error) { - if (toolInventoryRequestIdRef.current !== requestId) { - return; - } - - setToolInventoryError( - error instanceof Error ? error.message : "读取工具库存失败", - ); - } finally { - if (toolInventoryRequestIdRef.current === requestId) { - setToolInventoryLoading(false); - } - } - }, [chatMode, harnessRequestMetadata, mappedTheme]); - - useEffect(() => { - if (!harnessPanelVisible) { - return; - } - - void refreshToolInventory(); - }, [harnessPanelVisible, refreshToolInventory]); - - const socialMediaHarnessSummary = useMemo(() => { - if (!isThemeWorkbench || mappedTheme !== "social-media") { - return null; - } - - const latestTerminal = - themeWorkbenchBackendRunState?.latest_terminal ?? null; - const activeRun = themeWorkbenchActiveQueueItem ?? latestTerminal; - const artifactPaths = - Array.isArray(themeWorkbenchActiveQueueItem?.artifact_paths) && - themeWorkbenchActiveQueueItem.artifact_paths.length > 0 - ? themeWorkbenchActiveQueueItem.artifact_paths - : Array.isArray(latestTerminal?.artifact_paths) && - latestTerminal.artifact_paths.length > 0 - ? latestTerminal.artifact_paths - : []; - - return { - runState: themeWorkbenchRunState, - stageTitle: currentGate.title, - stageDescription: currentGate.description, - runTitle: activeRun?.title || null, - artifactCount: artifactPaths.length, - updatedAt: - themeWorkbenchBackendRunState?.updated_at || - latestTerminal?.finished_at || - latestTerminal?.started_at || - themeWorkbenchActiveQueueItem?.started_at || - null, - pendingCount: harnessPendingCount, - }; - }, [ - currentGate.description, - currentGate.title, - harnessPendingCount, - isThemeWorkbench, + const harnessInventoryRuntime = useWorkspaceHarnessInventoryRuntime({ + chatMode, mappedTheme, - themeWorkbenchActiveQueueItem, - themeWorkbenchBackendRunState?.latest_terminal, - themeWorkbenchBackendRunState?.updated_at, - themeWorkbenchRunState, - ]); - - useEffect(() => { - if (!isThemeWorkbench || themeWorkbenchRunState !== "idle") { - return; - } - if (!canvasState || canvasState.type !== "document") { - return; - } - - setDocumentVersionStatusMap((previous) => { - const latestTerminal = themeWorkbenchBackendRunState?.latest_terminal; - if (latestTerminal) { - const terminalVersionId = latestTerminal.run_id; - const terminalVersionExists = canvasState.versions.some( - (version) => version.id === terminalVersionId, - ); - if (terminalVersionExists) { - const terminalStatus: TopicBranchStatus = - latestTerminal.status === "success" ? "merged" : "candidate"; - if (previous[terminalVersionId] !== terminalStatus) { - return { - ...previous, - [terminalVersionId]: terminalStatus, - }; - } - } - } - - const currentVersionId = canvasState.currentVersionId; - if (!currentVersionId || previous[currentVersionId] !== "in_progress") { - return previous; - } - return { - ...previous, - [currentVersionId]: "pending", - }; - }); - }, [ - canvasState, + harnessPanelVisible, + harnessRequestMetadata, isThemeWorkbench, - themeWorkbenchBackendRunState?.latest_terminal, themeWorkbenchRunState, - ]); + currentGate, + themeWorkbenchBackendRunState, + themeWorkbenchActiveQueueItem, + harnessPendingCount, + }); + + useWorkspaceThemeWorkbenchVersionStatusRuntime({ + isThemeWorkbench, + themeWorkbenchRunState, + canvasState, + latestTerminal: themeWorkbenchBackendRunState?.latest_terminal ?? null, + setDocumentVersionStatusMap, + }); // 会话文件持久化 hook const { @@ -5873,161 +1527,12 @@ export function AgentChatWorkspace({ autoInit: true, }); - const syncResourceProjectSelection = useCallback( - (targetProjectId: string | null | undefined) => { - const normalizedProjectId = normalizeProjectId(targetProjectId); - if (!normalizedProjectId) { - return; - } - - setStoredResourceProjectId(normalizedProjectId, { - source: "general-chat", - emitEvent: true, - }); - }, - [], - ); - - const ensureGeneralResourceHashes = useCallback( - async (targetProjectId: string) => { - const existingHashes = - generalResourceHashesRef.current.get(targetProjectId); - if (existingHashes) { - return existingHashes; - } - - const nextHashes = new Set(); - - try { - const materials = await listMaterials(targetProjectId); - materials.forEach((material) => { - const hash = extractGeneralChatResourceHash(material); - if (hash) { - nextHashes.add(hash); - } - }); - } catch (error) { - console.warn("[AgentChatPage] 读取资源去重缓存失败:", error); - } - - generalResourceHashesRef.current.set(targetProjectId, nextHashes); - return nextHashes; - }, - [], - ); - - const resolveGeneralArtifactSyncPath = useCallback( - async (rawFilePath: string): Promise => { - const normalizedFilePath = rawFilePath.trim(); - if (!normalizedFilePath) { - return null; - } - - if ( - normalizedFilePath.startsWith("/") || - normalizedFilePath.startsWith("~/") || - normalizedFilePath.startsWith("\\\\") || - /^[A-Za-z]:[\\/]/.test(normalizedFilePath) - ) { - return normalizedFilePath; - } - - if (sessionId) { - try { - return await resolveSessionFilePath(sessionId, normalizedFilePath); - } catch (error) { - console.warn("[AgentChatPage] 解析会话文件路径失败:", error); - } - } - - return ( - resolveAbsoluteWorkspacePath(project?.rootPath, normalizedFilePath) || - null - ); - }, - [project?.rootPath, sessionId], - ); - - const syncGeneralArtifactToResource = useCallback( - async (input: { rawFilePath: string; preferredName?: string }) => { - if (activeTheme !== "general") { - return; - } - - const normalizedProjectId = normalizeProjectId(projectId); - const normalizedRawFilePath = input.rawFilePath.trim(); - if (!normalizedProjectId || !normalizedRawFilePath) { - return; - } - - const materialType = inferGeneralChatResourceMaterialType( - normalizedRawFilePath, - ); - if (!materialType) { - return; - } - - const resolvedFilePath = await resolveGeneralArtifactSyncPath( - normalizedRawFilePath, - ); - const normalizedResolvedFilePath = resolvedFilePath?.trim(); - if (!normalizedResolvedFilePath) { - return; - } - - const pathHash = buildGeneralChatResourceHash(normalizedResolvedFilePath); - const dedupeKey = `${normalizedProjectId}:${pathHash}`; - if (generalResourceSyncInFlightRef.current.has(dedupeKey)) { - return; - } - - const knownHashes = - await ensureGeneralResourceHashes(normalizedProjectId); - if (knownHashes.has(pathHash)) { - return; - } - - generalResourceSyncInFlightRef.current.add(dedupeKey); - try { - await uploadMaterial({ - projectId: normalizedProjectId, - name: - input.preferredName?.trim() || - extractFileNameFromPath(normalizedResolvedFilePath), - type: materialType, - filePath: normalizedResolvedFilePath, - tags: buildGeneralChatResourceTags( - normalizedResolvedFilePath, - sessionId, - ), - description: buildGeneralChatResourceDescription(sessionId), - }); - - knownHashes.add(pathHash); - syncResourceProjectSelection(normalizedProjectId); - } catch (error) { - console.warn("[AgentChatPage] 自动补录资源失败:", error); - } finally { - generalResourceSyncInFlightRef.current.delete(dedupeKey); - } - }, - [ - activeTheme, - ensureGeneralResourceHashes, - projectId, - resolveGeneralArtifactSyncPath, - sessionId, - syncResourceProjectSelection, - ], - ); - - useEffect(() => { - if (activeTheme !== "general") { - return; - } - - syncResourceProjectSelection(projectId); - }, [activeTheme, projectId, syncResourceProjectSelection]); + const { syncGeneralArtifactToResource } = useWorkspaceGeneralResourceSync({ + activeTheme, + projectId, + sessionId, + projectRootPath: project?.rootPath || null, + }); // 监听画布状态变化,自动同步到 Content useEffect(() => { @@ -6056,14 +1561,15 @@ export function AgentChatWorkspace({ } }, [canvasState, contentId, syncContent]); - // 追踪已恢复元数据和文件的会话 ID - const restoredMetaSessionId = useRef(null); - const restoredFilesSessionId = useRef(null); // 用于追踪是否已触发过 AI 引导 const hasTriggeredGuide = useRef(false); const consumedInitialPromptRef = useRef(null); - const [runtimeTeamDispatchPreview, setRuntimeTeamDispatchPreview] = - useState(null); + const { runtimeTeamDispatchPreview, setRuntimeTeamDispatchPreview } = + useWorkspaceRuntimeTeamDispatchPreviewRuntime({ + messagesLength: messages.length, + runtimeTeamState, + sessionId, + }); const { initialDispatchKey, isBootstrapDispatchPending, @@ -6098,40 +1604,8 @@ export function AgentChatWorkspace({ setInput((previous) => previous.trim() || prompt); }, []), }); - useEffect(() => { - setRuntimeTeamDispatchPreview(null); - }, [sessionId]); - useEffect(() => { - if (!runtimeTeamDispatchPreview) { - return; - } - - if (messages.length > runtimeTeamDispatchPreview.baseMessageCount) { - setRuntimeTeamDispatchPreview(null); - } - }, [messages.length, runtimeTeamDispatchPreview]); - useEffect(() => { - if ( - !runtimeTeamDispatchPreview || - runtimeTeamDispatchPreview.status === "failed" || - runtimeTeamState?.status !== "failed" - ) { - return; - } - - setRuntimeTeamDispatchPreview((current) => - current - ? { - ...current, - status: "failed", - formationState: null, - failureMessage: runtimeTeamState.errorMessage?.trim() || null, - } - : null, - ); - }, [runtimeTeamDispatchPreview, runtimeTeamState]); const consumeInitialPrompt = useCallback( - (dispatchKey: string) => { + (dispatchKey: string | null) => { consumedInitialPromptRef.current = dispatchKey; onInitialUserPromptConsumed?.(); }, @@ -6140,6 +1614,10 @@ export function AgentChatWorkspace({ const resetConsumedInitialPrompt = useCallback(() => { consumedInitialPromptRef.current = null; }, []); + const resetGuideState = useCallback(() => { + hasTriggeredGuide.current = false; + consumedInitialPromptRef.current = null; + }, []); const prepareBrowserTaskPreflight = useCallback( (preflight: BrowserTaskPreflight) => { setInput(""); @@ -6169,269 +1647,72 @@ export function AgentChatWorkspace({ onClearEntryPrompt: clearThemeWorkbenchEntryPrompt, onPrepareBrowserTaskPreflight: prepareBrowserTaskPreflight, }); + const { resetRestoredSessionState } = useWorkspaceSessionRestore({ + sessionId, + sessionMeta, + lockTheme, + initialTheme, + sessionFiles, + readSessionFile, + taskFilesLength: taskFiles.length, + setActiveTheme, + setCreationMode, + setTaskFiles, + }); + const { + handleClearMessages, + handleBackHome, + resetTopicLocalState, + } = useWorkspaceResetRuntime({ + clearMessages, + clearRuntimeTeamState, + clearProjectSelectionRuntime, + resetRestoredSessionState, + resetProjectSelection, + resetGuideState, + hasHandledNewChatRequest, + markNewChatRequestHandled, + createFreshSession, + defaultTopicSidebarVisible, + normalizedInitialTheme: normalizedEntryTheme, + initialCreationMode, + newChatAt, + initialSessionName, + projectId, + externalProjectId, + onNavigate: _onNavigate, + autoCollapsedTopicSidebarRef, + processedMessageIdsRef: processedMessageIds, + setInput, + setSelectedText, + setLayoutMode, + setShowSidebar, + setCanvasState, + setGeneralCanvasState, + setTaskFiles, + setSelectedFileId, + setBrowserTaskPreflight, + setMentionedCharacters, + setProject, + setProjectMemory, + setActiveTheme, + setCreationMode, + }); - // 当 sessionMeta 加载完成时,恢复主题和创建模式 - useEffect(() => { - if (!sessionId || !sessionMeta) { - return; - } - - // 检查 sessionMeta 是否属于当前 sessionId - if (sessionMeta.sessionId !== sessionId) { - return; - } - - // 避免重复恢复 - if (restoredMetaSessionId.current === sessionId) { - return; - } - - console.log("[AgentChatPage] 恢复会话元数据:", sessionId, sessionMeta); - - // 从会话元数据恢复主题(类型已统一,直接使用) - if (sessionMeta.theme && (!lockTheme || !initialTheme)) { - // 通用对话入口(initialTheme 为空或 "general")不应恢复为内容创作主题, - // 避免切换历史任务时错误激活社媒等创作模式 - const entryIsGeneral = !initialTheme || initialTheme === "general"; - const restoredIsCreation = isContentCreationTheme(sessionMeta.theme); - if (entryIsGeneral && restoredIsCreation) { - console.log( - "[AgentChatPage] 通用对话入口,跳过恢复内容创作主题:", - sessionMeta.theme, - ); - } else { - console.log("[AgentChatPage] 恢复主题:", sessionMeta.theme); - setActiveTheme(sessionMeta.theme); - } - } - - // 从会话元数据恢复创建模式 - if (sessionMeta.creationMode) { - console.log("[AgentChatPage] 恢复创建模式:", sessionMeta.creationMode); - setCreationMode(sessionMeta.creationMode as CreationMode); - } - - restoredMetaSessionId.current = sessionId; - }, [sessionId, sessionMeta, lockTheme, initialTheme]); - - // 当 sessionFiles 加载完成时,恢复文件到 taskFiles - useEffect(() => { - if (!sessionId || sessionFiles.length === 0) { - return; - } - - // 避免重复恢复 - if (restoredFilesSessionId.current === sessionId) { - return; - } - - // 如果当前已有 taskFiles,说明是本次会话新生成的文件,不需要从持久化恢复 - if (taskFiles.length > 0) { - restoredFilesSessionId.current = sessionId; - return; - } - - console.log( - "[AgentChatPage] 开始恢复文件:", - sessionId, - sessionFiles.length, - "个文件", - ); - - // 恢复文件到 taskFiles - const restoreFiles = async () => { - const restoredFiles: TaskFile[] = []; - - for (const file of sessionFiles) { - try { - const content = await readSessionFile(file.name); - if (content) { - restoredFiles.push({ - id: crypto.randomUUID(), - name: file.name, - type: normalizeSessionTaskFileType( - file.fileType, - file.name, - content, - ), - content, - version: 1, - createdAt: file.createdAt, - updatedAt: file.updatedAt, - }); - } - } catch (err) { - console.error("[AgentChatPage] 恢复文件失败:", file.name, err); - } - } - - if (restoredFiles.length > 0) { - console.log( - "[AgentChatPage] 从持久化存储恢复", - restoredFiles.length, - "个文件", - ); - setTaskFiles(restoredFiles); - } - restoredFilesSessionId.current = sessionId; - }; - - restoreFiles(); - }, [sessionId, sessionFiles, readSessionFile, taskFiles.length]); - - const resetTopicLocalState = useCallback(() => { - setLayoutMode("chat"); - setCanvasState(null); - setGeneralCanvasState(DEFAULT_CANVAS_STATE); - setTaskFiles([]); - clearRuntimeTeamState(); - setBrowserTaskPreflight(null); - setSelectedFileId(undefined); - processedMessageIds.current.clear(); - restoredMetaSessionId.current = null; - restoredFilesSessionId.current = null; - hasTriggeredGuide.current = false; - consumedInitialPromptRef.current = null; - }, [clearRuntimeTeamState]); - - const runTopicSwitch = useCallback( - async (topicId: string) => { - const startedAt = Date.now(); - logAgentDebug("AgentChatPage", "runTopicSwitch.start", { - currentProjectId: projectId ?? null, - topicId, - }); - resetTopicLocalState(); - try { - await originalSwitchTopic(topicId); - logAgentDebug("AgentChatPage", "runTopicSwitch.success", { - durationMs: Date.now() - startedAt, - topicId, - }); - } catch (error) { - logAgentDebug( - "AgentChatPage", - "runTopicSwitch.error", - { - durationMs: Date.now() - startedAt, - error, - topicId, - }, - { level: "error" }, - ); - throw error; - } - }, - [originalSwitchTopic, projectId, resetTopicLocalState], - ); - - const switchTopic = useCallback( - async (topicId: string) => { - if (!startTopicProjectResolution()) { - logAgentDebug( - "AgentChatPage", - "switchTopic.skipWhileResolving", - { topicId }, - { level: "warn", throttleMs: 1000 }, - ); - return; - } - - try { - logAgentDebug("AgentChatPage", "switchTopic.start", { - currentProjectId: projectId ?? null, - externalProjectId: externalProjectId ?? null, - topicId, - }); - const decision = await resolveTopicSwitchProject({ - lockedProjectId: externalProjectId ?? null, - topicBoundProjectId: loadPersistedProjectId( - `${TOPIC_PROJECT_KEY_PREFIX}${topicId}`, - ), - lastProjectId: getRememberedProjectId(), - loadProjectById: async (candidateProjectId) => { - const project = await getProject(candidateProjectId); - return project - ? { id: project.id, isArchived: project.isArchived } - : null; - }, - loadDefaultProject: async () => { - const project = await getDefaultProject(); - return project - ? { id: project.id, isArchived: project.isArchived } - : null; - }, - createDefaultProject: async () => { - const project = await getOrCreateDefaultProject(); - return project - ? { id: project.id, isArchived: project.isArchived } - : null; - }, - }); - logAgentDebug("AgentChatPage", "switchTopic.decision", { - createdDefault: - decision.status === "ok" ? decision.createdDefault : false, - decisionStatus: decision.status, - projectId: decision.status === "ok" ? decision.projectId : null, - topicId, - }); - - if (decision.status === "blocked") { - toast.error("该任务绑定了其他项目,请先切换到对应项目"); - return; - } - - if (decision.status === "missing") { - toast.error("未找到可用项目,请先创建项目"); - return; - } - - const targetProjectId = decision.projectId; - if (decision.createdDefault) { - toast.info("未找到可用项目,已自动创建默认项目"); - } - - const currentProjectId = normalizeProjectId(projectId); - if (currentProjectId !== targetProjectId) { - deferTopicSwitch(topicId, targetProjectId); - logAgentDebug("AgentChatPage", "switchTopic.deferUntilProjectReady", { - currentProjectId, - targetProjectId, - topicId, - }); - return; - } - - rememberProjectId(targetProjectId); - await runTopicSwitch(topicId); - } catch (error) { - console.error("[AgentChatPage] 解析任务项目失败:", error); - logAgentDebug( - "AgentChatPage", - "switchTopic.error", - { - error, - projectId: projectId ?? null, - topicId, - }, - { level: "error" }, - ); - toast.error("切换任务失败,请稍后重试"); - } finally { - finishTopicProjectResolution(); - } - }, - [ - deferTopicSwitch, - externalProjectId, - finishTopicProjectResolution, - getRememberedProjectId, - projectId, - rememberProjectId, - runTopicSwitch, - startTopicProjectResolution, - ], - ); + const { switchTopic } = useWorkspaceTopicSwitch({ + projectId, + externalProjectId, + originalSwitchTopic, + startTopicProjectResolution, + finishTopicProjectResolution, + deferTopicSwitch, + consumePendingTopicSwitch, + rememberProjectId, + getRememberedProjectId, + loadTopicBoundProjectId: (topicId) => + loadPersistedProjectId(`${TOPIC_PROJECT_KEY_PREFIX}${topicId}`), + resetTopicLocalState, + }); useTrayModelShortcuts({ providerType, @@ -6442,1467 +1723,151 @@ export function AgentChatWorkspace({ deferInitialSync: false, }); - useEffect(() => { - const pending = consumePendingTopicSwitch(projectId); - if (!pending) { - return; - } - - const currentProjectId = normalizeProjectId(projectId); - logAgentDebug("AgentChatPage", "switchTopic.resumePending", { - projectId: currentProjectId, - topicId: pending.topicId, - }); - runTopicSwitch(pending.topicId).catch((error) => { - console.error("[AgentChatPage] 执行待切换任务失败:", error); - logAgentDebug( - "AgentChatPage", - "switchTopic.resumePendingError", - { - error, - projectId: currentProjectId, - topicId: pending.topicId, - }, - { level: "error" }, - ); - toast.error("加载任务失败,请重试"); - }); - }, [consumePendingTopicSwitch, projectId, runTopicSwitch]); - - /** - * 从 AI 响应中提取文档内容 - * 支持多种格式: - * 1. ... 标签(推荐) - * 2. ```markdown ... ``` 代码块 - * 3. 以 # 开头的 Markdown 内容(仅非主题工作台) - */ - const extractDocumentContent = useCallback( - (content: string): string | null => { - // 1. 检查 标签 - const documentMatch = content.match(/([\s\S]*?)<\/document>/); - if (documentMatch) { - return documentMatch[1].trim(); - } - - // 2. 检查 markdown 代码块 - const markdownMatch = content.match(/```(?:markdown|md)\n([\s\S]*?)```/); - if (markdownMatch) { - return markdownMatch[1].trim(); - } - - // 3. 主题工作台:不使用启发式规则,避免误判普通回复 - if (isThemeWorkbench) { - return null; - } - - // 4. 非主题工作台:如果整个内容以 # 开头且长度超过 200 字符,认为是文档 - if (content.trim().startsWith("#") && content.length > 200) { - return content.trim(); - } - - return null; - }, - [isThemeWorkbench], - ); - - const looksLikeSerializedNovelState = useCallback((content: string) => { - const trimmed = content.trim(); - if (!trimmed) return false; - - const jsonCandidate = - trimmed.match(/^```json\s*([\s\S]*?)```$/i)?.[1] || trimmed; - - if (!(jsonCandidate.startsWith("[") || jsonCandidate.startsWith("{"))) { - return false; - } - - return ( - jsonCandidate.includes('"title"') && - (jsonCandidate.includes('"number"') || - jsonCandidate.includes('"chapters"')) - ); - }, []); - - const upsertNovelCanvasState = useCallback( - (prev: CanvasStateUnion | null, content: string) => { - if (!prev || prev.type !== "novel") { - return createInitialNovelState(content); - } - - if (looksLikeSerializedNovelState(content)) { - return createInitialNovelState(content); - } - - const targetChapterId = - prev.currentChapterId || prev.chapters[0]?.id || crypto.randomUUID(); - const now = Date.now(); - - if (prev.chapters.length === 0) { - const initialized = createInitialNovelState(content); - return { - ...initialized, - currentChapterId: initialized.chapters[0]?.id || targetChapterId, - }; - } - - return { - ...prev, - chapters: prev.chapters.map((chapter) => - chapter.id === targetChapterId - ? { - ...chapter, - content, - wordCount: countNovelWords(content), - updatedAt: now, - } - : chapter, - ), - }; - }, - [looksLikeSerializedNovelState], - ); - - const handleImageWorkbenchViewportChange = useCallback( - (viewport: ImageWorkbenchViewport) => { - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - viewport, - })); - }, - [updateCurrentImageWorkbenchState], - ); - - const handleSelectImageWorkbenchOutput = useCallback( - (outputId: string) => { - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - selectedOutputId: outputId, - })); - }, - [updateCurrentImageWorkbenchState], - ); - - const handleSeedImageWorkbenchFollowUp = useCallback( - (command: string) => { - setInput(command); - toast.info("已在输入框填入配图命令"); - }, - [setInput], - ); - - const handleOpenImageWorkbenchAsset = useCallback((url: string) => { - if (!url.trim()) { - return; - } - window.open(url, "_blank", "noopener,noreferrer"); - }, []); - - const handleStopImageWorkbenchGeneration = useCallback(() => { - cancelImageWorkbenchGeneration(); - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - tasks: current.tasks.map((task) => - task.status === "routing" || task.status === "running" - ? { - ...task, - status: "error", - failureMessage: IMAGE_GENERATION_CANCELED_MESSAGE, - } - : task, - ), - })); - toast.info(IMAGE_GENERATION_CANCELED_MESSAGE); - }, [cancelImageWorkbenchGeneration, updateCurrentImageWorkbenchState]); - - const handleSaveSelectedImageWorkbenchOutput = useCallback(async () => { - const selectedOutput = currentImageWorkbenchState.outputs.find( - (item) => item.id === currentImageWorkbenchState.selectedOutputId, - ); - if (!selectedOutput) { - toast.info("请先选择一张图片"); - return; - } - if (!projectId) { - toast.error("请先选择项目后再保存到素材库"); - return; - } - - const result = await saveImageWorkbenchImagesToResource( - [selectedOutput.hookImageId], - projectId, - ); - if (result.saved > 0) { - updateCurrentImageWorkbenchState((current) => ({ - ...current, - outputs: current.outputs.map((item) => - item.id === selectedOutput.id - ? { ...item, resourceSaved: true } - : item, - ), - })); - toast.success("已保存到素材库"); - return; - } - - if (result.skipped > 0) { - toast.info("该图片已在当前素材库中"); - return; - } - - toast.error(result.errors[0] || "保存到素材库失败"); - }, [ - currentImageWorkbenchState.outputs, - currentImageWorkbenchState.selectedOutputId, - projectId, - saveImageWorkbenchImagesToResource, - updateCurrentImageWorkbenchState, - ]); - - const handleApplySelectedImageWorkbenchOutput = useCallback(() => { - const selectedOutput = currentImageWorkbenchState.outputs.find( - (item) => item.id === currentImageWorkbenchState.selectedOutputId, - ); - if (!selectedOutput) { - toast.info("请先选择一张图片"); - return; - } - - const applyTarget = selectedOutput.applyTarget; - if (!applyTarget) { - toast.info("当前结果还没有绑定落位目标"); - return; - } - - if (applyTarget.kind === "document-cover") { - let replaced = false; - setCanvasState((previous) => { - if (!previous || previous.type !== "document") { - return previous; - } - - const updatedContent = previous.content.split(applyTarget.placeholder).join( - selectedOutput.url, - ); - if (updatedContent === previous.content) { - return previous; - } - - replaced = true; - return { - ...previous, - content: updatedContent, - }; - }); - - if (!replaced) { - toast.error("未找到待替换的封面占位"); - return; - } - - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: false, - })); - setLayoutMode("chat-canvas"); - toast.success(applyTarget.successLabel); - return; - } - - emitCanvasImageInsertRequest({ - projectId: applyTarget.projectId ?? projectId ?? null, - contentId: applyTarget.contentId ?? contentId ?? null, - canvasType: applyTarget.canvasType, - anchorHint: applyTarget.anchorHint, - source: "manual", - image: { - id: selectedOutput.id, - previewUrl: selectedOutput.url, - contentUrl: selectedOutput.url, - title: collapseWhitespace(selectedOutput.prompt) || selectedOutput.refId, - provider: selectedOutput.providerName, - }, - }); - - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: false, - })); - setLayoutMode("chat-canvas"); - toast.info(applyTarget.dispatchLabel); - }, [ - contentId, - currentImageWorkbenchState.outputs, - currentImageWorkbenchState.selectedOutputId, - projectId, - setCanvasState, - updateCurrentImageWorkbenchState, - ]); - - const imageWorkbenchPrimaryActionLabel = useMemo(() => { - const selectedOutput = currentImageWorkbenchState.outputs.find( - (item) => item.id === currentImageWorkbenchState.selectedOutputId, - ); - return resolveImageWorkbenchActionLabel(selectedOutput?.applyTarget); - }, [ - currentImageWorkbenchState.outputs, - currentImageWorkbenchState.selectedOutputId, - ]); - - const handleImageWorkbenchCommand = useCallback( - async (params: { - rawText: string; - parsedCommand: NonNullable< - ReturnType - >; - images: MessageImage[]; - applyTarget?: ImageWorkbenchApplyTarget | null; - }): Promise => { - if (!projectId) { - toast.error("请先选择项目后再开始配图"); - return false; - } - - const { rawText, parsedCommand, images } = params; - const targetOutput = parsedCommand.targetRef - ? currentImageWorkbenchState.outputs.find( - (item) => - item.refId.toLowerCase() === parsedCommand.targetRef?.toLowerCase(), - ) || null - : null; - const effectiveApplyTarget = - params.applyTarget ?? targetOutput?.applyTarget ?? null; - - if ( - (parsedCommand.mode === "edit" || parsedCommand.mode === "variation") && - !targetOutput && - images.length === 0 - ) { - toast.error("编辑或变体任务需要选择已有图片,或先附加参考图"); - return false; - } - - const effectivePrompt = - parsedCommand.prompt.trim() || - (parsedCommand.mode === "generate" - ? "" - : "请基于参考图继续优化画面表现"); - if (!effectivePrompt) { - toast.error("请补充清晰的配图描述后再提交"); - return false; - } - - const taskId = `image-task-${Date.now()}-${Math.random() - .toString(36) - .slice(2, 8)}`; - const referenceImages = [ - ...(targetOutput?.url ? [targetOutput.url] : []), - ...images.map((image) => image.data).filter(Boolean), - ]; - const now = Date.now(); - - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - tasks: [ - { - sessionId: imageWorkbenchSessionKey, - id: taskId, - mode: parsedCommand.mode, - status: "routing", - prompt: effectivePrompt, - rawText, - expectedCount: parsedCommand.count, - outputIds: [], - targetOutputId: targetOutput?.id ?? null, - createdAt: now, - hookImageIds: [], - applyTarget: effectiveApplyTarget, - }, - ...current.tasks, - ], - selectedOutputId: targetOutput?.id ?? current.selectedOutputId, - })); - - appendLocalDispatchMessages( - buildImageWorkbenchDispatchMessages({ - rawText, - images, - taskId, - prompt: effectivePrompt, - mode: parsedCommand.mode, - count: parsedCommand.count, - }), - ); - - setLayoutMode("chat-canvas"); - setInput(""); - setMentionedCharacters([]); - - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - tasks: current.tasks.map((task) => - task.id === taskId ? { ...task, status: "running" } : task, - ), - })); - - try { - const generatedImages = await runImageWorkbenchGeneration(effectivePrompt, { - imageCount: parsedCommand.count, - referenceImages, - size: parsedCommand.size || imageWorkbenchSelectedSize, - }); - const hookImageIds = generatedImages.map((image) => image.id); - - let successCount = 0; - updateCurrentImageWorkbenchState((current) => { - let nextOutputIndex = current.nextOutputIndex; - const nextOutputs = [...current.outputs]; - const createdOutputIds: string[] = []; - - for (const image of generatedImages) { - if (image.status !== "complete" || !image.url) { - continue; - } - successCount += 1; - const outputId = `${taskId}:${image.id}`; - const refId = `img-${nextOutputIndex}`; - nextOutputIndex += 1; - nextOutputs.unshift({ - id: outputId, - taskId, - hookImageId: image.id, - refId, - url: image.url, - prompt: image.prompt, - createdAt: image.createdAt, - providerName: image.providerName, - modelName: image.model, - size: image.size, - parentOutputId: targetOutput?.refId ?? null, - resourceSaved: Boolean(image.resourceMaterialId), - applyTarget: effectiveApplyTarget, - }); - createdOutputIds.push(outputId); - } - const failedCount = Math.max(0, parsedCommand.count - successCount); - - const nextStatus: ImageWorkbenchTaskStatus = - successCount === 0 - ? "error" - : failedCount > 0 - ? "partial" - : "complete"; - - return { - ...current, - active: true, - outputs: nextOutputs, - selectedOutputId: - createdOutputIds[0] || current.selectedOutputId || targetOutput?.id || null, - nextOutputIndex, - tasks: current.tasks.map((task) => - task.id === taskId - ? { - ...task, - status: nextStatus, - outputIds: createdOutputIds, - hookImageIds, - failureMessage: - successCount === 0 - ? "图片服务未返回可用结果" - : failedCount > 0 - ? `有 ${failedCount} 张结果生成失败` - : undefined, - } - : task, - ), - }; - }); - - appendLocalDispatchMessages([ - buildImageWorkbenchCompletionMessage({ - taskId, - successCount, - failedCount: Math.max(0, parsedCommand.count - successCount), - mode: parsedCommand.mode, - }), - ]); - - if (successCount === 0) { - toast.error("图片任务失败,未生成可用结果"); - } else if (parsedCommand.count - successCount > 0) { - toast.warning( - `图片任务已完成 ${successCount} 张,失败 ${Math.max( - 0, - parsedCommand.count - successCount, - )} 张`, - ); - } else { - toast.success(`图片任务已完成,共生成 ${successCount} 张`); - } - return true; - } catch (error) { - const failureMessage = - error instanceof Error ? error.message : "图片任务执行失败"; - const canceled = failureMessage === IMAGE_GENERATION_CANCELED_MESSAGE; - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - tasks: current.tasks.map((task) => - task.id === taskId - ? { - ...task, - status: "error", - failureMessage, - } - : task, - ), - })); - if (!canceled) { - appendLocalDispatchMessages([ - { - id: `image-workbench:${taskId}:failed`, - role: "assistant", - content: `当前图片任务失败:${failureMessage}`, - timestamp: new Date(), - runtimeStatus: { - phase: "failed", - title: "图片任务失败", - detail: failureMessage, - }, - }, - ]); - toast.error(failureMessage); - } - return true; - } - }, - [ - appendLocalDispatchMessages, - currentImageWorkbenchState.outputs, - imageWorkbenchSelectedSize, - imageWorkbenchSessionKey, - projectId, - runImageWorkbenchGeneration, - updateCurrentImageWorkbenchState, - ], - ); - - // 监听 AI 消息变化,自动提取文档内容 - useEffect(() => { - if (!isContentCreationMode) return; - - // 找到最新的 assistant 消息 - const lastAssistantMsg = [...messages] - .reverse() - .find( - (msg) => - msg.role === "assistant" && - !msg.isThinking && - msg.content && - msg.purpose !== "content_review" && - msg.purpose !== "style_audit", - ); - - if (!lastAssistantMsg) return; - - // 主题工作台 fallback:仅在 AI 未使用 write_file 且画布为空时提取 - if (isThemeWorkbench) { - const hasWriteFileToolCall = lastAssistantMsg.toolCalls?.some((tc) => { - const name = (tc.name || "").toLowerCase(); - return name.includes("write") || name.includes("create_file"); - }); - if (hasWriteFileToolCall) return; - if (canvasState && !isCanvasStateEmpty(canvasState)) return; - } - - // 检查是否已处理过 - if (processedMessageIds.current.has(lastAssistantMsg.id)) return; - - // 提取文档内容 - const docContent = extractDocumentContent(lastAssistantMsg.content); - if (docContent) { - // 标记为已处理 - processedMessageIds.current.add(lastAssistantMsg.id); - - // 更新画布内容(仅文档类型画布支持流式更新) - setCanvasState((prev) => { - // 如果是海报主题,不自动更新画布 - if (mappedTheme === "poster") { - return prev; - } - - if (mappedTheme === "novel") { - return upsertNovelCanvasState(prev, docContent); - } - - if (!prev || prev.type !== "document") { - return createInitialDocumentState(docContent); - } - // 添加新版本 - const newVersion = { - id: crypto.randomUUID(), - content: docContent, - createdAt: Date.now(), - description: `AI 生成 - 版本 ${prev.versions.length + 1}`, - }; - return { - ...prev, - content: docContent, - versions: [...prev.versions, newVersion], - currentVersionId: newVersion.id, - }; - }); - - // 自动打开画布 - setLayoutMode("chat-canvas"); - } - }, [ - messages, + const { upsertNovelCanvasState } = useWorkspaceCanvasMessageSyncRuntime({ + canvasState, isContentCreationMode, isThemeWorkbench, - extractDocumentContent, mappedTheme, - upsertNovelCanvasState, - canvasState, - ]); + messages, + processedMessageIdsRef: processedMessageIds, + setCanvasState, + setLayoutMode, + }); - const ensureBrowserAssistCanvasRef = useRef< - ( - sourceText: string, - options?: { - silent?: boolean; - navigationMode?: "none" | "explicit-url" | "best-effort"; - }, - ) => Promise - >(async () => false); + const imageWorkbenchActionRuntime = useWorkspaceImageWorkbenchActionRuntime({ + appendLocalDispatchMessages, + cancelImageWorkbenchGeneration, + contentId, + currentImageWorkbenchState, + imageWorkbenchSelectedSize, + imageWorkbenchSessionKey, + projectId, + runImageWorkbenchGeneration, + saveImageWorkbenchImagesToResource, + setCanvasState, + setInput, + setLayoutMode, + setMentionedCharacters, + updateCurrentImageWorkbenchState, + }); + const { handleImageWorkbenchCommand } = imageWorkbenchActionRuntime; - const runBrowserTaskPreflight = useCallback( - async (preflight: BrowserTaskPreflight) => { - setBrowserTaskPreflight((current) => - current?.requestId === preflight.requestId - ? { - ...current, - phase: "launching", - detail: current.detail, - } - : current, - ); - - const launchInput = preflight.launchUrl || preflight.sourceText; - const navigationMode = - preflight.launchUrl && preflight.launchUrl !== preflight.sourceText - ? ("explicit-url" as const) - : ("best-effort" as const); - - try { - const launched = await ensureBrowserAssistCanvasRef.current( - launchInput, - { - silent: false, - navigationMode, - }, - ); - - setBrowserTaskPreflight((current) => { - if (current?.requestId !== preflight.requestId) { - return current; - } - - if (!launched) { - return { - ...current, - phase: "failed", - detail: - "还没有建立可用的浏览器会话。请确认本机浏览器/CDP 可用后重试。", - }; - } - - return { - ...current, - phase: "awaiting_user", - detail: - preflight.requirement === "required_with_user_step" - ? `已为你打开${preflight.platformLabel || "浏览器协助"}。请先在右侧浏览器完成登录、扫码、验证码或授权,再继续当前任务。` - : "浏览器已经准备好。请确认右侧页面可操作后继续当前任务。", - }; - }); - } catch (error) { - setBrowserTaskPreflight((current) => { - if (current?.requestId !== preflight.requestId) { - return current; - } - - return { - ...current, - phase: "failed", - detail: - error instanceof Error && error.message - ? error.message - : "启动浏览器协助失败,请稍后重试。", - }; - }); - } + const { + handleSend, + handleRecommendationClick, + handleSendRef, + webSearchPreferenceRef, + } = useWorkspaceSendActions({ + input, + setInput, + mentionedCharacters, + setMentionedCharacters, + chatToolPreferences, + setChatToolPreferences, + activeTheme, + mappedTheme, + creationMode, + chatMode, + isThemeWorkbench, + contextWorkspace: { + enabled: contextWorkspace.enabled, + prepareActiveContextPrompt: contextWorkspace.prepareActiveContextPrompt, }, - [], - ); + runtimeStyleMessagePrompt, + projectId, + sessionId, + providerType, + model, + setModel, + executionStrategy, + preferredTeamPresetId, + selectedTeam, + selectedTeamLabel, + selectedTeamSummary, + currentGateKey: currentGate.key, + themeWorkbenchActiveQueueTitle: themeWorkbenchActiveQueueItem?.title, + contentId, + messagesCount: messages.length, + sendMessage, + resolveSendBoundary, + isBlockedByBrowserPreflight, + maybeStartBrowserTaskPreflight, + finalizeAfterSendSuccess, + rollbackAfterSendFailure, + prepareRuntimeTeamBeforeSend, + setRuntimeTeamDispatchPreview, + ensureBrowserAssistCanvas, + handleImageWorkbenchCommand, + }); - const handleSend = useCallback( - async ( - images?: MessageImage[], - webSearch?: boolean, - thinking?: boolean, - textOverride?: string, - sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", - autoContinuePayload?: AutoContinueRequestPayload, - sendOptions?: HandleSendOptions, - ) => { - let sourceText = textOverride ?? input; - if (!sourceText.trim() && (!images || images.length === 0)) return false; - const sendBoundary = resolveSendBoundary({ - sourceText, - sendOptions, - }); - sourceText = sendBoundary.sourceText; - if (isBlockedByBrowserPreflight(sendOptions)) { - toast.info("请先完成当前浏览器准备后,再继续发送新的任务"); - return false; - } - const effectiveToolPreferences = - sendOptions?.toolPreferencesOverride ?? chatToolPreferences; - - const { browserRequirementMatch } = sendBoundary; - const requestedWebSearch = - webSearch ?? effectiveToolPreferences.webSearch; - const effectiveWebSearch = - browserRequirementMatch && - browserRequirementMatch.requirement !== "optional" - ? false - : requestedWebSearch; - const effectiveThinking = thinking ?? effectiveToolPreferences.thinking; - - if (!projectId) { - sendOptions?.observer?.onError?.("请先选择项目后再开始对话"); - toast.error("请先选择项目后再开始对话"); - return false; - } - - const parsedImageWorkbenchCommand = - !sendOptions?.purpose && sourceText.trim() - ? parseImageWorkbenchCommand(sourceText) - : null; - if (parsedImageWorkbenchCommand) { - return handleImageWorkbenchCommand({ - rawText: sourceText, - parsedCommand: parsedImageWorkbenchCommand, - images: images || [], - }); - } - - if ( - maybeStartBrowserTaskPreflight({ - boundary: sendBoundary, - images, - webSearch, - thinking, - sendExecutionStrategy, - autoContinuePayload, - sendOptions, - }) - ) { - return true; - } - - let text = sourceText; - - const preparedActiveContextPrompt = contextWorkspace.enabled - ? await contextWorkspace.prepareActiveContextPrompt() - : ""; - - if (contextWorkspace.enabled && preparedActiveContextPrompt) { - const slashCommandMatch = text.match( - /^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/, - ); - if (slashCommandMatch) { - const [, skillName, skillArgs] = slashCommandMatch; - const mergedArgs = [preparedActiveContextPrompt, skillArgs.trim()] - .filter((part) => part.length > 0) - .join("\n\n"); - text = `/${skillName} ${mergedArgs}`.trim(); - } else { - text = `${preparedActiveContextPrompt}\n\n${text}`; - } - } - - // 如果有引用的角色,注入角色信息 - if (mentionedCharacters.length > 0) { - const characterContext = mentionedCharacters - .map((char) => { - let context = `角色:${char.name}`; - if (char.description) context += `\n简介:${char.description}`; - if (char.personality) context += `\n性格:${char.personality}`; - if (char.background) context += `\n背景:${char.background}`; - return context; - }) - .join("\n\n"); - - text = `[角色上下文]\n${characterContext}\n\n[用户输入]\n${text}`; - } - - if (!sendOptions?.purpose && runtimeStyleMessagePrompt) { - text = `[本次任务风格要求]\n${runtimeStyleMessagePrompt}\n\n[用户输入]\n${text}`; - } - - if (browserRequirementMatch) { - void ensureBrowserAssistCanvasRef - .current(browserRequirementMatch.launchUrl || sourceText, { - silent: true, - navigationMode: - browserRequirementMatch.launchUrl && - browserRequirementMatch.launchUrl !== sourceText - ? "explicit-url" - : "best-effort", - }) - .catch((error) => { - console.warn( - "[AgentChatPage] 强浏览器任务发送前准备浏览器失败,继续由主流程处理:", - error, - ); - }); - } else { - preheatBrowserAssistInBackground({ - activeTheme, - sourceText, - ensureBrowserAssistCanvas: ensureBrowserAssistCanvasRef.current, - onError: (error) => { - console.warn( - "[AgentChatPage] 发送前预热浏览器协助失败,继续发送消息:", - error, - ); - }, - }); - } - - setRuntimeTeamDispatchPreview(null); - - try { - const { selectedProvider, providerModels } = - await resolveSendProviderContext(); - const memoryParams = { - scope: "aster" as const, - workspaceId: projectId, - sessionId, - providerKey: providerType, - }; - const rememberedBaseModel = loadRememberedBaseModel(memoryParams); - let effectiveModel = model; - - if (effectiveThinking) { - if (!isReasoningModel(model, providerModels)) { - saveRememberedBaseModel({ - ...memoryParams, - modelId: model, - }); - } - - const thinkingResult = resolveThinkingModel({ - currentModelId: model, - models: providerModels, - }); - effectiveModel = thinkingResult.targetModelId; - - if (thinkingResult.switched) { - setModel(thinkingResult.targetModelId); - } else if ( - thinkingResult.reason === "no_variant" && - providerModels.length > 0 - ) { - const warnKey = `${providerType}:${model}`; - if (!thinkingVariantWarnedRef.current.has(warnKey)) { - thinkingVariantWarnedRef.current.add(warnKey); - toast.warning( - "当前 Provider 没有可用的 Thinking 模型,已保持原模型", - ); - } - } - } else { - const restoreResult = resolveBaseModelOnThinkingOff({ - currentModelId: model, - models: providerModels, - rememberedBaseModel, - }); - effectiveModel = restoreResult.targetModelId; - - if (restoreResult.switched) { - setModel(restoreResult.targetModelId); - } - } - - const compatibilityResult = resolveProviderModelCompatibility({ - providerType, - configuredProviderType: selectedProvider?.type, - model: effectiveModel, - }); - if (compatibilityResult.changed) { - effectiveModel = compatibilityResult.model; - if (model !== compatibilityResult.model) { - setModel(compatibilityResult.model); - } - if (compatibilityResult.reason) { - toast.warning(compatibilityResult.reason); - } - } - - if ((images?.length || 0) > 0) { - const visionResult = resolveVisionModel({ - currentModelId: effectiveModel, - models: providerModels, - }); - - if (visionResult.reason === "no_vision_model") { - toast.error( - "当前 Provider 没有可用的多模态模型,请切换到支持多模态的 Provider 或模型后再发送图片", - ); - return false; - } - - if (visionResult.reason !== "already_vision") { - const suggestedModel = visionResult.targetModelId.trim(); - toast.error( - suggestedModel - ? `当前模型 ${effectiveModel} 不支持多模态图片理解,请切换到 ${suggestedModel} 或其他支持多模态的模型后再发送图片` - : `当前模型 ${effectiveModel} 不支持多模态图片理解,请切换到支持多模态的模型后再发送图片`, - ); - return false; - } - } - - const shouldPrepareRuntimeTeam = shouldPrepareRuntimeTeamBeforeSend({ - subagentEnabled: effectiveToolPreferences.subagent, - projectId, - input: sourceText, - purpose: sendOptions?.purpose, - }); - - if (shouldPrepareRuntimeTeam) { - setRuntimeTeamDispatchPreview({ - key: crypto.randomUUID(), - prompt: sourceText, - images: images || [], - baseMessageCount: messages.length, - status: "forming", - formationState: null, - }); - } - - const preparedRuntimeTeamState = await prepareRuntimeTeamBeforeSend({ - input: sourceText, - providerType, - model: effectiveModel, - executionStrategy: sendExecutionStrategy ?? executionStrategy, - purpose: sendOptions?.purpose, - subagentEnabled: effectiveToolPreferences.subagent, - }); - if (preparedRuntimeTeamState?.status === "formed") { - setRuntimeTeamDispatchPreview((current) => - current - ? { - ...current, - status: "formed", - formationState: preparedRuntimeTeamState, - failureMessage: null, - } - : current, - ); - } else if (preparedRuntimeTeamState?.status === "failed") { - setRuntimeTeamDispatchPreview((current) => - current - ? { - ...current, - status: "failed", - formationState: null, - failureMessage: - preparedRuntimeTeamState.errorMessage?.trim() || null, - } - : current, - ); - } - const turnTeamBlueprint = - preparedRuntimeTeamState?.status === "formed" && - preparedRuntimeTeamState.members.length > 0 - ? { - label: preparedRuntimeTeamState.label?.trim() || undefined, - description: - preparedRuntimeTeamState.summary?.trim() || undefined, - roles: preparedRuntimeTeamState.members.map((member) => ({ - id: member.id, - label: member.label, - summary: member.summary, - profileId: member.profileId, - roleKey: member.roleKey, - skillIds: [...member.skillIds], - })), - } - : undefined; - const turnTeamDecision = - preparedRuntimeTeamState?.status === "formed" - ? "team_prepared" - : "single_agent"; - const turnTeamReason = - preparedRuntimeTeamState?.status === "formed" - ? "runtime_team_prepared" - : preparedRuntimeTeamState?.status === "failed" - ? "runtime_team_generation_failed" - : !effectiveToolPreferences.subagent - ? "subagent_disabled" - : sendOptions?.purpose - ? "turn_purpose_override" - : "single_agent_direct"; - const assistantDraft = buildRuntimeTeamAssistantDraft( - preparedRuntimeTeamState, - ); - - setInput(""); - setMentionedCharacters([]); // 清空引用的角色 - - const existingHarnessMetadata = extractExistingHarnessMetadata( - sendOptions?.requestMetadata, - ); - const nextSendOptions: HandleSendOptions = { - ...(sendOptions || {}), - requestMetadata: { - ...(sendOptions?.requestMetadata || {}), - harness: buildHarnessRequestMetadata({ - base: existingHarnessMetadata, - theme: mappedTheme, - creationMode, - chatMode, - webSearchEnabled: effectiveWebSearch, - thinkingEnabled: effectiveThinking, - taskModeEnabled: effectiveToolPreferences.task, - subagentModeEnabled: effectiveToolPreferences.subagent, - sessionMode: isThemeWorkbench ? "theme_workbench" : "default", - gateKey: isThemeWorkbench ? currentGate.key : undefined, - runTitle: - themeWorkbenchActiveQueueItem?.title?.trim() || undefined, - contentId: contentId || undefined, - browserRequirement: browserRequirementMatch?.requirement, - browserRequirementReason: browserRequirementMatch?.reason, - browserLaunchUrl: browserRequirementMatch?.launchUrl, - browserAssistProfileKey: - mappedTheme === "general" - ? GENERAL_BROWSER_ASSIST_PROFILE_KEY - : undefined, - preferredTeamPresetId, - selectedTeamId: selectedTeam?.id, - selectedTeamSource: selectedTeam?.source, - selectedTeamLabel, - selectedTeamSummary, - selectedTeamRoles: selectedTeam?.roles, - turnTeamDecision, - turnTeamReason, - turnTeamBlueprint, - }), - }, - }; - const runtimeSendOptions = assistantDraft - ? { - ...nextSendOptions, - assistantDraft, - } - : nextSendOptions; - - if (autoContinuePayload) { - await sendMessage( - text, - images || [], - effectiveWebSearch, - effectiveThinking, - false, - sendExecutionStrategy, - effectiveModel, - autoContinuePayload, - runtimeSendOptions, - ); - } else { - await sendMessage( - text, - images || [], - effectiveWebSearch, - effectiveThinking, - false, - sendExecutionStrategy, - effectiveModel, - undefined, - runtimeSendOptions, - ); - } - - finalizeAfterSendSuccess(sendBoundary); - - return true; - } catch (error) { - rollbackAfterSendFailure(sendBoundary); - const errorMessage = - error instanceof Error ? error.message : String(error); - setRuntimeTeamDispatchPreview((current) => - current - ? { - ...current, - status: "failed", - failureMessage: errorMessage, - } - : null, - ); - sendOptions?.observer?.onError?.(errorMessage); - console.error("[AgentChat] 发送消息失败:", error); - toast.error(`发送失败: ${errorMessage}`); - // 恢复输入内容,让用户可以重试 - setInput(sourceText); - return false; - } - }, - [ - chatToolPreferences, - contextWorkspace, - input, - creationMode, - contentId, - currentGate.key, - chatMode, - isThemeWorkbench, - mentionedCharacters, - mappedTheme, - activeTheme, - executionStrategy, - messages.length, - model, - projectId, - preferredTeamPresetId, - selectedTeam?.id, - selectedTeam?.roles, - selectedTeam?.source, - selectedTeamLabel, - selectedTeamSummary, - providerType, - finalizeAfterSendSuccess, - handleImageWorkbenchCommand, - isBlockedByBrowserPreflight, - maybeStartBrowserTaskPreflight, - prepareRuntimeTeamBeforeSend, - resolveSendBoundary, - resolveSendProviderContext, - rollbackAfterSendFailure, - runtimeStyleMessagePrompt, - sendMessage, - sessionId, - setModel, - themeWorkbenchActiveQueueItem?.title, - ], - ); - - const handleRecommendationClick = useCallback( - (shortLabel: string, fullPrompt: string) => { - setInput(fullPrompt); - - if ( - activeTheme !== "general" || - !isTeamRuntimeRecommendation(shortLabel, fullPrompt) - ) { - return; - } - - const nextToolPreferences = chatToolPreferences.subagent - ? chatToolPreferences - : { - ...chatToolPreferences, - subagent: true, - }; - - if (!chatToolPreferences.subagent) { - setChatToolPreferences(nextToolPreferences); - } - saveChatToolPreferences(nextToolPreferences, activeTheme); - void handleSend( - [], - nextToolPreferences.webSearch, - nextToolPreferences.thinking, - fullPrompt, - executionStrategy, - undefined, - { - toolPreferencesOverride: nextToolPreferences, - }, - ); - }, - [ - activeTheme, - chatToolPreferences, - executionStrategy, - handleSend, - setChatToolPreferences, - ], - ); - - const handleSendRef = useRef(handleSend); - const webSearchPreferenceRef = useRef(chatToolPreferences.webSearch); - - useEffect(() => { - handleSendRef.current = handleSend; - }, [handleSend]); - - useEffect(() => { - webSearchPreferenceRef.current = chatToolPreferences.webSearch; - }, [chatToolPreferences.webSearch]); - - 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; - } - - if (isBrowserAssistReady) { - if ( - browserTaskPreflight.phase === "launching" || - browserTaskPreflight.phase === "failed" - ) { - setBrowserTaskPreflight((current) => - current?.requestId === browserTaskPreflight.requestId - ? { - ...current, - phase: "awaiting_user", - detail: - current.requirement === "required_with_user_step" - ? `浏览器已经连接。请先在右侧完成${current.platformLabel || "目标站点"}登录、扫码或验证码,然后继续当前任务。` - : "浏览器已经连接,请确认页面可操作后继续当前任务。", - } - : current, - ); - } - return; - } - - if ( - browserTaskPreflight.phase === "awaiting_user" || - browserTaskPreflight.phase === "ready_to_resume" - ) { - setBrowserTaskPreflight((current) => - current?.requestId === browserTaskPreflight.requestId - ? { - ...current, - phase: "failed", - detail: "浏览器会话已断开,请重新启动浏览器后再继续。", - } - : current, - ); - } - }, [browserTaskPreflight, isBrowserAssistReady]); - - const handlePermissionResponseWithBrowserPreflight = useCallback( - async (response: { - requestId: string; - confirmed: boolean; - response?: string; - actionType?: "tool_confirmation" | "ask_user" | "elicitation"; - userData?: unknown; - }) => { - if ( - !browserTaskPreflight || - response.requestId !== browserTaskPreflight.requestId - ) { - await handlePermissionResponse(response); - return; - } - - const userData = - response.userData && typeof response.userData === "object" - ? (response.userData as Record) - : null; - const browserAction = - typeof userData?.browserAction === "string" - ? userData.browserAction - : ""; - - if (browserAction === "launch") { - await runBrowserTaskPreflight(browserTaskPreflight); - return; - } - - if (browserAction === "continue") { - if (!isBrowserAssistReady) { - setBrowserTaskPreflight((current) => - current?.requestId === browserTaskPreflight.requestId - ? { - ...current, - phase: "failed", - detail: "尚未检测到可用的浏览器会话,请先启动或恢复浏览器。", - } - : current, - ); - toast.error("浏览器还没有准备好,请先完成启动或恢复浏览器"); - return; - } - - const pending = browserTaskPreflight; - setBrowserTaskPreflight(null); - await handleSendRef.current( - pending.images, - pending.webSearch, - pending.thinking, - pending.sourceText, - pending.sendExecutionStrategy, - pending.autoContinuePayload, - { - ...(pending.sendOptions || {}), - browserPreflightConfirmed: true, - }, - ); - return; - } - - await handlePermissionResponse(response); - }, - [ - browserTaskPreflight, - handlePermissionResponse, - isBrowserAssistReady, - runBrowserTaskPreflight, - ], - ); - - const handleDocumentThinkingEnabledChange = useCallback( - (enabled: boolean) => { - setChatToolPreferences((previous) => - previous.thinking === enabled - ? previous - : { - ...previous, - thinking: enabled, - }, - ); - }, - [setChatToolPreferences], - ); - - const handleDocumentAutoContinueRun = useCallback( - async (payload: AutoContinueRunPayload) => { + const { + handleContinueThemeWorkbenchEntryPrompt, + handleRestartThemeWorkbenchEntryPrompt, + } = useThemeWorkbenchEntryPromptActions({ + themeWorkbenchEntryPrompt, + input, + initialDispatchKey, + onContinuePrompt: async (promptToSend) => { await handleSendRef.current( [], webSearchPreferenceRef.current, - payload.thinkingEnabled, - payload.prompt, - undefined, - { - enabled: payload.settings.enabled, - fast_mode_enabled: payload.settings.fastModeEnabled, - continuation_length: payload.settings.continuationLength, - sensitivity: payload.settings.sensitivity, - source: "theme_workbench_document_auto_continue", - }, + chatToolPreferences.thinking, + promptToSend, ); }, - [], - ); - - const handleDocumentContentReviewRun = useCallback( - async (payload: ContentReviewRunPayload) => { - return await new Promise((resolve, reject) => { - void handleSendRef - .current( - [], - webSearchPreferenceRef.current, - payload.thinkingEnabled, - payload.prompt, - undefined, - undefined, - { - skipThemeSkillPrefix: true, - purpose: "content_review", - observer: { - onComplete: resolve, - onError: (message) => reject(new Error(message)), - }, - }, - ) - .catch((error) => { - reject(error instanceof Error ? error : new Error(String(error))); - }); - }); + dismissThemeWorkbenchEntryPrompt, + onConsumeInitialPrompt: (dispatchKey) => { + consumedInitialPromptRef.current = dispatchKey; + onInitialUserPromptConsumed?.(); }, - [], - ); - - const handleDocumentTextStylizeRun = useCallback( - async (payload: TextStylizeRunPayload) => { - return await new Promise((resolve, reject) => { - void handleSendRef - .current( - [], - webSearchPreferenceRef.current, - payload.thinkingEnabled, - payload.prompt, - undefined, - undefined, - { - skipThemeSkillPrefix: true, - purpose: "text_stylize", - observer: { - onComplete: resolve, - onError: (message) => reject(new Error(message)), - }, - }, - ) - .catch((error) => { - reject(error instanceof Error ? error : new Error(String(error))); - }); - }); + onInputChange: setInput, + onRequirePrompt: () => { + toast.info("请先补充要继续执行的内容"); }, - [], - ); + }); + const { + browserAssistEntryLabel, + browserAssistAttentionLevel, + browserPreflightMessages, + handlePermissionResponseWithBrowserPreflight, + } = useWorkspaceBrowserPreflightRuntime({ + browserTaskPreflight, + setBrowserTaskPreflight, + browserAssistLaunching, + isBrowserAssistReady, + ensureBrowserAssistCanvas, + handlePermissionResponse, + sendRef: handleSendRef, + }); + const { + handleDocumentThinkingEnabledChange, + handleDocumentAutoContinueRun, + handleDocumentContentReviewRun, + handleDocumentTextStylizeRun, + handleSwitchBranchVersion, + handleCreateVersionSnapshot, + handleSetBranchStatus, + handleAddImage, + handleImportDocument, + } = useWorkspaceCanvasWorkflowActions({ + setChatToolPreferences, + sendRef: handleSendRef, + webSearchPreferenceRef, + setCanvasState, + setTopicStatus, + projectId, + projectName: project?.name, + canvasState, + contentId, + onRunImageWorkbenchCommand: handleImageWorkbenchCommand, + }); + const { handleA2UISubmit, handleInputbarA2UISubmit } = + useWorkspaceA2UISubmitActions({ + handlePermissionResponseWithBrowserPreflight, + pendingLegacyQuestionnaireA2UIForm, + pendingPromotedA2UIActionRequest, + sendMessage, + }); // 监听主题工作台技能触发 useEffect(() => { @@ -7919,373 +1884,25 @@ export function AgentChatWorkspace({ handleSend([], false, false, command); }, [pendingSkillKey, isThemeWorkbench, consumePendingSkill, handleSend]); - const handleClearMessages = useCallback(() => { - clearMessages(); - setInput(""); - setSelectedText(""); - setBrowserTaskPreflight(null); - // 重置布局模式 - setLayoutMode("chat"); - autoCollapsedTopicSidebarRef.current = false; - setShowSidebar(defaultTopicSidebarVisible); - // 清理画布和文件状态 - setCanvasState(null); - setGeneralCanvasState(DEFAULT_CANVAS_STATE); - setTaskFiles([]); - setSelectedFileId(undefined); - processedMessageIds.current.clear(); - clearProjectSelectionRuntime(); - }, [clearMessages, clearProjectSelectionRuntime, defaultTopicSidebarVisible]); - - const handleSwitchBranchVersion = useCallback( - (versionId: string) => { - setCanvasState((previous) => { - if (!previous || previous.type !== "document") { - return previous; - } - - const targetVersion = previous.versions.find( - (version) => version.id === versionId, - ); - if (!targetVersion) { - return previous; - } - - return { - ...previous, - currentVersionId: targetVersion.id, - content: targetVersion.content, - }; - }); - }, - [setCanvasState], - ); - - const handleCreateVersionSnapshot = useCallback(() => { - setCanvasState((previous) => { - if (!previous || previous.type !== "document") { - toast.info("当前没有可管理的文稿版本"); - return previous; - } - - const content = previous.content.trim(); - if (!content) { - toast.info("主稿为空,无法创建版本快照"); - return previous; - } - - const nextIndex = previous.versions.length + 1; - const newVersion = { - id: crypto.randomUUID(), - content: previous.content, - createdAt: Date.now(), - description: `手动快照 - 版本 ${nextIndex}`, - }; - - toast.success("已创建版本快照"); - return { - ...previous, - versions: [...previous.versions, newVersion], - currentVersionId: newVersion.id, - }; - }); - }, [setCanvasState]); - - const handleSetBranchStatus = useCallback( - ( - topicId: string, - status: "in_progress" | "pending" | "merged" | "candidate", - ) => { - setTopicStatus(topicId, status); - if (status === "merged") { - toast.success("已将该版本标记为主稿"); - } else if (status === "pending") { - toast.info("已将该版本标记为待评审"); - } - }, - [setTopicStatus], - ); - - const handleAddImage = useCallback(async () => { - if (!projectId) { - toast.error("请先选择项目后再开始配图"); - return; - } - - if (!canvasState) { - toast.info("当前没有可用画布"); - return; - } - - let rawText = ""; - let applyTarget: ImageWorkbenchApplyTarget | null = null; - - if (canvasState.type === "document") { - rawText = buildImageWorkbenchCommandText( - buildDocumentImageWorkbenchPrompt({ - projectName: project?.name, - platform: canvasState.platform, - content: canvasState.content, - }), - ); - applyTarget = buildDefaultCanvasImageApplyTarget({ - canvasState, - projectId, - contentId: contentId ?? null, - }); - } else if (canvasState.type === "poster") { - const currentPage = - canvasState.pages[canvasState.currentPageIndex] || canvasState.pages[0]; - if (!currentPage) { - toast.error("海报画布缺少有效页面"); - return; - } - rawText = buildImageWorkbenchCommandText( - buildPosterImageWorkbenchPrompt({ - projectName: project?.name, - width: currentPage.width, - height: currentPage.height, - }), - { - aspectRatio: resolveClosestImageAspectRatio( - currentPage.width, - currentPage.height, - ), - }, - ); - applyTarget = buildDefaultCanvasImageApplyTarget({ - canvasState, - projectId, - contentId: contentId ?? null, - }); - } else { - toast.info("当前画布暂未接入配图工作台"); - return; - } - - const parsedCommand = parseImageWorkbenchCommand(rawText); - if (!parsedCommand) { - toast.error("配图任务初始化失败"); - return; - } - - await handleImageWorkbenchCommand({ - rawText, - parsedCommand, - images: [], - applyTarget, - }); - }, [ - canvasState, - contentId, - handleImageWorkbenchCommand, - project?.name, - projectId, - ]); - - const handleImportDocument = useCallback(async () => { - try { - const selected = await openDialog({ - multiple: false, - filters: [ - { - name: "文档", - extensions: ["md", "txt"], - }, - ], - }); - - if (!selected) { - return; - } - - const filePath = selected; - if (!filePath) { - toast.error("未选择文件"); - return; - } - - toast.info("正在导入文稿..."); - - // 调用后端解析接口 - const content = await importDocument(filePath); - - // 加载到文档 - setCanvasState((previous) => { - if (!previous || previous.type !== "document") { - toast.error("当前不在文档编辑模式"); - return previous; - } - - return { - ...previous, - content: content, - }; - }); - - toast.success("文稿已导入"); - } catch (error) { - console.error("导入文稿失败:", error); - toast.error(error instanceof Error ? error.message : "导入文稿失败"); - } - }, [setCanvasState]); - - // 响应首页导航触发的新会话请求 - useEffect(() => { - if (!newChatAt) { - return; - } - - const requestKey = String(newChatAt); - if (hasHandledNewChatRequest(requestKey)) { - return; - } - markNewChatRequestHandled(requestKey); - - clearMessages({ - showToast: false, - }); - setInput(""); - setSelectedText(""); - setBrowserTaskPreflight(null); - setLayoutMode("chat"); - autoCollapsedTopicSidebarRef.current = false; - setShowSidebar(defaultTopicSidebarVisible); - setCanvasState(null); - setGeneralCanvasState(DEFAULT_CANVAS_STATE); - setTaskFiles([]); - setSelectedFileId(undefined); - setMentionedCharacters([]); - processedMessageIds.current.clear(); - clearProjectSelectionRuntime(); - restoredMetaSessionId.current = null; - restoredFilesSessionId.current = null; - hasTriggeredGuide.current = false; - consumedInitialPromptRef.current = null; - - if (!externalProjectId) { - resetProjectSelection(); - setProject(null); - setProjectMemory(null); - setActiveTheme(normalizeInitialTheme(initialTheme)); - setCreationMode(initialCreationMode ?? "guided"); - } - - const toastId = initialSessionName - ? "openclaw-agent-handoff" - : "agent-new-chat"; - const canCreateFreshSession = Boolean(projectId?.trim()); - - if (!canCreateFreshSession) { - return; - } - - void (async () => { - const newSessionId = await createFreshSession(initialSessionName); - if (newSessionId) { - toast.success( - initialSessionName - ? `已创建新任务:${initialSessionName}` - : "已创建新任务", - { id: toastId }, - ); - } else { - toast.error("创建新任务失败,请重试。", { id: toastId }); - } - })(); - }, [ - createFreshSession, - initialSessionName, - newChatAt, - clearMessages, - defaultTopicSidebarVisible, - externalProjectId, - hasHandledNewChatRequest, - initialTheme, - initialCreationMode, - markNewChatRequestHandled, - projectId, - clearProjectSelectionRuntime, - resetProjectSelection, - ]); - - const handleBackHome = useCallback(() => { - clearMessages({ - showToast: false, - }); - setInput(""); - setSelectedText(""); - setLayoutMode("chat"); - setShowSidebar(true); - setCanvasState(null); - setGeneralCanvasState(DEFAULT_CANVAS_STATE); - setTaskFiles([]); - setSelectedFileId(undefined); - processedMessageIds.current.clear(); - resetProjectSelection(); - setProject(null); - setProjectMemory(null); - setActiveTheme("general"); - setCreationMode("guided"); - _onNavigate?.("agent", buildHomeAgentParams()); - }, [clearMessages, _onNavigate, resetProjectSelection]); - - const displayMessages = useMemo(() => { - const collapsedMessages = collapseLegacyQuestionnaireMessages(messages); - const runtimeTeamDispatchPreviewMessages = runtimeTeamDispatchPreview - ? buildRuntimeTeamDispatchPreviewMessages(runtimeTeamDispatchPreview) - : []; - if (browserTaskPreflight) { - return [ - ...collapsedMessages, - ...buildBrowserPreflightMessages(browserTaskPreflight), - ]; - } - - if (runtimeTeamDispatchPreviewMessages.length > 0) { - return [...collapsedMessages, ...runtimeTeamDispatchPreviewMessages]; - } - - if ( - collapsedMessages.length === 0 && - bootstrapDispatchPreviewMessages.length > 0 - ) { - return bootstrapDispatchPreviewMessages; - } - - return collapsedMessages; - }, [ + const { displayMessages } = useWorkspaceDisplayMessagesRuntime({ bootstrapDispatchPreviewMessages, - browserTaskPreflight, - messages, - runtimeTeamDispatchPreview, - ]); - - useEffect(() => { - if (!sessionId) { - return; - } - - updateTopicSnapshot( - sessionId, - buildLiveTaskSnapshot({ - messages: displayMessages, - isSending, - pendingActionCount: pendingActions.length, - queuedTurnCount: queuedTurns.length, - workspaceError: Boolean(workspacePathMissing || workspaceHealthError), - }), - ); - }, [ - displayMessages, + browserPreflightMessages, isSending, - pendingActions.length, - queuedTurns.length, + messages, + pendingActionCount: pendingActions.length, + queuedTurnCount: queuedTurns.length, + runtimeTeamDispatchPreview, sessionId, updateTopicSnapshot, - workspaceHealthError, - workspacePathMissing, - ]); + workspaceError: Boolean(workspacePathMissing || workspaceHealthError), + }); + const latestAssistantMessageId = useMemo( + () => + [...displayMessages] + .reverse() + .find((message) => message.role === "assistant")?.id ?? null, + [displayMessages], + ); // 当开始对话时自动折叠侧边栏 const hasMessages = messages.length > 0; @@ -8306,312 +1923,57 @@ export function AgentChatWorkspace({ setSelectedText(""); }, [activeTheme, contentId]); - useEffect(() => { - if (!canvasState || canvasState.type !== "novel") { - setNovelChapterListCollapsed(false); - } - }, [canvasState]); - - useEffect(() => { - autoCollapsedTopicSidebarRef.current = false; - setShowSidebar(defaultTopicSidebarVisible); - }, [defaultTopicSidebarVisible]); - - useEffect(() => { - if (showChatPanel) { - setLayoutMode((previous) => - previous === "canvas" ? "chat-canvas" : previous, - ); - return; - } - - setShowSidebar(false); - - if (layoutMode === "canvas") { - return; - } - - if (layoutMode === "chat-canvas") { - setLayoutMode("canvas"); - return; - } - - const fallbackContent = "# 新文档\n\n在这里开始编写内容..."; - - if (activeTheme === "general") { - setGeneralCanvasState((previous) => ({ - ...previous, - isOpen: true, - contentType: - previous.contentType === "empty" ? "markdown" : previous.contentType, - content: previous.content || fallbackContent, - })); - } else if (!canvasState) { - const initialState = - createInitialCanvasState(mappedTheme, fallbackContent) || - createInitialDocumentState(fallbackContent); - setCanvasState(initialState); - } - - setLayoutMode("canvas"); - }, [showChatPanel, layoutMode, activeTheme, canvasState, mappedTheme]); - - useEffect(() => { - if ( - isThemeWorkbench || - activeTheme !== "general" || - layoutMode !== "chat-canvas" - ) { - setCanvasWorkbenchLayoutMode("split"); - } - }, [activeTheme, isThemeWorkbench, layoutMode]); - - useEffect(() => { - const shouldAutoHideTopicSidebar = - showChatPanel && - !isThemeWorkbench && - activeTheme === "general" && - layoutMode === "chat-canvas" && - canvasWorkbenchLayoutMode === "stacked"; - - if (shouldAutoHideTopicSidebar) { - if (showSidebar) { - autoCollapsedTopicSidebarRef.current = true; - setShowSidebar(false); - } - return; - } - - if (autoCollapsedTopicSidebarRef.current) { - autoCollapsedTopicSidebarRef.current = false; - setShowSidebar(true); - } - }, [ + const { + handleToggleSidebar, + handleToggleNovelChapterList, + handleAddNovelChapter, + handleToggleCanvas, + handleCloseCanvas, + resolvedCanvasState, + showNovelNavbarControls, + } = useWorkspaceCanvasLayoutRuntime({ activeTheme, - canvasWorkbenchLayoutMode, isThemeWorkbench, layoutMode, showChatPanel, showSidebar, - ]); - - useEffect(() => { - onHasMessagesChange?.(hasMessages); - }, [hasMessages, onHasMessagesChange]); - - // 当有可渲染主稿文件时,仅在需要时同步到画布,避免打断当前编辑 - useEffect(() => { - const renderableFiles = taskFiles.filter((file) => - isRenderableTaskFile(file, isThemeWorkbench), - ); - if (renderableFiles.length === 0) { - return; - } - - const { targetFile, nextSelectedFileId } = resolveCanvasTaskFileTarget( - renderableFiles, - selectedFileId, - ); - if (!targetFile?.content) { - return; - } - - if (nextSelectedFileId) { - setSelectedFileId((previous) => - previous === nextSelectedFileId ? previous : nextSelectedFileId, - ); - } - - if ( - shouldDeferCanvasSyncWhileEditing({ - canvasType: canvasState?.type ?? null, - editorFocused: documentEditorFocusedRef.current, - }) - ) { - return; - } - - const targetContent = targetFile.content; - setCanvasState((prev) => { - if (mappedTheme === "music") { - const sections = parseLyrics(targetContent); - if (!prev || prev.type !== "music") { - const musicState = createInitialMusicState(); - musicState.sections = sections; - const titleMatch = targetContent.match(/^#\s*(.+)$/m); - if (titleMatch) { - musicState.spec.title = titleMatch[1].trim(); - } - return musicState; - } - return { ...prev, sections }; - } - - if (mappedTheme === "novel") { - return upsertNovelCanvasState(prev, targetContent); - } - - if (!prev || prev.type !== "document") { - return createInitialDocumentState(targetContent); - } - if (prev.content === targetContent) { - return prev; - } - return { ...prev, content: targetContent }; - }); - setLayoutMode("chat-canvas"); - }, [ - taskFiles, - isThemeWorkbench, - mappedTheme, - upsertNovelCanvasState, - selectedFileId, - canvasState?.type, - ]); - - const handleToggleSidebar = useCallback(() => { - if (!showChatPanel) { - return; - } - setShowSidebar((prev) => !prev); - }, [showChatPanel]); - - const handleToggleNovelChapterList = useCallback(() => { - setNovelChapterListCollapsed((prev) => !prev); - }, []); - - const handleAddNovelChapter = useCallback(() => { - setCanvasState((prev) => { - if (!prev || prev.type !== "novel") { - return prev; - } - - const now = Date.now(); - const chapterNumber = prev.chapters.length + 1; - const title = `第${chapterNumber}章`; - const newChapter = { - id: crypto.randomUUID(), - number: chapterNumber, - title, - content: `# ${title}\n\n`, - wordCount: 0, - status: "draft" as const, - createdAt: now, - updatedAt: now, - }; - - return { - ...prev, - chapters: [...prev.chapters, newChapter], - currentChapterId: newChapter.id, - }; - }); - setNovelChapterListCollapsed(false); - }, []); - - // 切换画布显示 - const handleToggleCanvas = useCallback(() => { - // General 主题使用专门的画布 - if (activeTheme === "general") { - if (layoutMode !== "chat" && isBrowserAssistCanvasVisible) { - suppressBrowserAssistCanvasAutoOpen(); - } - setGeneralCanvasState((prev) => ({ - ...prev, - isOpen: !prev.isOpen, - contentType: - prev.contentType === "empty" ? "markdown" : prev.contentType, - content: prev.content || "# 新文档\n\n在这里开始编写内容...", - })); - setLayoutMode((prev) => (prev === "chat" ? "chat-canvas" : "chat")); - return; - } - - setLayoutMode((prev) => { - if (prev === "chat") { - // 打开画布时,如果没有画布状态则创建初始状态 - if (!canvasState) { - const initialState = - createInitialCanvasState( - mappedTheme, - "# 新文档\n\n在这里开始编写内容...", - ) || - createInitialDocumentState("# 新文档\n\n在这里开始编写内容..."); - setCanvasState(initialState); - } - return "chat-canvas"; - } - return "chat"; - }); - }, [ - 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, - currentCanvasArtifact?.type, - suppressBrowserAssistCanvasAutoOpen, - ]); - - const resolvedCanvasState = useMemo(() => { - if (canvasState) { - return canvasState; - } - - if (shouldBootstrapCanvasOnEntry) { - return ( - createInitialCanvasState(normalizedEntryTheme, "") || - createInitialDocumentState("") - ); - } - - if (isThemeWorkbench && isContentCreationTheme(activeTheme)) { - return ( - createInitialCanvasState(mappedTheme, "") || - createInitialDocumentState("") - ); - } - - return null; - }, [ - activeTheme, - canvasState, - isThemeWorkbench, + defaultTopicSidebarVisible, + hasMessages, + canvasWorkbenchLayoutMode, + autoCollapsedTopicSidebarRef, mappedTheme, normalizedEntryTheme, shouldBootstrapCanvasOnEntry, - ]); + canvasState, + showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard, + hasCurrentCanvasArtifact: Boolean(currentCanvasArtifact), + currentCanvasArtifactType: currentCanvasArtifact?.type, + currentImageWorkbenchActive: currentImageWorkbenchState.active, + isBrowserAssistCanvasVisible, + onHasMessagesChange, + dismissActiveTeamWorkbenchAutoOpen, + suppressGeneralCanvasArtifactAutoOpen, + suppressBrowserAssistCanvasAutoOpen, + setShowSidebar, + setLayoutMode, + setGeneralCanvasState, + setCanvasState, + setCanvasWorkbenchLayoutMode, + setNovelChapterListCollapsed, + }); - const showNovelNavbarControls = - layoutMode !== "chat" && resolvedCanvasState?.type === "novel"; - - const upsertGeneralArtifact = useCallback( - (artifact: Artifact) => { - setArtifacts((currentArtifacts) => - mergeArtifacts([...currentArtifacts, artifact]), - ); - }, - [setArtifacts], - ); + useWorkspaceCanvasTaskFileSync({ + taskFiles, + isThemeWorkbench, + selectedFileId, + canvasState, + mappedTheme, + documentEditorFocusedRef, + setSelectedFileId, + setCanvasState, + setLayoutMode, + upsertNovelCanvasState, + }); useEffect(() => { if ( @@ -8626,354 +1988,6 @@ export function AgentChatWorkspace({ upsertGeneralArtifact(settledLiveArtifact); }, [activeTheme, liveArtifact, settledLiveArtifact, upsertGeneralArtifact]); - const commitBrowserAssistSessionState = useCallback( - (candidate: BrowserAssistSessionState | null) => { - if (activeTheme !== "general" || !candidate) { - return; - } - - setBrowserAssistSessionState((current) => { - const next = mergeBrowserAssistSessionStates(current, candidate); - return areBrowserAssistSessionStatesEqual(current, next) - ? current - : next; - }); - }, - [activeTheme], - ); - - useEffect(() => { - if (activeTheme !== "general") { - setBrowserAssistSessionState(null); - return; - } - - setBrowserAssistSessionState( - loadBrowserAssistSessionState(projectId, sessionId), - ); - }, [activeTheme, browserAssistStorageKey, projectId, sessionId]); - - useEffect(() => { - if (activeTheme !== "general") { - return; - } - - commitBrowserAssistSessionState(browserAssistSessionFromArtifact); - }, [ - activeTheme, - browserAssistSessionFromArtifact, - commitBrowserAssistSessionState, - ]); - - useEffect(() => { - if (activeTheme !== "general") { - return; - } - - commitBrowserAssistSessionState(latestBrowserAssistSessionFromMessages); - }, [ - activeTheme, - commitBrowserAssistSessionState, - latestBrowserAssistSessionFromMessages, - ]); - - useEffect(() => { - if (activeTheme !== "general") { - return; - } - - if (browserAssistSessionState) { - saveBrowserAssistSessionState( - projectId, - sessionId, - browserAssistSessionState, - ); - return; - } - - clearBrowserAssistSessionState(projectId, sessionId); - }, [ - activeTheme, - browserAssistSessionState, - browserAssistStorageKey, - projectId, - sessionId, - ]); - - const navigateBrowserAssistCanvasToUrl = useCallback( - async (url: string, options?: { silent?: boolean }): Promise => { - if (activeTheme !== "general" || !url.trim()) { - return false; - } - - const artifactMeta = asRecord(browserAssistArtifact?.meta); - const profileKey = - browserAssistSessionState?.profileKey || - readFirstString(artifactMeta ? [artifactMeta] : [], [ - "profileKey", - "profile_key", - ]) || - GENERAL_BROWSER_ASSIST_PROFILE_KEY; - const currentUrl = - browserAssistSessionState?.url || - readFirstString(artifactMeta ? [artifactMeta] : [], [ - "url", - "launchUrl", - ]) || - ""; - const fallbackTitle = - browserAssistSessionState?.title || - browserAssistArtifact?.title?.trim() || - "浏览器协助"; - - if (currentUrl === url) { - openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - return true; - } - - setBrowserAssistLaunching(true); - - try { - const result = await browserExecuteAction({ - profile_key: profileKey, - backend: "cdp_direct", - action: "navigate", - args: { - action: "goto", - url, - wait_for_page_info: true, - }, - timeout_ms: 20000, - }); - - if (!result.success) { - throw new Error(result.error || "浏览器导航失败"); - } - - const resultData = asRecord(result.data); - const pageInfo = - asRecord(resultData?.page_info) || asRecord(resultData?.pageInfo); - const nextUrl = - readFirstString( - [pageInfo, resultData], - ["url", "target_url", "targetUrl"], - ) || url; - const nextTitle = - readFirstString( - [pageInfo, resultData], - ["title", "target_title", "targetTitle"], - ) || fallbackTitle; - - commitBrowserAssistSessionState( - createBrowserAssistSessionState({ - sessionId: - result.session_id || - browserAssistSessionState?.sessionId || - undefined, - profileKey: profileKey, - url: nextUrl, - title: nextTitle, - targetId: - result.target_id || - browserAssistSessionState?.targetId || - undefined, - transportKind: browserAssistSessionState?.transportKind, - lifecycleState: browserAssistSessionState?.lifecycleState || "live", - controlMode: browserAssistSessionState?.controlMode, - source: "runtime_launch", - updatedAt: Date.now(), - }), - ); - openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - - if (!options?.silent) { - toast.success(`已切换浏览器页面:${nextTitle}`); - } - return true; - } catch (error) { - if (!options?.silent) { - toast.error( - `切换浏览器页面失败: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - return false; - } finally { - setBrowserAssistLaunching(false); - } - }, - [ - activeTheme, - browserAssistArtifact, - browserAssistSessionState, - commitBrowserAssistSessionState, - openBrowserAssistCanvas, - ], - ); - - const ensureBrowserAssistCanvas = useCallback( - async ( - sourceText: string, - options?: { - silent?: boolean; - navigationMode?: "none" | "explicit-url" | "best-effort"; - }, - ): Promise => { - if (activeTheme !== "general") { - return false; - } - - const navigationMode = options?.navigationMode || "best-effort"; - const targetUrl = - navigationMode === "explicit-url" - ? extractExplicitUrlFromText(sourceText) - : navigationMode === "best-effort" - ? resolveBrowserAssistLaunchUrl(sourceText) - : null; - const artifactMeta = asRecord(browserAssistArtifact?.meta); - const hasSessionContext = Boolean( - browserAssistSessionState?.sessionId || - browserAssistSessionState?.profileKey || - readFirstString(artifactMeta ? [artifactMeta] : [], [ - "sessionId", - "session_id", - "profileKey", - "profile_key", - ]) || - browserAssistArtifact, - ); - - if (hasSessionContext) { - openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - if (!targetUrl) { - return true; - } - return navigateBrowserAssistCanvasToUrl(targetUrl, options); - } - - if (!targetUrl) { - return false; - } - - const browserAssistScopeKey = - currentBrowserAssistScopeKey || - resolveBrowserAssistSessionScopeKey(projectId, sessionId); - const launchKey = `${GENERAL_BROWSER_ASSIST_PROFILE_KEY}:${targetUrl}`; - if (autoLaunchingBrowserAssistKeyRef.current === launchKey) { - openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - return true; - } - autoLaunchingBrowserAssistKeyRef.current = launchKey; - upsertGeneralArtifact( - buildPendingBrowserAssistArtifact({ - scopeKey: browserAssistScopeKey, - profileKey: GENERAL_BROWSER_ASSIST_PROFILE_KEY, - url: targetUrl, - title: "浏览器协助", - }), - ); - openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - setBrowserAssistLaunching(true); - - try { - const result = await launchBrowserSession({ - profile_key: GENERAL_BROWSER_ASSIST_PROFILE_KEY, - url: targetUrl, - open_window: false, - stream_mode: "both", - }); - - commitBrowserAssistSessionState( - createBrowserAssistSessionState({ - sessionId: result.session.session_id, - profileKey: result.session.profile_key, - url: - result.session.last_page_info?.url?.trim() || - result.session.target_url?.trim() || - targetUrl, - title: - result.session.last_page_info?.title?.trim() || - result.session.target_title?.trim() || - "浏览器协助", - targetId: result.session.target_id, - transportKind: result.session.transport_kind, - lifecycleState: result.session.lifecycle_state, - controlMode: result.session.control_mode, - source: "runtime_launch", - updatedAt: Date.now(), - }), - ); - openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - - if (!options?.silent) { - toast.success( - `浏览器协助已启动:${ - result.session.target_title || - result.session.target_url || - targetUrl - }`, - ); - } - return true; - } catch (error) { - upsertGeneralArtifact( - buildFailedBrowserAssistArtifact({ - scopeKey: browserAssistScopeKey, - profileKey: GENERAL_BROWSER_ASSIST_PROFILE_KEY, - url: targetUrl, - title: "浏览器协助", - error: error instanceof Error ? error.message : String(error), - }), - ); - autoLaunchingBrowserAssistKeyRef.current = ""; - if (!options?.silent) { - toast.error( - `启动浏览器协助失败: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - return false; - } finally { - setBrowserAssistLaunching(false); - } - }, - [ - activeTheme, - browserAssistArtifact, - browserAssistSessionState?.profileKey, - browserAssistSessionState?.sessionId, - commitBrowserAssistSessionState, - navigateBrowserAssistCanvasToUrl, - currentBrowserAssistScopeKey, - openBrowserAssistCanvas, - projectId, - sessionId, - upsertGeneralArtifact, - ], - ); - - const handleOpenBrowserAssistInCanvas = useCallback(async () => { - await ensureBrowserAssistCanvas(input, { - navigationMode: "best-effort", - }); - }, [ensureBrowserAssistCanvas, input]); - - useEffect(() => { - if ( - !openBrowserAssistOnMount || - openBrowserAssistOnMountHandledRef.current - ) { - return; - } - - openBrowserAssistOnMountHandledRef.current = true; - void ensureBrowserAssistCanvas(initialUserPrompt || "", { - navigationMode: "best-effort", - }); - }, [ensureBrowserAssistCanvas, initialUserPrompt, openBrowserAssistOnMount]); - const handleResumeSidebarTask = useCallback( async (topicId: string, statusReason?: TaskStatusReason) => { if (topicId === sessionId && isResumableBrowserTaskReason(statusReason)) { @@ -8986,3574 +2000,428 @@ export function AgentChatWorkspace({ [handleOpenBrowserAssistInCanvas, sessionId, switchTopic], ); - useEffect(() => { - ensureBrowserAssistCanvasRef.current = ensureBrowserAssistCanvas; - }, [ensureBrowserAssistCanvas]); - - useEffect(() => { - if (!browserTaskPreflight || browserTaskPreflight.phase !== "launching") { - if (!browserTaskPreflight) { - browserTaskPreflightLaunchIdRef.current = ""; - } - return; - } - - if ( - browserTaskPreflightLaunchIdRef.current === browserTaskPreflight.requestId - ) { - return; - } - - browserTaskPreflightLaunchIdRef.current = browserTaskPreflight.requestId; - void runBrowserTaskPreflight(browserTaskPreflight); - }, [browserTaskPreflight, runBrowserTaskPreflight]); - - useEffect(() => { - if (activeTheme !== "general") { - autoOpenedBrowserAssistSessionIdRef.current = ""; - autoLaunchingBrowserAssistKeyRef.current = ""; - browserAssistLaunchRequestIdRef.current += 1; - return; - } - - if ( - !browserAssistSessionState?.sessionId && - !browserAssistSessionState?.profileKey - ) { - return; - } - - const artifactMeta = asRecord(browserAssistArtifact?.meta); - const currentSessionId = readFirstString( - artifactMeta ? [artifactMeta] : [], - ["sessionId", "session_id"], - ); - const currentProfileKey = readFirstString( - artifactMeta ? [artifactMeta] : [], - ["profileKey", "profile_key"], - ); - const currentUrl = readFirstString(artifactMeta ? [artifactMeta] : [], [ - "url", - "launchUrl", - ]); - const currentTargetId = readFirstString( - artifactMeta ? [artifactMeta] : [], - ["targetId", "target_id"], - ); - const currentTransportKind = readFirstString( - artifactMeta ? [artifactMeta] : [], - ["transportKind", "transport_kind"], - ); - const currentLifecycleState = readFirstString( - artifactMeta ? [artifactMeta] : [], - ["lifecycleState", "lifecycle_state"], - ); - const currentControlMode = readFirstString( - artifactMeta ? [artifactMeta] : [], - ["controlMode", "control_mode"], - ); - const currentTitle = browserAssistArtifact?.title?.trim(); - - const nextArtifact = buildBrowserAssistArtifact({ - scopeKey: - currentBrowserAssistScopeKey || - resolveBrowserAssistSessionScopeKey(projectId, sessionId), - profileKey: - browserAssistSessionState.profileKey || - currentProfileKey || - GENERAL_BROWSER_ASSIST_PROFILE_KEY, - browserSessionId: - browserAssistSessionState.sessionId || currentSessionId || "", - url: - browserAssistSessionState.url || currentUrl || "https://www.google.com", - title: browserAssistSessionState.title || currentTitle || "浏览器协助", - targetId: browserAssistSessionState.targetId || currentTargetId, - transportKind: - browserAssistSessionState.transportKind || currentTransportKind, - lifecycleState: - browserAssistSessionState.lifecycleState || currentLifecycleState, - controlMode: browserAssistSessionState.controlMode || currentControlMode, - }); - - const nextMeta = asRecord(nextArtifact.meta); - const nextSessionId = readFirstString(nextMeta ? [nextMeta] : [], [ - "sessionId", - "session_id", - ]); - const nextProfileKey = readFirstString(nextMeta ? [nextMeta] : [], [ - "profileKey", - "profile_key", - ]); - const nextUrl = readFirstString(nextMeta ? [nextMeta] : [], [ - "url", - "launchUrl", - ]); - const nextTargetId = readFirstString(nextMeta ? [nextMeta] : [], [ - "targetId", - "target_id", - ]); - const nextTransportKind = readFirstString(nextMeta ? [nextMeta] : [], [ - "transportKind", - "transport_kind", - ]); - const nextLifecycleState = readFirstString(nextMeta ? [nextMeta] : [], [ - "lifecycleState", - "lifecycle_state", - ]); - const nextControlMode = readFirstString(nextMeta ? [nextMeta] : [], [ - "controlMode", - "control_mode", - ]); - const currentScopeKey = resolveBrowserAssistArtifactScopeKey( - browserAssistArtifact, - ); - const nextScopeKey = resolveBrowserAssistArtifactScopeKey(nextArtifact); - - const shouldUpsertArtifact = - !browserAssistArtifact || - currentScopeKey !== nextScopeKey || - currentSessionId !== nextSessionId || - currentProfileKey !== nextProfileKey || - currentUrl !== nextUrl || - currentTargetId !== nextTargetId || - currentTransportKind !== nextTransportKind || - currentLifecycleState !== nextLifecycleState || - currentControlMode !== nextControlMode || - currentTitle !== nextArtifact.title; - - if (shouldUpsertArtifact) { - upsertGeneralArtifact(nextArtifact); - } - - const autoOpenKey = - browserAssistSessionState.sessionId || - `${ - browserAssistSessionState.profileKey || - GENERAL_BROWSER_ASSIST_PROFILE_KEY - }:${browserAssistSessionState.url || currentUrl || "pending"}`; - if (autoOpenedBrowserAssistSessionIdRef.current !== autoOpenKey) { - autoOpenedBrowserAssistSessionIdRef.current = autoOpenKey; - autoOpenBrowserAssistCanvas(nextArtifact.id); - } - }, [ + const handleWriteFile = useWorkspaceWriteFileAction({ activeTheme, - autoOpenBrowserAssistCanvas, - browserAssistArtifact, - currentBrowserAssistScopeKey, - browserAssistSessionState, + artifacts, + contentId, + currentGateKey: currentGate.key, + currentStepIndex, + isContentCreationMode, + isThemeWorkbench, + mappedTheme, projectId, sessionId, + themeWorkbenchActiveQueueItem, + taskFilesRef, + socialStageLogRef, + setDocumentVersionStatusMap, + saveSessionFile, + syncGeneralArtifactToResource, upsertGeneralArtifact, - ]); - - useEffect(() => { - if (activeTheme !== "general") { - autoLaunchingBrowserAssistKeyRef.current = ""; - browserAssistLaunchRequestIdRef.current += 1; - return; - } - - if ( - !browserAssistSessionState?.sessionId && - !browserAssistSessionState?.profileKey - ) { - return; - } - - const nextSessionId = browserAssistSessionState.sessionId || ""; - const nextProfileKey = - browserAssistSessionState.profileKey || - GENERAL_BROWSER_ASSIST_PROFILE_KEY; - const nextUrl = browserAssistSessionState.url || "https://www.google.com"; - const nextTitle = browserAssistSessionState.title || "浏览器协助"; - - if (nextSessionId || !nextProfileKey || !nextUrl) { - return; - } - - const launchKey = `${nextProfileKey}:${nextUrl}`; - if (autoLaunchingBrowserAssistKeyRef.current === launchKey) { - return; - } - autoLaunchingBrowserAssistKeyRef.current = launchKey; - const browserAssistScopeKey = - currentBrowserAssistScopeKey || - resolveBrowserAssistSessionScopeKey(projectId, sessionId); - upsertGeneralArtifact( - buildPendingBrowserAssistArtifact({ - scopeKey: browserAssistScopeKey, - profileKey: nextProfileKey, - url: nextUrl, - title: nextTitle, - }), - ); - autoOpenBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - const launchRequestId = browserAssistLaunchRequestIdRef.current + 1; - browserAssistLaunchRequestIdRef.current = launchRequestId; - void (async () => { - try { - setBrowserAssistLaunching(true); - const result = await launchBrowserSession({ - profile_key: nextProfileKey, - url: nextUrl, - open_window: false, - stream_mode: "both", - }); - if (browserAssistLaunchRequestIdRef.current !== launchRequestId) { - return; - } - - commitBrowserAssistSessionState( - createBrowserAssistSessionState({ - sessionId: result.session.session_id, - profileKey: result.session.profile_key, - url: - result.session.last_page_info?.url?.trim() || - result.session.target_url?.trim() || - nextUrl, - title: - result.session.last_page_info?.title?.trim() || - result.session.target_title?.trim() || - nextTitle, - targetId: result.session.target_id, - transportKind: result.session.transport_kind, - lifecycleState: result.session.lifecycle_state, - controlMode: result.session.control_mode, - source: "runtime_launch", - updatedAt: Date.now(), - }), - ); - autoOpenBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - } catch (error) { - upsertGeneralArtifact( - buildFailedBrowserAssistArtifact({ - scopeKey: browserAssistScopeKey, - profileKey: nextProfileKey, - url: nextUrl, - title: nextTitle, - error: error instanceof Error ? error.message : String(error), - }), - ); - autoLaunchingBrowserAssistKeyRef.current = ""; - console.warn("[AgentChatPage] 自动拉起浏览器协助实时会话失败:", error); - } finally { - if (browserAssistLaunchRequestIdRef.current === launchRequestId) { - setBrowserAssistLaunching(false); - } - } - })(); - }, [ - activeTheme, - autoOpenBrowserAssistCanvas, - browserAssistSessionState, - commitBrowserAssistSessionState, - currentBrowserAssistScopeKey, - projectId, - sessionId, - upsertGeneralArtifact, - ]); - - // 处理文件写入 - 同名文件更新内容,不同名文件独立保存 - const handleWriteFile = useCallback( - (content: string, fileName: string, context?: WriteArtifactContext) => { - console.log( - "[AgentChatPage] 收到文件写入:", - fileName, - content.length, - "字符", - ); - - // General 主题使用专门的画布处理 - if (activeTheme === "general") { - const existingArtifact = artifacts.find((artifact) => { - if (context?.artifactId && artifact.id === context.artifactId) { - return true; - } - - if (context?.artifact?.id && artifact.id === context.artifact.id) { - return true; - } - - return ( - typeof artifact.meta.filePath === "string" && - artifact.meta.filePath === fileName - ); - }); - const nextContent = - content.length > 0 - ? content - : context?.artifact?.content || existingArtifact?.content || ""; - const nextArtifact = context?.artifact - ? { - ...(existingArtifact || {}), - ...context.artifact, - content: nextContent, - status: - context.status || - context.artifact.status || - existingArtifact?.status || - "pending", - meta: { - ...(existingArtifact?.meta || {}), - ...context.artifact.meta, - ...(context.metadata || {}), - }, - updatedAt: Date.now(), - } - : buildArtifactFromWrite({ - filePath: fileName, - content: nextContent, - context: { - ...context, - artifact: existingArtifact, - status: - context?.status || - (nextContent.length > 0 ? "complete" : "pending"), - }, - }); - - const syncResource = () => { - if (nextArtifact.status !== "complete") { - return; - } - - void syncGeneralArtifactToResource({ - rawFilePath: resolveArtifactFilePath(nextArtifact), - preferredName: nextArtifact.title, - }); - }; - - if (nextContent.length > 0) { - void saveSessionFile(fileName, nextContent) - .then(() => { - syncResource(); - }) - .catch((error) => { - console.error("[AgentChatPage] 持久化 artifact 失败:", error); - syncResource(); - }); - } else { - syncResource(); - } - - upsertGeneralArtifact(nextArtifact); - setSelectedArtifactId(nextArtifact.id); - setArtifactViewMode(resolveDefaultArtifactViewMode(nextArtifact)); - setLayoutMode("chat-canvas"); - return; - } - - const now = Date.now(); - const nextFileType = resolveTaskFileType(fileName, content); - const activeQueueItem = themeWorkbenchActiveQueueItem; - const activeRunVersionId = activeQueueItem?.run_id?.trim() || null; - const activeRunDescription = - activeQueueItem?.title?.trim() || `产物更新 - ${fileName}`; - const socialGateKey = - currentGate.key === "idle" || - currentGate.key === "topic_select" || - currentGate.key === "write_mode" || - currentGate.key === "publish_confirm" - ? currentGate.key - : undefined; - const socialArtifact = - mappedTheme === "social-media" - ? resolveSocialMediaArtifactDescriptor({ - fileName, - gateKey: socialGateKey, - runTitle: activeRunDescription, - }) - : null; - const isThemeWorkbenchPrimaryArtifact = - !isThemeWorkbench || isThemeWorkbenchPrimaryDocumentArtifact(fileName); - const shouldApplyToMainDocument = - nextFileType === "document" && - isThemeWorkbenchPrimaryArtifact && - (!isThemeWorkbench || currentGate.key !== "topic_select"); - const effectiveDocumentVersionId = - activeRunVersionId || - ((isThemeWorkbench || mappedTheme === "social-media") && - shouldApplyToMainDocument - ? `artifact:${fileName}` - : null); - const effectiveVersionDescription = - socialArtifact?.versionLabel || activeRunDescription; - const baseVersionMetadata = - socialArtifact && shouldApplyToMainDocument - ? { - artifactId: socialArtifact.artifactId, - artifactType: socialArtifact.artifactType, - stage: socialArtifact.stage, - platform: socialArtifact.platform, - sourceFileName: fileName, - runId: activeRunVersionId || undefined, - correlationId: - effectiveDocumentVersionId || activeRunVersionId || undefined, - } - : undefined; - const existingTaskFile = taskFilesRef.current.find( - (file) => file.name === fileName, - ); - const hasTaskFileChanged = existingTaskFile?.content !== content; - - if (isThemeWorkbench && effectiveDocumentVersionId) { - const nextStatus: TopicBranchStatus = - activeQueueItem?.status === "running" ? "in_progress" : "pending"; - setDocumentVersionStatusMap((previous) => { - if (previous[effectiveDocumentVersionId] === nextStatus) { - return previous; - } - return { - ...previous, - [effectiveDocumentVersionId]: nextStatus, - }; - }); - } - - // 持久化文件到会话目录 - saveSessionFile(fileName, content).catch((err) => { - console.error("[AgentChatPage] 持久化文件失败:", err); - }); - - // 同步内容到项目(如果有 contentId,先验证存在性) - if (contentId && shouldApplyToMainDocument) { - getContent(contentId) - .then((existingContent) => { - if (existingContent) { - updateContent(contentId, { - body: content, - }).catch((err) => { - console.error("[AgentChatPage] 同步内容到项目失败:", err); - }); - } else { - console.warn( - "[AgentChatPage] contentId 对应的内容不存在,跳过同步:", - contentId, - ); - } - }) - .catch((err) => { - console.error("[AgentChatPage] 检查内容存在性失败:", err); - }); - } else if (isThemeWorkbench && !shouldApplyToMainDocument) { - console.log("[AgentChatPage] 主题工作台非成文阶段,跳过主稿写入:", { - gate: currentGate.key, - fileName, - isPrimaryArtifact: isThemeWorkbenchPrimaryArtifact, - }); - } - - // 根据文件名推进工作流步骤(使用动态映射) - const fileToStepMap = getFileToStepMap(mappedTheme); - const stepIndex = fileToStepMap[fileName]; - if ( - stepIndex !== undefined && - stepIndex === currentStepIndex && - isContentCreationMode - ) { - console.log( - "[AgentChatPage] 推进工作流步骤:", - stepIndex, - "->", - stepIndex + 1, - ); - completeStep({ - aiOutput: { fileName, preview: content.slice(0, 100) }, - }); - } - - if (socialArtifact && hasTaskFileChanged) { - activityLogger.log({ - eventType: existingTaskFile ? "file_update" : "file_create", - status: "success", - title: `${existingTaskFile ? "更新" : "生成"}${socialArtifact.versionLabel}`, - description: fileName, - workspaceId: projectId || undefined, - sessionId: sessionId || undefined, - source: "aster-chat", - correlationId: - effectiveDocumentVersionId || activeRunVersionId || fileName, - metadata: { - ...baseVersionMetadata, - stageLabel: socialArtifact.stageLabel, - isAuxiliary: socialArtifact.isAuxiliary, - }, - }); - - const stageLogKey = `${ - effectiveDocumentVersionId || socialArtifact.artifactId - }:${socialArtifact.stage}`; - if ( - !socialArtifact.isAuxiliary && - socialStageLogRef.current[stageLogKey] !== socialArtifact.stage - ) { - socialStageLogRef.current[stageLogKey] = socialArtifact.stage; - activityLogger.log({ - eventType: "step_complete", - status: "success", - title: socialArtifact.stageLabel, - description: `${socialArtifact.versionLabel}已进入版本链`, - workspaceId: projectId || undefined, - sessionId: sessionId || undefined, - source: "aster-chat", - correlationId: - effectiveDocumentVersionId || activeRunVersionId || fileName, - metadata: { - ...baseVersionMetadata, - stageLabel: socialArtifact.stageLabel, - }, - }); - } - } - - // 更新或创建文件 - setTaskFiles((prev) => { - // 查找同名文件 - const existingIndex = prev.findIndex((f) => f.name === fileName); - - if (existingIndex >= 0) { - // 同名文件存在 - 直接更新内容(不创建新版本) - const existing = prev[existingIndex]; - - // 如果内容完全相同,跳过 - if (existing.content === content) { - console.log("[AgentChatPage] 文件内容相同,跳过:", fileName); - setSelectedFileId(existing.id); - return prev; - } - - // 更新文件内容 - console.log("[AgentChatPage] 更新文件:", fileName); - const updated = [...prev]; - updated[existingIndex] = { - ...existing, - type: nextFileType, - content, - updatedAt: now, - metadata: socialArtifact - ? { - ...(existing.metadata || {}), - ...baseVersionMetadata, - stageLabel: socialArtifact.stageLabel, - versionLabel: socialArtifact.versionLabel, - } - : existing.metadata, - }; - setSelectedFileId(existing.id); - return updated; - } - - // 新文件 - 添加到列表 - console.log("[AgentChatPage] 创建新文件:", fileName); - const newFile: TaskFile = { - id: crypto.randomUUID(), - name: fileName, - type: nextFileType, - content, - version: 1, - createdAt: now, - updatedAt: now, - metadata: socialArtifact - ? { - ...baseVersionMetadata, - stageLabel: socialArtifact.stageLabel, - versionLabel: socialArtifact.versionLabel, - } - : undefined, - }; - setSelectedFileId(newFile.id); - return [...prev, newFile]; - }); - - if (!shouldApplyToMainDocument) { - return; - } - - // 更新画布内容 - setCanvasState((prev) => { - console.log("[AgentChatPage] 更新画布状态:", { - prevType: prev?.type, - mappedTheme, - contentLength: content.length, - }); - - // 海报主题不自动更新画布 - if (mappedTheme === "poster") { - return prev; - } - - // 音乐主题:解析歌词并更新 sections - if (mappedTheme === "music") { - const sections = parseLyrics(content); - if (!prev || prev.type !== "music") { - const musicState = createInitialMusicState(); - musicState.sections = sections; - // 尝试从内容中提取歌曲名称 - const titleMatch = content.match(/^#\s*(.+)$/m); - if (titleMatch) { - musicState.spec.title = titleMatch[1].trim(); - } - console.log("[AgentChatPage] 创建新音乐状态"); - return musicState; - } - // 更新现有音乐状态的 sections - return { - ...prev, - sections, - }; - } - - if (mappedTheme === "novel") { - return upsertNovelCanvasState(prev, content); - } - - // 文档类型画布 - if (!prev || prev.type !== "document") { - console.log("[AgentChatPage] 创建新文档状态"); - const initialDocumentState = createInitialDocumentState(content); - if (!effectiveDocumentVersionId) { - if (!socialArtifact) { - return initialDocumentState; - } - return { - ...initialDocumentState, - platform: - socialArtifact.platform || initialDocumentState.platform, - versions: initialDocumentState.versions.map((version) => ({ - ...version, - description: effectiveVersionDescription, - metadata: baseVersionMetadata, - })), - }; - } - if (!isThemeWorkbench && mappedTheme !== "social-media") { - return initialDocumentState; - } - return { - ...initialDocumentState, - platform: socialArtifact?.platform || initialDocumentState.platform, - versions: [ - { - id: effectiveDocumentVersionId, - content, - createdAt: now, - description: effectiveVersionDescription, - metadata: baseVersionMetadata, - }, - ], - currentVersionId: effectiveDocumentVersionId, - content, - }; - } - - if (effectiveDocumentVersionId) { - const existingIndex = prev.versions.findIndex( - (version) => version.id === effectiveDocumentVersionId, - ); - - if (existingIndex >= 0) { - const nextVersions = [...prev.versions]; - const currentVersion = nextVersions[existingIndex]; - nextVersions[existingIndex] = { - ...currentVersion, - content, - description: - currentVersion.description || effectiveVersionDescription, - metadata: { - ...(currentVersion.metadata || {}), - ...(baseVersionMetadata || {}), - }, - }; - return { - ...prev, - content, - platform: socialArtifact?.platform || prev.platform, - versions: nextVersions, - currentVersionId: effectiveDocumentVersionId, - }; - } - - const parentVersion = - prev.versions.find( - (version) => version.id === prev.currentVersionId, - ) || prev.versions[prev.versions.length - 1]; - const nextVersions = [ - ...prev.versions, - { - id: effectiveDocumentVersionId, - content, - createdAt: now, - description: effectiveVersionDescription, - metadata: { - ...(baseVersionMetadata || {}), - parentVersionId: - parentVersion && - parentVersion.id !== effectiveDocumentVersionId - ? parentVersion.id - : undefined, - parentArtifactId: parentVersion?.metadata?.artifactId, - }, - }, - ].slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); - - return { - ...prev, - content, - platform: socialArtifact?.platform || prev.platform, - versions: nextVersions, - currentVersionId: effectiveDocumentVersionId, - }; - } - console.log("[AgentChatPage] 更新现有文档状态"); - return { - ...prev, - content, - platform: socialArtifact?.platform || prev.platform, - }; - }); - - // 自动打开画布显示流式内容 - setLayoutMode("chat-canvas"); - }, - [ - activeTheme, // 添加 activeTheme 依赖 - artifacts, - setArtifactViewMode, - setSelectedArtifactId, - currentGate.key, - contentId, - currentStepIndex, - isContentCreationMode, - isThemeWorkbench, - completeStep, - mappedTheme, - projectId, - saveSessionFile, - sessionId, - syncGeneralArtifactToResource, - themeWorkbenchActiveQueueItem, - upsertGeneralArtifact, - upsertNovelCanvasState, - ], - ); + setSelectedArtifactId, + setArtifactViewMode, + setLayoutMode, + completeStep, + setTaskFiles, + setSelectedFileId, + setCanvasState, + upsertNovelCanvasState, + }); // 更新 ref,供统一聊天主链 Hook 使用 useEffect(() => { handleWriteFileRef.current = handleWriteFile; }, [handleWriteFile]); - const handleHarnessLoadFilePreview = useCallback( - async (path: string): Promise => { - const normalizedPath = path.trim(); - const createFallbackResult = ( - overrides: Partial = {}, - ): HarnessFilePreviewResult => ({ - path: normalizedPath, - content: null, - isBinary: false, - size: 0, - error: null, - ...overrides, - }); - - if (!normalizedPath) { - return createFallbackResult({ error: "文件路径为空" }); - } - - const fileName = extractFileNameFromPath(normalizedPath); - const candidateNames = [...new Set([normalizedPath, fileName])]; - - const matchedTaskFile = taskFiles.find((file) => - candidateNames.includes(file.name), - ); - if (matchedTaskFile) { - const content = matchedTaskFile.content ?? ""; - return createFallbackResult({ - path: matchedTaskFile.name, - content, - size: content.length, - }); - } - - const matchedSessionFile = sessionFiles.find((file) => - candidateNames.includes(file.name), - ); - if (matchedSessionFile) { - const content = await readSessionFile(matchedSessionFile.name); - if (content !== null) { - return createFallbackResult({ - path: matchedSessionFile.name, - content, - size: content.length, - }); - } - } - - try { - const result = await readFilePreview(normalizedPath, 64 * 1024); - - return createFallbackResult({ - path: result.path || normalizedPath, - content: result.content ?? null, - isBinary: result.isBinary ?? false, - size: result.size ?? 0, - error: result.error ?? null, - }); - } catch (error) { - return createFallbackResult({ - error: error instanceof Error ? error.message : String(error), - }); - } - }, - [readSessionFile, sessionFiles, taskFiles], - ); - - useArtifactAutoPreviewSync({ - enabled: activeTheme === "general", - artifact: currentCanvasArtifact, - loadPreview: handleHarnessLoadFilePreview, - onSyncArtifact: upsertGeneralArtifact, + const { + handleHarnessLoadFilePreview, + handleArtifactClick, + handleFileClick, + handleCodeBlockClick, + shouldCollapseCodeBlocks, + shouldCollapseCodeBlockInChat, + handleTaskFileClick, + } = useWorkspaceArtifactPreviewActions({ + activeTheme, + mappedTheme, + layoutMode, + isThemeWorkbench, + isGeneralCanvasOpen: generalCanvasState.isOpen, + artifacts, + currentCanvasArtifact, + taskFiles, + sessionFiles, + readSessionFile, + upsertGeneralArtifact, + setSelectedArtifactId, + setArtifactViewMode, + setLayoutMode, + setTaskFiles, + setSelectedFileId, + setCanvasState, + upsertNovelCanvasState, }); - const openArtifactInWorkbench = useCallback( - async (artifact: Artifact) => { - let nextArtifact = artifact; - const artifactPath = resolveArtifactFilePath(artifact); - const shouldLoadPreview = artifact.content.length === 0 && artifactPath; - - if (shouldLoadPreview) { - const preview = await handleHarnessLoadFilePreview(artifactPath); - if (preview.error) { - toast.error(`读取产物失败: ${preview.error}`); - } else if (preview.isBinary) { - toast.info("该产物为二进制文件,暂不支持在工作台预览"); - } else if (typeof preview.content === "string") { - nextArtifact = { - ...artifact, - content: preview.content, - meta: { - ...artifact.meta, - filePath: preview.path || artifactPath, - filename: - artifact.meta.filename || - extractFileNameFromPath(preview.path || artifactPath), - }, - updatedAt: Date.now(), - }; - upsertGeneralArtifact(nextArtifact); - } - } - - setSelectedArtifactId(nextArtifact.id); - setArtifactViewMode(resolveDefaultArtifactViewMode(nextArtifact)); - setLayoutMode("chat-canvas"); - }, - [ - handleHarnessLoadFilePreview, - setSelectedArtifactId, - upsertGeneralArtifact, - ], - ); - - const handleArtifactClick = useCallback( - (artifact: Artifact) => { - void openArtifactInWorkbench(artifact); - }, - [openArtifactInWorkbench], - ); - - const findArtifactForCodeBlock = useCallback( - (code: string) => { - const normalizedCode = code.replace(/\r\n/g, "\n").trimEnd(); - if (!normalizedCode) { - return undefined; - } - - return artifacts.find((artifact) => { - if (typeof artifact.content !== "string") { - return false; - } - return ( - artifact.content.replace(/\r\n/g, "\n").trimEnd() === normalizedCode - ); - }); - }, - [artifacts], - ); - - // 处理文件点击 - 在画布中显示文件内容 - const handleFileClick = useCallback( - (fileName: string, content: string) => { - console.log("[AgentChatPage] 文件点击:", fileName, "主题:", activeTheme); - - // General 主题统一走 artifact 工作台 - if (activeTheme === "general") { - const matchingArtifact = artifacts.find((artifact) => { - const artifactPath = resolveArtifactFilePath(artifact); - return ( - artifactPath === fileName || - artifact.title === extractFileNameFromPath(fileName) || - (content.trim().length > 0 && artifact.content === content) - ); - }); - const nextArtifact = - matchingArtifact || - buildArtifactFromWrite({ - filePath: fileName, - content, - context: { - source: "message_content", - status: content.length > 0 ? "complete" : "pending", - }, - }); - - if (!matchingArtifact) { - upsertGeneralArtifact(nextArtifact); - } - - void openArtifactInWorkbench(nextArtifact); - return; - } - - // 查找或创建任务文件 - const nextFileType = resolveTaskFileType(fileName, content); - setTaskFiles((prev) => { - const existingFile = prev.find((f) => f.name === fileName); - if (existingFile) { - setSelectedFileId(existingFile.id); - return prev; - } - // 如果文件不存在,添加到列表 - const newFile: TaskFile = { - id: crypto.randomUUID(), - name: fileName, - type: nextFileType, - content, - version: 1, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - setSelectedFileId(newFile.id); - return [...prev, newFile]; - }); - - if ( - !isRenderableTaskFile( - { name: fileName, type: nextFileType }, - isThemeWorkbench, - ) - ) { - toast.info("该文件为辅助产物,暂不在主稿画布渲染"); - return; - } - - // 更新画布内容 - setCanvasState((prev) => { - // 音乐主题:解析歌词并更新 sections - if (mappedTheme === "music") { - const sections = parseLyrics(content); - if (!prev || prev.type !== "music") { - const musicState = createInitialMusicState(); - musicState.sections = sections; - const titleMatch = content.match(/^#\s*(.+)$/m); - if (titleMatch) { - musicState.spec.title = titleMatch[1].trim(); - } - return musicState; - } - return { ...prev, sections }; - } - - if (mappedTheme === "novel") { - return upsertNovelCanvasState(prev, content); - } - - // 文档类型画布 - if (!prev || prev.type !== "document") { - return createInitialDocumentState(content); - } - return { - ...prev, - content, - }; - }); - - // 打开画布 - setLayoutMode("chat-canvas"); - }, - [ - activeTheme, - artifacts, - isThemeWorkbench, - mappedTheme, - openArtifactInWorkbench, - upsertGeneralArtifact, - upsertNovelCanvasState, - ], - ); - - // 处理代码块点击 - 在画布中显示代码(General 主题专用) - const handleCodeBlockClick = useCallback( - (language: string, code: string) => { - console.log("[AgentChatPage] 代码块点击:", language); - - const matchingArtifact = findArtifactForCodeBlock(code); - if (!matchingArtifact) { - console.warn( - "[AgentChatPage] 代码块未匹配到 artifact,保持内联渲染:", - language, - ); - return; - } - - console.log("[AgentChatPage] 找到匹配的 artifact:", matchingArtifact.id); - void openArtifactInWorkbench(matchingArtifact); - }, - [findArtifactForCodeBlock, openArtifactInWorkbench], - ); - - // 判断是否应该折叠代码块(当画布打开且有 artifact 时) - const shouldCollapseCodeBlocks = useMemo(() => { - if (activeTheme !== "general") return false; - if (layoutMode === "chat") return false; - // 当画布打开时折叠代码块 - return artifacts.length > 0 || generalCanvasState.isOpen; - }, [activeTheme, layoutMode, artifacts.length, generalCanvasState.isOpen]); - - const shouldCollapseCodeBlockInChat = useCallback( - (language: string, code: string) => { - if (!shouldCollapseCodeBlocks) { - return false; - } - - const normalizedLanguage = language.trim().toLowerCase(); - if ( - ["", "text", "plaintext", "plain", "txt", "markdown", "md"].includes( - normalizedLanguage, - ) - ) { - return false; - } - - return Boolean(findArtifactForCodeBlock(code)); - }, - [findArtifactForCodeBlock, shouldCollapseCodeBlocks], - ); - - // 处理任务文件点击 - 在画布中显示文件内容 - const handleTaskFileClick = useCallback( - (file: TaskFile) => { - setSelectedFileId(file.id); - - if ( - !isRenderableTaskFile(file, isThemeWorkbench) || - looksLikeSocialPublishPayload(file.content || "") || - !file.content?.trim() - ) { - toast.info("该文件为辅助产物,暂不在主稿画布渲染"); - return; - } - - const fileContent = file.content ?? ""; - - setCanvasState((prev) => { - // 音乐主题:解析歌词并更新 sections - if (mappedTheme === "music") { - const sections = parseLyrics(fileContent); - if (!prev || prev.type !== "music") { - const musicState = createInitialMusicState(); - musicState.sections = sections; - const titleMatch = fileContent.match(/^#\s*(.+)$/m); - if (titleMatch) { - musicState.spec.title = titleMatch[1].trim(); - } - return musicState; - } - return { ...prev, sections }; - } - - if (mappedTheme === "novel") { - return upsertNovelCanvasState(prev, fileContent); - } - - // 文档类型画布 - if (!prev || prev.type !== "document") { - return createInitialDocumentState(fileContent); - } - return { - ...prev, - content: fileContent, - }; - }); - // 只打开画布,不关闭文件列表(让用户自己关闭) - setLayoutMode("chat-canvas"); - }, - [isThemeWorkbench, mappedTheme, upsertNovelCanvasState], - ); - - // A2UI 表单提交处理 - const handleA2UISubmit = useCallback( - async (formData: A2UIFormData, _messageId: string) => { - console.log("[AgentChatPage] A2UI 表单提交:", formData); - - // 将表单数据格式化为用户消息 - const formattedData = Object.entries(formData) - .map(([key, value]) => { - if (Array.isArray(value)) { - return `- ${key}: ${value.join(", ")}`; - } - return `- ${key}: ${value}`; - }) - .join("\n"); - - const userMessage = `我的选择:\n${formattedData}`; - - // 发送用户消息 - await sendMessage(userMessage, [], false, false); - }, - [sendMessage], - ); - - // 包装 A2UI 表单提交,适配 Inputbar 的签名 - const handleInputbarA2UISubmit = useCallback( - (formData: A2UIFormData) => { - if (pendingPromotedA2UIActionRequest) { - const payload = buildActionRequestSubmissionPayload( - pendingPromotedA2UIActionRequest, - formData, - ); - - void handlePermissionResponseWithBrowserPreflight({ - requestId: pendingPromotedA2UIActionRequest.requestId, - confirmed: true, - actionType: pendingPromotedA2UIActionRequest.actionType, - response: payload.responseText, - userData: payload.userData, - }); - return; - } - - if (pendingLegacyQuestionnaireA2UIForm) { - const submissionPayload = buildLegacyQuestionnaireSubmissionPayload( - pendingLegacyQuestionnaireA2UIForm, - formData, - ); - - if (!submissionPayload) { - toast.info("请至少补充一项信息后再继续"); - return; - } - - void sendMessage( - submissionPayload.formattedMessage, - [], - false, - false, - false, - undefined, - undefined, - undefined, - { - requestMetadata: submissionPayload.requestMetadata, - }, - ); - return; - } - - void handleA2UISubmit(formData, ""); - }, - [ - handleA2UISubmit, - handlePermissionResponseWithBrowserPreflight, - pendingLegacyQuestionnaireA2UIForm, - pendingPromotedA2UIActionRequest, - sendMessage, - ], - ); - - // 存储 triggerAIGuide 函数引用,避免在 useEffect 依赖中包含函数 - const triggerAIGuideRef = useRef(triggerAIGuide); - triggerAIGuideRef.current = triggerAIGuide; - - // 当从项目进入且有 contentId 时,自动启动创作引导 - useEffect(() => { - if (shouldUseCompactThemeWorkbench) { - return; - } - - // 条件: - // - 有 contentId(从项目创建内容进入) - // - 没有消息(messages.length === 0) - // - 项目已加载 - // - 系统提示词已准备好 - // - 不在发送中 - // - 画布内容为空(canvasState 没有实际内容) - // - 尚未触发过引导 - const canvasEmpty = isCanvasStateEmpty(canvasState); - const pendingInitialPrompt = (initialUserPrompt || "").trim(); - const pendingInitialImages = initialUserImages || []; - const defaultGuidePrompt = - contentId && canvasEmpty && !isThemeWorkbench - ? getDefaultGuidePromptByTheme(mappedTheme) - : undefined; - - if ( - contentId && - messages.length === 0 && - project && - systemPrompt && - !isSending && - canvasEmpty - ) { - if (!initialDispatchKey && themeWorkbenchEntryCheckPending) { - return; - } - - if (initialDispatchKey) { - if (isThemeWorkbench && pendingInitialImages.length === 0) { - return; - } - if (consumedInitialPromptRef.current === initialDispatchKey) { - return; - } - consumedInitialPromptRef.current = initialDispatchKey; - hasTriggeredGuide.current = true; - console.log("[AgentChatPage] 自动发送首条创作意图消息"); - void (async () => { - const started = await handleSend( - pendingInitialImages, - chatToolPreferences.webSearch, - chatToolPreferences.thinking, - pendingInitialPrompt, - ); - if (!started) { - consumedInitialPromptRef.current = null; - return; - } - onInitialUserPromptConsumed?.(); - })(); - return; - } - - if (hasTriggeredGuide.current) { - return; - } - - if (themeWorkbenchEntryPrompt?.kind === "resume") { - return; - } - - if (defaultGuidePrompt) { - hasTriggeredGuide.current = true; - setInput((previous) => previous.trim() || defaultGuidePrompt); - return; - } - - if (isThemeWorkbench) { - if (shouldSkipThemeWorkbenchAutoGuideWithoutPrompt) { - return; - } - hasTriggeredGuide.current = true; - console.log("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流"); - // 同步创建后端工作流(不阻塞触发) - void (async () => { - try { - const themeForApi = mappedTheme as Parameters< - typeof contentWorkflowApi.create - >[1]; - const modeForApi = - (creationMode as Parameters[2]) ?? - "guided"; - await contentWorkflowApi.create( - contentId!, - themeForApi, - modeForApi, - ); - console.log("[AgentChatPage] 后端工作流创建成功"); - } catch (e) { - console.warn( - "[AgentChatPage] 后端工作流创建失败(不影响主流程):", - e, - ); - } - })(); - triggerAIGuideRef.current(); - return; - } - - hasTriggeredGuide.current = true; - console.log("[AgentChatPage] 自动触发 AI 创作引导"); - triggerAIGuideRef.current(); - } - }, [ - activeTheme, + useWorkspaceAutoGuideRuntime({ contentId, - mappedTheme, - creationMode, - messages.length, - project, - systemPrompt, - isSending, - canvasState, + sessionId, initialUserPrompt, initialUserImages, - setInput, - isThemeWorkbench, - handleSend, - chatToolPreferences, initialDispatchKey, - onInitialUserPromptConsumed, + messagesCount: messages.length, + projectReady: Boolean(project), + systemPromptReady: Boolean(systemPrompt), + isSending, + canvasState, + isThemeWorkbench, + mappedTheme, + creationMode, shouldUseCompactThemeWorkbench, shouldSkipThemeWorkbenchAutoGuideWithoutPrompt, themeWorkbenchEntryCheckPending, themeWorkbenchEntryPrompt, - ]); - - // 通用聊天场景:若带有 initialUserPrompt,则自动新建并发送首条消息 - useEffect(() => { - const pendingInitialPrompt = (initialUserPrompt || "").trim(); - const pendingInitialImages = initialUserImages || []; - if ( - shouldUseCompactThemeWorkbench || - !initialDispatchKey || - contentId || - !sessionId || - messages.length > 0 || - isSending - ) { - return; - } - - if (consumedInitialPromptRef.current === initialDispatchKey) { - return; - } - - consumedInitialPromptRef.current = initialDispatchKey; - void (async () => { - const started = await handleSend( - pendingInitialImages, - chatToolPreferences.webSearch, - chatToolPreferences.thinking, - pendingInitialPrompt, - ); - if (!started) { - consumedInitialPromptRef.current = null; - return; - } - onInitialUserPromptConsumed?.(); - })(); - }, [ chatToolPreferences, - contentId, + setInput, handleSend, - initialDispatchKey, - initialUserPrompt, - initialUserImages, - isSending, - messages.length, + triggerAIGuide, onInitialUserPromptConsumed, - sessionId, - shouldUseCompactThemeWorkbench, - ]); + hasTriggeredGuideRef: hasTriggeredGuide, + consumedInitialPromptRef, + }); - // 当 contentId 变化时重置引导状态 - useEffect(() => { - hasTriggeredGuide.current = false; - consumedInitialPromptRef.current = null; - }, [contentId]); - - // 当 contentId 变化且是主题工作台时,尝试从后端恢复工作流 - useEffect(() => { - if (!contentId || !isThemeWorkbench) return; - - void (async () => { - try { - const workflow = await contentWorkflowApi.getByContent(contentId); - if (workflow) { - const completedCount = workflow.steps.filter( - (s) => s.status === "completed" || s.status === "skipped", - ).length; - console.log( - `[AgentChatPage] 找到已有工作流: ${workflow.id},已完成步骤 ${completedCount}/${workflow.steps.length}`, - ); - } - } catch (e) { - // 查询失败不影响主流程 - console.debug("[AgentChatPage] 查询后端工作流失败:", e); - } - })(); - }, [contentId, isThemeWorkbench]); - - useEffect(() => { - const handler = (event: Event) => { - const detail = (event as CustomEvent) - .detail; - if (!detail?.prompt?.trim()) { - return; - } - - const rawText = buildImageWorkbenchCommandText(detail.prompt, { - aspectRatio: - canvasState?.type === "document" - ? resolveCoverAspectRatio(canvasState.platform) - : resolveCoverAspectRatio(), - }); - const parsedCommand = parseImageWorkbenchCommand(rawText); - if (!parsedCommand) { - toast.error("封面任务初始化失败"); - return; - } - - void handleImageWorkbenchCommand({ - rawText, - parsedCommand, - images: [], - applyTarget: { - kind: "document-cover", - placeholder: detail.placeholder, - actionLabel: "设为封面", - successLabel: "已设为封面", - }, - }); - }; - - window.addEventListener(COVER_IMAGE_WORKBENCH_REQUEST_EVENT, handler); - return () => - window.removeEventListener(COVER_IMAGE_WORKBENCH_REQUEST_EVENT, handler); - }, [canvasState, handleImageWorkbenchCommand]); - - useEffect(() => { - return onImageWorkbenchRequest((detail: ImageWorkbenchExternalRequestDetail) => { - if (detail.projectId && detail.projectId !== (projectId ?? null)) { - return; - } - if (detail.contentId && detail.contentId !== (contentId ?? null)) { - return; - } - if (!detail.prompt.trim()) { - return; - } - - if (detail.modelPreset) { - const preferredProvider = findImageProviderForSelection( - imageWorkbenchProviders, - detail.modelPreset as ImageModelPreset, - ); - if (preferredProvider) { - setImageWorkbenchSelectedProviderId(preferredProvider.id); - const nextModel = pickImageModelBySelection( - getImageModelIdsForProvider( - preferredProvider.id, - preferredProvider.type, - preferredProvider.custom_models, - ), - detail.modelPreset as ImageModelPreset, - ); - if (nextModel) { - setImageWorkbenchSelectedModelId(nextModel); - } - } - } - - const rawText = buildImageWorkbenchCommandText(detail.prompt, { - aspectRatio: detail.aspectRatio, - count: detail.count, - }); - const parsedCommand = parseImageWorkbenchCommand(rawText); - if (!parsedCommand) { - toast.error("图片任务初始化失败"); - return; - } - if (parsedCommand.size) { - setImageWorkbenchSelectedSize(parsedCommand.size); - } - - void handleImageWorkbenchCommand({ - rawText, - parsedCommand, - images: [], - applyTarget: resolveScopedImageWorkbenchApplyTarget({ - canvasState, - projectId: projectId ?? null, - contentId: contentId ?? null, - requestedTarget: detail.target, - }), - }); - }); - }, [ + useWorkspaceImageWorkbenchEventRuntime({ canvasState, - contentId, - handleImageWorkbenchCommand, - imageWorkbenchProviders, projectId, - setImageWorkbenchSelectedModelId, + contentId, + imageWorkbenchProviders, setImageWorkbenchSelectedProviderId, + setImageWorkbenchSelectedModelId, setImageWorkbenchSelectedSize, - ]); + setLayoutMode, + setCanvasState, + updateCurrentImageWorkbenchState, + handleImageWorkbenchCommand, + }); - useEffect(() => { - return onImageWorkbenchFocus((detail: ImageWorkbenchFocusDetail) => { - if (detail.projectId && detail.projectId !== (projectId ?? null)) { - return; - } - if (detail.contentId && detail.contentId !== (contentId ?? null)) { - return; - } - - updateCurrentImageWorkbenchState((current) => { - if (current.tasks.length === 0 && current.outputs.length === 0) { - return current; - } - return { - ...current, - active: true, - }; - }); - setLayoutMode("chat-canvas"); - }); - }, [contentId, projectId, updateCurrentImageWorkbenchState]); - - // 监听封面图重新生成成功事件,将占位 URL 替换为真实图片 URL - useEffect(() => { - const handler = (e: Event) => { - const { placeholder, imageUrl } = ( - e as CustomEvent - ).detail; - if (!placeholder || !imageUrl) return; - setCanvasState((prev) => { - if (!prev || prev.type !== "document") return prev; - const updatedContent = prev.content.split(placeholder).join(imageUrl); - if (updatedContent === prev.content) return prev; - return { ...prev, content: updatedContent }; - }); - }; - window.addEventListener(COVER_IMAGE_REPLACED_EVENT, handler); - return () => - window.removeEventListener(COVER_IMAGE_REPLACED_EVENT, handler); - }, []); - - // 主题工作台始终使用聊天布局与浮层输入,不走旧 EmptyState 输入流程 - const hasUnconsumedInitialDispatch = - !shouldUseCompactThemeWorkbench && isBootstrapDispatchPending; - const showChatLayout = - agentEntry === "claw" || - hasDisplayMessages || - isThemeWorkbench || - hasUnconsumedInitialDispatch || - isSending || - queuedTurns.length > 0 || - Boolean(browserTaskPreflight); - const shouldHideThemeWorkbenchInputForTheme = shouldUseCompactThemeWorkbench; - const shouldShowThemeWorkbenchFloatingInputOverlay = - isThemeWorkbench && - showChatLayout && - !shouldHideThemeWorkbenchInputForTheme; - const shouldShowThemeWorkbenchSidebarForTheme = - !shouldUseCompactThemeWorkbench; - const showThemeWorkbenchSidebar = - showChatPanel && - showSidebar && - isThemeWorkbench && - shouldShowThemeWorkbenchSidebarForTheme && - (!enableThemeWorkbenchPanelCollapse || !themeWorkbenchSidebarCollapsed); - const showThemeWorkbenchLeftExpandButton = - showChatPanel && - showSidebar && - shouldShowThemeWorkbenchSidebarForTheme && - enableThemeWorkbenchPanelCollapse && - themeWorkbenchSidebarCollapsed; - const handleThemeWorkbenchDeleteTopic = useCallback(() => {}, []); - const handleThemeWorkbenchSidebarCollapse = useCallback(() => { - setThemeWorkbenchSidebarCollapsed(true); - }, []); - const themeWorkbenchSidebarCollapseHandler = useMemo( - () => - enableThemeWorkbenchPanelCollapse - ? handleThemeWorkbenchSidebarCollapse - : undefined, - [enableThemeWorkbenchPanelCollapse, handleThemeWorkbenchSidebarCollapse], - ); - const themeWorkbenchHarnessHeaderAction = useMemo(() => { - if (!isThemeWorkbench || !socialMediaHarnessSummary) { - return null; - } - - return ( - - ); - }, [ - handleToggleHarnessPanel, - harnessPanelVisible, + const shellChromeRuntime = useWorkspaceShellChromeRuntime({ + agentEntry, + browserTaskPreflight, + contextWorkspaceEnabled: contextWorkspace.enabled, + hasDisplayMessages, + hideTopBar, + isBootstrapDispatchPending, + isContentCreationMode, + isSending, isThemeWorkbench, - socialMediaHarnessSummary, - ]); - const themeWorkbenchHarnessSlot = useMemo(() => { - return null; - }, []); - const themeWorkbenchHarnessDialog = useMemo(() => { - if (!isThemeWorkbench) { - return null; - } - - return ( - - - - - - ); - }, [ - handleFileClick, - handleHarnessLoadFilePreview, - handleOpenSubagentSession, - childSubagentSessions, - harnessEnvironment, - harnessPanelVisible, - harnessState, - isThemeWorkbench, - refreshToolInventory, - compatSubagentRuntime, - selectedTeam?.roles, - selectedTeamLabel, - selectedTeamSummary, - toolInventory, - toolInventoryError, - toolInventoryLoading, - ]); - const themeWorkbenchSidebarNode = useMemo(() => { - if (!showThemeWorkbenchSidebar) { - return null; - } - return ( - - ); - }, [ - branchItems, - contextWorkspace.addFileContext, - contextWorkspace.addLinkContext, - contextWorkspace.addTextContext, - contextWorkspace.contextBudget, - contextWorkspace.contextSearchBlockedReason, - contextWorkspace.contextSearchError, - contextWorkspace.contextSearchLoading, - contextWorkspace.contextSearchMode, - contextWorkspace.contextSearchQuery, - contextWorkspace.setContextSearchMode, - contextWorkspace.setContextSearchQuery, - contextWorkspace.sidebarContextItems, - contextWorkspace.submitContextSearch, - contextWorkspace.toggleContextActive, + layoutMode, + queuedTurnCount: queuedTurns.length, + shouldUseCompactThemeWorkbench, + showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard, + topBarChrome, + themeWorkbenchRunState, + currentGateStatus: currentGate.status, + hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph, + runtimeTeamState, + }); + const themeWorkbenchShellRuntime = useWorkspaceThemeWorkbenchShellRuntime({ + showChatPanel, + showSidebar, + contextHarnessRuntime, + themeWorkbenchScaffoldRuntime, + themeWorkbenchSidebarRuntime, + harnessInventoryRuntime, + handleCreateVersionSnapshot, + handleSwitchBranchVersion, + handleSetBranchStatus, handleAddImage, handleImportDocument, - handleCreateVersionSnapshot, - handleSetBranchStatus, - handleSwitchBranchVersion, - handleThemeWorkbenchDeleteTopic, handleViewContextDetail, - handleLoadMoreThemeWorkbenchHistory, - handleViewThemeWorkbenchRunDetail, - selectedThemeWorkbenchRunDetail, - showThemeWorkbenchSidebar, - themeWorkbenchHarnessHeaderAction, - themeWorkbenchHarnessSlot, - themeWorkbenchCreationTaskEvents, - themeWorkbenchActivityLogs, - themeWorkbenchHistoryHasMore, - themeWorkbenchHistoryLoading, - themeWorkbenchRunDetailLoading, - themeWorkbenchSidebarCollapseHandler, - themeWorkbenchSkillDetailMap, - themeWorkbenchWorkflowSteps, messages, - ]); - - const workflowProgressSignature = useMemo(() => { - const shouldShow = isContentCreationMode && hasMessages && steps.length > 0; - if (!shouldShow) { - return "hidden"; - } - - const stepSignature = steps - .map((step) => `${step.id}:${step.status}:${step.title}`) - .join("|"); - return `${currentStepIndex}:${stepSignature}`; - }, [isContentCreationMode, hasMessages, steps, currentStepIndex]); - - const lastWorkflowProgressSignatureRef = useRef(""); - useEffect(() => { - if (!onWorkflowProgressChange) return; - if ( - lastWorkflowProgressSignatureRef.current === workflowProgressSignature - ) { - return; - } - lastWorkflowProgressSignatureRef.current = workflowProgressSignature; - - const shouldShow = isContentCreationMode && hasMessages && steps.length > 0; - if (!shouldShow) { - onWorkflowProgressChange(null); - return; - } - - onWorkflowProgressChange({ - currentIndex: currentStepIndex, - steps: steps.map((step) => ({ - id: step.id, - title: step.title, - status: step.status, - })), - }); - }, [ - onWorkflowProgressChange, - workflowProgressSignature, - isContentCreationMode, - hasMessages, - steps, - currentStepIndex, - ]); - - useEffect(() => { - return () => { - onWorkflowProgressChange?.(null); - }; - }, [onWorkflowProgressChange]); - - const handleManageProviders = useCallback(() => { - _onNavigate?.("settings", { - tab: SettingsTabs.Providers, - }); - }, [_onNavigate]); - - const handleBackToResources = useCallback(() => { - _onNavigate?.("resources"); - }, [_onNavigate]); - - const handleProjectChange = useCallback( - (newProjectId: string) => { - applyProjectSelection(newProjectId); - }, - [applyProjectSelection], - ); - - const handleSelectWorkspaceDirectory = useCallback(async () => { - const newPath = await openDialog({ directory: true, multiple: false }); - if (!newPath) return; - if (workspacePathMissing) { - // 发送失败场景:更新路径并重试原来的消息 - await fixWorkspacePathAndRetry(newPath); - } else if (projectId) { - // 主动健康检查发现问题:只更新路径,不需要重试 - try { - await updateProjectById(projectId, { rootPath: newPath }); - setWorkspaceHealthError(false); - notifyProjectRuntimeAgentsGuide( - { - id: projectId, - rootPath: newPath, - }, - { - successMessage: "工作区目录已更新", - }, - ); - } catch (err) { - toast.error( - `更新路径失败: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - }, [fixWorkspacePathAndRetry, projectId, workspacePathMissing]); - - const handleSelectCharacter = useCallback((character: Character) => { - setMentionedCharacters((prev) => { - if (prev.find((c) => c.id === character.id)) { - return prev; - } - return [...prev, character]; - }); - }, []); - - const handleToggleTaskFiles = useCallback(() => { - setTaskFilesExpanded((previous) => !previous); - }, []); - - const visibleTaskFiles = useMemo( - () => - taskFiles.filter((file) => isRenderableTaskFile(file, isThemeWorkbench)), - [taskFiles, isThemeWorkbench], - ); - - const visibleSelectedFileId = useMemo(() => { - if (!selectedFileId) { - return undefined; - } - return visibleTaskFiles.some((file) => file.id === selectedFileId) - ? selectedFileId - : undefined; - }, [selectedFileId, visibleTaskFiles]); - - const activeCanvasTaskFile = useMemo(() => { - return resolveCanvasTaskFileTarget(visibleTaskFiles, visibleSelectedFileId) - .targetFile; - }, [visibleSelectedFileId, visibleTaskFiles]); - - const styleActionContent = useMemo( - () => - extractStyleActionContent({ - activeTheme: mappedTheme, - generalCanvasState, - resolvedCanvasState, - taskFiles: visibleTaskFiles, - selectedFileId: visibleSelectedFileId, - }), - [ - generalCanvasState, - mappedTheme, - resolvedCanvasState, - visibleSelectedFileId, - visibleTaskFiles, - ], - ); - - const styleActionFileName = useMemo( - () => - resolveStyleActionFileName({ - activeTheme: mappedTheme, - generalCanvasState, - resolvedCanvasState, - taskFiles: visibleTaskFiles, - selectedFileId: visibleSelectedFileId, - }), - [ - generalCanvasState, - mappedTheme, - resolvedCanvasState, - visibleSelectedFileId, - visibleTaskFiles, - ], - ); - - const styleActionsDisabled = - !projectId || !runtimeStylePrompt || !styleActionContent.trim(); - - const handleRunStyleRewrite = useCallback(() => { - if (!styleActionContent.trim()) { - toast.error("当前画布还没有可重写的正文内容"); - return; - } - - if (!runtimeStylePrompt) { - toast.error("请先选择项目默认风格或任务风格"); - return; - } - - void handleSend( - [], - chatToolPreferences.webSearch, - chatToolPreferences.thinking, - buildStyleRewritePrompt({ - content: styleActionContent, - stylePrompt: runtimeStylePrompt, - fileName: styleActionFileName, - }), - undefined, - undefined, - { - skipThemeSkillPrefix: true, - purpose: "style_rewrite", - }, - ); - }, [ - chatToolPreferences.thinking, - chatToolPreferences.webSearch, - handleSend, - runtimeStylePrompt, - styleActionContent, - styleActionFileName, - ]); - - const handleRunStyleAudit = useCallback(() => { - if (!styleActionContent.trim()) { - toast.error("当前画布还没有可检查的正文内容"); - return; - } - - if (!runtimeStylePrompt) { - toast.error("请先选择项目默认风格或任务风格"); - return; - } - - void handleSend( - [], - chatToolPreferences.webSearch, - chatToolPreferences.thinking, - buildStyleAuditPrompt({ - content: styleActionContent, - stylePrompt: runtimeStylePrompt, - }), - undefined, - undefined, - { - skipThemeSkillPrefix: true, - purpose: "style_audit", - }, - ); - }, [ - chatToolPreferences.thinking, - chatToolPreferences.webSearch, - handleSend, - runtimeStylePrompt, - styleActionContent, - ]); - - const inputbarNode = useMemo( - () => ( - 0} - providerType={providerType} - setProviderType={setProviderType} - model={model} - setModel={setModel} - workspaceId={projectId ?? null} - executionStrategy={executionStrategy} - setExecutionStrategy={setExecutionStrategy} - activeTheme={activeTheme} - onManageProviders={handleManageProviders} - selectedTeam={selectedTeam} - onSelectTeam={handleSelectTeam} - onEnableSuggestedTeam={handleEnableSuggestedTeam} - disabled={!projectId} - onClearMessages={handleClearMessages} - onToggleCanvas={handleToggleCanvas} - isCanvasOpen={layoutMode !== "chat"} - taskFiles={visibleTaskFiles} - selectedFileId={visibleSelectedFileId} - taskFilesExpanded={taskFilesExpanded} - onToggleTaskFiles={handleToggleTaskFiles} - onTaskFileClick={handleTaskFileClick} - overlayAccessory={ - <> - {themeWorkbenchEntryPromptAccessory} - {shouldShowThemeWorkbenchFloatingInputOverlay && - showTeamWorkspaceBoard && - layoutMode === "chat" ? ( - - ) : null} - - } - characters={projectMemory?.characters || []} - skills={skills} - isSkillsLoading={skillsLoading} - toolStates={chatToolPreferences} - onToolStatesChange={setChatToolPreferences} - onSelectCharacter={handleSelectCharacter} - onNavigateToSettings={handleNavigateToSkillSettings} - onRefreshSkills={handleRefreshSkills} - queuedTurns={queuedTurns} - onPromoteQueuedTurn={promoteQueuedTurn} - onRemoveQueuedTurn={removeQueuedTurn} - /> - ), - [ - activeTheme, - chatToolPreferences, - currentGate, - executionStrategy, - handleClearMessages, - handleActivateTeamWorkbench, - handleManageProviders, - handleNavigateToSkillSettings, - handleRefreshSkills, - handleSelectCharacter, - handleSend, - handleTaskFileClick, - handleToggleCanvas, - handleToggleTaskFiles, - input, - queuedTurns, - isSending, - isThemeWorkbench, - layoutMode, - model, - projectId, - projectMemory?.characters, - promoteQueuedTurn, - providerType, - removeQueuedTurn, - setExecutionStrategy, - setInput, - setChatToolPreferences, - setModel, - setProviderType, - selectedTeam, - selectedTeamLabel, - selectedTeamSummary, - shouldShowThemeWorkbenchFloatingInputOverlay, - skills, - skillsLoading, - showTeamWorkspaceBoard, - steps, - stopSending, - visibleSelectedFileId, - visibleTaskFiles, - taskFilesExpanded, - themeWorkbenchRunState, - themeWorkbenchEntryPromptAccessory, - themeWorkbenchWorkflowSteps, - handleInputbarA2UISubmit, - childSubagentSessions, - currentSessionLatestTurnStatus, - currentSessionRuntimeStatus, - currentSessionTitle, - handleCloseCompletedTeamSessions, - handleCloseSubagentSession, - handleEnableSuggestedTeam, - handleOpenSubagentSession, - handleResumeSubagentSession, - handleReturnToParentSession, - handleSendSubagentInput, - handleSelectTeam, - handleWaitActiveTeamSessions, - handleWaitSubagentSession, - pendingA2UIForm, - sessionId, - subagentParentContext, - teamControlSummary, - teamWaitSummary, - teamActivityRefreshVersionBySessionId, - teamLiveActivityBySessionId, - teamLiveRuntimeBySessionId, - runtimeTeamState, - a2uiSubmissionNotice, - ], - ); - - const generalWorkbenchDialog = useMemo(() => { - if (chatMode !== "general" || isThemeWorkbench) { - return null; - } - - return ( - - - - } - onOpenSubagentSession={handleOpenSubagentSession} - onLoadFilePreview={handleHarnessLoadFilePreview} - onOpenFile={handleFileClick} - /> - - - ); - }, [ - chatMode, - chatToolPreferences, - activeRuntimeStatusTitle, - childSubagentSessions, - handleFileClick, - handleHarnessLoadFilePreview, - handleOpenSubagentSession, - harnessPanelVisible, - harnessEnvironment, harnessState, - isSending, - isThemeWorkbench, - mappedTheme, - refreshToolInventory, compatSubagentRuntime, - selectedTeam?.roles, + childSubagentSessions, selectedTeamLabel, selectedTeamSummary, - toolInventory, - toolInventoryError, - toolInventoryLoading, - ]); + selectedTeamRoles: selectedTeam?.roles, + handleOpenSubagentSession, + handleHarnessLoadFilePreview, + handleFileClick, + }); - const canvasRenderTheme = useMemo( - () => - (shouldBootstrapCanvasOnEntry - ? normalizedEntryTheme - : mappedTheme) as ThemeType, - [mappedTheme, normalizedEntryTheme, shouldBootstrapCanvasOnEntry], - ); + useWorkspaceWorkflowProgressSync({ + enabled: isContentCreationMode && hasMessages && steps.length > 0, + currentStepIndex, + steps, + onWorkflowProgressChange, + }); + const navigationActions = useWorkspaceNavigationActions({ + applyProjectSelection, + compactSession, + dismissWorkspacePathError, + fixWorkspacePathAndRetry, + onNavigate: _onNavigate, + projectId: projectId || undefined, + setEntryBannerVisible, + setWorkspaceHealthError, + workspacePathMissing, + }); - const shouldShowCanvasLoadingState = useMemo( - () => - (!canvasState && - (shouldBootstrapCanvasOnEntry || - isInitialContentLoading || - Boolean(initialContentLoadError))) || - (resolvedCanvasState?.type === "document" && - !resolvedCanvasState.content.trim() && - (isInitialContentLoading || Boolean(initialContentLoadError))), - [ - canvasState, - initialContentLoadError, - isInitialContentLoading, - resolvedCanvasState, - shouldBootstrapCanvasOnEntry, - ], - ); - - const canvasWorkbenchDefaultPreview = - useMemo(() => { - const workspaceRoot = project?.rootPath || null; - - if (canvasRenderTheme === "general") { - if (!generalCanvasState.isOpen || !generalCanvasState.content.trim()) { - return null; - } - - const filePath = generalCanvasState.filename?.trim() || undefined; - return { - title: filePath ? extractFileNameFromPath(filePath) : "当前画布草稿", - content: generalCanvasState.content, - filePath, - absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), - previousContent: null, - }; - } - - if (!resolvedCanvasState || isCanvasStateEmpty(resolvedCanvasState)) { - return null; - } - - const taskFile = activeCanvasTaskFile; - const taskSelectionKey = taskFile ? `task:${taskFile.id}` : undefined; - - if (resolvedCanvasState.type === "document") { - const currentVersion = - resolvedCanvasState.versions.find( - (item) => item.id === resolvedCanvasState.currentVersionId, - ) || - resolvedCanvasState.versions[ - resolvedCanvasState.versions.length - 1 - ] || - null; - const filePath = - taskFile?.name || currentVersion?.metadata?.sourceFileName; - - return { - selectionKey: - taskSelectionKey || - (currentVersion ? `version:${currentVersion.id}` : undefined), - title: filePath ? extractFileNameFromPath(filePath) : "当前文稿", - content: resolvedCanvasState.content, - filePath, - absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), - previousContent: resolvePreviousDocumentVersionContent( - currentVersion, - resolvedCanvasState.versions, - ), - }; - } - - const filePath = taskFile?.name; - return { - selectionKey: taskSelectionKey, - title: filePath ? extractFileNameFromPath(filePath) : "当前画布", - content: serializeCanvasStateForSync(resolvedCanvasState), - filePath, - absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), - previousContent: null, - }; - }, [ - activeCanvasTaskFile, - canvasRenderTheme, - generalCanvasState.content, - generalCanvasState.filename, - generalCanvasState.isOpen, - project?.rootPath, - resolvedCanvasState, - ]); - - const handleOpenCanvasWorkbenchPath = useCallback(async (path: string) => { - try { - await openPathWithDefaultApp(path); - } catch (error) { - toast.error( - `打开文件失败: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, []); - - const handleRevealCanvasWorkbenchPath = useCallback(async (path: string) => { - try { - await revealPathInFinder(path); - } catch (error) { - toast.error( - `定位文件失败: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, []); - - const renderArtifactWorkbenchPreview = useCallback( - (artifact: Artifact, stackedWorkbenchTrigger?: ReactNode) => { - const isLiveSelectedArtifact = - currentCanvasArtifact?.id === artifact.id && - displayedCanvasArtifact !== null; - const toolbarArtifact = - isLiveSelectedArtifact && currentCanvasArtifact - ? currentCanvasArtifact - : artifact; - const previewArtifact = - isLiveSelectedArtifact && displayedCanvasArtifact - ? displayedCanvasArtifact - : artifact; - const isBrowserAssistArtifact = previewArtifact.type === "browser_assist"; - - if (isBrowserAssistArtifact) { - return wrapPreviewWithWorkbenchTrigger( -
- - {isLiveSelectedArtifact && artifactDisplayState.overlay ? ( - - ) : null} -
, - stackedWorkbenchTrigger, - ); - } - - return ( -
-
- -
- - {isLiveSelectedArtifact && artifactDisplayState.overlay ? ( - - ) : null} -
-
-
- ); - }, - [ - artifactDisplayState.overlay, - artifactDisplayState.showPreviousVersionBadge, - artifactPreviewSize, - artifactViewMode, - currentCanvasArtifact, - displayedCanvasArtifact, - handleCloseCanvas, - setArtifactPreviewSize, - setArtifactViewMode, - ], - ); - - const renderLiveCanvasPreview = useCallback( - (stackedWorkbenchTrigger?: ReactNode) => { - if (currentImageWorkbenchState.active) { - return wrapPreviewWithWorkbenchTrigger( - ({ - id: provider.id, - name: provider.name, - }))} - selectedProviderId={imageWorkbenchSelectedProviderId} - onProviderChange={setImageWorkbenchSelectedProviderId} - availableModels={imageWorkbenchModels} - selectedModelId={imageWorkbenchSelectedModelId} - onModelChange={setImageWorkbenchSelectedModelId} - selectedSize={imageWorkbenchSelectedSize} - onSizeChange={setImageWorkbenchSelectedSize} - generating={imageWorkbenchGenerating} - savingToResource={imageWorkbenchSavingToResource} - onStopGeneration={handleStopImageWorkbenchGeneration} - onViewportChange={handleImageWorkbenchViewportChange} - onSelectOutput={handleSelectImageWorkbenchOutput} - onSaveSelectedToLibrary={handleSaveSelectedImageWorkbenchOutput} - applySelectedOutputLabel={imageWorkbenchPrimaryActionLabel} - onApplySelectedOutput={ - currentImageWorkbenchState.outputs.length > 0 - ? handleApplySelectedImageWorkbenchOutput - : undefined - } - onSeedFollowUpCommand={handleSeedImageWorkbenchFollowUp} - onOpenImage={handleOpenImageWorkbenchAsset} - />, - stackedWorkbenchTrigger, - ); - } - - if ( - canvasRenderTheme === "general" && - currentCanvasArtifact && - displayedCanvasArtifact - ) { - return renderArtifactWorkbenchPreview( - currentCanvasArtifact, - stackedWorkbenchTrigger, - ); - } - - if (canvasRenderTheme === "general") { - if (generalCanvasState.isOpen) { - return ( - - setGeneralCanvasState((prev) => ({ ...prev, content })) - } - toolbarActions={stackedWorkbenchTrigger} - /> - ); - } - return null; - } - - if (shouldShowCanvasLoadingState) { - return wrapPreviewWithWorkbenchTrigger( -
- {isInitialContentLoading - ? "正在加载文稿内容..." - : initialContentLoadError || "正在准备文稿画布..."} -
, - stackedWorkbenchTrigger, - ); - } - - if (!resolvedCanvasState) { - return null; - } - - return wrapPreviewWithWorkbenchTrigger( - , - stackedWorkbenchTrigger, - ); - }, - [ - canvasRenderTheme, - chatToolPreferences.thinking, - contentId, - currentImageWorkbenchState, - currentCanvasArtifact, - displayedCanvasArtifact, - generalCanvasState, - handleAddImage, - handleBackHome, - handleCloseCanvas, - handleCanvasSelectionTextChange, - handleDocumentAutoContinueRun, - handleDocumentContentReviewRun, - handleDocumentThinkingEnabledChange, - handleDocumentTextStylizeRun, - handleApplySelectedImageWorkbenchOutput, - handleImageWorkbenchViewportChange, - handleImportDocument, - handleOpenImageWorkbenchAsset, - handleSaveSelectedImageWorkbenchOutput, - handleStopImageWorkbenchGeneration, - handleSeedImageWorkbenchFollowUp, - handleSelectImageWorkbenchOutput, - imageWorkbenchPrimaryActionLabel, - imageWorkbenchGenerating, - imageWorkbenchModels, - imageWorkbenchPreferenceSummary, - imageWorkbenchPreferenceWarning, - imageWorkbenchProviders, - imageWorkbenchSavingToResource, - imageWorkbenchSelectedModelId, - imageWorkbenchSelectedProviderId, - imageWorkbenchSelectedSize, - initialContentLoadError, - isInitialContentLoading, - isSending, - model, - novelChapterListCollapsed, - preferContentReviewInRightRail, - project?.name, - projectId, - providerType, - renderArtifactWorkbenchPreview, - resolvedCanvasState, - setImageWorkbenchSelectedModelId, - setImageWorkbenchSelectedProviderId, - setImageWorkbenchSelectedSize, - setModel, - setProviderType, - shouldShowCanvasLoadingState, - ], - ); - - 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, + const inputbarScene = useWorkspaceInputbarSceneRuntime({ + setMentionedCharacters, + taskFiles, + taskFilesExpanded, + setTaskFilesExpanded, + selectedFileId, + isThemeWorkbench, + sessionId, + childSubagentSessions, + subagentParentContext, + selectedTeamLabel, + selectedTeamSummary, runtimeTeamState, - showTeamWorkspaceBoard, - teamControlSummary, - teamLiveActivityBySessionId, - teamWaitSummary, - teamWorkbenchExecutionSummary.queuedSessionCount, - teamWorkbenchExecutionSummary.runningSessionCount, - ]); + teamSessionRuntime, + teamSessionControlRuntime, + handleOpenSubagentSession, + handleReturnToParentSession, + input, + setInput, + currentGate, + pendingA2UIForm, + handleInputbarA2UISubmit, + a2uiSubmissionNotice, + themeWorkbenchSidebarRuntime, + steps, + themeWorkbenchRunState, + handleSend, + isSending, + providerType, + setProviderType, + model, + setModel, + projectId: projectId ?? null, + executionStrategy, + setExecutionStrategy, + activeTheme, + navigationActions, + selectedTeam, + handleSelectTeam, + handleEnableSuggestedTeam, + handleClearMessages, + handleToggleCanvas, + layoutMode, + handleTaskFileClick, + characters: projectMemory?.characters || [], + skills, + skillsLoading, + setChatToolPreferences, + handleNavigateToSkillSettings, + handleRefreshSkills, + turns, + threadItems: effectiveThreadItems, + currentTurnId, + threadRead, + pendingActions, + submittedActionsInFlight, + queuedTurns, + resumeThread, + replayPendingAction, + promoteQueuedTurn, + removeQueuedTurn, + latestAssistantMessageId, + themeWorkbenchEntryPrompt, + handleRestartThemeWorkbenchEntryPrompt, + handleContinueThemeWorkbenchEntryPrompt, + generalWorkbenchEnabled: chatMode === "general", + contextHarnessRuntime, + harnessState, + compatSubagentRuntime, + harnessInventoryRuntime, + mappedTheme, + handleHarnessLoadFilePreview, + handleFileClick, + shellChromeRuntime, + handleActivateTeamWorkbench, + chatToolPreferences, + }); - const renderCanvasWorkbenchPreview = useCallback( - ( - target: CanvasWorkbenchPreviewTarget, - options?: { - stackedWorkbenchTrigger?: ReactNode; - }, - ) => { - switch (target.kind) { - case "default-canvas": - return renderLiveCanvasPreview(options?.stackedWorkbenchTrigger); - case "artifact": - case "synthetic-artifact": - return renderArtifactWorkbenchPreview( - target.artifact, - options?.stackedWorkbenchTrigger, - ); - case "loading": - return wrapPreviewWithWorkbenchTrigger( -
- 正在准备预览... -
, - options?.stackedWorkbenchTrigger, - ); - case "unsupported": - return wrapPreviewWithWorkbenchTrigger( -
- {target.reason} -
, - options?.stackedWorkbenchTrigger, - ); - case "empty": - return wrapPreviewWithWorkbenchTrigger( -
- 暂无可预览内容 -
, - options?.stackedWorkbenchTrigger, - ); - case "team-workbench": - return renderTeamWorkbenchPreview(options?.stackedWorkbenchTrigger); - default: - return null; - } - }, - [ - renderArtifactWorkbenchPreview, - renderLiveCanvasPreview, - renderTeamWorkbenchPreview, - ], - ); + const canvasScene = useWorkspaceCanvasSceneRuntime({ + shouldBootstrapCanvasOnEntry, + normalizedEntryTheme, + mappedTheme, + canvasState, + resolvedCanvasState, + isInitialContentLoading, + initialContentLoadError, + imageWorkbenchGenerationRuntime, + imageWorkbenchActionRuntime, + inputbarScene, + projectRootPath: project?.rootPath || null, + generalCanvasState, + setGeneralCanvasState, + currentCanvasArtifact, + displayedCanvasArtifact, + artifactDisplayState, + artifactViewMode, + setArtifactViewMode, + artifactPreviewSize, + setArtifactPreviewSize, + handleCloseCanvas, + currentImageWorkbenchState, + imageWorkbenchPreferenceSummary, + imageWorkbenchPreferenceWarning, + setCanvasState, + handleBackHome, + isSending, + handleCanvasSelectionTextChange, + projectId: projectId ?? null, + contentId: contentId ?? null, + projectName: project?.name || undefined, + providerType, + setProviderType, + model, + setModel, + documentThinkingEnabled: chatToolPreferences.thinking, + handleDocumentThinkingEnabledChange, + handleDocumentAutoContinueRun, + handleAddImage, + handleImportDocument, + handleDocumentContentReviewRun, + handleDocumentTextStylizeRun, + preferContentReviewInRightRail, + novelChapterListCollapsed, + setNovelChapterListCollapsed, + teamSessionRuntime, + teamSessionControlRuntime, + teamWorkbenchAutoFocusToken, + runtimeTeamState, + }); - 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" - ? "正在为当前任务组建成员,画布会先展示蓝图轨道,真实角色加入后自动接管。" - : runtimeTeamState?.status === "formed" - ? "Team 已就绪,主对话仅保留调度记录,执行正文继续留在各角色轨道。" - : runtimeTeamState?.status === "failed" - ? runtimeTeamState.errorMessage?.trim() || - "这次 Team 准备失败,可直接查看失败原因并继续当前对话。" - : "Team 模式已启用,等待系统创建真实团队成员。", - autoFocusToken: teamWorkbenchAutoFocusToken, - preferFullscreenPreview: true, - triggerState: teamWorkbenchTriggerState, - renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => - renderTeamWorkbenchPreview(options?.stackedWorkbenchTrigger), - renderPanel: () => teamWorkbenchSummaryPanel, - } - : null, - [ - hasRealTeamGraph, - renderTeamWorkbenchPreview, - runtimeTeamState, - selectedTeamLabel, - showTeamWorkspaceBoard, - teamWorkbenchTriggerState, - teamWorkbenchAutoFocusToken, - teamWorkbenchSummaryPanel, - ], - ); - const shouldUseTeamPrimaryChatPanelWidth = - layoutMode === "chat-canvas" && - showTeamWorkspaceBoard && - (hasRealTeamGraph || Boolean(runtimeTeamState)); - const layoutTransitionChatPanelWidth = shouldUseTeamPrimaryChatPanelWidth - ? TEAM_PRIMARY_CHAT_PANEL_WIDTH - : undefined; - const layoutTransitionChatPanelMinWidth = shouldUseTeamPrimaryChatPanelWidth - ? TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH - : undefined; - const themeWorkbenchLayoutBottomSpacing = - resolveThemeWorkbenchLayoutBottomSpacing({ - contextWorkspaceEnabled: contextWorkspace.enabled, - showFloatingInputOverlay: shouldShowThemeWorkbenchFloatingInputOverlay, - hasCanvasContent: layoutMode !== "chat", - themeWorkbenchRunState, - gateStatus: currentGate.status, - }); - - // 聊天区域内容 - const chatContent = useMemo( - () => ( - - - {entryBannerVisible && entryBannerMessage ? ( - - - {entryBannerMessage} - setEntryBannerVisible(false)} - aria-label="关闭入口提示" - > - 关闭 - - - ) : null} - {!hideInlineStepProgress && - isContentCreationMode && - hasMessages && - steps.length > 0 && ( - - )} - - {isContentCreationMode && projectId ? ( - - ) : null} - {showChatLayout ? ( - - <> - {contextWorkspace.enabled ? ( - - - - ) : ( - - )} - {showTeamWorkspaceBoard && - !shouldShowThemeWorkbenchFloatingInputOverlay && - layoutMode === "chat" ? ( - - ) : null} - - - ) : ( - { - handleSend( - images || [], - chatToolPreferences.webSearch, - chatToolPreferences.thinking, - text, - sendExecutionStrategy, - ); - }} - providerType={providerType} - setProviderType={setProviderType} - model={model} - setModel={setModel} - executionStrategy={executionStrategy} - setExecutionStrategy={setExecutionStrategy} - onManageProviders={handleManageProviders} - webSearchEnabled={chatToolPreferences.webSearch} - onWebSearchEnabledChange={(enabled) => - setChatToolPreferences((prev) => ({ - ...prev, - webSearch: enabled, - })) - } - thinkingEnabled={chatToolPreferences.thinking} - onThinkingEnabledChange={(enabled) => - setChatToolPreferences((prev) => ({ - ...prev, - thinking: enabled, - })) - } - taskEnabled={chatToolPreferences.task} - onTaskEnabledChange={(enabled) => - setChatToolPreferences((prev) => ({ - ...prev, - task: enabled, - })) - } - subagentEnabled={chatToolPreferences.subagent} - onSubagentEnabledChange={(enabled) => - setChatToolPreferences((prev) => ({ - ...prev, - subagent: enabled, - })) - } - selectedTeam={selectedTeam} - onSelectTeam={handleSelectTeam} - onEnableSuggestedTeam={handleEnableSuggestedTeam} - creationMode={creationMode} - onCreationModeChange={setCreationMode} - activeTheme={activeTheme} - onThemeChange={(theme) => { - if (!lockTheme) { - setActiveTheme(theme); - } - }} - showThemeTabs={false} - hasCanvasContent={ - activeTheme === "general" - ? artifacts.length > 0 || - Boolean(generalCanvasState.content?.trim()) - : !isCanvasStateEmpty(resolvedCanvasState) - } - hasContentId={Boolean(contentId)} - selectedText={selectedText} - onRecommendationClick={handleRecommendationClick} - characters={projectMemory?.characters || []} - skills={skills} - isSkillsLoading={skillsLoading} - onNavigateToSettings={handleNavigateToSkillSettings} - onRefreshSkills={handleRefreshSkills} - onLaunchBrowserAssist={handleOpenBrowserAssistInCanvas} - browserAssistLoading={browserAssistLaunching} - projectId={projectId ?? null} - onProjectChange={handleProjectChange} - onOpenSettings={() => { - _onNavigate?.("settings", { - tab: SettingsTabs.Appearance, - }); - }} - /> - )} - - {showChatLayout && ( - <> - {(workspacePathMissing || workspaceHealthError) && ( -
- - 工作区目录不存在,请重新选择一个本地目录后继续 - - - -
- )} - {!contextWorkspace.enabled && - !shouldHideThemeWorkbenchInputForTheme - ? inputbarNode - : null} - - )} -
-
- ), - [ - _onNavigate, - activeTheme, - artifacts.length, - browserAssistLaunching, - chatToolPreferences, - contentId, - contextWorkspace.enabled, - creationMode, - currentStepIndex, - currentTurnId, - deleteMessage, - dismissWorkspacePathError, - entryBannerMessage, - entryBannerVisible, - editMessage, - executionStrategy, - generalCanvasState.content, - goToStep, - handleA2UISubmit, - handleActivateTeamWorkbench, - handleArtifactClick, - handleCloseCompletedTeamSessions, - handleCloseSubagentSession, - handleCodeBlockClick, - handleFileClick, - handleEnableSuggestedTeam, - handleManageProviders, - handleNavigateToSkillSettings, - handleOpenBrowserAssistInCanvas, - handleOpenSubagentSession, - handleReturnToParentSession, - handleResumeSubagentSession, - handleSendSubagentInput, - handleWaitActiveTeamSessions, - handleWaitSubagentSession, - handleProjectChange, - handleRecommendationClick, - handleRefreshSkills, - handlePermissionResponseWithBrowserPreflight, - handleSelectTeam, - handleSelectWorkspaceDirectory, - handleSend, - handleWriteFile, - hideInlineStepProgress, - input, - inputbarNode, - isContentCreationMode, - isThemeWorkbench, - isWorkspaceCompactChrome, - layoutMode, - lockTheme, - displayMessages, - model, - turns, + const workspaceShellSceneRuntime = + useWorkspaceConversationShellSceneRuntime({ + navigationActions, + inputbarScene, + canvasScene, + shellChromeRuntime, + themeWorkbenchShellRuntime, + contextHarnessRuntime, + teamSessionRuntime, + currentImageWorkbenchState, + project, projectId, - projectMemory?.characters, - projectMemory?.style_guide, - providerType, - pendingPromotedA2UIActionRequest, - setCreationMode, - setExecutionStrategy, - setChatToolPreferences, - setInput, - setModel, - setProviderType, - selectedTeam, - selectedTeamLabel, - selectedTeamSummary, - setWorkspaceHealthError, - shouldCollapseCodeBlocks, - selectedText, - setEntryBannerVisible, - showChatLayout, - effectiveThreadItems, - handleRunStyleAudit, - handleRunStyleRewrite, - hasMessages, - childSubagentSessions, - currentSessionLatestTurnStatus, - currentSessionRuntimeStatus, - currentSessionTitle, + projectMemory, + handleSend, mappedTheme, runtimeStyleSelection, - sessionId, - showTeamWorkspaceBoard, - teamActivityRefreshVersionBySessionId, - teamLiveActivityBySessionId, - teamLiveRuntimeBySessionId, - styleActionsDisabled, + setRuntimeStyleSelection, + generalCanvasState, + runtimeStylePrompt, + showSidebar, + topics, + switchTopic, + handleResumeSidebarTask, + deleteTopic, + renameTopic, + childSubagentSessions, + subagentParentContext, + handleReturnToParentSession, + entryBannerVisible, + entryBannerMessage, + contextWorkspaceEnabled: contextWorkspace.enabled, + input, + setInput, + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + chatToolPreferences, + setChatToolPreferences, + selectedTeam, + handleSelectTeam, + handleEnableSuggestedTeam, + creationMode, + setCreationMode, + activeTheme, + setActiveTheme, + lockTheme, + artifacts, + resolvedCanvasState, + contentId, + selectedText, + handleRecommendationClick, skills, skillsLoading, - steps, - subagentParentContext, - teamControlSummary, - teamWaitSummary, - runtimeTeamState, - workspaceHealthError, - workspacePathMissing, - resolvedCanvasState, - shouldHideThemeWorkbenchInputForTheme, - shouldCollapseCodeBlockInChat, - shouldShowThemeWorkbenchFloatingInputOverlay, - themeWorkbenchLayoutBottomSpacing.messageViewportBottomPadding, - queuedTurns.length, - shouldRenderInlineA2UI, - ], - ); - - // 画布区域内容 - const canvasContent = useMemo(() => { - const liveCanvasPreview = renderLiveCanvasPreview(); - if (!liveCanvasPreview && !teamWorkbenchView) { - return null; - } - - if (currentImageWorkbenchState.active) { - return liveCanvasPreview; - } - - if ( - !teamWorkbenchView && - (shouldShowCanvasLoadingState || isBrowserAssistCanvasVisible) - ) { - return liveCanvasPreview; - } - - return ( - - ); - }, [ - canvasWorkbenchDefaultPreview, - handleHarnessLoadFilePreview, - handleOpenCanvasWorkbenchPath, - handleRevealCanvasWorkbenchPath, - project, - renderCanvasWorkbenchPreview, - renderLiveCanvasPreview, - resolvedCanvasState, - selectedFileId, - settledWorkbenchArtifacts, - currentImageWorkbenchState.active, - shouldShowCanvasLoadingState, - isBrowserAssistCanvasVisible, - setCanvasWorkbenchLayoutMode, - taskFiles, - teamWorkbenchView, - workspaceHealthError, - workspacePathMissing, - ]); - - const mainAreaNode = useMemo( - () => ( - - {shouldRenderTopBar && ( - <> - {}} - onBackToProjectManagement={onBackToProjectManagement} - onBackToResources={ - fromResources ? handleBackToResources : undefined - } - showCanvasToggle={!isThemeWorkbench} - isCanvasOpen={layoutMode !== "chat"} - onToggleCanvas={handleToggleCanvas} - projectId={projectId ?? null} - onProjectChange={handleProjectChange} - workspaceType={activeTheme} - onBackHome={handleBackHome} - showBrowserAssistEntry={ - chatMode === "general" && !isThemeWorkbench - } - browserAssistActive={isBrowserAssistCanvasVisible} - browserAssistLoading={browserAssistLaunching} - browserAssistAttentionLevel={browserAssistAttentionLevel} - browserAssistLabel={browserAssistEntryLabel} - onOpenBrowserAssist={() => { - void handleOpenBrowserAssistInCanvas(); - }} - showHarnessToggle={showHarnessToggle} - harnessPanelVisible={navbarHarnessPanelVisible} - onToggleHarnessPanel={handleToggleHarnessPanel} - harnessPendingCount={harnessPendingCount} - harnessAttentionLevel={harnessAttentionLevel} - harnessToggleLabel={ - chatMode === "general" && !isThemeWorkbench - ? "工作台" - : undefined - } - onToggleSettings={() => { - _onNavigate?.("settings", { - tab: SettingsTabs.Appearance, - }); - }} - novelCanvasControls={ - showNovelNavbarControls - ? { - chapterListCollapsed: novelChapterListCollapsed, - onToggleChapterList: handleToggleNovelChapterList, - onAddChapter: handleAddNovelChapter, - onCloseCanvas: handleCloseCanvas, - } - : null - } - /> - - {!isThemeWorkbench && - contentId && - syncStatus !== "idle" && - (() => { - const notice = resolveContentSyncNotice(syncStatus); - const NoticeIcon = notice.Icon; - - return ( - - - - {notice.label} - - - ); - })()} - - )} - - - - - {generalWorkbenchDialog} - {themeWorkbenchHarnessDialog} - {shouldShowThemeWorkbenchFloatingInputOverlay ? ( - - {inputbarNode} - - ) : null} - - ), - [ - _onNavigate, - activeTheme, - canvasContent, - chatContent, + handleNavigateToSkillSettings, + handleRefreshSkills, + handleOpenBrowserAssistInCanvas, + browserAssistLaunching, + hideHistoryToggle, + showChatPanel, + topBarChrome, + onBackToProjectManagement, + fromResources, + handleBackHome, + handleToggleSidebar, + chatMode, + isBrowserAssistCanvasVisible, browserAssistAttentionLevel, browserAssistEntryLabel, - browserAssistLaunching, - contentId, - fromResources, - handleAddNovelChapter, - handleBackHome, - handleBackToResources, - handleCloseCanvas, - handleOpenBrowserAssistInCanvas, - handleProjectChange, - handleToggleHarnessPanel, - handleToggleNovelChapterList, - handleToggleCanvas, - handleToggleSidebar, - hideHistoryToggle, - inputbarNode, - isSending, - isWorkspaceCompactChrome, - isThemeWorkbench, - chatMode, - generalWorkbenchDialog, - harnessAttentionLevel, - isBrowserAssistCanvasVisible, + showHarnessToggle, navbarHarnessPanelVisible, harnessPendingCount, - layoutTransitionChatPanelMinWidth, - layoutTransitionChatPanelWidth, - layoutMode, - novelChapterListCollapsed, - onBackToProjectManagement, - pendingA2UIForm, - projectId, - shouldShowThemeWorkbenchFloatingInputOverlay, - showChatPanel, - showHarnessToggle, - showNovelNavbarControls, - shouldRenderTopBar, + harnessAttentionLevel, + sessionId, syncStatus, - themeWorkbenchHarnessDialog, - themeWorkbenchLayoutBottomSpacing.shellBottomInset, - topBarChrome, - ], - ); + pendingA2UIForm, + handleToggleCanvas, + hideInlineStepProgress, + isContentCreationMode, + hasMessages, + steps, + currentStepIndex, + goToStep, + displayMessages, + turns, + effectiveThreadItems, + currentTurnId, + threadRead, + pendingActions, + submittedActionsInFlight, + queuedTurns, + isSending, + stopSending, + resumeThread, + replayPendingAction, + promoteQueuedTurn, + deleteMessage, + editMessage, + handleA2UISubmit, + handleWriteFile, + handleFileClick, + handleArtifactClick, + handleOpenSubagentSession, + handlePermissionResponseWithBrowserPreflight, + pendingPromotedA2UIActionRequest, + shouldCollapseCodeBlocks, + shouldCollapseCodeBlockInChat, + handleCodeBlockClick, + layoutMode, + handleActivateTeamWorkbench, + isThemeWorkbench, + showNovelNavbarControls, + novelChapterListCollapsed, + handleToggleNovelChapterList, + handleAddNovelChapter, + handleCloseCanvas, + settledWorkbenchArtifacts, + taskFiles, + selectedFileId, + handleHarnessLoadFilePreview, + setCanvasWorkbenchLayoutMode, + workspacePathMissing, + workspaceHealthError, + }); - // ========== 渲染逻辑 ========== - - // 所有主题统一使用 useAgentChatUnified / useAsterAgentChat 的状态和渲染逻辑 - // General 主题与其他主题的区别仅在于不显示步骤进度条 - return ( - - {isThemeWorkbench ? ( - themeWorkbenchSidebarNode - ) : showChatPanel && showSidebar ? ( - - ) : null} - {showThemeWorkbenchLeftExpandButton ? ( - setThemeWorkbenchSidebarCollapsed(false)} - title="展开上下文侧栏" - > - - - ) : null} - - {mainAreaNode} - - ); + return workspaceShellSceneRuntime.shellSceneNode; } diff --git a/src/components/agent/chat/agentChatWorkspaceContract.ts b/src/components/agent/chat/agentChatWorkspaceContract.ts new file mode 100644 index 000000000..b8aa4a421 --- /dev/null +++ b/src/components/agent/chat/agentChatWorkspaceContract.ts @@ -0,0 +1,45 @@ +import type { CreationMode } from "./components/types"; +import type { MessageImage } from "./types"; +import type { StepStatus } from "@/components/content-creator/types"; +import type { Page, PageParams } from "@/types/page"; + +export interface WorkflowProgressSnapshot { + steps: Array<{ + id: string; + title: string; + status: StepStatus; + }>; + currentIndex: number; +} + +export interface AgentChatWorkspaceProps { + onNavigate?: (page: Page, params?: PageParams) => void; + projectId?: string; + contentId?: string; + agentEntry?: "new-task" | "claw"; + immersiveHome?: boolean; + theme?: string; + initialCreationMode?: CreationMode; + lockTheme?: boolean; + fromResources?: boolean; + hideHistoryToggle?: boolean; + showChatPanel?: boolean; + hideTopBar?: boolean; + topBarChrome?: "full" | "workspace-compact"; + onBackToProjectManagement?: () => void; + hideInlineStepProgress?: boolean; + onWorkflowProgressChange?: ( + snapshot: WorkflowProgressSnapshot | null, + ) => void; + initialUserPrompt?: string; + initialUserImages?: MessageImage[]; + initialSessionName?: string; + entryBannerMessage?: string; + onInitialUserPromptConsumed?: () => void; + newChatAt?: number; + onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; + onHasMessagesChange?: (hasMessages: boolean) => void; + onSessionChange?: (sessionId: string | null) => void; + preferContentReviewInRightRail?: boolean; + openBrowserAssistOnMount?: boolean; +} diff --git a/src/components/agent/chat/agentChatWorkspaceShared.ts b/src/components/agent/chat/agentChatWorkspaceShared.ts new file mode 100644 index 000000000..48da9b0ff --- /dev/null +++ b/src/components/agent/chat/agentChatWorkspaceShared.ts @@ -0,0 +1,79 @@ +import type { ThemeType } from "@/components/content-creator/types"; +import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime"; +import type { ProjectType } from "@/lib/api/project"; + +const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ + "general", + "social-media", + "poster", + "music", + "knowledge", + "planning", + "document", + "video", + "novel", +]; + +export function normalizeInitialTheme(value?: string): ThemeType { + if (!value) { + return "general"; + } + if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) { + return value as ThemeType; + } + return "general"; +} + +export function deriveCurrentSessionRuntimeStatus(params: { + isSending: boolean; + queuedTurnCount: number; + turns: Array<{ status: string }>; +}): AsterSubagentSessionInfo["runtime_status"] | undefined { + if ( + params.isSending || + params.turns.some((turn) => turn.status === "running") + ) { + return "running"; + } + if (params.queuedTurnCount > 0) { + return "queued"; + } + + const latestStatus = params.turns[params.turns.length - 1]?.status; + switch (latestStatus) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "aborted": + return "aborted"; + default: + return undefined; + } +} + +export function deriveLatestTurnRuntimeStatus( + turns: Array<{ status: string }>, +): AsterSubagentSessionInfo["runtime_status"] | undefined { + switch (turns[turns.length - 1]?.status) { + case "queued": + return "queued"; + case "running": + return "running"; + case "completed": + return "completed"; + case "failed": + return "failed"; + case "aborted": + return "aborted"; + default: + return undefined; + } +} + +export function projectTypeToTheme(projectType: ProjectType): ThemeType { + if (projectType === "persistent" || projectType === "temporary") { + return "general"; + } + return projectType as ThemeType; +} diff --git a/src/components/agent/chat/commands/catalog.ts b/src/components/agent/chat/commands/catalog.ts new file mode 100644 index 000000000..f74827030 --- /dev/null +++ b/src/components/agent/chat/commands/catalog.ts @@ -0,0 +1,178 @@ +import type { CodexSlashCommandDefinition } from "./types"; + +export const CODEX_SLASH_COMMANDS: CodexSlashCommandDefinition[] = [ + { + key: "compact", + commandName: "compact", + commandPrefix: "/compact", + label: "压缩上下文", + description: "压缩当前会话上下文并写入摘要", + aliases: ["compress", "summarize"], + kind: "local_action", + support: "supported", + }, + { + key: "clear", + commandName: "clear", + commandPrefix: "/clear", + label: "清空任务", + description: "清空当前任务消息并回到空白输入态", + aliases: ["reset"], + kind: "local_action", + support: "supported", + }, + { + key: "new", + commandName: "new", + commandPrefix: "/new", + label: "新建任务", + description: "创建一个新任务,可选附带标题", + aliases: ["chat-new", "task-new"], + kind: "local_action", + support: "supported", + argumentHint: "[title]", + }, + { + key: "review", + commandName: "review", + commandPrefix: "/review", + label: "代码审查", + description: "以审查模式检查当前改动或指定范围", + aliases: ["cr"], + kind: "prompt_action", + support: "supported", + argumentHint: "[scope]", + }, + { + key: "init", + commandName: "init", + commandPrefix: "/init", + label: "初始化仓库指令", + description: "生成或更新仓库根目录的 AGENTS.md", + aliases: ["bootstrap"], + kind: "prompt_action", + support: "supported", + argumentHint: "[requirements]", + }, + { + key: "diff", + commandName: "diff", + commandPrefix: "/diff", + label: "检查变更", + description: "分析当前 diff 或指定变更范围", + aliases: ["changes"], + kind: "prompt_action", + support: "supported", + argumentHint: "[scope]", + }, + { + key: "status", + commandName: "status", + commandPrefix: "/status", + label: "会话状态", + description: "查看当前会话、执行与排队状态", + aliases: ["state"], + kind: "info", + support: "supported", + }, + { + key: "model", + commandName: "model", + commandPrefix: "/model", + label: "当前模型", + description: "查看当前 provider 与模型配置", + aliases: ["models"], + kind: "info", + support: "supported", + }, + { + key: "help", + commandName: "help", + commandPrefix: "/help", + label: "命令帮助", + description: "查看已接入的 Codex slash 命令", + aliases: ["?"], + kind: "info", + support: "supported", + }, + { + key: "quit", + commandName: "quit", + commandPrefix: "/quit", + label: "退出", + description: "Codex TUI 退出命令,Lime 暂未接入", + aliases: ["exit"], + kind: "local_action", + support: "unsupported", + }, + { + key: "ps", + commandName: "ps", + commandPrefix: "/ps", + label: "任务列表", + description: "Codex 任务面板命令,Lime 暂未接入", + aliases: [], + kind: "info", + support: "unsupported", + }, + { + key: "stop", + commandName: "stop", + commandPrefix: "/stop", + label: "停止任务", + description: "Codex stop 命令,Lime 暂未接入", + aliases: [], + kind: "local_action", + support: "unsupported", + }, + { + key: "statusline", + commandName: "statusline", + commandPrefix: "/statusline", + label: "状态栏", + description: "Codex 状态栏命令,Lime 暂未接入", + aliases: [], + kind: "info", + support: "unsupported", + }, + { + key: "theme", + commandName: "theme", + commandPrefix: "/theme", + label: "界面主题", + description: "Codex TUI 主题命令,Lime 暂未接入", + aliases: [], + kind: "info", + support: "unsupported", + }, + { + key: "mcp", + commandName: "mcp", + commandPrefix: "/mcp", + label: "MCP 面板", + description: "Codex MCP 面板命令,Lime 暂未接入", + aliases: [], + kind: "info", + support: "unsupported", + }, + { + key: "apps", + commandName: "apps", + commandPrefix: "/apps", + label: "应用面板", + description: "Codex apps 命令,Lime 暂未接入", + aliases: [], + kind: "info", + support: "unsupported", + }, + { + key: "plugins", + commandName: "plugins", + commandPrefix: "/plugins", + label: "插件面板", + description: "Codex plugins 命令,Lime 暂未接入", + aliases: [], + kind: "info", + support: "unsupported", + }, +]; diff --git a/src/components/agent/chat/commands/executor.ts b/src/components/agent/chat/commands/executor.ts new file mode 100644 index 000000000..56307074e --- /dev/null +++ b/src/components/agent/chat/commands/executor.ts @@ -0,0 +1,103 @@ +import { + buildCodexSlashHelpMessage, + buildCodexSlashModelMessage, + buildCodexSlashPrompt, + buildCodexSlashStatusMessage, + buildUnsupportedCodexSlashCommandMessage, +} from "./formatter"; +import type { ExecuteCodexSlashCommandParams } from "./types"; + +function ensureSessionMutationAllowed( + isSending: boolean, + commandPrefix: string, + notifyInfo: (message: string) => void, +): boolean { + if (!isSending) { + return true; + } + + notifyInfo(`当前仍有任务执行中,请先等待结束或停止生成后再执行 ${commandPrefix}`); + return false; +} + +export async function executeCodexSlashCommand( + params: ExecuteCodexSlashCommandParams, +): Promise { + const { + command, + statusSnapshot, + sendPrompt, + compactSession, + clearMessages, + createFreshSession, + appendAssistantMessage, + notifyInfo, + notifySuccess, + } = params; + + switch (command.definition.key) { + case "compact": + await compactSession(); + return true; + case "clear": + if ( + !ensureSessionMutationAllowed( + statusSnapshot.isSending, + command.definition.commandPrefix, + notifyInfo, + ) + ) { + return true; + } + clearMessages({ toastMessage: "已清空当前任务" }); + return true; + case "new": { + if ( + !ensureSessionMutationAllowed( + statusSnapshot.isSending, + command.definition.commandPrefix, + notifyInfo, + ) + ) { + return true; + } + const sessionName = command.userInput.trim() || undefined; + const nextSessionId = await createFreshSession(sessionName); + if (nextSessionId) { + notifySuccess( + sessionName ? `已创建新任务:${sessionName}` : "已创建新任务", + ); + } + return true; + } + case "help": + appendAssistantMessage(buildCodexSlashHelpMessage()); + return true; + case "status": + appendAssistantMessage(buildCodexSlashStatusMessage(statusSnapshot)); + return true; + case "model": + if (command.userInput.trim()) { + notifyInfo("当前暂不支持通过 /model 切换模型,请使用输入框右侧的模型选择器"); + return true; + } + appendAssistantMessage(buildCodexSlashModelMessage(statusSnapshot)); + return true; + case "review": + case "diff": + case "init": { + const prompt = buildCodexSlashPrompt(command); + if (prompt) { + await sendPrompt(prompt); + return true; + } + return false; + } + default: + if (command.definition.support === "unsupported") { + notifyInfo(buildUnsupportedCodexSlashCommandMessage(command)); + return true; + } + return false; + } +} diff --git a/src/components/agent/chat/commands/formatter.ts b/src/components/agent/chat/commands/formatter.ts new file mode 100644 index 000000000..7cf729692 --- /dev/null +++ b/src/components/agent/chat/commands/formatter.ts @@ -0,0 +1,97 @@ +import { + getSupportedCodexSlashCommands, + getUnsupportedCodexSlashCommands, +} from "./parser"; +import type { CodexSlashStatusSnapshot, ParsedCodexSlashCommand } from "./types"; + +function formatExecutionStrategyLabel( + strategy: CodexSlashStatusSnapshot["executionStrategy"], +): string { + switch (strategy) { + case "code_orchestrated": + return "代码编排"; + case "auto": + return "自动路由"; + case "react": + default: + return "对话执行"; + } +} + +export function buildCodexSlashHelpMessage(): string { + const supported = getSupportedCodexSlashCommands().map((command) => { + const suffix = command.argumentHint ? ` ${command.argumentHint}` : ""; + return `- ${command.commandPrefix}${suffix}:${command.description}`; + }); + const unsupported = getUnsupportedCodexSlashCommands() + .map((command) => command.commandPrefix) + .join("、"); + + return [ + "可用 Codex 命令:", + ...supported, + "", + `暂未支持:${unsupported}`, + ].join("\n"); +} + +export function buildCodexSlashStatusMessage( + snapshot: CodexSlashStatusSnapshot, +): string { + const providerLabel = snapshot.providerType.trim() || "未设置"; + const modelLabel = snapshot.model.trim() || "未设置"; + + return [ + "当前会话状态:", + `- 会话:${snapshot.sessionId || "未创建"}`, + `- 当前 Turn:${snapshot.currentTurnId || "无"}`, + `- 模型:${providerLabel} / ${modelLabel}`, + `- 执行策略:${formatExecutionStrategyLabel(snapshot.executionStrategy)}`, + `- 运行中:${snapshot.isSending ? "是" : "否"}`, + `- 排队消息:${snapshot.queuedTurnsCount}`, + ].join("\n"); +} + +export function buildCodexSlashModelMessage( + snapshot: CodexSlashStatusSnapshot, +): string { + const providerLabel = snapshot.providerType.trim() || "未设置"; + const modelLabel = snapshot.model.trim() || "未设置"; + + return [ + "当前模型配置:", + `- Provider:${providerLabel}`, + `- 模型:${modelLabel}`, + "", + "切换模型请使用输入框右侧的模型选择器。", + ].join("\n"); +} + +export function buildCodexSlashPrompt( + command: ParsedCodexSlashCommand, +): string | null { + const userInput = command.userInput.trim(); + + switch (command.definition.key) { + case "review": + return userInput + ? `请对以下对象进行代码审查,优先关注 bug、风险、行为回归与缺失测试;先列 findings,再给简短结论。\n\n${userInput}` + : "请对当前工作区未提交的改动做代码审查,优先关注 bug、风险、行为回归与缺失测试;先列 findings,再给简短结论。"; + case "diff": + return userInput + ? `请查看并解释以下 diff 或变更范围,概括关键修改、潜在风险与建议验证项:\n\n${userInput}` + : "请查看当前工作区的 diff,概括关键修改、潜在风险与建议验证项。"; + case "init": + return userInput + ? `请初始化或更新仓库根目录的 AGENTS.md,内容聚焦仓库约定、构建/测试命令、模块边界与开发守则,并结合以下额外要求:\n\n${userInput}` + : "请初始化或更新仓库根目录的 AGENTS.md,内容聚焦仓库约定、构建/测试命令、模块边界与开发守则。"; + default: + return null; + } +} + +export function buildUnsupportedCodexSlashCommandMessage( + command: ParsedCodexSlashCommand, +): string { + return `命令 ${command.definition.commandPrefix} 已识别,但当前 Lime 暂未支持。可先使用 /help 查看已接入命令。`; +} diff --git a/src/components/agent/chat/commands/index.ts b/src/components/agent/chat/commands/index.ts new file mode 100644 index 000000000..8d87cf0b2 --- /dev/null +++ b/src/components/agent/chat/commands/index.ts @@ -0,0 +1,5 @@ +export * from "./catalog"; +export * from "./executor"; +export * from "./formatter"; +export * from "./parser"; +export * from "./types"; diff --git a/src/components/agent/chat/commands/parser.ts b/src/components/agent/chat/commands/parser.ts new file mode 100644 index 000000000..4a4977502 --- /dev/null +++ b/src/components/agent/chat/commands/parser.ts @@ -0,0 +1,88 @@ +import { CODEX_SLASH_COMMANDS } from "./catalog"; +import type { + CodexSlashCommandDefinition, + ParsedCodexSlashCommand, +} from "./types"; + +const SLASH_COMMAND_REGEX = /^\/([a-zA-Z0-9._-]+)(?:\s+([\s\S]*))?$/; + +const COMMAND_LOOKUP = new Map(); + +for (const command of CODEX_SLASH_COMMANDS) { + COMMAND_LOOKUP.set(command.commandName.toLowerCase(), command); + for (const alias of command.aliases) { + COMMAND_LOOKUP.set(alias.toLowerCase(), command); + } +} + +function normalizeQuery(query: string): string { + return query.trim().replace(/^\//, "").toLowerCase(); +} + +export function resolveCodexSlashCommand( + commandName: string, +): CodexSlashCommandDefinition | null { + return COMMAND_LOOKUP.get(commandName.trim().toLowerCase()) ?? null; +} + +export function parseCodexSlashCommand( + content: string, +): ParsedCodexSlashCommand | null { + const match = content.match(SLASH_COMMAND_REGEX); + if (!match) { + return null; + } + + const [, commandName, userInput] = match; + const definition = resolveCodexSlashCommand(commandName); + if (!definition) { + return null; + } + + return { + definition, + commandName: commandName.toLowerCase(), + userInput: userInput?.trim() || "", + rawContent: content, + }; +} + +export function filterCodexSlashCommands( + query: string, + options: { includeUnsupported?: boolean } = {}, +): CodexSlashCommandDefinition[] { + const { includeUnsupported = true } = options; + const normalizedQuery = normalizeQuery(query); + const candidates = includeUnsupported + ? CODEX_SLASH_COMMANDS + : CODEX_SLASH_COMMANDS.filter((command) => command.support === "supported"); + + if (!normalizedQuery) { + return candidates; + } + + return candidates.filter((command) => { + const haystacks = [ + command.commandName, + command.commandPrefix, + command.label, + command.description, + ...command.aliases, + ]; + return haystacks.some((value) => + value.toLowerCase().includes(normalizedQuery), + ); + }); +} + +export function getSupportedCodexSlashCommands() { + return CODEX_SLASH_COMMANDS.filter( + (command) => command.support === "supported", + ); +} + +export function getUnsupportedCodexSlashCommands() { + return CODEX_SLASH_COMMANDS.filter( + (command) => command.support === "unsupported", + ); +} diff --git a/src/components/agent/chat/commands/types.ts b/src/components/agent/chat/commands/types.ts new file mode 100644 index 000000000..afb4eff0e --- /dev/null +++ b/src/components/agent/chat/commands/types.ts @@ -0,0 +1,48 @@ +import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; + +export type CodexSlashCommandSupport = "supported" | "unsupported"; +export type CodexSlashCommandKind = "local_action" | "prompt_action" | "info"; + +export interface CodexSlashCommandDefinition { + key: string; + commandName: string; + commandPrefix: `/${string}`; + label: string; + description: string; + aliases: string[]; + kind: CodexSlashCommandKind; + support: CodexSlashCommandSupport; + argumentHint?: string; +} + +export interface ParsedCodexSlashCommand { + definition: CodexSlashCommandDefinition; + commandName: string; + userInput: string; + rawContent: string; +} + +export interface CodexSlashStatusSnapshot { + sessionId: string | null; + currentTurnId: string | null; + providerType: string; + model: string; + executionStrategy: AsterExecutionStrategy; + queuedTurnsCount: number; + isSending: boolean; +} + +export interface ExecuteCodexSlashCommandParams { + command: ParsedCodexSlashCommand; + statusSnapshot: CodexSlashStatusSnapshot; + sendPrompt: (prompt: string) => Promise; + compactSession: () => Promise; + clearMessages: (options?: { + showToast?: boolean; + toastMessage?: string; + }) => void; + createFreshSession: (sessionName?: string) => Promise; + appendAssistantMessage: (content: string) => void; + notifyInfo: (message: string) => void; + notifySuccess: (message: string) => void; +} diff --git a/src/components/agent/chat/components/AgentIncidentPanel.test.tsx b/src/components/agent/chat/components/AgentIncidentPanel.test.tsx new file mode 100644 index 000000000..cd9d6272c --- /dev/null +++ b/src/components/agent/chat/components/AgentIncidentPanel.test.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { AgentIncidentPanel } from "./AgentIncidentPanel"; +import type { ThreadReliabilityIncidentDisplay } from "../utils/threadReliabilityView"; + +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(); + } +}); + +function renderPanel(incidents: ThreadReliabilityIncidentDisplay[]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mountedRoots.push({ container, root }); + return container; +} + +describe("AgentIncidentPanel", () => { + it("无 active incident 时应展示空态", () => { + const container = renderPanel([]); + + expect( + container.querySelector('[data-testid="agent-incident-panel-empty"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("当前未发现活跃 incident"); + }); + + it("存在 active incident 时应展示标题、说明与状态", () => { + const container = renderPanel([ + { + id: "incident-1", + title: "审批等待超过阈值", + detail: "当前线程等待工具确认时间过长", + statusLabel: "进行中", + severityLabel: "高", + tone: "failed", + }, + { + id: "incident-2", + title: "线程正在等待人工处理", + detail: "等待你确认是否继续发布", + statusLabel: "进行中", + severityLabel: "中", + tone: "waiting", + }, + ]); + + expect( + container.querySelector('[data-testid="agent-incident-panel"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("审批等待超过阈值"); + expect(container.textContent).toContain("当前线程等待工具确认时间过长"); + expect(container.textContent).toContain("高优先级"); + expect(container.textContent).toContain("线程正在等待人工处理"); + expect(container.textContent).toContain("等待你确认是否继续发布"); + }); +}); diff --git a/src/components/agent/chat/components/AgentIncidentPanel.tsx b/src/components/agent/chat/components/AgentIncidentPanel.tsx new file mode 100644 index 000000000..c55c4bf7b --- /dev/null +++ b/src/components/agent/chat/components/AgentIncidentPanel.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { AlertTriangle, ShieldAlert } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { ThreadReliabilityIncidentDisplay } from "../utils/threadReliabilityView"; + +interface AgentIncidentPanelProps { + incidents: ThreadReliabilityIncidentDisplay[]; +} + +function resolveIncidentBadgeClassName( + tone: ThreadReliabilityIncidentDisplay["tone"], +) { + switch (tone) { + case "failed": + return "border-rose-200 bg-rose-50 text-rose-700"; + case "waiting": + return "border-amber-200 bg-amber-50 text-amber-700"; + default: + return "border-slate-200 bg-slate-50 text-slate-700"; + } +} + +function resolveIncidentShellClassName( + tone: ThreadReliabilityIncidentDisplay["tone"], +) { + switch (tone) { + case "failed": + return "border-rose-200/80 bg-rose-50"; + case "waiting": + return "border-amber-200/80 bg-amber-50"; + default: + return "border-slate-200/80 bg-slate-50"; + } +} + +export const AgentIncidentPanel: React.FC = ({ + incidents, +}) => { + if (incidents.length === 0) { + return ( +
+
+ + 当前未发现活跃 incident +
+
+ ); + } + + return ( +
+ {incidents.map((incident) => ( +
+
+
+ + {incident.title} +
+ + {incident.severityLabel}优先级 + + + {incident.statusLabel} + +
+ {incident.detail ? ( +
+ {incident.detail} +
+ ) : null} +
+ ))} +
+ ); +}; diff --git a/src/components/agent/chat/components/AgentThreadOutcomeSummary.tsx b/src/components/agent/chat/components/AgentThreadOutcomeSummary.tsx new file mode 100644 index 000000000..da5d73e78 --- /dev/null +++ b/src/components/agent/chat/components/AgentThreadOutcomeSummary.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { CheckCircle2, RotateCcw } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { ThreadReliabilityOutcomeDisplay } from "../utils/threadReliabilityView"; + +interface AgentThreadOutcomeSummaryProps { + outcome: ThreadReliabilityOutcomeDisplay; +} + +function resolveOutcomeBadgeClassName( + tone: ThreadReliabilityOutcomeDisplay["tone"], +) { + switch (tone) { + case "completed": + return "border-emerald-200 bg-emerald-50 text-emerald-700"; + case "failed": + return "border-rose-200 bg-rose-50 text-rose-700"; + case "paused": + return "border-slate-200 bg-slate-50 text-slate-700"; + case "waiting": + return "border-amber-200 bg-amber-50 text-amber-700"; + default: + return "border-sky-200 bg-sky-50 text-sky-700"; + } +} + +function resolveOutcomeShellClassName( + tone: ThreadReliabilityOutcomeDisplay["tone"], +) { + switch (tone) { + case "completed": + return "border-emerald-200/80 bg-emerald-50"; + case "failed": + return "border-rose-200/80 bg-rose-50"; + case "paused": + return "border-slate-200/80 bg-slate-50"; + case "waiting": + return "border-amber-200/80 bg-amber-50"; + default: + return "border-sky-200/80 bg-sky-50"; + } +} + +export const AgentThreadOutcomeSummary: React.FC< + AgentThreadOutcomeSummaryProps +> = ({ outcome }) => { + return ( +
+
+
+ + 最近结果 +
+ + {outcome.label} + + {outcome.endedAtLabel ? ( + + {outcome.endedAtLabel} + + ) : null} +
+ +
+ {outcome.summary} +
+ + {outcome.primaryCause ? ( +
+ 主因:{outcome.primaryCause} +
+ ) : null} + +
+ + {outcome.retryable ? "建议可重试或恢复" : "当前无需人工恢复"} +
+
+ ); +}; diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx new file mode 100644 index 000000000..8c7c29c20 --- /dev/null +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -0,0 +1,615 @@ +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 { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel"; +import type { ActionRequired, AgentThreadItem, AgentThreadTurn } from "../types"; +import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; + +const { mockToast } = vi.hoisted(() => ({ + mockToast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("sonner", () => ({ + toast: mockToast, +})); + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedHarness[] = []; +let originalClipboard: Clipboard | undefined; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + originalClipboard = navigator.clipboard; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + }); + vi.clearAllMocks(); +}); + +function renderPanel(props?: { + threadRead?: AgentRuntimeThreadReadModel | null; + turns?: AgentThreadTurn[]; + threadItems?: AgentThreadItem[]; + pendingActions?: ActionRequired[]; + submittedActionsInFlight?: ActionRequired[]; + currentTurnId?: string | null; + canInterrupt?: boolean; + onInterruptCurrentTurn?: () => void | Promise; + onResumeThread?: () => void | Promise; + onReplayPendingRequest?: (requestId: string) => void | Promise; + onLocatePendingRequest?: (requestId: string) => void; + onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise; +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ container, root }); + return container; +} + +describe("AgentThreadReliabilityPanel", () => { + it("应优先展示 thread_read 中的 outcome 与 incident", () => { + const container = renderPanel({ + threadRead: { + thread_id: "thread-1", + status: "running", + active_turn_id: "turn-1", + pending_requests: [ + { + id: "req-1", + thread_id: "thread-1", + turn_id: "turn-1", + request_type: "tool_confirmation", + status: "pending", + title: "确认是否执行 browser_click", + created_at: "2026-03-23T09:00:00Z", + }, + ], + last_outcome: { + thread_id: "thread-1", + turn_id: "turn-0", + outcome_type: "failed_provider", + summary: "最近一次 provider 请求失败", + primary_cause: "429 rate limited", + retryable: true, + ended_at: "2026-03-23T08:58:00Z", + }, + incidents: [ + { + id: "incident-1", + thread_id: "thread-1", + turn_id: "turn-1", + incident_type: "approval_timeout", + severity: "high", + status: "active", + title: "审批等待超过阈值", + details: "当前线程等待工具确认时间过长", + }, + ], + }, + turns: [ + { + id: "turn-1", + thread_id: "thread-1", + prompt_text: "发布文章到公众号", + status: "running", + started_at: "2026-03-23T09:00:00Z", + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:10Z", + }, + ], + currentTurnId: "turn-1", + }); + + expect( + container.querySelector('[data-testid="agent-thread-reliability-panel"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("线程可靠性"); + expect(container.textContent).toContain("待处理请求"); + expect(container.textContent).toContain("Provider 失败"); + expect(container.textContent).toContain("审批等待超过阈值"); + expect(container.textContent).toContain("审批等待过久,建议尽快处理或停止当前执行"); + }); + + it("缺少 thread_read 时,应从当前 turn 与 pendingActions 推导并支持中断", async () => { + const onInterruptCurrentTurn = vi.fn().mockResolvedValue(undefined); + const container = renderPanel({ + turns: [ + { + id: "turn-2", + thread_id: "thread-1", + prompt_text: "继续检查发布结果", + status: "running", + started_at: "2026-03-23T09:00:00Z", + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:12Z", + }, + ], + threadItems: [ + { + id: "item-1", + thread_id: "thread-1", + turn_id: "turn-2", + sequence: 1, + status: "in_progress", + started_at: "2026-03-23T09:00:01Z", + updated_at: "2026-03-23T09:00:05Z", + type: "turn_summary", + text: "正在等待用户确认是否继续执行", + }, + ], + pendingActions: [ + { + requestId: "req-local-1", + actionType: "ask_user", + prompt: "请确认是否继续发布", + status: "pending", + }, + ], + currentTurnId: "turn-2", + canInterrupt: true, + onInterruptCurrentTurn, + }); + + expect(container.textContent).toContain("等待人工处理"); + expect(container.textContent).toContain("请确认是否继续发布"); + + const button = Array.from(container.querySelectorAll("button")).find((node) => + node.textContent?.includes("停止当前执行"), + ); + expect(button).toBeDefined(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onInterruptCurrentTurn).toHaveBeenCalledTimes(1); + }); + + it("中断进行中时,面板应展示中断中的瞬时状态", async () => { + let resolveInterrupt: (() => void) | null = null; + const onInterruptCurrentTurn = vi.fn( + () => + new Promise((resolve) => { + resolveInterrupt = resolve; + }), + ); + const container = renderPanel({ + threadRead: { + thread_id: "thread-1", + status: "running", + active_turn_id: "turn-1", + pending_requests: [], + incidents: [], + }, + turns: [ + { + id: "turn-1", + thread_id: "thread-1", + prompt_text: "继续整理发布说明", + status: "running", + started_at: "2026-03-23T09:00:00Z", + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:05Z", + }, + ], + currentTurnId: "turn-1", + canInterrupt: true, + onInterruptCurrentTurn, + }); + + const button = Array.from(container.querySelectorAll("button")).find((node) => + node.textContent?.includes("停止当前执行"), + ); + expect(button).toBeDefined(); + + act(() => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("中断中"); + expect(container.textContent).toContain("正在请求停止当前执行"); + expect(container.textContent).toContain("正在停止"); + + await act(async () => { + resolveInterrupt?.(); + await Promise.resolve(); + }); + }); + + it("应展示最近刷新时间、运行时中断态,并支持跳转待处理请求与恢复排队回合", async () => { + const onLocatePendingRequest = vi.fn(); + const onResumeThread = vi.fn().mockResolvedValue(true); + const onPromoteQueuedTurn = vi.fn().mockResolvedValue(true); + const container = renderPanel({ + threadRead: { + thread_id: "thread-1", + status: "aborted", + active_turn_id: "turn-1", + pending_requests: [ + { + id: "req-1", + thread_id: "thread-1", + turn_id: "turn-1", + request_type: "ask_user", + status: "pending", + title: "请确认是否继续发布", + created_at: "2026-03-23T09:00:00Z", + }, + ], + queued_turns: [ + { + queued_turn_id: "queued-1", + message_preview: "继续执行排队任务", + message_text: "继续执行排队任务正文", + created_at: 1711184400, + image_count: 0, + position: 1, + }, + ], + interrupt_state: "interrupted", + updated_at: "2026-03-23T09:00:20Z", + incidents: [], + }, + onResumeThread, + onLocatePendingRequest, + onPromoteQueuedTurn, + }); + + expect(container.textContent).toContain("最近刷新"); + expect(container.textContent).toContain("运行时已确认中断"); + expect(container.textContent).toContain("前往待处理请求"); + expect(container.textContent).toContain("恢复执行"); + expect(container.textContent).toContain("优先执行 队列第 1 位"); + + const buttons = Array.from(container.querySelectorAll("button")); + const locateButton = buttons.find((node) => + node.textContent?.includes("前往待处理请求"), + ); + const resumeButton = buttons.find((node) => + node.textContent?.includes("恢复执行"), + ); + const promoteButton = buttons.find((node) => + node.textContent?.includes("优先执行 队列第 1 位"), + ); + + act(() => { + locateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onLocatePendingRequest).toHaveBeenCalledWith("req-1"); + + await act(async () => { + resumeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + expect(onResumeThread).toHaveBeenCalledTimes(1); + + await act(async () => { + promoteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + expect(onPromoteQueuedTurn).toHaveBeenCalledWith("queued-1"); + }); + + it("请求已提交待回填时,应压住旧 pending 并展示继续处理中", () => { + const container = renderPanel({ + threadRead: { + thread_id: "thread-1", + status: "waiting_request", + active_turn_id: "turn-1", + pending_requests: [ + { + id: "req-1", + thread_id: "thread-1", + turn_id: "turn-1", + request_type: "ask_user", + status: "pending", + title: "请确认是否继续发布", + created_at: "2026-03-23T09:00:00Z", + }, + ], + incidents: [ + { + id: "incident-req-1", + thread_id: "thread-1", + turn_id: "turn-1", + incident_type: "waiting_user_input", + severity: "medium", + status: "active", + title: "线程正在等待人工处理", + }, + ], + }, + submittedActionsInFlight: [ + { + requestId: "req-1", + actionType: "ask_user", + prompt: "请确认是否继续发布", + status: "submitted", + submittedResponse: '{"answer":"继续"}', + submittedUserData: { answer: "继续" }, + }, + ], + turns: [ + { + id: "turn-1", + thread_id: "thread-1", + prompt_text: "继续发布", + status: "running", + started_at: "2026-03-23T09:00:00Z", + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:10Z", + }, + ], + currentTurnId: "turn-1", + }); + + expect(container.textContent).toContain("处理中"); + expect(container.textContent).toContain("已提交响应,等待线程继续执行"); + expect(container.textContent).toContain("已提交响应:请确认是否继续发布"); + expect(container.textContent).not.toContain("当前最需要处理的请求"); + }); + + it("运行回合卡住时,应展示主动恢复建议", () => { + const container = renderPanel({ + threadRead: { + thread_id: "thread-1", + status: "running", + active_turn_id: "turn-stuck", + pending_requests: [], + incidents: [ + { + id: "incident-stuck", + thread_id: "thread-1", + turn_id: "turn-stuck", + incident_type: "turn_stuck", + severity: "high", + status: "active", + title: "当前回合长时间无进展", + details: "最近 3 分钟内没有新的线程更新,可尝试停止后恢复执行。", + }, + ], + }, + turns: [ + { + id: "turn-stuck", + thread_id: "thread-1", + prompt_text: "继续回填发布摘要", + status: "running", + started_at: "2026-03-23T09:55:00Z", + created_at: "2026-03-23T09:55:00Z", + updated_at: "2026-03-23T09:56:00Z", + }, + ], + currentTurnId: "turn-stuck", + canInterrupt: true, + onInterruptCurrentTurn: vi.fn().mockResolvedValue(undefined), + }); + + expect(container.textContent).toContain("当前回合长时间无进展"); + expect(container.textContent).toContain("当前回合长时间无进展,建议停止后恢复执行"); + }); + + it("存在待处理请求时应支持重新拉起请求", async () => { + const onReplayPendingRequest = vi.fn().mockResolvedValue(true); + const container = renderPanel({ + threadRead: { + thread_id: "thread-1", + status: "waiting_request", + pending_requests: [ + { + id: "req-replay-1", + thread_id: "thread-1", + turn_id: "turn-1", + request_type: "ask_user", + status: "pending", + title: "请重新确认执行模式", + created_at: "2026-03-23T09:00:00Z", + }, + ], + incidents: [], + }, + onReplayPendingRequest, + }); + + const replayButton = Array.from(container.querySelectorAll("button")).find( + (node) => node.textContent?.includes("重新拉起请求"), + ); + expect(replayButton).toBeDefined(); + + await act(async () => { + replayButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onReplayPendingRequest).toHaveBeenCalledWith("req-replay-1"); + }); + + it("应支持复制给 AI 的可靠性诊断包", async () => { + const container = renderPanel({ + threadRead: { + thread_id: "thread-1", + status: "aborted", + active_turn_id: "turn-1", + pending_requests: [], + last_outcome: { + thread_id: "thread-1", + turn_id: "turn-1", + outcome_type: "failed_tool", + summary: "工具执行中断", + primary_cause: "浏览器页面已关闭", + retryable: true, + ended_at: "2026-03-23T10:02:00Z", + }, + incidents: [ + { + id: "incident-1", + thread_id: "thread-1", + turn_id: "turn-1", + incident_type: "tool_failed", + severity: "high", + status: "active", + title: "浏览器工具执行失败", + details: "页面上下文已销毁", + }, + ], + updated_at: "2026-03-23T10:03:00Z", + }, + turns: [ + { + id: "turn-1", + thread_id: "thread-1", + prompt_text: "继续发布公众号文章", + status: "aborted", + started_at: "2026-03-23T10:00:00Z", + created_at: "2026-03-23T10:00:00Z", + updated_at: "2026-03-23T10:03:00Z", + }, + ], + currentTurnId: "turn-1", + }); + + const copyButton = container.querySelector( + '[data-testid="agent-thread-reliability-copy"]', + ); + expect(copyButton).not.toBeNull(); + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("# Lime 线程可靠性诊断任务"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("请按以下结构输出:"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("### Incident"), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("浏览器工具执行失败"), + ); + expect(mockToast.success).toHaveBeenCalledWith("AI 诊断内容已复制"); + expect(container.textContent).toContain("复制给 AI"); + expect(container.textContent).toContain("复制原始 JSON"); + expect(container.textContent).toContain("会附带诊断任务说明"); + }); + + it("应支持复制原始 JSON 诊断数据", async () => { + const container = renderPanel({ + threadRead: { + thread_id: "thread-json-1", + status: "waiting_request", + active_turn_id: "turn-json-1", + pending_requests: [ + { + id: "req-json-1", + thread_id: "thread-json-1", + turn_id: "turn-json-1", + request_type: "ask_user", + status: "pending", + title: "请确认是否继续执行", + created_at: "2026-03-23T10:00:00Z", + }, + ], + incidents: [], + }, + turns: [ + { + id: "turn-json-1", + thread_id: "thread-json-1", + prompt_text: "继续执行 JSON 校验任务", + status: "waiting_request", + started_at: "2026-03-23T10:00:00Z", + created_at: "2026-03-23T10:00:00Z", + updated_at: "2026-03-23T10:01:00Z", + }, + ], + currentTurnId: "turn-json-1", + pendingActions: [ + { + requestId: "req-json-1", + actionType: "ask_user", + prompt: "请确认是否继续执行", + status: "pending", + }, + ], + }); + + const jsonButton = container.querySelector( + '[data-testid="agent-thread-reliability-copy-json"]', + ); + expect(jsonButton).not.toBeNull(); + + await act(async () => { + jsonButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('"thread_read"'), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('"reliability_view"'), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('"pending_actions"'), + ); + expect(mockToast.success).toHaveBeenCalledWith("原始 JSON 已复制"); + }); +}); diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx new file mode 100644 index 000000000..277664d21 --- /dev/null +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -0,0 +1,743 @@ +import React, { useMemo, useState } from "react"; +import { + AlertTriangle, + Clock3, + Copy, + ListTodo, + Loader2, + PauseCircle, + PlayCircle, + Waves, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { + AgentRuntimeThreadReadModel, + QueuedTurnSnapshot, +} from "@/lib/api/agentRuntime"; +import type { ActionRequired, AgentThreadItem, AgentThreadTurn } from "../types"; +import { + buildThreadReliabilityView, + type ThreadReliabilityTone, +} from "../utils/threadReliabilityView"; +import { AgentIncidentPanel } from "./AgentIncidentPanel"; +import { AgentThreadOutcomeSummary } from "./AgentThreadOutcomeSummary"; + +interface AgentThreadReliabilityPanelProps { + threadRead?: AgentRuntimeThreadReadModel | null; + turns?: AgentThreadTurn[]; + threadItems?: AgentThreadItem[]; + currentTurnId?: string | null; + pendingActions?: ActionRequired[]; + submittedActionsInFlight?: ActionRequired[]; + queuedTurns?: QueuedTurnSnapshot[]; + canInterrupt?: boolean; + onInterruptCurrentTurn?: () => void | Promise; + onResumeThread?: () => boolean | Promise; + onReplayPendingRequest?: (requestId: string) => boolean | Promise; + onLocatePendingRequest?: (requestId: string) => void; + onPromoteQueuedTurn?: ( + queuedTurnId: string, + ) => boolean | Promise; + className?: string; +} + +function serializeClipboardPayload(value: unknown): string { + return JSON.stringify( + value, + (_key, item) => (item instanceof Date ? item.toISOString() : item), + 2, + ); +} + +function resolveToneClassName(tone: ThreadReliabilityTone) { + switch (tone) { + case "running": + return "border-sky-200 bg-sky-50 text-sky-700"; + case "waiting": + return "border-amber-200 bg-amber-50 text-amber-700"; + case "completed": + return "border-emerald-200 bg-emerald-50 text-emerald-700"; + case "failed": + return "border-rose-200 bg-rose-50 text-rose-700"; + case "paused": + return "border-slate-200 bg-slate-50 text-slate-700"; + default: + return "border-slate-200 bg-slate-50 text-slate-700"; + } +} + +function resolveStatShellClassName(tone: ThreadReliabilityTone) { + switch (tone) { + case "running": + return "border-sky-200/80 bg-sky-50"; + case "waiting": + return "border-amber-200/80 bg-amber-50"; + case "completed": + return "border-emerald-200/80 bg-emerald-50"; + case "failed": + return "border-rose-200/80 bg-rose-50"; + case "paused": + return "border-slate-200/80 bg-slate-50"; + default: + return "border-slate-200/80 bg-slate-50"; + } +} + +function buildReliabilityDiagnosticText(params: { + statusLabel: string; + summary: string; + view: ReturnType; +}): string { + const { statusLabel, summary, view } = params; + const sections: string[] = [ + "# Lime 线程可靠性诊断任务", + "", + "你现在是一名 AI 任务可靠性分析助手。请基于下面的线程可靠性数据,判断这次任务执行得好不好;如果执行不好,请找出根因,并给出可落地的修复建议。", + "", + "请重点回答以下问题:", + "1. 这次任务整体表现属于:好 / 一般 / 差?请先给结论。", + "2. 直接导致中断、失败、等待或漂移的主要原因是什么?", + "3. 这是模型能力问题、Prompt/规划问题、工具问题、上下文问题、人工阻塞问题,还是产品交互问题?可多选,但要说明主次。", + "4. 哪些问题是一次性偶发,哪些问题是系统性缺陷?", + "5. 如果要优先修复,只做 1~3 件事,应该做什么?请按优先级排序。", + "6. 如果当前信息还不足,请明确指出还缺哪些日志、埋点或上下文。", + "", + "请按以下结构输出:", + "## 结论", + "## 根因分析", + "## 问题归类", + "## 修复建议", + "## 还缺少的信息", + "", + "---", + "", + "## 诊断数据", + "", + "### 当前状态", + `- 状态:${statusLabel}`, + `- 当前回合:${view.activeTurnLabel || "未知"}`, + `- 摘要:${summary}`, + `- 最近刷新:${view.updatedAtLabel || "未知"}`, + `- 中断状态:${view.interruptStateLabel || "无"}`, + "", + "### 核心指标", + `- 待处理请求:${view.pendingRequestCount}`, + `- 活跃 Incident:${view.activeIncidentCount}`, + `- 排队回合:${view.queuedTurnCount}`, + "", + "### 待处理请求", + ]; + + if (view.pendingRequests.length > 0) { + for (const request of view.pendingRequests) { + sections.push( + `- ${request.title}|${request.typeLabel}|${request.statusLabel}${request.waitingLabel ? `|${request.waitingLabel}` : ""}`, + ); + } + } else { + sections.push("- 无"); + } + + sections.push("", "### 已提交待继续的请求"); + if (view.submittedRequests.length > 0) { + for (const request of view.submittedRequests) { + sections.push( + `- ${request.title}|${request.typeLabel}|${request.statusLabel}`, + ); + } + } else { + sections.push("- 无"); + } + + sections.push("", "### Incident"); + if (view.incidents.length > 0) { + for (const incident of view.incidents) { + sections.push( + `- ${incident.title}|${incident.incidentType}|${incident.severityLabel}|${incident.statusLabel}${incident.detail ? `|${incident.detail}` : ""}`, + ); + } + } else { + sections.push("- 无"); + } + + sections.push("", "### 最近结果"); + if (view.outcome) { + sections.push(`- 标签:${view.outcome.label}`); + sections.push(`- 摘要:${view.outcome.summary}`); + sections.push(`- 主因:${view.outcome.primaryCause || "未知"}`); + sections.push(`- 可重试:${view.outcome.retryable ? "是" : "否"}`); + sections.push(`- 结束时间:${view.outcome.endedAtLabel || "未知"}`); + } else { + sections.push("- 无稳定 outcome"); + } + + sections.push("", "### 下一条排队回合"); + if (view.nextQueuedTurn) { + sections.push( + `- ${view.nextQueuedTurn.title}${view.nextQueuedTurn.positionLabel ? `|${view.nextQueuedTurn.positionLabel}` : ""}`, + ); + } else { + sections.push("- 无"); + } + + sections.push("", "### 当前建议"); + if (view.recommendations.length > 0) { + for (const recommendation of view.recommendations) { + sections.push(`- ${recommendation}`); + } + } else { + sections.push("- 暂无额外建议"); + } + + return sections.join("\n"); +} + +function buildReliabilityRawPayload(params: { + threadRead?: AgentRuntimeThreadReadModel | null; + turns: AgentThreadTurn[]; + threadItems: AgentThreadItem[]; + currentTurnId?: string | null; + pendingActions: ActionRequired[]; + submittedActionsInFlight: ActionRequired[]; + queuedTurns: QueuedTurnSnapshot[]; + view: ReturnType; +}): Record { + return { + exported_at: new Date().toISOString(), + current_turn_id: params.currentTurnId || null, + thread_read: params.threadRead || null, + turns: params.turns, + thread_items: params.threadItems, + pending_actions: params.pendingActions, + submitted_actions_in_flight: params.submittedActionsInFlight, + queued_turns: params.queuedTurns, + reliability_view: params.view, + }; +} + +export const AgentThreadReliabilityPanel: React.FC< + AgentThreadReliabilityPanelProps +> = ({ + threadRead, + turns = [], + threadItems = [], + currentTurnId = null, + pendingActions = [], + submittedActionsInFlight = [], + queuedTurns = [], + canInterrupt = false, + onInterruptCurrentTurn, + onResumeThread, + onReplayPendingRequest, + onLocatePendingRequest, + onPromoteQueuedTurn, + className, +}) => { + const [isInterrupting, setIsInterrupting] = useState(false); + const [isResumingThread, setIsResumingThread] = useState(false); + const [isReplayingRequest, setIsReplayingRequest] = useState(false); + const [isPromotingQueuedTurn, setIsPromotingQueuedTurn] = useState(false); + const view = useMemo( + () => + buildThreadReliabilityView({ + threadRead, + turns, + threadItems, + currentTurnId, + pendingActions, + submittedActionsInFlight, + queuedTurns, + }), + [ + currentTurnId, + pendingActions, + queuedTurns, + submittedActionsInFlight, + threadItems, + threadRead, + turns, + ], + ); + const statusLabel = isInterrupting ? "中断中" : view.statusLabel; + const statusTone = isInterrupting ? "paused" : view.statusTone; + const summary = isInterrupting + ? "正在请求停止当前执行,等待运行时确认最新线程状态。" + : view.summary; + + if (!view.shouldRender) { + return null; + } + + const handleInterrupt = async () => { + if (!onInterruptCurrentTurn || isInterrupting) { + return; + } + + setIsInterrupting(true); + try { + await onInterruptCurrentTurn(); + } finally { + setIsInterrupting(false); + } + }; + + const handleLocatePendingRequest = () => { + const requestId = view.pendingRequests[0]?.id; + if (!requestId || !onLocatePendingRequest) { + return; + } + onLocatePendingRequest(requestId); + }; + + const handlePromoteQueuedTurn = async () => { + const queuedTurnId = view.nextQueuedTurn?.id; + if (!queuedTurnId || !onPromoteQueuedTurn || isPromotingQueuedTurn) { + return; + } + + setIsPromotingQueuedTurn(true); + try { + await onPromoteQueuedTurn(queuedTurnId); + } finally { + setIsPromotingQueuedTurn(false); + } + }; + + const handleReplayPendingRequest = async () => { + const requestId = view.pendingRequests[0]?.id; + if (!requestId || !onReplayPendingRequest || isReplayingRequest) { + return; + } + + setIsReplayingRequest(true); + try { + await onReplayPendingRequest(requestId); + } finally { + setIsReplayingRequest(false); + } + }; + + const handleResumeThread = async () => { + if (!onResumeThread || isResumingThread) { + return; + } + + setIsResumingThread(true); + try { + await onResumeThread(); + } finally { + setIsResumingThread(false); + } + }; + + const handleCopyDiagnostic = async () => { + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + toast.error("当前环境不支持剪贴板复制"); + return; + } + + try { + await navigator.clipboard.writeText( + buildReliabilityDiagnosticText({ + statusLabel, + summary, + view, + }), + ); + toast.success("AI 诊断内容已复制"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "复制 AI 诊断内容失败", + ); + } + }; + + const handleCopyRawJson = async () => { + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + toast.error("当前环境不支持剪贴板复制"); + return; + } + + try { + await navigator.clipboard.writeText( + serializeClipboardPayload( + buildReliabilityRawPayload({ + threadRead, + turns, + threadItems, + currentTurnId, + pendingActions, + submittedActionsInFlight, + queuedTurns, + view, + }), + ), + ); + toast.success("原始 JSON 已复制"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "复制原始 JSON 失败"); + } + }; + + return ( +
+
+
+
+ 线程可靠性 +
+
+ + {statusLabel} + + {view.activeTurnLabel ? ( + + {view.activeTurnLabel} + + ) : null} +
+
{summary}
+
+ +
+ {view.updatedAtLabel ? 最近刷新 {view.updatedAtLabel} : null} + {view.interruptStateLabel ? ( + + {view.interruptStateLabel} + + ) : null} + + +
+
+
+ “复制给 AI” 会附带诊断任务说明;“复制原始 JSON” 适合程序化分析、存档或二次处理。 +
+ +
+
0 ? "waiting" : "neutral", + ), + )} + > +
+ + 待处理请求 +
+
+ {view.pendingRequestCount} +
+
+ +
0 ? "failed" : "neutral", + ), + )} + > +
+ + 活跃 Incident +
+
+ {view.activeIncidentCount} +
+
+ +
0 ? "waiting" : "neutral", + ), + )} + > +
+ + 排队回合 +
+
+ {view.queuedTurnCount} +
+
+
+ + {(canInterrupt && onInterruptCurrentTurn) || + (view.pendingRequests.length > 0 && onReplayPendingRequest) || + (view.nextQueuedTurn && onResumeThread) || + (view.pendingRequests.length > 0 && onLocatePendingRequest) || + (view.nextQueuedTurn && onPromoteQueuedTurn) || + view.recommendations.length > 0 ? ( +
+
当前操作
+
+ {canInterrupt && onInterruptCurrentTurn ? ( + + ) : null} + + {view.pendingRequests.length > 0 && onReplayPendingRequest ? ( + + ) : null} + + {view.pendingRequests.length > 0 && onLocatePendingRequest ? ( + + ) : null} + + {view.nextQueuedTurn && onResumeThread ? ( + + ) : null} + + {view.nextQueuedTurn && onPromoteQueuedTurn ? ( + + ) : null} +
+ + {view.recommendations.length > 0 ? ( +
+ {view.recommendations.map((recommendation) => ( + + {recommendation} + + ))} +
+ ) : null} +
+ ) : null} + + {view.pendingRequests.length > 0 ? ( +
+
+ + 当前最需要处理的请求 +
+
+ {view.pendingRequests.map((request) => ( +
+
+
+ {request.title} +
+ + {request.typeLabel} + + + {request.statusLabel} + +
+ {request.waitingLabel || request.createdAtLabel ? ( +
+ {[request.waitingLabel, request.createdAtLabel] + .filter(Boolean) + .join(" · ")} +
+ ) : null} +
+ ))} +
+
+ ) : null} + + {view.submittedRequests.length > 0 ? ( +
+
+ + 已提交响应,等待线程继续执行 +
+
+ {view.submittedRequests.map((request) => ( +
+
+
+ {request.title} +
+ + {request.typeLabel} + + + {request.statusLabel} + +
+
+ ))} +
+
+ ) : null} + +
+ {view.outcome ? ( + + ) : ( +
+
最近结果
+
+ 当前尚未沉淀出稳定 outcome,继续以下方时间线为准。 +
+
+ )} + + +
+
+ ); +}; diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index cd2f2eb31..4b3466552 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -9,6 +9,7 @@ import type { AgentThreadItem, AgentThreadTurn, } from "../types"; +import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; const parseAIResponseMock = vi.fn(); @@ -130,6 +131,7 @@ function renderTimeline( props?: { isCurrentTurn?: boolean; turn?: Partial; + threadRead?: AgentRuntimeThreadReadModel | null; actionRequests?: ActionRequired[]; onOpenSubagentSession?: (sessionId: string) => void; }, @@ -143,6 +145,7 @@ function renderTimeline( { + it("应在时间线头部展示当前 turn 的 compact outcome 与 incident 徽标", () => { + const container = renderTimeline( + [ + { + ...createBaseItem("summary-1", 1), + type: "turn_summary", + text: "最近一次 Provider 调用失败,等待人工处理。", + }, + ], + { + threadRead: { + thread_id: "thread-1", + status: "failed", + active_turn_id: "turn-1", + pending_requests: [], + last_outcome: { + thread_id: "thread-1", + turn_id: "turn-1", + outcome_type: "failed_provider", + summary: "Provider 请求失败", + primary_cause: "429 rate limited", + retryable: true, + ended_at: at(9), + }, + incidents: [ + { + id: "incident-1", + thread_id: "thread-1", + turn_id: "turn-1", + incident_type: "provider_failure", + severity: "high", + status: "active", + title: "Provider 连续失败", + }, + ], + }, + }, + ); + + expect( + container.querySelector('[data-testid="agent-thread-compact-outcome"]') + ?.textContent, + ).toContain("Provider 失败"); + expect( + container.querySelector('[data-testid="agent-thread-compact-incident"]') + ?.textContent, + ).toContain("1 个 incident"); + + clickTimelineToggle(container); + + expect( + container.querySelector('[data-testid="agent-thread-summary-outcome"]') + ?.textContent, + ).toContain("Provider 失败"); + expect( + container.querySelector('[data-testid="agent-thread-summary-incident"]') + ?.textContent, + ).toContain("1 个 incident"); + }); + it("应渲染当前阶段概览与按时序组织的分组块", () => { const items: AgentThreadItem[] = [ { diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 02ac01fbe..4e98b22e9 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -34,12 +34,19 @@ import { type AgentThreadOrderedBlock, type AgentThreadSummaryChip, } from "../utils/agentThreadGrouping"; +import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; 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"; import { cn } from "@/lib/utils"; +import { + resolveIncidentToneFromSeverity, + resolveOutcomeLabel, + resolveOutcomeTone, + type ThreadReliabilityTone, +} from "../utils/threadReliabilityView"; import { MarkdownRenderer } from "./MarkdownRenderer"; import { ActionRequestA2UIPreviewCard } from "./ActionRequestA2UIPreviewCard"; import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard"; @@ -50,6 +57,7 @@ import { AgentPlanBlock } from "./AgentPlanBlock"; interface AgentThreadTimelineProps { turn: AgentThreadTurn; items: AgentThreadItem[]; + threadRead?: AgentRuntimeThreadReadModel | null; actionRequests?: ActionRequired[]; isCurrentTurn?: boolean; onFileClick?: (fileName: string, content: string) => void; @@ -71,6 +79,12 @@ type TimelineCompactTone = | "paused" | "done"; +interface TimelineCompactReliabilityBadge { + key: string; + label: string; + tone: ThreadReliabilityTone; +} + function shortenInlineText( value: string | undefined | null, maxLength = 72, @@ -103,6 +117,82 @@ function formatTimestamp(value?: string): string | null { }); } +function resolveReliabilityBadgeClassName( + tone: ThreadReliabilityTone, +): string { + if (tone === "completed") { + return "border-emerald-200 bg-emerald-50 text-emerald-700"; + } + if (tone === "failed") { + return "border-rose-200 bg-rose-50 text-rose-700"; + } + if (tone === "waiting") { + return "border-amber-200 bg-amber-50 text-amber-700"; + } + if (tone === "running") { + return "border-sky-200 bg-sky-50 text-sky-700"; + } + if (tone === "paused") { + return "border-slate-200 bg-slate-50 text-slate-700"; + } + return "border-slate-200 bg-slate-50 text-slate-700"; +} + +function buildCompactReliabilityBadges( + turn: AgentThreadTurn, + threadRead?: AgentRuntimeThreadReadModel | null, +): TimelineCompactReliabilityBadge[] { + if (!threadRead) { + return []; + } + + const badges: TimelineCompactReliabilityBadge[] = []; + + if (threadRead.last_outcome?.turn_id === turn.id) { + badges.push({ + key: "outcome", + label: resolveOutcomeLabel(threadRead.last_outcome.outcome_type), + tone: resolveOutcomeTone(threadRead.last_outcome.outcome_type), + }); + } + + const activeIncidents = (threadRead.incidents ?? []).filter((incident) => { + const normalizedStatus = (incident.status || "").toLowerCase(); + return ( + incident.turn_id === turn.id && + !incident.cleared_at && + !normalizedStatus.includes("clear") + ); + }); + + if (activeIncidents.length > 0) { + const incidentTone = activeIncidents.reduce( + (currentTone, incident) => { + const nextTone = resolveIncidentToneFromSeverity(incident.severity); + if (currentTone === "failed" || nextTone === currentTone) { + return currentTone; + } + if (nextTone === "failed") { + return nextTone; + } + if (nextTone === "waiting" && currentTone !== "failed") { + return nextTone; + } + return currentTone; + }, + "neutral", + ); + + badges.push({ + key: "incident", + label: `${activeIncidents.length} 个 incident`, + tone: incidentTone, + }); + } + + return badges; +} + function toQuestionOptions( options: Array<{ label: string; description?: string }> | undefined, ) { @@ -633,6 +723,57 @@ function ThinkingItemCard({ ); } +function ContextCompactionCard({ + item, +}: { + item: Extract; +}) { + const triggerLabel = + item.trigger === "manual" + ? "手动压缩" + : item.trigger === "overflow" + ? "超限恢复" + : item.trigger === "auto" + ? "自动压缩" + : "上下文压缩"; + const title = + item.stage === "completed" || item.status === "completed" + ? "上下文已压缩" + : "正在压缩上下文"; + const detail = + item.detail?.trim() || + (item.stage === "completed" || item.status === "completed" + ? "较早消息已替换为摘要,后续回复会基于压缩后的上下文继续。" + : "系统正在将较早消息整理为摘要,以释放上下文窗口。"); + + return ( + + {item.status === "in_progress" ? ( + + + 压缩中 + + ) : ( + resolveItemStatusLabel(item.status) + )} + + } + timestamp={formatTimestamp(item.completed_at || item.updated_at)} + > +
+
{detail}
+
+ {triggerLabel} +
+
+
+ ); +} + function renderThinkingItemDetails(item: AgentThreadItem) { if (item.type === "plan") { return ( @@ -651,6 +792,10 @@ function renderThinkingItemDetails(item: AgentThreadItem) { return ; } + if (item.type === "context_compaction") { + return ; + } + return null; } @@ -1445,6 +1590,7 @@ function TimelineBlockCard({ export const AgentThreadTimeline: React.FC = ({ turn, items, + threadRead, actionRequests = [], isCurrentTurn = false, onFileClick, @@ -1520,6 +1666,11 @@ export const AgentThreadTimeline: React.FC = ({ } }, [defaultDetailsExpanded, turn.id]); + const compactReliabilityBadges = useMemo( + () => buildCompactReliabilityBadges(turn, threadRead), + [threadRead, turn], + ); + if (visibleItems.length === 0) { return null; } @@ -1605,6 +1756,16 @@ export const AgentThreadTimeline: React.FC = ({ > {turnStatusMeta.label} + {compactReliabilityBadges.map((badge) => ( + + {badge.label} + + ))} {isCurrentTurn ? "当前任务" : "历史记录"} @@ -1668,6 +1829,16 @@ export const AgentThreadTimeline: React.FC = ({ > {collapsedProcess.statusLabel} + {compactReliabilityBadges.map((badge) => ( + + {badge.label} + + ))}
= ({ className="flex flex-wrap items-center gap-2" data-testid="agent-thread-summary-header" > -
+
{isCurrentTurn ? "当前任务摘要" : "任务摘要"}
+ {compactReliabilityBadges.map((badge) => ( + + {badge.label} + + ))} + ) : null} + + {showContextCompactionAction && + (showBrowserAssistEntry || showHarnessToggle) ? ( + @@ -636,7 +759,10 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { // 渲染 elicitation 面板 if (request.actionType === "elicitation") { return ( - + @@ -667,7 +793,9 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { isSelected ? "border-indigo-500 bg-indigo-100 dark:border-indigo-400 dark:bg-indigo-900/30" : "border-border bg-background hover:border-indigo-300 hover:bg-muted", + isSubmitting && "cursor-not-allowed opacity-70", )} + disabled={isSubmitting} onClick={() => setElicitationAnswer(option)} > {option} @@ -684,6 +812,7 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { setElicitationAnswer(e.target.value)} />
@@ -695,6 +824,7 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { setElicitationOther(e.target.value)} />
@@ -703,15 +833,28 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { - @@ -727,7 +870,10 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { ) { const questions = request.questions; return ( - + @@ -764,22 +910,28 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { isSelected ? "border-blue-500 bg-blue-100 dark:border-blue-400 dark:bg-blue-900/30" : "border-border bg-background hover:border-blue-300 hover:bg-muted", + isSubmitting && "cursor-not-allowed opacity-70", )} + disabled={isSubmitting} onClick={() => { if (shouldAutoSubmit) { - onSubmit({ - requestId: request.requestId, - confirmed: true, - response: option.label, - actionType: request.actionType, - userData: { answer: option.label }, - }); + handleAutoSubmitOption( + option.label, + qIndex, + request.actionType, + ); return; } toggleOption(qIndex, option.label, q.multiSelect); }} > -
{option.label}
+
+ {submissionState?.key === + `option:${qIndex}:${option.label}` ? ( + + ) : null} + {option.label} +
{option.description && (
{option.description} @@ -799,6 +951,7 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { setOtherInputs((prev) => ({ ...prev, @@ -827,15 +980,34 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { -
@@ -845,7 +1017,10 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { // 渲染工具确认面板 return ( - + @@ -879,13 +1054,27 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { size="sm" onClick={handleAllow} className="bg-green-600 hover:bg-green-700" + disabled={isSubmitting} > - - 允许 + {submissionState?.key === "allow" ? ( + + ) : ( + + )} + {submissionState?.key === "allow" ? "处理中..." : "允许"} - diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index d560266b9..4ae533119 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -400,6 +400,38 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).toContain("等待首个模型事件"); }); + it("存在线程可靠性信号时应在工作台展示可靠性入口与面板", () => { + renderPanel({ + layout: "dialog", + turns: [ + { + id: "turn-reliability", + thread_id: "thread-1", + prompt_text: "继续发布文章", + status: "running", + started_at: "2026-03-24T09:00:00Z", + created_at: "2026-03-24T09:00:00Z", + updated_at: "2026-03-24T09:00:12Z", + }, + ], + currentTurnId: "turn-reliability", + pendingActions: [ + { + requestId: "req-reliability-1", + actionType: "ask_user", + prompt: "请确认是否继续发布", + status: "pending", + }, + ], + }); + + expect(document.body.textContent).toContain("线程可靠性"); + expect(document.body.textContent).toContain("请确认是否继续发布"); + expect( + document.body.querySelector('button[aria-label="跳转到可靠性"]'), + ).not.toBeNull(); + }); + it("runtimeStatus 为 failed 时应展示失败阶段与失败详情", () => { renderPanel({ harnessState: createHarnessState({ diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index bcba83a6e..13f9ecf61 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -37,8 +37,10 @@ import type { AgentRuntimeToolInventory, AgentRuntimeToolInventoryCatalogEntry, AgentRuntimeToolInventoryRegistryEntry, + AgentRuntimeThreadReadModel, AgentToolExecutionPolicySource, AsterSubagentSessionInfo, + QueuedTurnSnapshot, } from "@/lib/api/agentRuntime"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -57,7 +59,11 @@ import { revealPathInFinder, } from "@/lib/api/fileSystem"; import { SearchResultPreviewList } from "./SearchResultPreviewList"; -import type { ActionRequired } from "../types"; +import type { + ActionRequired, + AgentThreadItem, + AgentThreadTurn, +} from "../types"; import type { HarnessFileAction, HarnessActiveFileWrite, @@ -78,9 +84,11 @@ import { normalizeToolNameKey, resolveToolDisplayLabel, } from "../utils/toolDisplayInfo"; +import { buildThreadReliabilityView } from "../utils/threadReliabilityView"; import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspaceCopy"; import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime"; import type { TeamRoleDefinition } from "../utils/teamDefinitions"; +import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel"; interface HarnessEnvironmentSummary { skillsCount: number; @@ -122,6 +130,20 @@ interface HarnessStatusPanelProps { selectedTeamLabel?: string | null; selectedTeamSummary?: string | null; selectedTeamRoles?: TeamRoleDefinition[] | null; + threadRead?: AgentRuntimeThreadReadModel | null; + turns?: AgentThreadTurn[]; + threadItems?: AgentThreadItem[]; + currentTurnId?: string | null; + pendingActions?: ActionRequired[]; + submittedActionsInFlight?: ActionRequired[]; + queuedTurns?: QueuedTurnSnapshot[]; + canInterrupt?: boolean; + onInterruptCurrentTurn?: () => void | Promise; + onResumeThread?: () => boolean | Promise; + onReplayPendingRequest?: (requestId: string) => boolean | Promise; + onPromoteQueuedTurn?: ( + queuedTurnId: string, + ) => boolean | Promise; } interface PreviewDialogState { @@ -146,6 +168,7 @@ type ToolInventoryFilterValue = "all" | "runtime" | "persisted" | "default"; type HarnessSectionKey = | "team_config" | "runtime" + | "reliability" | "inventory" | "approvals" | "writes" @@ -1374,6 +1397,18 @@ export function HarnessStatusPanel({ selectedTeamLabel = null, selectedTeamSummary = null, selectedTeamRoles = [], + threadRead = null, + turns = [], + threadItems = [], + currentTurnId = null, + pendingActions = [], + submittedActionsInFlight = [], + queuedTurns = [], + canInterrupt = false, + onInterruptCurrentTurn, + onResumeThread, + onReplayPendingRequest, + onPromoteQueuedTurn, }: HarnessStatusPanelProps) { const [expanded, setExpanded] = useState(true); const isDialogLayout = layout === "dialog"; @@ -1424,6 +1459,13 @@ export function HarnessStatusPanel({ ), [toolInventory, toolInventoryFilter], ); + const toolInventoryWarnings = toolInventory?.warnings || []; + const toolInventoryCatalogTools = toolInventory?.catalog_tools || []; + const toolInventoryRegistryTools = toolInventory?.registry_tools || []; + const toolInventoryExtensionSurfaces = + toolInventory?.extension_surfaces || []; + const toolInventoryExtensionTools = toolInventory?.extension_tools || []; + const toolInventoryMcpTools = toolInventory?.mcp_tools || []; const realTeamSummary = useMemo( () => summarizeChildSubagentSessions(childSubagentSessions), [childSubagentSessions], @@ -1432,6 +1474,27 @@ export function HarnessStatusPanel({ const hasSelectedTeamConfig = Boolean(selectedTeamLabel?.trim()) || Boolean(selectedTeamSummary?.trim()) || (selectedTeamRoles?.length ?? 0) > 0; + const threadReliabilityView = useMemo( + () => + buildThreadReliabilityView({ + threadRead, + turns, + threadItems, + currentTurnId, + pendingActions, + submittedActionsInFlight, + queuedTurns, + }), + [ + currentTurnId, + pendingActions, + queuedTurns, + submittedActionsInFlight, + threadItems, + threadRead, + turns, + ], + ); const fileFilterOptions = useMemo( () => @@ -1577,6 +1640,9 @@ export function HarnessStatusPanel({ if (harnessState.runtimeStatus) { sections.push({ key: "runtime", label: "任务进展" }); } + if (threadReliabilityView.shouldRender) { + sections.push({ key: "reliability", label: "可靠性" }); + } if (harnessState.activeFileWrites.length > 0) { sections.push({ key: "writes", label: "文件写入" }); } @@ -1629,6 +1695,7 @@ export function HarnessStatusPanel({ hasSelectedTeamConfig, hasCompatSchedulerSignals, realTeamSummary.total, + threadReliabilityView.shouldRender, ]); const summaryCards = useMemo(() => { @@ -1645,6 +1712,16 @@ export function HarnessStatusPanel({ }); } + if (threadReliabilityView.shouldRender) { + cards.push({ + sectionKey: "reliability", + title: "可靠性", + value: threadReliabilityView.statusLabel, + hint: threadReliabilityView.summary, + icon: AlertCircle, + }); + } + if (hasSelectedTeamConfig) { cards.push({ sectionKey: "team_config", @@ -1777,6 +1854,9 @@ export function HarnessStatusPanel({ toolInventory, toolInventoryError, toolInventoryLoading, + threadReliabilityView.shouldRender, + threadReliabilityView.statusLabel, + threadReliabilityView.summary, ]); const openPreview = useCallback( @@ -2226,6 +2306,31 @@ export function HarnessStatusPanel({ ) : null} + {threadReliabilityView.shouldRender ? ( +
+ +
+ ) : null} + {harnessState.activeFileWrites.length > 0 ? (
- caller:{toolInventory.request.caller} + caller:{toolInventory.request?.caller || "未知"} Creator: - {toolInventory.request.surface.creator + {toolInventory.request?.surface?.creator ? "开启" : "关闭"} Browser Assist: - {toolInventory.request.surface.browser_assist + {toolInventory.request?.surface?.browser_assist ? "开启" : "关闭"} @@ -2584,13 +2689,13 @@ export function HarnessStatusPanel({ ))} - {toolInventory.warnings.length > 0 ? ( + {toolInventoryWarnings.length > 0 ? (
库存告警
- {toolInventory.warnings.map((warning, index) => ( + {toolInventoryWarnings.map((warning, index) => (
{warning}
))}
@@ -2604,7 +2709,7 @@ export function HarnessStatusPanel({
{filteredCatalogTools.length} /{" "} - {toolInventory.catalog_tools.length} + {toolInventoryCatalogTools.length}
@@ -2619,7 +2724,7 @@ export function HarnessStatusPanel({ ].map((option) => { const active = option.value === toolInventoryFilter; const count = countCatalogToolsByInventoryFilter( - toolInventory.catalog_tools, + toolInventoryCatalogTools, option.value, ); @@ -2775,8 +2880,8 @@ export function HarnessStatusPanel({
Runtime Registry
- {toolInventory.registry_tools.length > 0 ? ( - toolInventory.registry_tools.map((entry) => ( + {toolInventoryRegistryTools.length > 0 ? ( + toolInventoryRegistryTools.map((entry) => (
- {toolInventory.extension_surfaces.length > 0 ? ( + {toolInventoryExtensionSurfaces.length > 0 ? (
Extension Surfaces
- {toolInventory.extension_surfaces.map((entry) => ( + {toolInventoryExtensionSurfaces.map((entry) => (
) : null} - {toolInventory.extension_tools.length > 0 ? ( + {toolInventoryExtensionTools.length > 0 ? (
Extension Tools
- {toolInventory.extension_tools.map((entry) => ( + {toolInventoryExtensionTools.map((entry) => (
) : null} - {toolInventory.mcp_tools.length > 0 ? ( + {toolInventoryMcpTools.length > 0 ? (
MCP Tools
- {toolInventory.mcp_tools.map((entry) => ( + {toolInventoryMcpTools.map((entry) => (
{ + textarea.focus(); + textarea.value = value; + textarea.setSelectionRange(value.length, value.length); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + async function typeAtAndWait(textarea: HTMLTextAreaElement) { await act(async () => { await import("./CharacterMentionPanel"); @@ -247,6 +256,20 @@ async function typeAtAndWait(textarea: HTMLTextAreaElement) { }); } +async function typeSlashAndWait( + textarea: HTMLTextAreaElement, + value = "/", +) { + await act(async () => { + await import("./CharacterMentionPanel"); + }); + + typeSlash(textarea, value); + await act(async () => { + await Promise.resolve(); + }); +} + function createSkill(name: string, key: string, installed: boolean): Skill { return { key, @@ -353,6 +376,60 @@ describe("CharacterMention", () => { expect(onChangeSpy).toHaveBeenCalledWith("/skill-a "); }); + it("输入 / 时应显示 Codex slash 命令列表", async () => { + const container = renderHarness(); + const textarea = getTextarea(container); + + await typeSlashAndWait(textarea); + + expect(document.body.textContent).toContain("Codex 命令"); + expect(document.body.textContent).toContain("/compact"); + expect(document.body.textContent).toContain("/review"); + }); + + it("slash 面板选择 Codex 命令时应回填到输入框", async () => { + const onChangeSpy = vi.fn<(value: string) => void>(); + const container = renderHarness({ + onChangeSpy, + }); + const textarea = getTextarea(container); + + await typeSlashAndWait(textarea, "/com"); + + const commandButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("/compact")); + expect(commandButton).toBeTruthy(); + + act(() => { + commandButton?.click(); + }); + + expect(onChangeSpy).toHaveBeenCalledWith("/compact "); + }); + + it("slash 面板选择已安装技能时应直接回填 slash skill", async () => { + const onChangeSpy = vi.fn<(value: string) => void>(); + const container = renderHarness({ + skills: [createSkill("技能A", "skill-a", true)], + onChangeSpy, + }); + const textarea = getTextarea(container); + + await typeSlashAndWait(textarea, "/ski"); + + const skillButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("技能A"), + ); + expect(skillButton).toBeTruthy(); + + act(() => { + skillButton?.click(); + }); + + expect(onChangeSpy).toHaveBeenCalledWith("/skill-a "); + }); + it("提及面板应锚定在输入框正上方,并禁止自动翻转到下方", async () => { const container = renderHarness(); const textarea = getTextarea(container); @@ -378,10 +455,12 @@ describe("CharacterMention", () => { ) as HTMLDivElement | null; expect(anchor?.style.top).toBe("240px"); - expect(anchor?.style.left).toBe("420px"); + expect(anchor?.style.left).toBe("120px"); + expect(anchor?.style.width).toBe("600px"); expect(popover?.getAttribute("data-side")).toBe("top"); - expect(popover?.getAttribute("data-align")).toBe("center"); + expect(popover?.getAttribute("data-align")).toBe("start"); expect(popover?.getAttribute("data-avoid-collisions")).toBe("false"); - expect(popover?.style.width).toBe("420px"); + expect(popover?.style.width).toBe("600px"); + expect(popover?.style.bottom).toBe("536px"); }); }); diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx index 245a54a1b..0f397b4c9 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx @@ -1,7 +1,7 @@ /** * 角色与技能引用组件 * - * 在输入框中检测 @ 符号,显示角色和技能列表供选择 + * 在输入框中检测 @ 或 / 符号,显示角色、技能与命令列表供选择 */ import React, { @@ -13,14 +13,11 @@ import React, { useRef, useCallback, } from "react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { createPortal } from "react-dom"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import { toast } from "sonner"; +import { filterCodexSlashCommands, type CodexSlashCommandDefinition } from "../../../commands"; import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; import { filterBuiltinCommands, @@ -55,6 +52,67 @@ interface CharacterMentionProps { onNavigateToSettings?: () => void; } +type TriggerMode = "mention" | "slash"; + +interface ActiveTrigger { + mode: TriggerMode; + triggerIndex: number; + query: string; +} + +function resolveMentionTrigger(textBeforeCursor: string): ActiveTrigger | null { + const lastAtIndex = textBeforeCursor.lastIndexOf("@"); + if (lastAtIndex === -1) { + return null; + } + + const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1); + if (textAfterAt.includes(" ") || textAfterAt.includes("\n")) { + return null; + } + + return { + mode: "mention", + triggerIndex: lastAtIndex, + query: textAfterAt, + }; +} + +function resolveSlashTrigger(textBeforeCursor: string): ActiveTrigger | null { + const slashMatch = textBeforeCursor.match(/(?:^|[\s\n])(\/[^\s\n/]*)$/); + if (!slashMatch) { + return null; + } + + const slashToken = slashMatch[1]; + const triggerIndex = textBeforeCursor.length - slashToken.length; + return { + mode: "slash", + triggerIndex, + query: slashToken.slice(1), + }; +} + +function resolveActiveTrigger( + value: string, + cursorPos: number, +): ActiveTrigger | null { + const textBeforeCursor = value.slice(0, cursorPos); + const mentionTrigger = resolveMentionTrigger(textBeforeCursor); + const slashTrigger = resolveSlashTrigger(textBeforeCursor); + + if (!mentionTrigger) { + return slashTrigger; + } + if (!slashTrigger) { + return mentionTrigger; + } + + return mentionTrigger.triggerIndex > slashTrigger.triggerIndex + ? mentionTrigger + : slashTrigger; +} + export function CharacterMention({ characters, skills = [], @@ -68,13 +126,16 @@ export function CharacterMention({ }: CharacterMentionProps) { const [showMentions, setShowMentions] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); + const [triggerMode, setTriggerMode] = useState("mention"); const [panelAnchor, setPanelAnchor] = useState({ top: 0, left: 0, width: 320, + bottom: 0, + maxHeight: 320, }); - const popoverRef = useRef(null); const commandRef = useRef(null); + const panelRef = useRef(null); useEffect(() => { return scheduleIdleModulePreload(() => { @@ -86,8 +147,11 @@ export function CharacterMention({ () => filterBuiltinCommands(mentionQuery), [mentionQuery], ); + const filteredSlashCommands = useMemo( + () => filterCodexSlashCommands(mentionQuery), + [mentionQuery], + ); - // 过滤角色列表 const filteredCharacters = useMemo(() => { if (!mentionQuery) return characters; const query = mentionQuery.toLowerCase(); @@ -98,7 +162,6 @@ export function CharacterMention({ ); }, [characters, mentionQuery]); - // 过滤已安装技能 const installedSkills = useMemo(() => { const installed = skills.filter((s) => s.installed); if (!mentionQuery) return installed; @@ -111,7 +174,6 @@ export function CharacterMention({ ); }, [skills, mentionQuery]); - // 过滤未安装技能 const availableSkills = useMemo(() => { const available = skills.filter((s) => !s.installed); if (!mentionQuery) return available; @@ -132,38 +194,43 @@ export function CharacterMention({ } const cursorPos = textarea.selectionStart ?? textarea.value.length; - const textBeforeCursor = textarea.value.slice(0, cursorPos); - const lastAtIndex = textBeforeCursor.lastIndexOf("@"); - - if (lastAtIndex !== -1) { - const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1); - if (!textAfterAt.includes(" ") && !textAfterAt.includes("\n")) { - setMentionQuery(textAfterAt); - setShowMentions(true); - - const rect = textarea.getBoundingClientRect(); - const viewportWidth = - window.innerWidth || document.documentElement.clientWidth || 1280; - const panelWidth = Math.min(Math.max(rect.width - 24, 320), 420); - const centerLeft = rect.left + rect.width / 2; - const safeHalfWidth = panelWidth / 2 + 16; - const left = Math.min( - Math.max(centerLeft, safeHalfWidth), - viewportWidth - safeHalfWidth, - ); - setPanelAnchor({ - top: rect.top, - left, - width: panelWidth, - }); - return; - } + const activeTrigger = resolveActiveTrigger(textarea.value, cursorPos); + if (!activeTrigger) { + setShowMentions(false); + return; } - setShowMentions(false); + setMentionQuery(activeTrigger.query); + setTriggerMode(activeTrigger.mode); + setShowMentions(true); + + const rect = textarea.getBoundingClientRect(); + const viewportWidth = + window.innerWidth || document.documentElement.clientWidth || 1280; + const viewportHeight = + window.innerHeight || document.documentElement.clientHeight || 800; + const screenPadding = viewportWidth < 640 ? 12 : 16; + const panelGap = viewportWidth < 640 ? 6 : 8; + const panelWidth = Math.min( + rect.width, + Math.max(viewportWidth - screenPadding * 2, 240), + ); + const left = Math.min( + Math.max(rect.left, screenPadding), + viewportWidth - panelWidth - screenPadding, + ); + setPanelAnchor({ + top: rect.top, + left, + width: panelWidth, + bottom: Math.max(viewportHeight - rect.top + panelGap, panelGap), + maxHeight: Math.max( + Math.min(rect.top - panelGap - screenPadding, 420), + 120, + ), + }); }, [inputRef]); - // 检测 @ 符号 useEffect(() => { const textarea = inputRef.current; if (!textarea) return; @@ -199,73 +266,113 @@ export function CharacterMention({ }; }, [showMentions, updateMentionState]); - // 插入角色引用 + useEffect(() => { + if (!showMentions) { + return; + } + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if (panelRef.current?.contains(target) || inputRef.current?.contains(target)) { + return; + } + setShowMentions(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + }; + }, [inputRef, showMentions]); + const handleSelectCharacter = (character: Character) => { const textarea = inputRef.current; if (!textarea) return; - const cursorPos = textarea.selectionStart; - const textBeforeCursor = value.slice(0, cursorPos); - const textAfterCursor = value.slice(cursorPos); - const lastAtIndex = textBeforeCursor.lastIndexOf("@"); + const currentValue = textarea.value || value; + const cursorPos = textarea.selectionStart ?? currentValue.length; + const textAfterCursor = currentValue.slice(cursorPos); + const activeTrigger = resolveActiveTrigger(currentValue, cursorPos); + if (!activeTrigger || activeTrigger.mode !== "mention") { + return; + } - // 替换 @ 和后面的查询文本为角色名 const newValue = - value.slice(0, lastAtIndex) + `@${character.name} ` + textAfterCursor; + currentValue.slice(0, activeTrigger.triggerIndex) + + `@${character.name} ` + + textAfterCursor; onChange(newValue); setShowMentions(false); - - // 通知父组件 onSelectCharacter?.(character); - // 恢复焦点并设置光标位置 setTimeout(() => { textarea.focus(); - const newCursorPos = lastAtIndex + character.name.length + 2; // @ + 名字 + 空格 + const newCursorPos = activeTrigger.triggerIndex + character.name.length + 2; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); }; - // 选择已安装技能 → 通知父组件,清除 @ 查询文本 const handleSelectInstalledSkill = (skill: Skill) => { const textarea = inputRef.current; if (!textarea) return; - const cursorPos = textarea.selectionStart; - const textBeforeCursor = value.slice(0, cursorPos); - const textAfterCursor = value.slice(cursorPos); - const lastAtIndex = textBeforeCursor.lastIndexOf("@"); + const currentValue = textarea.value || value; + const cursorPos = textarea.selectionStart ?? currentValue.length; + const textAfterCursor = currentValue.slice(cursorPos); + const activeTrigger = resolveActiveTrigger(currentValue, cursorPos); + if (!activeTrigger) { + return; + } + + if (activeTrigger.mode === "slash") { + const newValue = + currentValue.slice(0, activeTrigger.triggerIndex) + + `/${skill.key} ` + + textAfterCursor; + onChange(newValue); + setShowMentions(false); + + setTimeout(() => { + textarea.focus(); + const newCursorPos = activeTrigger.triggerIndex + skill.key.length + 2; + textarea.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + return; + } - // Inputbar 场景:由父组件接管 activeSkill(显示 SkillBadge) if (onSelectSkill) { - const newValue = value.slice(0, lastAtIndex) + textAfterCursor; + const newValue = + currentValue.slice(0, activeTrigger.triggerIndex) + textAfterCursor; onChange(newValue.trimEnd() === "" ? "" : newValue); setShowMentions(false); onSelectSkill(skill); setTimeout(() => { textarea.focus(); - const newCursorPos = Math.max(0, lastAtIndex); + const newCursorPos = Math.max(0, activeTrigger.triggerIndex); textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); return; } - // 通用场景(例如 EmptyState):直接回填为 /skillKey,保证可见且可发送 const newValue = - value.slice(0, lastAtIndex) + `/${skill.key} ` + textAfterCursor; + currentValue.slice(0, activeTrigger.triggerIndex) + + `/${skill.key} ` + + textAfterCursor; onChange(newValue); setShowMentions(false); setTimeout(() => { textarea.focus(); - const newCursorPos = lastAtIndex + skill.key.length + 2; + const newCursorPos = activeTrigger.triggerIndex + skill.key.length + 2; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); }; - // 选择未安装技能 → toast 提示 const handleSelectAvailableSkill = (skill: Skill) => { setShowMentions(false); @@ -283,38 +390,72 @@ export function CharacterMention({ const textarea = inputRef.current; if (!textarea) return; - const cursorPos = textarea.selectionStart; - const textBeforeCursor = value.slice(0, cursorPos); - const textAfterCursor = value.slice(cursorPos); - const lastAtIndex = textBeforeCursor.lastIndexOf("@"); + const currentValue = textarea.value || value; + const cursorPos = textarea.selectionStart ?? currentValue.length; + const textAfterCursor = currentValue.slice(cursorPos); + const activeTrigger = resolveActiveTrigger(currentValue, cursorPos); + if (!activeTrigger || activeTrigger.mode !== "mention") { + return; + } if (onSelectBuiltinCommand) { - const newValue = value.slice(0, lastAtIndex) + textAfterCursor; + const newValue = + currentValue.slice(0, activeTrigger.triggerIndex) + textAfterCursor; onChange(newValue.trimEnd() === "" ? "" : newValue); setShowMentions(false); onSelectBuiltinCommand(command); setTimeout(() => { textarea.focus(); - const newCursorPos = Math.max(0, lastAtIndex); + const newCursorPos = Math.max(0, activeTrigger.triggerIndex); textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); return; } const newValue = - value.slice(0, lastAtIndex) + `${command.commandPrefix} ` + textAfterCursor; + currentValue.slice(0, activeTrigger.triggerIndex) + + `${command.commandPrefix} ` + + textAfterCursor; onChange(newValue); setShowMentions(false); setTimeout(() => { textarea.focus(); - const newCursorPos = lastAtIndex + command.commandPrefix.length + 1; + const newCursorPos = + activeTrigger.triggerIndex + command.commandPrefix.length + 1; + textarea.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + }; + + const handleSelectSlashCommand = (command: CodexSlashCommandDefinition) => { + const textarea = inputRef.current; + if (!textarea) return; + + const currentValue = textarea.value || value; + const cursorPos = textarea.selectionStart ?? currentValue.length; + const textAfterCursor = currentValue.slice(cursorPos); + const activeTrigger = resolveActiveTrigger(currentValue, cursorPos); + if (!activeTrigger || activeTrigger.mode !== "slash") { + return; + } + + const newValue = + currentValue.slice(0, activeTrigger.triggerIndex) + + `${command.commandPrefix} ` + + textAfterCursor; + + onChange(newValue); + setShowMentions(false); + + setTimeout(() => { + textarea.focus(); + const newCursorPos = + activeTrigger.triggerIndex + command.commandPrefix.length + 1; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); }; - // 处理键盘事件:Escape 关闭,ArrowUp/ArrowDown/Enter 转发给 cmdk useEffect(() => { const textarea = inputRef.current; if (!textarea || !showMentions) return; @@ -334,7 +475,6 @@ export function CharacterMention({ return; } - // 转发 ArrowUp/ArrowDown/Enter 给 cmdk Command 根元素 if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter") { e.preventDefault(); e.stopPropagation(); @@ -357,67 +497,72 @@ export function CharacterMention({ if (!showMentions) return null; - return ( - - -
- - +
+
e.preventDefault()} > - {showMentions ? ( - - 加载中... -
+ + 加载中... +
+ } + > + { + setShowMentions(false); + onNavigateToSettings(); + } + : undefined } - > - { - setShowMentions(false); - onNavigateToSettings(); - } - : undefined - } - /> - - ) : null} -
- + /> + +
+ , + document.body, ); } diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx index 45c506b42..a0b1f8ebf 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ImagePlus, User, Zap } from "lucide-react"; +import { Command as CommandIcon, ImagePlus, User, Zap } from "lucide-react"; import { Command, CommandGroup, @@ -9,17 +9,21 @@ import { } from "@/components/ui/command"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; +import type { CodexSlashCommandDefinition } from "../../../commands"; import type { BuiltinInputCommand } from "./builtinCommands"; interface CharacterMentionPanelProps { + mode: "mention" | "slash"; mentionQuery: string; builtinCommands: BuiltinInputCommand[]; + slashCommands: CodexSlashCommandDefinition[]; filteredCharacters: Character[]; installedSkills: Skill[]; availableSkills: Skill[]; commandRef: React.RefObject; onQueryChange: (query: string) => void; onSelectBuiltinCommand: (command: BuiltinInputCommand) => void; + onSelectSlashCommand: (command: CodexSlashCommandDefinition) => void; onSelectCharacter: (character: Character) => void; onSelectInstalledSkill: (skill: Skill) => void; onSelectAvailableSkill: (skill: Skill) => void; @@ -27,36 +31,43 @@ interface CharacterMentionPanelProps { } export const CharacterMentionPanel: React.FC = ({ + mode, mentionQuery, builtinCommands, + slashCommands, filteredCharacters, installedSkills, availableSkills, commandRef, onQueryChange, onSelectBuiltinCommand, + onSelectSlashCommand, onSelectCharacter, onSelectInstalledSkill, onSelectAvailableSkill, onNavigateToSettings, }) => { + const visibleBuiltinCommands = mode === "mention" ? builtinCommands : []; + const visibleCharacters = mode === "mention" ? filteredCharacters : []; + const visibleSlashCommands = mode === "slash" ? slashCommands : []; const hasFilteredResults = - builtinCommands.length > 0 || - filteredCharacters.length > 0 || + visibleSlashCommands.length > 0 || + visibleBuiltinCommands.length > 0 || + visibleCharacters.length > 0 || installedSkills.length > 0 || availableSkills.length > 0; return ( {!hasFilteredResults ? (
-
暂无可用角色或技能
+
{mode === "slash" ? "暂无可用命令或技能" : "暂无可用角色或技能"}
{onNavigateToSettings ? (
) : null} - {builtinCommands.length > 0 ? ( + {visibleSlashCommands.length > 0 ? ( + + {visibleSlashCommands.map((command) => ( + onSelectSlashCommand(command)} + className="cursor-pointer" + > + +
+
+ {command.commandPrefix} + {command.support === "unsupported" ? ( + + 暂未支持 + + ) : null} +
+
+ {command.description} +
+
+
+ ))} +
+ ) : null} + {visibleBuiltinCommands.length > 0 ? ( - {builtinCommands.map((command) => ( + {visibleBuiltinCommands.map((command) => ( onSelectBuiltinCommand(command)} @@ -88,9 +125,9 @@ export const CharacterMentionPanel: React.FC = ({ ))} ) : null} - {filteredCharacters.length > 0 ? ( + {visibleCharacters.length > 0 ? ( - {filteredCharacters.map((character) => ( + {visibleCharacters.map((character) => ( onSelectCharacter(character)} @@ -154,4 +191,4 @@ export const CharacterMentionPanel: React.FC = ({
); -}; +} diff --git a/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx b/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx index 7009c22fd..6201f570c 100644 --- a/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx @@ -94,4 +94,62 @@ describe("QueuedTurnsPanel", () => { expect(onRemoveQueuedTurn).toHaveBeenCalledWith("queued-1"); }); + + it("队列操作进行中时,应展示明确 loading 状态", async () => { + let resolvePromote: (() => void) | null = null; + const onPromoteQueuedTurn = vi.fn( + () => + new Promise((resolve) => { + resolvePromote = () => resolve(true); + }), + ); + const container = renderQueuedTurnsPanel({ onPromoteQueuedTurn }); + + const promoteButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("立即执行")); + + expect(promoteButton).toBeTruthy(); + + act(() => { + promoteButton?.click(); + }); + + expect(promoteButton?.textContent).toContain("切换中"); + expect(promoteButton?.disabled).toBe(true); + + await act(async () => { + resolvePromote?.(); + await Promise.resolve(); + }); + }); + + it("移除进行中时,应更新无障碍标签并禁用按钮", async () => { + let resolveRemove: (() => void) | null = null; + const onRemoveQueuedTurn = vi.fn( + () => + new Promise((resolve) => { + resolveRemove = () => resolve(true); + }), + ); + const container = renderQueuedTurnsPanel({ onRemoveQueuedTurn }); + + const removeButton = container.querySelector( + 'button[aria-label="移除排队消息"]', + ); + + expect(removeButton).toBeTruthy(); + + act(() => { + removeButton?.click(); + }); + + expect(removeButton?.getAttribute("aria-label")).toBe("正在移除排队消息"); + expect(removeButton?.disabled).toBe(true); + + await act(async () => { + resolveRemove?.(); + await Promise.resolve(); + }); + }); }); diff --git a/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx b/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx index a0cb1472f..21c5a83f7 100644 --- a/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Play, X } from "lucide-react"; +import { Loader2, Play, X } from "lucide-react"; import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime"; interface QueuedTurnsPanelProps { @@ -82,6 +82,7 @@ export const QueuedTurnsPanel: React.FC = ({
diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index 9561fdca2..441212b3e 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -507,6 +507,71 @@ describe("MessageList", () => { (streamingNodes[1] as Node).compareDocumentPosition(timelineNodes[0] as Node) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); - expect(timelineNodes[0]?.previousElementSibling).toBe(streamingNodes[1]); + expect( + container.querySelector('[data-testid="agent-thread-reliability-panel"]'), + ).toBeNull(); + }); + + it("应不再在消息区渲染 reliability panel,避免占用对话列表空间", () => { + const messages: Message[] = [ + { + id: "msg-assistant-earlier", + role: "assistant", + content: "较早的中间反馈。", + timestamp: new Date("2026-03-15T09:00:05Z"), + }, + { + id: "msg-assistant-latest", + role: "assistant", + content: "最新回合的输出。", + timestamp: new Date("2026-03-15T09:00:20Z"), + }, + ]; + + const container = render(messages, { + currentTurnId: "turn-latest", + turns: [ + { + id: "turn-latest", + thread_id: "thread-1", + prompt_text: "继续执行发布", + status: "running", + started_at: "2026-03-15T09:00:00Z", + created_at: "2026-03-15T09:00:00Z", + updated_at: "2026-03-15T09:00:06Z", + }, + ], + threadItems: [ + { + id: "item-latest", + thread_id: "thread-1", + turn_id: "turn-latest", + sequence: 1, + status: "completed", + started_at: "2026-03-15T09:00:01Z", + completed_at: "2026-03-15T09:00:02Z", + updated_at: "2026-03-15T09:00:02Z", + type: "plan", + text: "继续执行当前任务", + }, + ], + pendingActions: [ + { + requestId: "req-1", + actionType: "ask_user", + prompt: "请确认是否继续发布", + status: "pending", + }, + ], + }); + + const timelineNodes = Array.from( + container.querySelectorAll('[data-testid="agent-thread-timeline"]'), + ); + + expect( + container.querySelector('[data-testid="agent-thread-reliability-panel"]'), + ).toBeNull(); + expect(timelineNodes).toHaveLength(1); }); }); diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index 28a1b0c34..b615c42ba 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -40,12 +40,17 @@ import { } from "../utils/internalImagePlaceholder"; import { Message, + type ActionRequired, type AgentThreadItem, type AgentThreadTurn, type WriteArtifactContext, } from "../types"; import type { A2UIFormData } from "@/components/content-creator/a2ui/types"; import type { ConfirmResponse } from "../types"; +import type { + AgentRuntimeThreadReadModel, + QueuedTurnSnapshot, +} from "@/lib/api/agentRuntime"; import { buildMessageTurnTimeline } from "../utils/threadTimelineView"; import { buildMessageTurnGroups } from "../utils/messageTurnGrouping"; import logoImg from "/logo.png"; @@ -55,6 +60,11 @@ interface MessageListProps { turns?: AgentThreadTurn[]; threadItems?: AgentThreadItem[]; currentTurnId?: string | null; + threadRead?: AgentRuntimeThreadReadModel | null; + pendingActions?: ActionRequired[]; + submittedActionsInFlight?: ActionRequired[]; + queuedTurns?: QueuedTurnSnapshot[]; + isSending?: boolean; assistantLabel?: string; onDeleteMessage?: (id: string) => void; onEditMessage?: (id: string, content: string) => void; @@ -88,6 +98,19 @@ interface MessageListProps { onCodeBlockClick?: (language: string, code: string) => void; /** 是否将待处理问答提升为输入区 A2UI 表单 */ promoteActionRequestsToA2UI?: boolean; + /** 中断当前执行 */ + onInterruptCurrentTurn?: () => void | Promise; + /** 恢复当前线程排队执行 */ + onResumeThread?: () => boolean | Promise; + /** 重新拉起当前最重要的待处理请求 */ + onReplayPendingRequest?: ( + requestId: string, + assistantMessageId: string, + ) => boolean | Promise; + /** 立即恢复下一条排队回合 */ + onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise; + /** 是否压缩左侧留白,适用于工作台右栏 */ + compactLeadingSpacing?: boolean; } const MessageListInner: React.FC = ({ @@ -95,6 +118,7 @@ const MessageListInner: React.FC = ({ turns = [], threadItems = [], currentTurnId = null, + threadRead = null, assistantLabel = "Lime", onDeleteMessage, onEditMessage, @@ -111,6 +135,7 @@ const MessageListInner: React.FC = ({ shouldCollapseCodeBlock, onCodeBlockClick, promoteActionRequestsToA2UI = false, + compactLeadingSpacing = false, }) => { const scrollRef = useRef(null); const containerRef = useRef(null); @@ -284,7 +309,11 @@ const MessageListInner: React.FC = ({ const showIdentity = options?.showIdentity ?? true; return ( - + {msg.role === "user" ? ( @@ -415,6 +444,7 @@ const MessageListInner: React.FC = ({ = ({ return ( -
+
{messageGroups.length === 0 && (
= ({ key={group.id} data-testid="message-turn-group" data-group-index={groupIndex + 1} - className="border-t border-slate-200/70 pl-3 pr-1 py-4 first:border-t-0 first:pt-0 dark:border-slate-800/70" + className={ + compactLeadingSpacing + ? "border-t border-slate-200/70 py-4 pl-0 pr-0 first:border-t-0 first:pt-0 dark:border-slate-800/70" + : "border-t border-slate-200/70 py-4 pl-2 pr-1 first:border-t-0 first:pt-0 dark:border-slate-800/70" + } >
- Team Workbench + 团队工作台 {runtimeTeamLabel ? ( {runtimeTeamLabel} diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx index 2679eb616..606fc0c48 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx @@ -756,10 +756,11 @@ describe("TeamWorkspaceBoard", () => { '[data-testid="team-workspace-board-body"]', ); - expect(embeddedShell?.className).toContain("bg-white"); expect(embeddedShell?.className).toContain("pointer-events-auto"); expect(embeddedShell?.className).toContain("overflow-hidden"); expect(embeddedShell?.className).toContain("flex-col"); + expect(embeddedShell?.className).toContain("bg-transparent"); + expect(embeddedShell?.className).toContain("border-0"); expect(embeddedShell?.className).not.toContain("backdrop-blur"); expect(boardBody?.className).toContain("overflow-y-auto"); expect(railList?.getAttribute("data-layout-kind")).toBe("free-canvas"); diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.tsx index 078374edb..a271a064a 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.tsx @@ -3211,10 +3211,10 @@ export function TeamWorkspaceBoard({ >
-
+
{TEAM_WORKSPACE_SURFACE_TITLE} - + {TEAM_WORKSPACE_REALTIME_BADGE_LABEL} @@ -3318,7 +3318,7 @@ export function TeamWorkspaceBoard({ ].filter(Boolean) as string[]; const boardShellClassName = cn( embedded - ? "pointer-events-auto flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-36px_rgba(15,23,42,0.18)]" + ? "pointer-events-auto flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-none border-0 bg-transparent shadow-none" : "overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-42px_rgba(15,23,42,0.24)]", embedded ? "mx-0 mt-0" : "mx-3 mt-2", className, @@ -3707,10 +3707,10 @@ export function TeamWorkspaceBoard({ data-testid={embedded ? "team-workspace-board-header" : undefined} >
-
+
{TEAM_WORKSPACE_SURFACE_TITLE} - + {TEAM_WORKSPACE_REALTIME_BADGE_LABEL} diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx index e9d30d07f..caf1cfb6a 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx @@ -611,6 +611,73 @@ describe("TeamWorkspaceDock", () => { expect(container.textContent).toContain("收起协作面板"); }); + it("旧会话初次挂载时即使已有真实成员,也不应自动展开", async () => { + const { container } = await renderDock({ + placement: "inline", + currentSessionId: "parent-history", + childSubagentSessions: [ + { + id: "child-history-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", + }, + ], + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeNull(); + expect(container.textContent).toContain("查看任务进展 · 1"); + }); + + it("已有真实成员的 Dock 切到 inline 时,不应因为布局变化自动展开", async () => { + const { container, render } = await renderDock({ + placement: "floating", + childSubagentSessions: [ + { + id: "child-layout-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: "executor", + }, + ], + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeNull(); + + await render({ + placement: "inline", + childSubagentSessions: [ + { + id: "child-layout-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: "executor", + }, + ], + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeNull(); + expect(container.textContent).toContain("查看任务进展 · 1"); + }); + it("本轮协作方案已就绪时,应在空态 Dock 展示成员摘要", async () => { const { container } = await renderDock({ runtimeTeamState: { diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.tsx index fa2c8aa0c..9834faac5 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.tsx @@ -320,7 +320,7 @@ const EmptyStateEyebrow = styled.div` display: flex; flex-wrap: wrap; align-items: center; - gap: 8px; + gap: 10px; color: #475569; font-size: 11px; font-weight: 700; @@ -527,14 +527,11 @@ export function TeamWorkspaceDock({ runtimeTeamState?.blueprint?.summary?.trim() || selectedTeamSummary?.trim() || null; - const [expanded, setExpanded] = useState( - () => placement === "inline" && hasRealTeamGraph, - ); + const [expanded, setExpanded] = useState(false); const [userDismissedAutoExpand, setUserDismissedAutoExpand] = useState(false); const hasInitializedRef = useRef(false); const previousSessionIdRef = useRef(currentSessionId ?? null); - const previousHasRealGraphRef = useRef(false); - const previousPlacementRef = useRef(placement); + const previousHasRealGraphRef = useRef(hasRealTeamGraph); const toggleRef = useRef(null); const [inlinePanelLayout, setInlinePanelLayout] = useState(null); @@ -678,12 +675,8 @@ export function TeamWorkspaceDock({ 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; } @@ -691,8 +684,8 @@ export function TeamWorkspaceDock({ if (previousSessionIdRef.current !== normalizedSessionId) { previousSessionIdRef.current = normalizedSessionId; previousHasRealGraphRef.current = hasRealTeamGraph; - previousPlacementRef.current = placement; setUserDismissedAutoExpand(false); + setExpanded(false); return; } @@ -701,19 +694,6 @@ export function TeamWorkspaceDock({ const graphCleared = previousHasRealGraphRef.current && !hasRealTeamGraph; - if ( - previousPlacementRef.current !== placement && - placement === "inline" && - hasRealTeamGraph && - !userDismissedAutoExpand - ) { - setExpanded(true); - previousSessionIdRef.current = normalizedSessionId; - previousHasRealGraphRef.current = hasRealTeamGraph; - previousPlacementRef.current = placement; - return; - } - if (graphCleared) { setUserDismissedAutoExpand(false); } @@ -724,8 +704,7 @@ export function TeamWorkspaceDock({ previousSessionIdRef.current = normalizedSessionId; previousHasRealGraphRef.current = hasRealTeamGraph; - previousPlacementRef.current = placement; - }, [currentSessionId, hasRealTeamGraph, placement, userDismissedAutoExpand]); + }, [currentSessionId, hasRealTeamGraph, userDismissedAutoExpand]); const updateInlinePanelLayout = useCallback(() => { if ( diff --git a/src/components/agent/chat/hooks/agentChatActionState.ts b/src/components/agent/chat/hooks/agentChatActionState.ts index c3f33b2dc..7946e936f 100644 --- a/src/components/agent/chat/hooks/agentChatActionState.ts +++ b/src/components/agent/chat/hooks/agentChatActionState.ts @@ -33,11 +33,9 @@ export const upsertAssistantActionRequest = ({ }); } - if (next.some((item) => item.requestId === actionData.requestId)) { - return next; - } - - return [...next, actionData]; + next = next.filter((item) => item.requestId !== actionData.requestId); + next.push(actionData); + return next; }); setMessages((prev) => @@ -66,12 +64,16 @@ export const upsertAssistantActionRequest = ({ ); } - if ( - nextRequests.some((item) => item.requestId === actionData.requestId) - ) { - return msg; - } - + nextRequests = nextRequests.filter( + (item) => item.requestId !== actionData.requestId, + ); + nextParts = nextParts.filter( + (part) => + !( + part.type === "action_required" && + part.actionRequired.requestId === actionData.requestId + ), + ); nextRequests.push(actionData); nextParts = appendActionRequiredToParts(nextParts, actionData); diff --git a/src/components/agent/chat/hooks/agentChatHistory.test.ts b/src/components/agent/chat/hooks/agentChatHistory.test.ts index 20ffda8f1..7b8740082 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.test.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.test.ts @@ -60,7 +60,9 @@ describe("agentChatHistory", () => { { role: "user", timestamp: 1710000000, - content: [{ type: "text", text: "请给我一版可直接使用的图片 Prompt" }], + content: [ + { type: "text", text: "请给我一版可直接使用的图片 Prompt" }, + ], }, { role: "assistant", @@ -75,11 +77,15 @@ describe("agentChatHistory", () => { }; const messages = hydrateSessionDetailMessages(detail, "session-1"); - const assistantMessage = messages.find((message) => message.role === "assistant"); + const assistantMessage = messages.find( + (message) => message.role === "assistant", + ); expect(assistantMessage).toBeDefined(); expect(assistantMessage?.content).toBe("下面是整理好的 Prompt。"); - expect(assistantMessage?.thinkingContent).toBe("先理解主题,再组织结构。\n"); + expect(assistantMessage?.thinkingContent).toBe( + "先理解主题,再组织结构。\n", + ); expect(assistantMessage?.contentParts).toEqual([ { type: "thinking", @@ -174,4 +180,28 @@ describe("agentChatHistory", () => { expect(mergedMessages[0]?.images).toEqual(localMessages[0]?.images); expect(mergedMessages[1]?.images).toBeUndefined(); }); + + it("后端暂未返回历史时应保留本地消息,避免刷新后界面空白", () => { + const localMessages = [ + { + id: "local-user-1", + role: "user" as const, + content: "继续刚才的任务", + timestamp: new Date("2026-03-19T00:00:00.000Z"), + }, + { + id: "local-assistant-1", + role: "assistant" as const, + content: "这是刚刚停止后的对话内容", + timestamp: new Date("2026-03-19T00:00:01.000Z"), + }, + ]; + + const mergedMessages = mergeHydratedMessagesWithLocalState( + localMessages, + [], + ); + + expect(mergedMessages).toEqual(localMessages); + }); }); diff --git a/src/components/agent/chat/hooks/agentChatHistory.ts b/src/components/agent/chat/hooks/agentChatHistory.ts index 1869095ad..d335a18d1 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.ts @@ -134,7 +134,9 @@ export const extractThinkingContentFromParts = ( return thinkingText || undefined; }; -export const mergeAdjacentAssistantMessages = (messages: Message[]): Message[] => { +export const mergeAdjacentAssistantMessages = ( + messages: Message[], +): Message[] => { const merged: Message[] = []; for (const current of messages) { @@ -269,11 +271,15 @@ export const mergeHydratedMessagesWithLocalState = ( localMessages: Message[], hydratedMessages: Message[], ): Message[] => { + if (hydratedMessages.length === 0) { + return localMessages; + } + const localUserMessages = localMessages.filter( (message) => message.role === "user", ); - if (localUserMessages.length === 0 || hydratedMessages.length === 0) { + if (localUserMessages.length === 0) { return hydratedMessages; } @@ -295,7 +301,11 @@ export const mergeHydratedMessagesWithLocalState = ( localUserCursor = matchedIndex + 1; const localMessage = localUserMessages[matchedIndex]; - if (!localMessage || hasMessageImages(message) || !hasMessageImages(localMessage)) { + if ( + !localMessage || + hasMessageImages(message) || + !hasMessageImages(localMessage) + ) { return message; } @@ -354,12 +364,16 @@ const messageContentPartsSignature = (parts?: ContentPart[]): string => { .join("|"); }; -const messageArtifactsSignature = (artifacts?: Message["artifacts"]): string => { +const messageArtifactsSignature = ( + artifacts?: Message["artifacts"], +): string => { if (!artifacts || artifacts.length === 0) return ""; return artifacts .map((artifact) => { const filePath = - typeof artifact.meta.filePath === "string" ? artifact.meta.filePath : ""; + typeof artifact.meta.filePath === "string" + ? artifact.meta.filePath + : ""; return [ artifact.id, artifact.type, @@ -383,7 +397,9 @@ const buildHistoryMessageSignature = (message: Message): string => { ].join("::"); }; -export const dedupeAdjacentHistoryMessages = (messages: Message[]): Message[] => { +export const dedupeAdjacentHistoryMessages = ( + messages: Message[], +): Message[] => { const deduped: Message[] = []; let previousSignature: string | null = null; let previousTimestampMs: number | null = null; @@ -520,17 +536,19 @@ export const hydrateSessionDetailMessages = ( if (partType === "tool_response") { if (!part.id || typeof part.id !== "string") continue; const toolName = resolveHistoryToolName(part.id, historyToolNameById); - const rawOutputText = typeof part.output === "string" ? part.output : ""; + const rawOutputText = + typeof part.output === "string" ? part.output : ""; const rawErrorText = typeof part.error === "string" ? part.error : ""; - const normalizedOutput = - extractLimeToolMetadataBlock(rawOutputText); - const normalizedError = - extractLimeToolMetadataBlock(rawErrorText); + const normalizedOutput = extractLimeToolMetadataBlock(rawOutputText); + const normalizedError = extractLimeToolMetadataBlock(rawErrorText); const normalizedResult = { success: part.success !== false, output: normalizedOutput.text, error: normalizedError.text || undefined, - images: normalizeToolResultImages(part.images, normalizedOutput.text), + images: normalizeToolResultImages( + part.images, + normalizedOutput.text, + ), metadata: normalizeToolResultMetadata( part.metadata, rawOutputText, diff --git a/src/components/agent/chat/hooks/agentRuntimeAdapter.ts b/src/components/agent/chat/hooks/agentRuntimeAdapter.ts index f6c8f477c..f51fe0311 100644 --- a/src/components/agent/chat/hooks/agentRuntimeAdapter.ts +++ b/src/components/agent/chat/hooks/agentRuntimeAdapter.ts @@ -1,13 +1,18 @@ import { safeListen } from "@/lib/dev-bridge"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { + compactAgentRuntimeSession, createAgentRuntimeSession, deleteAgentRuntimeSession, + type AgentRuntimeReplayedActionRequiredView, getAgentRuntimeSession, + getAgentRuntimeThreadRead, initAsterAgent, interruptAgentRuntimeTurn, promoteAgentRuntimeQueuedTurn, + replayAgentRuntimeRequest, removeAgentRuntimeQueuedTurn, + resumeAgentRuntimeThread, listAgentRuntimeSessions, respondAgentRuntimeAction, submitAgentRuntimeTurn, @@ -62,6 +67,11 @@ export interface AgentRuntimeAdapter { ): Promise; listSessions(): Promise; getSession(sessionId: string): Promise; + getSessionReadModel(sessionId: string): Promise; + replayRequest( + sessionId: string, + requestId: string, + ): Promise; renameSession(sessionId: string, title: string): Promise; deleteSession(sessionId: string): Promise; setSessionExecutionStrategy( @@ -69,7 +79,9 @@ export interface AgentRuntimeAdapter { executionStrategy: AsterExecutionStrategy, ): Promise; submitTurn(request: AgentRuntimeTurnRequest): Promise; + compactSession(sessionId: string, eventName: string): Promise; interruptTurn(sessionId: string): Promise; + resumeThread(sessionId: string): Promise; promoteQueuedTurn(sessionId: string, queuedTurnId: string): Promise; removeQueuedTurn(sessionId: string, queuedTurnId: string): Promise; respondToAction(request: AgentRuntimeActionResponse): Promise; @@ -96,6 +108,15 @@ export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = { async getSession(sessionId) { return getAgentRuntimeSession(sessionId); }, + async getSessionReadModel(sessionId) { + return getAgentRuntimeThreadRead(sessionId); + }, + async replayRequest(sessionId, requestId) { + return replayAgentRuntimeRequest({ + session_id: sessionId, + request_id: requestId, + }); + }, async renameSession(sessionId, title) { await updateAgentRuntimeSession({ session_id: sessionId, @@ -132,11 +153,22 @@ export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = { queued_turn_id: request.queuedTurnId, }); }, + async compactSession(sessionId, eventName) { + await compactAgentRuntimeSession({ + session_id: sessionId, + event_name: eventName, + }); + }, async interruptTurn(sessionId) { return interruptAgentRuntimeTurn({ session_id: sessionId, }); }, + async resumeThread(sessionId) { + return resumeAgentRuntimeThread({ + session_id: sessionId, + }); + }, async promoteQueuedTurn(sessionId, queuedTurnId) { return promoteAgentRuntimeQueuedTurn({ session_id: sessionId, diff --git a/src/components/agent/chat/hooks/useAgentSession.ts b/src/components/agent/chat/hooks/useAgentSession.ts index 22fed35ed..ba4ef5c51 100644 --- a/src/components/agent/chat/hooks/useAgentSession.ts +++ b/src/components/agent/chat/hooks/useAgentSession.ts @@ -11,6 +11,7 @@ import type { AsterExecutionStrategy, AsterSubagentParentContext, AsterSubagentSessionInfo, + AgentRuntimeThreadReadModel, AsterTodoItem, QueuedTurnSnapshot, } from "@/lib/api/agentRuntime"; @@ -149,6 +150,8 @@ export function useAgentSession(options: UseAgentSessionOptions) { ), ); const [queuedTurns, setQueuedTurns] = useState([]); + const [threadRead, setThreadRead] = + useState(null); const [todoItems, setTodoItems] = useState([]); const [childSubagentSessions, setChildSubagentSessions] = useState< AsterSubagentSessionInfo[] @@ -260,6 +263,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setThreadItems([]); setCurrentTurnId(null); setQueuedTurns([]); + setThreadRead(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -295,6 +299,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setThreadItems(scopedItems); setCurrentTurnId(scopedCurrentTurnId); setQueuedTurns([]); + setThreadRead(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -434,6 +439,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setThreadItems([]); setCurrentTurnId(null); setQueuedTurns([]); + setThreadRead(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -527,6 +533,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setThreadItems([]); setCurrentTurnId(null); setQueuedTurns([]); + setThreadRead(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -563,6 +570,17 @@ export function useAgentSession(options: UseAgentSessionOptions) { ); }, []); + const applyRuntimeReadModel = useCallback( + (detail: Awaited>) => { + setQueuedTurns(normalizeQueuedTurnSnapshots(detail.queued_turns)); + setThreadRead(detail.thread_read ?? null); + setTodoItems(detail.todo_items ?? []); + setChildSubagentSessions(detail.child_subagent_sessions ?? []); + setSubagentParentContext(detail.subagent_parent_context ?? null); + }, + [], + ); + const applySessionDetail = useCallback( ( topicId: string, @@ -589,10 +607,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setMessages(nextMessages); setThreadTurns(nextTurns); setThreadItems(nextItems); - setQueuedTurns(normalizeQueuedTurnSnapshots(detail.queued_turns)); - setTodoItems(detail.todo_items ?? []); - setChildSubagentSessions(detail.child_subagent_sessions ?? []); - setSubagentParentContext(detail.subagent_parent_context ?? null); + applyRuntimeReadModel(detail); setCurrentTurnId( nextTurns.length > 0 ? nextTurns[nextTurns.length - 1]?.id || null @@ -610,7 +625,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setSessionId(topicId); } }, - [sessionIdRef, setExecutionStrategyState, topics], + [applyRuntimeReadModel, sessionIdRef, setExecutionStrategyState, topics], ); const switchTopic = useCallback( @@ -671,6 +686,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setThreadItems([]); setCurrentTurnId(null); setQueuedTurns([]); + setThreadRead(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -685,6 +701,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setThreadItems([]); setCurrentTurnId(null); setQueuedTurns([]); + setThreadRead(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -734,6 +751,29 @@ export function useAgentSession(options: UseAgentSessionOptions) { [applySessionDetail, runtime, sessionIdRef], ); + const refreshSessionReadModel = useCallback( + async (targetSessionId?: string) => { + const resolvedSessionId = targetSessionId || sessionIdRef.current; + if (!resolvedSessionId?.trim()) { + return false; + } + + try { + const threadRead = await runtime.getSessionReadModel(resolvedSessionId); + if (sessionIdRef.current !== resolvedSessionId) { + return false; + } + setQueuedTurns(normalizeQueuedTurnSnapshots(threadRead?.queued_turns)); + setThreadRead(threadRead ?? null); + return true; + } catch (error) { + console.warn("[AsterChat] 刷新运行态摘要失败:", error); + return false; + } + }, + [runtime, sessionIdRef], + ); + useEffect(() => { const resolvedWorkspaceId = workspaceId?.trim(); if (!resolvedWorkspaceId) return; @@ -826,6 +866,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setThreadItems([]); setCurrentTurnId(null); setQueuedTurns([]); + setThreadRead(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -896,6 +937,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setThreadItems([]); setCurrentTurnId(null); setQueuedTurns([]); + setThreadRead(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -1027,6 +1069,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setThreadItems([]); setCurrentTurnId(null); setQueuedTurns([]); + setThreadRead(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -1158,6 +1201,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { childSubagentSessions, subagentParentContext, queuedTurns, + threadRead, setQueuedTurns, topics, setTopics, @@ -1169,6 +1213,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { deleteTopic, renameTopic, refreshSessionDetail, + refreshSessionReadModel, clearMessages, deleteMessage, editMessage, diff --git a/src/components/agent/chat/hooks/useAgentStream.ts b/src/components/agent/chat/hooks/useAgentStream.ts index c834fc5c7..46948e93f 100644 --- a/src/components/agent/chat/hooks/useAgentStream.ts +++ b/src/components/agent/chat/hooks/useAgentStream.ts @@ -110,6 +110,7 @@ interface UseAgentStreamOptions { queuedTurns: QueuedTurnSnapshot[]; setQueuedTurns: Dispatch>; setPendingActions: Dispatch>; + refreshSessionReadModel: (targetSessionId?: string) => Promise; } export function useAgentStream(options: UseAgentStreamOptions) { @@ -135,6 +136,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { queuedTurns, setQueuedTurns, setPendingActions, + refreshSessionReadModel, } = options; const [isSending, setIsSending] = useState(false); @@ -796,8 +798,12 @@ export function useAgentStream(options: UseAgentStreamOptions) { } setActiveStream(null); + if (activeSessionId) { + await refreshSessionReadModel(activeSessionId); + } toast.info("已停止生成"); }, [ + refreshSessionReadModel, runtime, sessionIdRef, setActiveStream, @@ -830,16 +836,138 @@ export function useAgentStream(options: UseAgentStreamOptions) { })), ); } + await refreshSessionReadModel(activeSessionId); return removed; } catch (error) { console.error("[AsterChat] 移除排队消息失败:", error); + await refreshSessionReadModel(activeSessionId); toast.error("移除排队消息失败"); return false; } }, - [runtime, sessionIdRef, setQueuedTurns], + [refreshSessionReadModel, runtime, sessionIdRef, setQueuedTurns], ); + const compactSession = useCallback(async () => { + const activeSessionId = sessionIdRef.current; + if (!activeSessionId) { + toast.error("当前没有可压缩的会话"); + return; + } + + if (activeStreamRef.current) { + toast.info("当前仍有任务执行中,稍后再压缩上下文"); + return; + } + + const eventName = `agent_context_compaction_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + let disposed = false; + let unlisten: (() => void) | undefined; + const disposeListener = () => { + if (disposed) { + return; + } + disposed = true; + if (unlisten) { + unlisten(); + } + listenerMapRef.current.delete(eventName); + }; + + setActiveStream({ + assistantMsgId: `context_compaction:${crypto.randomUUID()}`, + eventName, + sessionId: activeSessionId, + }); + + try { + unlisten = await runtime.listenToTurnEvents(eventName, (event) => { + const data = parseStreamEvent(event.payload); + if (!data) { + return; + } + + switch (data.type) { + case "turn_started": + setCurrentTurnId(data.turn.id); + setThreadTurns((prev) => upsertThreadTurnState(prev, data.turn)); + break; + case "item_started": + case "item_updated": + case "item_completed": + setThreadItems((prev) => upsertThreadItemState(prev, data.item)); + break; + case "turn_completed": + case "turn_failed": + setCurrentTurnId(data.turn.id); + setThreadTurns((prev) => upsertThreadTurnState(prev, data.turn)); + break; + case "warning": { + const warningKey = `${activeSessionId}:${data.code || data.message}`; + if (!warnedKeysRef.current.has(warningKey)) { + warnedKeysRef.current.add(warningKey); + toast.warning(data.message); + } + break; + } + case "error": + toast.error(`压缩上下文失败: ${data.message}`); + clearActiveStreamIfMatch(eventName); + disposeListener(); + break; + case "final_done": + clearActiveStreamIfMatch(eventName); + disposeListener(); + break; + default: + break; + } + }); + + listenerMapRef.current.set(eventName, unlisten); + await runtime.compactSession(activeSessionId, eventName); + } catch (error) { + console.error("[AsterChat] 压缩上下文失败:", error); + clearActiveStreamIfMatch(eventName); + disposeListener(); + toast.error( + error instanceof Error ? error.message : "压缩上下文失败,请稍后重试", + ); + } + }, [ + clearActiveStreamIfMatch, + runtime, + sessionIdRef, + setActiveStream, + setCurrentTurnId, + setThreadItems, + setThreadTurns, + warnedKeysRef, + ]); + + const resumeThread = useCallback(async () => { + const activeSessionId = sessionIdRef.current; + if (!activeSessionId) { + return false; + } + + try { + const resumed = await runtime.resumeThread(activeSessionId); + await refreshSessionReadModel(activeSessionId); + if (resumed) { + toast.info("正在恢复排队执行"); + } + return resumed; + } catch (error) { + console.error("[AsterChat] 恢复线程执行失败:", error); + await refreshSessionReadModel(activeSessionId); + toast.error("恢复线程执行失败"); + return false; + } + }, [refreshSessionReadModel, runtime, sessionIdRef]); + const promoteQueuedTurn = useCallback( async (queuedTurnId: string) => { const activeSessionId = sessionIdRef.current; @@ -861,9 +989,8 @@ export function useAgentStream(options: UseAgentStreamOptions) { activeSessionId, queuedTurnId, ); + await refreshSessionReadModel(activeSessionId); if (!promoted) { - const detail = await runtime.getSession(activeSessionId); - setQueuedTurns(detail.queued_turns ?? []); return false; } @@ -871,23 +998,20 @@ export function useAgentStream(options: UseAgentStreamOptions) { return true; } catch (error) { console.error("[AsterChat] 立即执行排队消息失败:", error); + await refreshSessionReadModel(activeSessionId); toast.error("立即执行排队消息失败"); - try { - const detail = await runtime.getSession(activeSessionId); - setQueuedTurns(detail.queued_turns ?? []); - } catch (refreshError) { - console.error("[AsterChat] 刷新排队状态失败:", refreshError); - } return false; } }, - [runtime, sessionIdRef, setQueuedTurns], + [refreshSessionReadModel, runtime, sessionIdRef, setQueuedTurns], ); return { isSending, sendMessage, + compactSession, stopSending, + resumeThread, promoteQueuedTurn, removeQueuedTurn, }; diff --git a/src/components/agent/chat/hooks/useAgentTools.ts b/src/components/agent/chat/hooks/useAgentTools.ts index 017b056bd..b049ca9e9 100644 --- a/src/components/agent/chat/hooks/useAgentTools.ts +++ b/src/components/agent/chat/hooks/useAgentTools.ts @@ -14,8 +14,12 @@ import type { ActionRequired, AgentThreadItem, } from "../types"; -import { resolveActionPromptKey } from "./agentChatCoreUtils"; +import { + normalizeActionQuestions, + resolveActionPromptKey, +} from "./agentChatCoreUtils"; import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter"; +import { upsertAssistantActionRequest } from "./agentChatActionState"; import { markThreadActionItemSubmitted } from "./agentThreadState"; import { buildActionRequestSubmissionContext } from "../utils/actionRequestA2UI"; import { buildActionResumeRuntimeStatus } from "../utils/agentRuntimeStatus"; @@ -28,6 +32,16 @@ interface UseAgentToolsOptions { messages: Message[]; setMessages: Dispatch>; setThreadItems: Dispatch>; + refreshSessionReadModel: (targetSessionId?: string) => Promise; +} + +function upsertSubmittedAction( + actions: ActionRequired[], + nextAction: ActionRequired, +): ActionRequired[] { + const next = actions.filter((item) => item.requestId !== nextAction.requestId); + next.push(nextAction); + return next; } export function useAgentTools(options: UseAgentToolsOptions) { @@ -39,9 +53,13 @@ export function useAgentTools(options: UseAgentToolsOptions) { messages, setMessages, setThreadItems, + refreshSessionReadModel, } = options; const [pendingActions, setPendingActions] = useState([]); + const [submittedActionsInFlight, setSubmittedActionsInFlight] = useState< + ActionRequired[] + >([]); const warnedKeysRef = useRef>(new Set()); const queuedFallbackResponsesRef = useRef< Map< @@ -54,6 +72,7 @@ export function useAgentTools(options: UseAgentToolsOptions) { const confirmAction = useCallback( async (response: ConfirmResponse) => { + const acknowledgedRequestIds = new Set([response.requestId]); try { const pendingAction = pendingActions.find( (item) => item.requestId === response.requestId, @@ -73,7 +92,7 @@ export function useAgentTools(options: UseAgentToolsOptions) { let submittedUserData: unknown = response.userData; let effectiveRequestId = response.requestId; let metadataAction = persistedAction; - const acknowledgedRequestIds = new Set([response.requestId]); + let refreshSessionId: string | null = null; if (actionType === "elicitation" || actionType === "ask_user") { const activeSessionId = @@ -81,6 +100,7 @@ export function useAgentTools(options: UseAgentToolsOptions) { if (!activeSessionId) { throw new Error("缺少会话 ID,无法提交 elicitation 响应"); } + refreshSessionId = activeSessionId; let userData: unknown; if (!response.confirmed) { @@ -175,6 +195,20 @@ export function useAgentTools(options: UseAgentToolsOptions) { } } + setSubmittedActionsInFlight((prev) => + upsertSubmittedAction(prev, { + ...(metadataAction || persistedAction || { + requestId: effectiveRequestId, + actionType, + }), + requestId: effectiveRequestId, + actionType, + status: "submitted", + submittedResponse: normalizedResponse || undefined, + submittedUserData, + }), + ); + const submissionContext = metadataAction ? buildActionRequestSubmissionContext(metadataAction, userData) : null; @@ -195,8 +229,22 @@ export function useAgentTools(options: UseAgentToolsOptions) { actionScope: metadataAction?.scope, }); } else { + refreshSessionId = sessionIdRef.current; + setSubmittedActionsInFlight((prev) => + upsertSubmittedAction(prev, { + ...(metadataAction || persistedAction || { + requestId: effectiveRequestId, + actionType, + }), + requestId: effectiveRequestId, + actionType, + status: "submitted", + submittedResponse: normalizedResponse || undefined, + submittedUserData, + }), + ); await runtime.respondToAction({ - sessionId: sessionIdRef.current || "", + sessionId: refreshSessionId || "", requestId: effectiveRequestId, actionType, confirmed: response.confirmed, @@ -263,7 +311,16 @@ export function useAgentTools(options: UseAgentToolsOptions) { submittedUserData, ), ); + if (refreshSessionId) { + await refreshSessionReadModel(refreshSessionId); + } + setSubmittedActionsInFlight((prev) => + prev.filter((item) => !acknowledgedRequestIds.has(item.requestId)), + ); } catch (error) { + setSubmittedActionsInFlight((prev) => + prev.filter((item) => !acknowledgedRequestIds.has(item.requestId)), + ); console.error("[AsterChat] 确认失败:", error); toast.error( error instanceof Error && error.message @@ -278,6 +335,7 @@ export function useAgentTools(options: UseAgentToolsOptions) { messages, pendingActions, runtime, + refreshSessionReadModel, sessionIdRef, setMessages, setThreadItems, @@ -322,11 +380,91 @@ export function useAgentTools(options: UseAgentToolsOptions) { [confirmAction], ); + const replayPendingAction = useCallback( + async (requestId: string, assistantMessageId: string) => { + const activeSessionId = sessionIdRef.current; + if (!activeSessionId) { + toast.error("当前没有激活会话,无法重新拉起请求"); + return false; + } + + try { + const replayedAction = await runtime.replayRequest( + activeSessionId, + requestId, + ); + + if (!replayedAction) { + await refreshSessionReadModel(activeSessionId); + toast.error("待处理请求已不存在,无法重新拉起"); + return false; + } + + const actionData: ActionRequired = { + requestId: replayedAction.request_id, + actionType: replayedAction.action_type, + toolName: replayedAction.tool_name, + arguments: + replayedAction.arguments && + typeof replayedAction.arguments === "object" && + !Array.isArray(replayedAction.arguments) + ? replayedAction.arguments + : undefined, + prompt: replayedAction.prompt, + questions: normalizeActionQuestions( + replayedAction.questions, + replayedAction.prompt, + ), + requestedSchema: replayedAction.requested_schema, + scope: replayedAction.scope + ? { + sessionId: replayedAction.scope.session_id, + threadId: replayedAction.scope.thread_id, + turnId: replayedAction.scope.turn_id, + } + : undefined, + status: "pending", + isFallback: false, + }; + + upsertAssistantActionRequest({ + assistantMsgId: assistantMessageId, + actionData, + replaceByPrompt: + actionData.actionType === "ask_user" || + actionData.actionType === "elicitation", + setPendingActions, + setMessages, + }); + setSubmittedActionsInFlight((prev) => + prev.filter( + (item) => + item.requestId !== requestId && + item.requestId !== actionData.requestId, + ), + ); + toast.success("已重新拉起待处理请求"); + return true; + } catch (error) { + console.error("[AsterChat] 重新拉起请求失败:", error); + toast.error( + error instanceof Error && error.message + ? error.message + : "重新拉起请求失败", + ); + return false; + } + }, + [refreshSessionReadModel, runtime, sessionIdRef, setMessages], + ); + return { pendingActions, + submittedActionsInFlight, setPendingActions, warnedKeysRef, confirmAction, handlePermissionResponse, + replayPendingAction, }; } diff --git a/src/components/agent/chat/hooks/useArtifactAutoPreviewSync.ts b/src/components/agent/chat/hooks/useArtifactAutoPreviewSync.ts index 10f74a00b..d7c1b6688 100644 --- a/src/components/agent/chat/hooks/useArtifactAutoPreviewSync.ts +++ b/src/components/agent/chat/hooks/useArtifactAutoPreviewSync.ts @@ -6,6 +6,7 @@ import type { } from "../types"; import { buildArtifactFromWrite, + resolveArtifactFilePath, resolveArtifactWritePhase, } from "../utils/messageArtifacts"; @@ -28,16 +29,6 @@ const EMPTY_COMPLETE_SYNC_TIMEOUT_MS = 8000; const PREVIEW_TEXT_MAX_CHARS = 480; const LATEST_CHUNK_MAX_CHARS = 240; -function resolveArtifactFilePath(artifact: Pick): string { - if (typeof artifact.meta.filePath === "string" && artifact.meta.filePath.trim()) { - return artifact.meta.filePath.trim(); - } - if (typeof artifact.meta.filename === "string" && artifact.meta.filename.trim()) { - return artifact.meta.filename.trim(); - } - return artifact.title; -} - function normalizePreviewText(value: string, maxChars: number): string { const trimmed = value.trim(); if (trimmed.length <= maxChars) { @@ -51,6 +42,10 @@ export function shouldAutoSyncArtifactPreview(artifact: Artifact | null): boolea return false; } + if (artifact.type === "browser_assist") { + return false; + } + const artifactPath = resolveArtifactFilePath(artifact); if (!artifactPath.trim()) { return false; diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index b6b4737c4..c9aa09f46 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -9,9 +9,14 @@ const { mockCreateAgentRuntimeSession, mockListAgentRuntimeSessions, mockGetAgentRuntimeSession, + mockGetAgentRuntimeThreadRead, mockUpdateAgentRuntimeSession, mockDeleteAgentRuntimeSession, + mockCompactAgentRuntimeSession, mockInterruptAgentRuntimeTurn, + mockResumeAgentRuntimeThread, + mockReplayAgentRuntimeRequest, + mockPromoteAgentRuntimeQueuedTurn, mockRemoveAgentRuntimeQueuedTurn, mockRespondAgentRuntimeAction, mockParseStreamEvent, @@ -26,9 +31,14 @@ const { mockCreateAgentRuntimeSession: vi.fn(), mockListAgentRuntimeSessions: vi.fn(), mockGetAgentRuntimeSession: vi.fn(), + mockGetAgentRuntimeThreadRead: vi.fn(), mockUpdateAgentRuntimeSession: vi.fn(), mockDeleteAgentRuntimeSession: vi.fn(), + mockCompactAgentRuntimeSession: vi.fn(), mockInterruptAgentRuntimeTurn: vi.fn(), + mockResumeAgentRuntimeThread: vi.fn(), + mockReplayAgentRuntimeRequest: vi.fn(), + mockPromoteAgentRuntimeQueuedTurn: vi.fn(), mockRemoveAgentRuntimeQueuedTurn: vi.fn(), mockRespondAgentRuntimeAction: vi.fn(), mockParseStreamEvent: vi.fn((payload: unknown) => payload), @@ -52,9 +62,14 @@ vi.mock("@/lib/api/agentRuntime", () => ({ createAgentRuntimeSession: mockCreateAgentRuntimeSession, listAgentRuntimeSessions: mockListAgentRuntimeSessions, getAgentRuntimeSession: mockGetAgentRuntimeSession, + getAgentRuntimeThreadRead: mockGetAgentRuntimeThreadRead, updateAgentRuntimeSession: mockUpdateAgentRuntimeSession, deleteAgentRuntimeSession: mockDeleteAgentRuntimeSession, + compactAgentRuntimeSession: mockCompactAgentRuntimeSession, interruptAgentRuntimeTurn: mockInterruptAgentRuntimeTurn, + resumeAgentRuntimeThread: mockResumeAgentRuntimeThread, + replayAgentRuntimeRequest: mockReplayAgentRuntimeRequest, + promoteAgentRuntimeQueuedTurn: mockPromoteAgentRuntimeQueuedTurn, removeAgentRuntimeQueuedTurn: mockRemoveAgentRuntimeQueuedTurn, respondAgentRuntimeAction: mockRespondAgentRuntimeAction, })); @@ -142,16 +157,31 @@ async function flushEffects() { } function captureTurnStream() { + return captureRuntimeStream((eventName) => { + return ( + typeof eventName === "string" && eventName.startsWith("aster_stream_") + ); + }); +} + +function captureContextCompactionStream() { + return captureRuntimeStream((eventName) => { + return ( + typeof eventName === "string" && + eventName.startsWith("agent_context_compaction_") + ); + }); +} + +function captureRuntimeStream(matcher: (eventName: unknown) => boolean) { let streamHandler: ((event: { payload: unknown }) => void) | null = null; let activeEventName: string | null = null; mockSafeListen.mockImplementation(async (eventName, handler) => { - if ( - typeof eventName === "string" && - eventName.startsWith("aster_stream_") - ) { + if (matcher(eventName)) { streamHandler = handler as (event: { payload: unknown }) => void; - activeEventName = eventName; + activeEventName = + typeof eventName === "string" ? eventName : String(eventName); return () => { if (streamHandler === handler) { streamHandler = null; @@ -204,9 +234,13 @@ beforeEach(() => { mockCreateAgentRuntimeSession.mockReset(); mockListAgentRuntimeSessions.mockReset(); mockGetAgentRuntimeSession.mockReset(); + mockGetAgentRuntimeThreadRead.mockReset(); mockUpdateAgentRuntimeSession.mockReset(); mockDeleteAgentRuntimeSession.mockReset(); + mockCompactAgentRuntimeSession.mockReset(); mockInterruptAgentRuntimeTurn.mockReset(); + mockReplayAgentRuntimeRequest.mockReset(); + mockPromoteAgentRuntimeQueuedTurn.mockReset(); mockRemoveAgentRuntimeQueuedTurn.mockReset(); mockRespondAgentRuntimeAction.mockReset(); mockParseStreamEvent.mockReset(); @@ -229,9 +263,13 @@ beforeEach(() => { id: "session-from-api", messages: [], }); + mockGetAgentRuntimeThreadRead.mockResolvedValue(undefined); mockUpdateAgentRuntimeSession.mockResolvedValue(undefined); mockDeleteAgentRuntimeSession.mockResolvedValue(undefined); + mockCompactAgentRuntimeSession.mockResolvedValue(undefined); mockInterruptAgentRuntimeTurn.mockResolvedValue(undefined); + mockReplayAgentRuntimeRequest.mockResolvedValue(null); + mockPromoteAgentRuntimeQueuedTurn.mockResolvedValue(true); mockRemoveAgentRuntimeQueuedTurn.mockResolvedValue(true); mockRespondAgentRuntimeAction.mockResolvedValue(undefined); mockParseStreamEvent.mockImplementation((payload: unknown) => payload); @@ -381,9 +419,9 @@ describe("useAsterAgentChat 首页新会话", () => { expect(harness.getValue().sessionId).toBe("session-live-missing"); expect(harness.getValue().messages.length).toBeGreaterThan(0); expect( - harness.getValue().topics.some( - (topic) => topic.id === "session-live-missing", - ), + harness + .getValue() + .topics.some((topic) => topic.id === "session-live-missing"), ).toBe(true); expect(mockGetAgentRuntimeSession).toHaveBeenCalledWith( "session-live-missing", @@ -455,7 +493,9 @@ describe("useAsterAgentChat 首页新会话", () => { expect(mockGetAgentRuntimeSession).toHaveBeenCalledWith(missingSessionId); expect(harness.getValue().sessionId).toBe(activeSessionId); expect( - harness.getValue().topics.some((topic) => topic.id === missingSessionId), + harness + .getValue() + .topics.some((topic) => topic.id === missingSessionId), ).toBe(false); expect(harness.getValue().messages).toHaveLength(0); } finally { @@ -465,6 +505,97 @@ describe("useAsterAgentChat 首页新会话", () => { }); describe("useAsterAgentChat 任务快照", () => { + it("停止后刷新会话详情暂未返回历史时,应保留右侧本地对话内容", async () => { + const workspaceId = "ws-task-stop-refresh-empty-history"; + const sessionId = "session-task-stop-refresh-empty-history"; + captureTurnStream(); + mockCreateAgentRuntimeSession.mockResolvedValue(sessionId); + mockListAgentRuntimeSessions.mockResolvedValue([]); + mockGetAgentRuntimeSession.mockResolvedValue({ + id: sessionId, + name: "当前任务", + created_at: 1700000300, + updated_at: 1700000301, + messages: [], + turns: [], + items: [], + queued_turns: [], + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + + await act(async () => { + await harness + .getValue() + .sendMessage( + "帮我继续整理这份任务", + [], + false, + false, + false, + "react", + ); + }); + + await flushEffects(); + + await act(async () => { + await harness.getValue().stopSending(); + }); + + await flushEffects(); + await flushEffects(); + + expect(mockGetAgentRuntimeSession).toHaveBeenCalledWith(sessionId); + expect(harness.getValue().messages).toHaveLength(2); + expect(harness.getValue().messages[0]?.content).toContain( + "帮我继续整理这份任务", + ); + expect(harness.getValue().messages[1]?.content).toBe("(已停止)"); + } finally { + harness.unmount(); + } + }); + + it("恢复态会话执行 stopSending 时也应刷新 thread_read", async () => { + const workspaceId = "ws-stop-refresh"; + const sessionId = "session-stop-refresh"; + seedSession(workspaceId, sessionId); + mockGetAgentRuntimeThreadRead.mockResolvedValueOnce({ + thread_id: "thread-stop-refresh", + status: "interrupted", + pending_requests: [], + incidents: [], + queued_turns: [], + interrupt_state: "interrupted", + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + + await act(async () => { + await harness.getValue().stopSending(); + }); + + expect(mockInterruptAgentRuntimeTurn).toHaveBeenCalledWith({ + session_id: sessionId, + }); + expect(mockGetAgentRuntimeThreadRead).toHaveBeenCalledWith(sessionId); + expect(harness.getValue().threadRead).toMatchObject({ + thread_id: "thread-stop-refresh", + status: "interrupted", + interrupt_state: "interrupted", + }); + } finally { + harness.unmount(); + } + }); + it("空会话快照稳定后不应继续自发重渲染", async () => { const workspaceId = "ws-task-stable"; const sessionId = "session-task-stable"; @@ -498,7 +629,9 @@ describe("useAsterAgentChat 任务快照", () => { await flushEffects(); await flushEffects(); - let topic = harness.getValue().topics.find((item) => item.id === sessionId); + let topic = harness + .getValue() + .topics.find((item) => item.id === sessionId); for (let attempt = 0; !topic && attempt < 3; attempt += 1) { await flushEffects(); topic = harness.getValue().topics.find((item) => item.id === sessionId); @@ -687,7 +820,9 @@ describe("useAsterAgentChat team 订阅", () => { expect(mockGetAgentRuntimeSession).toHaveBeenCalledTimes(1); const listener = listeners - .filter((item) => item.eventName === `agent_subagent_status:${sessionId}`) + .filter( + (item) => item.eventName === `agent_subagent_status:${sessionId}`, + ) .at(-1); expect(listener).toBeTruthy(); @@ -768,7 +903,9 @@ describe("useAsterAgentChat team 订阅", () => { expect(mockGetAgentRuntimeSession).toHaveBeenCalledTimes(1); const listener = listeners - .filter((item) => item.eventName === `agent_subagent_status:${sessionId}`) + .filter( + (item) => item.eventName === `agent_subagent_status:${sessionId}`, + ) .at(-1); expect(listener).toBeTruthy(); @@ -884,6 +1021,94 @@ describe("useAsterAgentChat.confirmAction", () => { harness.unmount(); } }); + + it("confirmAction 成功后应刷新当前会话详情以同步 thread_read", async () => { + const workspaceId = "ws-ask-user-refresh"; + seedSession(workspaceId, "session-ask-user-refresh"); + mockGetAgentRuntimeThreadRead.mockResolvedValueOnce({ + thread_id: "thread-ask-user-refresh", + status: "running", + pending_requests: [], + incidents: [], + queued_turns: [], + }); + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().confirmAction({ + requestId: "req-ask-user-refresh-1", + confirmed: true, + actionType: "ask_user", + response: '{"answer":"已确认"}', + }); + }); + + expect(mockGetAgentRuntimeThreadRead).toHaveBeenCalledTimes(1); + expect(mockGetAgentRuntimeThreadRead).toHaveBeenCalledWith( + "session-ask-user-refresh", + ); + expect(harness.getValue().threadRead).toMatchObject({ + thread_id: "thread-ask-user-refresh", + status: "running", + }); + } finally { + harness.unmount(); + } + }); + + it("confirmAction 等待 read-model 回填时,应暴露 submittedActionsInFlight", async () => { + const workspaceId = "ws-ask-user-submitting"; + seedSession(workspaceId, "session-ask-user-submitting"); + let resolveRefresh: (() => void) | null = null; + mockGetAgentRuntimeThreadRead.mockImplementation( + () => + new Promise((resolve) => { + resolveRefresh = () => + resolve({ + thread_id: "thread-ask-user-submitting", + status: "running", + pending_requests: [], + incidents: [], + queued_turns: [], + }); + }), + ); + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + + let submissionPromise: Promise; + act(() => { + submissionPromise = harness.getValue().confirmAction({ + requestId: "req-ask-user-submitting-1", + confirmed: true, + actionType: "ask_user", + response: '{"answer":"已确认"}', + }); + }); + + await flushEffects(); + expect(harness.getValue().submittedActionsInFlight).toMatchObject([ + { + requestId: "req-ask-user-submitting-1", + actionType: "ask_user", + status: "submitted", + }, + ]); + + await act(async () => { + resolveRefresh?.(); + await submissionPromise!; + }); + + expect(harness.getValue().submittedActionsInFlight).toEqual([]); + } finally { + harness.unmount(); + } + }); }); describe("useAsterAgentChat queue hydration", () => { @@ -935,6 +1160,355 @@ describe("useAsterAgentChat queue hydration", () => { harness.unmount(); } }); + + it("removeQueuedTurn 后应刷新 thread_read 与队列快照", async () => { + const sessionId = "session-queue-remove"; + const harness = mountHook("ws-queue-remove"); + let removed = false; + mockRemoveAgentRuntimeQueuedTurn.mockImplementation(async () => { + removed = true; + return true; + }); + mockGetAgentRuntimeSession.mockImplementation(async () => + removed + ? { + id: sessionId, + messages: [], + turns: [], + items: [], + queued_turns: [], + thread_read: { + thread_id: "thread-queue-remove", + status: "idle", + pending_requests: [], + incidents: [], + queued_turns: [], + }, + } + : { + id: sessionId, + messages: [], + turns: [], + items: [], + queued_turns: [ + { + queuedTurnId: "queued-1", + messagePreview: "继续生成周报", + messageText: "继续生成周报正文", + createdAt: 1700000000000, + imageCount: 0, + position: 1, + }, + ], + thread_read: { + thread_id: "thread-queue-remove", + status: "queued", + pending_requests: [], + incidents: [], + queued_turns: [ + { + queuedTurnId: "queued-1", + messagePreview: "继续生成周报", + messageText: "继续生成周报正文", + createdAt: 1700000000000, + imageCount: 0, + position: 1, + }, + ], + }, + }, + ); + mockGetAgentRuntimeThreadRead.mockImplementation(async () => + removed + ? { + thread_id: "thread-queue-remove", + status: "idle", + pending_requests: [], + incidents: [], + queued_turns: [], + } + : { + thread_id: "thread-queue-remove", + status: "queued", + pending_requests: [], + incidents: [], + queued_turns: [ + { + queuedTurnId: "queued-1", + messagePreview: "继续生成周报", + messageText: "继续生成周报正文", + createdAt: 1700000000000, + imageCount: 0, + position: 1, + }, + ], + }, + ); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().switchTopic(sessionId); + }); + await flushEffects(); + expect(harness.getValue().threadRead).toMatchObject({ + thread_id: "thread-queue-remove", + status: "queued", + }); + + await act(async () => { + await harness.getValue().removeQueuedTurn("queued-1"); + }); + await flushEffects(); + + expect(mockRemoveAgentRuntimeQueuedTurn).toHaveBeenCalledWith({ + session_id: sessionId, + queued_turn_id: "queued-1", + }); + expect(mockGetAgentRuntimeThreadRead).toHaveBeenCalledWith(sessionId); + expect(harness.getValue().queuedTurns).toEqual([]); + expect(harness.getValue().threadRead).toMatchObject({ + thread_id: "thread-queue-remove", + status: "idle", + }); + } finally { + harness.unmount(); + } + }); + + it("promoteQueuedTurn 后应刷新 thread_read 为最新运行态", async () => { + const sessionId = "session-queue-promote"; + const harness = mountHook("ws-queue-promote"); + let promoted = false; + mockPromoteAgentRuntimeQueuedTurn.mockImplementation(async () => { + promoted = true; + return true; + }); + mockGetAgentRuntimeSession.mockImplementation(async () => + promoted + ? { + id: sessionId, + messages: [], + turns: [], + items: [], + queued_turns: [], + thread_read: { + thread_id: "thread-queue-promote", + status: "running", + active_turn_id: "turn-running-1", + pending_requests: [], + incidents: [], + queued_turns: [], + }, + } + : { + id: sessionId, + messages: [], + turns: [], + items: [], + queued_turns: [ + { + queuedTurnId: "queued-1", + messagePreview: "继续执行排队任务", + messageText: "继续执行排队任务正文", + createdAt: 1700000000000, + imageCount: 0, + position: 1, + }, + ], + thread_read: { + thread_id: "thread-queue-promote", + status: "queued", + pending_requests: [], + incidents: [], + queued_turns: [ + { + queuedTurnId: "queued-1", + messagePreview: "继续执行排队任务", + messageText: "继续执行排队任务正文", + createdAt: 1700000000000, + imageCount: 0, + position: 1, + }, + ], + }, + }, + ); + mockGetAgentRuntimeThreadRead.mockImplementation(async () => + promoted + ? { + thread_id: "thread-queue-promote", + status: "running", + active_turn_id: "turn-running-1", + pending_requests: [], + incidents: [], + queued_turns: [], + } + : { + thread_id: "thread-queue-promote", + status: "queued", + pending_requests: [], + incidents: [], + queued_turns: [ + { + queuedTurnId: "queued-1", + messagePreview: "继续执行排队任务", + messageText: "继续执行排队任务正文", + createdAt: 1700000000000, + imageCount: 0, + position: 1, + }, + ], + }, + ); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().switchTopic(sessionId); + }); + await flushEffects(); + expect(harness.getValue().threadRead).toMatchObject({ + thread_id: "thread-queue-promote", + status: "queued", + }); + + await act(async () => { + await harness.getValue().promoteQueuedTurn("queued-1"); + }); + await flushEffects(); + + expect(mockPromoteAgentRuntimeQueuedTurn).toHaveBeenCalledWith({ + session_id: sessionId, + queued_turn_id: "queued-1", + }); + expect(mockGetAgentRuntimeThreadRead).toHaveBeenCalledWith(sessionId); + expect(harness.getValue().queuedTurns).toEqual([]); + expect(harness.getValue().threadRead).toMatchObject({ + thread_id: "thread-queue-promote", + status: "running", + active_turn_id: "turn-running-1", + }); + } finally { + harness.unmount(); + } + }); + + it("resumeThread 后应刷新 thread_read 为最新运行态", async () => { + const sessionId = "session-thread-resume"; + const harness = mountHook("ws-thread-resume"); + let resumed = false; + mockResumeAgentRuntimeThread.mockImplementation(async () => { + resumed = true; + return true; + }); + mockGetAgentRuntimeSession.mockImplementation(async () => + resumed + ? { + id: sessionId, + messages: [], + turns: [], + items: [], + queued_turns: [], + thread_read: { + thread_id: "thread-thread-resume", + status: "running", + active_turn_id: "turn-running-1", + pending_requests: [], + incidents: [], + queued_turns: [], + }, + } + : { + id: sessionId, + messages: [], + turns: [], + items: [], + queued_turns: [ + { + queuedTurnId: "queued-1", + messagePreview: "继续执行排队任务", + messageText: "继续执行排队任务正文", + createdAt: 1700000000000, + imageCount: 0, + position: 1, + }, + ], + thread_read: { + thread_id: "thread-thread-resume", + status: "queued", + pending_requests: [], + incidents: [], + queued_turns: [ + { + queuedTurnId: "queued-1", + messagePreview: "继续执行排队任务", + messageText: "继续执行排队任务正文", + createdAt: 1700000000000, + imageCount: 0, + position: 1, + }, + ], + }, + }, + ); + mockGetAgentRuntimeThreadRead.mockImplementation(async () => + resumed + ? { + thread_id: "thread-thread-resume", + status: "running", + active_turn_id: "turn-running-1", + pending_requests: [], + incidents: [], + queued_turns: [], + } + : { + thread_id: "thread-thread-resume", + status: "queued", + pending_requests: [], + incidents: [], + queued_turns: [ + { + queuedTurnId: "queued-1", + messagePreview: "继续执行排队任务", + messageText: "继续执行排队任务正文", + createdAt: 1700000000000, + imageCount: 0, + position: 1, + }, + ], + }, + ); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().switchTopic(sessionId); + }); + await flushEffects(); + expect(harness.getValue().threadRead).toMatchObject({ + thread_id: "thread-thread-resume", + status: "queued", + }); + + await act(async () => { + await harness.getValue().resumeThread(); + }); + await flushEffects(); + + expect(mockResumeAgentRuntimeThread).toHaveBeenCalledWith({ + session_id: sessionId, + }); + expect(mockGetAgentRuntimeThreadRead).toHaveBeenCalledWith(sessionId); + expect(harness.getValue().threadRead).toMatchObject({ + thread_id: "thread-thread-resume", + status: "running", + active_turn_id: "turn-running-1", + }); + } finally { + harness.unmount(); + } + }); }); describe("useAsterAgentChat thread timeline", () => { @@ -1000,7 +1574,9 @@ describe("useAsterAgentChat thread timeline", () => { it("submitTurn 失败时应保留失败回合与失败消息,而不是清空当前过程", async () => { const workspaceId = "ws-thread-submit-failed"; seedSession(workspaceId, "session-thread-submit-failed"); - mockSubmitAgentRuntimeTurn.mockRejectedValueOnce(new Error("429 rate limit")); + mockSubmitAgentRuntimeTurn.mockRejectedValueOnce( + new Error("429 rate limit"), + ); const harness = mountHook(workspaceId); try { @@ -1019,7 +1595,7 @@ describe("useAsterAgentChat thread timeline", () => { expect(assistantMessage?.content).toContain("执行失败:429 rate limit"); expect(assistantMessage?.runtimeStatus).toMatchObject({ phase: "failed", - title: "当前执行失败", + title: "当前处理失败", }); expect(harness.getValue().turns).toEqual([ expect.objectContaining({ @@ -1033,7 +1609,9 @@ describe("useAsterAgentChat thread timeline", () => { status: "failed", }), ]); - expect(mockToast.warning).toHaveBeenCalledWith("请求过于频繁,请稍后重试"); + expect(mockToast.warning).toHaveBeenCalledWith( + "请求过于频繁,请稍后重试", + ); } finally { harness.unmount(); } @@ -1166,7 +1744,7 @@ describe("useAsterAgentChat thread timeline", () => { expect(assistantMessage?.content).toContain("执行失败:模型执行失败"); expect(assistantMessage?.runtimeStatus).toMatchObject({ phase: "failed", - title: "当前执行失败", + title: "当前处理失败", }); expect(harness.getValue().turns).toEqual([ expect.objectContaining({ @@ -1188,6 +1766,123 @@ describe("useAsterAgentChat thread timeline", () => { harness.unmount(); } }); + + it("手动压缩上下文时即使没有 assistant 正文也应完成时间线更新", async () => { + const workspaceId = "ws-context-compaction"; + seedSession(workspaceId, "session-context-compaction"); + const harness = mountHook(workspaceId); + const stream = captureContextCompactionStream(); + + try { + await flushEffects(); + + await act(async () => { + await harness.getValue().compactSession(); + }); + + expect(mockCompactAgentRuntimeSession).toHaveBeenCalledWith({ + session_id: "session-context-compaction", + event_name: stream.getEventName(), + }); + + act(() => { + stream.emit({ + type: "turn_started", + turn: { + id: "turn-compact-1", + thread_id: "session-context-compaction", + prompt_text: "压缩上下文", + status: "running", + started_at: "2026-03-23T09:00:00.000Z", + created_at: "2026-03-23T09:00:00.000Z", + updated_at: "2026-03-23T09:00:00.000Z", + }, + }); + stream.emit({ + type: "item_started", + item: { + id: "compact-1", + thread_id: "session-context-compaction", + turn_id: "turn-compact-1", + sequence: 1, + status: "in_progress", + started_at: "2026-03-23T09:00:01.000Z", + updated_at: "2026-03-23T09:00:01.000Z", + type: "context_compaction", + stage: "started", + trigger: "manual", + detail: "压缩当前会话上下文", + }, + }); + stream.emit({ + type: "item_completed", + item: { + id: "compact-1", + thread_id: "session-context-compaction", + turn_id: "turn-compact-1", + sequence: 1, + status: "completed", + started_at: "2026-03-23T09:00:01.000Z", + completed_at: "2026-03-23T09:00:03.000Z", + updated_at: "2026-03-23T09:00:03.000Z", + type: "context_compaction", + stage: "completed", + trigger: "manual", + detail: "已生成摘要并替换旧上下文", + }, + }); + stream.emit({ + type: "turn_completed", + turn: { + id: "turn-compact-1", + thread_id: "session-context-compaction", + prompt_text: "压缩上下文", + status: "completed", + started_at: "2026-03-23T09:00:00.000Z", + completed_at: "2026-03-23T09:00:04.000Z", + created_at: "2026-03-23T09:00:00.000Z", + updated_at: "2026-03-23T09:00:04.000Z", + }, + }); + stream.emit({ + type: "warning", + code: "context_compaction_accuracy", + message: + "长对话和多次上下文压缩会降低模型准确性;如果后续结果开始漂移,建议新开会话。", + }); + stream.emit({ + type: "final_done", + }); + }); + + expect(harness.getValue().isSending).toBe(false); + expect(harness.getValue().currentTurnId).toBe("turn-compact-1"); + expect(harness.getValue().turns).toEqual([ + expect.objectContaining({ + id: "turn-compact-1", + status: "completed", + }), + ]); + expect(harness.getValue().threadItems).toEqual([ + expect.objectContaining({ + id: "compact-1", + type: "context_compaction", + status: "completed", + stage: "completed", + trigger: "manual", + detail: "已生成摘要并替换旧上下文", + }), + ]); + expect(mockToast.warning).toHaveBeenCalledWith( + "长对话和多次上下文压缩会降低模型准确性;如果后续结果开始漂移,建议新开会话。", + ); + expect(mockToast.error).not.toHaveBeenCalledWith( + expect.stringContaining("压缩上下文失败"), + ); + } finally { + harness.unmount(); + } + }); }); describe("useAsterAgentChat runtime routing", () => { @@ -1531,6 +2226,141 @@ describe("useAsterAgentChat slash skill 执行链路", () => { harness.unmount(); } }); + + it("命中 /compact 时应走本地压缩分支而非 chat_stream", async () => { + const workspaceId = "ws-slash-compact"; + seedSession(workspaceId, "session-slash-compact"); + const harness = mountHook(workspaceId); + const stream = captureContextCompactionStream(); + + try { + await flushEffects(); + await act(async () => { + await harness + .getValue() + .sendMessage("/compact", [], false, false, false, "react"); + }); + + expect(mockCompactAgentRuntimeSession).toHaveBeenCalledWith({ + session_id: "session-slash-compact", + event_name: stream.getEventName(), + }); + expect(mockSubmitAgentRuntimeTurn).not.toHaveBeenCalled(); + expect(mockParseSkillSlashCommand).not.toHaveBeenCalled(); + } finally { + harness.unmount(); + } + }); + + it("命中 /clear 时应清空当前任务且不发送 chat_stream", async () => { + const workspaceId = "ws-slash-clear"; + seedSession(workspaceId, "session-slash-clear"); + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + expect(harness.getValue().messages).toHaveLength(1); + + await act(async () => { + await harness + .getValue() + .sendMessage("/clear", [], false, false, false, "react"); + }); + + expect(mockSubmitAgentRuntimeTurn).not.toHaveBeenCalled(); + expect(harness.getValue().messages).toEqual([]); + expect(harness.getValue().sessionId).toBeNull(); + expect(mockToast.success).toHaveBeenCalledWith("已清空当前任务"); + } finally { + harness.unmount(); + } + }); + + it("命中 /new 标题 时应创建新任务且不发送 chat_stream", async () => { + const workspaceId = "ws-slash-new"; + const harness = mountHook(workspaceId); + + mockCreateAgentRuntimeSession.mockResolvedValue("session-slash-new"); + + try { + await flushEffects(); + await act(async () => { + await harness + .getValue() + .sendMessage("/new 重构输入命令", [], false, false, false, "react"); + }); + + expect(mockCreateAgentRuntimeSession).toHaveBeenCalledWith( + workspaceId, + "重构输入命令", + "react", + ); + expect(mockSubmitAgentRuntimeTurn).not.toHaveBeenCalled(); + expect(harness.getValue().sessionId).toBe("session-slash-new"); + expect(mockToast.success).toHaveBeenCalledWith("已创建新任务:重构输入命令"); + } finally { + harness.unmount(); + } + }); + + it("命中 /review 时应转换为预置 prompt 后走 chat_stream", async () => { + const workspaceId = "ws-slash-review"; + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await act(async () => { + await harness + .getValue() + .sendMessage("/review src-tauri", [], false, false, false, "react"); + }); + + expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1); + expect(mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + message: expect.stringContaining("请对以下对象进行代码审查"), + }), + ); + expect(mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + message: expect.stringContaining("src-tauri"), + }), + ); + expect(mockParseSkillSlashCommand).toHaveBeenCalledWith( + expect.stringContaining("请对以下对象进行代码审查"), + ); + } finally { + harness.unmount(); + } + }); + + it("命中 /status 时应追加本地 assistant 状态消息", async () => { + const workspaceId = "ws-slash-status"; + seedSession(workspaceId, "session-slash-status"); + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await act(async () => { + await harness + .getValue() + .sendMessage("/status", [], false, false, false, "react"); + }); + + const latestMessage = + harness.getValue().messages[harness.getValue().messages.length - 1]; + expect(latestMessage).toEqual( + expect.objectContaining({ + role: "assistant", + content: expect.stringContaining("当前会话状态:"), + }), + ); + expect(latestMessage?.content).toContain("session-slash-status"); + expect(mockSubmitAgentRuntimeTurn).not.toHaveBeenCalled(); + } finally { + harness.unmount(); + } + }); }); describe("useAsterAgentChat action_required 渲染链路", () => { @@ -1896,7 +2726,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { }); expect(assistantMessage?.runtimeStatus).toMatchObject({ phase: "routing", - title: "已提交补充信息,继续执行中", + title: "已收到补充信息,继续处理中", }); expect( assistantMessage?.contentParts?.some( @@ -1911,6 +2741,125 @@ describe("useAsterAgentChat action_required 渲染链路", () => { } }); + it("replayPendingAction 应调用 replay request 命令并恢复 pendingActions", async () => { + const workspaceId = "ws-replay-action-required"; + seedSession(workspaceId, "session-replay-action-required"); + const harness = mountHook(workspaceId); + const stream = captureTurnStream(); + + try { + await flushEffects(); + + await act(async () => { + await harness + .getValue() + .sendMessage("请继续", [], false, false, false, "react"); + }); + + act(() => { + stream.emit({ + type: "action_required", + request_id: "req-replay-1", + action_type: "ask_user", + prompt: "请选择执行模式", + questions: [ + { + question: "请选择执行模式", + options: ["自动执行", "确认后执行"], + }, + ], + }); + }); + + await act(async () => { + await harness.getValue().confirmAction({ + requestId: "req-replay-1", + confirmed: true, + actionType: "ask_user", + response: '{"answer":"自动执行"}', + }); + }); + + let assistantMessage = [...harness.getValue().messages] + .reverse() + .find((msg) => msg.role === "assistant"); + + expect(assistantMessage?.actionRequests?.[0]).toMatchObject({ + requestId: "req-replay-1", + status: "submitted", + }); + + mockReplayAgentRuntimeRequest.mockResolvedValueOnce({ + type: "action_required", + request_id: "req-replay-1", + action_type: "ask_user", + prompt: "请选择执行模式", + questions: [ + { + question: "请选择执行模式", + options: ["自动执行", "确认后执行"], + }, + ], + scope: { + session_id: "session-replay-action-required", + thread_id: "thread-replay-action-required", + turn_id: "turn-replay-action-required", + }, + }); + + await act(async () => { + await expect( + harness + .getValue() + .replayPendingAction("req-replay-1", assistantMessage?.id || ""), + ).resolves.toBe(true); + }); + + assistantMessage = [...harness.getValue().messages] + .reverse() + .find((msg) => msg.role === "assistant"); + + expect(mockReplayAgentRuntimeRequest).toHaveBeenCalledWith({ + session_id: "session-replay-action-required", + request_id: "req-replay-1", + }); + expect(harness.getValue().pendingActions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestId: "req-replay-1", + actionType: "ask_user", + status: "pending", + scope: { + sessionId: "session-replay-action-required", + threadId: "thread-replay-action-required", + turnId: "turn-replay-action-required", + }, + }), + ]), + ); + expect(assistantMessage?.actionRequests).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestId: "req-replay-1", + actionType: "ask_user", + status: "pending", + }), + ]), + ); + expect( + assistantMessage?.contentParts?.some( + (part) => + part.type === "action_required" && + part.actionRequired.requestId === "req-replay-1" && + part.actionRequired.status === "pending", + ), + ).toBe(true); + expect(mockToast.success).toHaveBeenCalledWith("已重新拉起待处理请求"); + } finally { + harness.unmount(); + } + }); + it("fallback ask 在真实 request_id 未就绪前应先记录答案,并在真实 request_id 到达后自动提交", async () => { const workspaceId = "ws-ask-fallback-pending"; seedSession(workspaceId, "session-ask-fallback-pending"); diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index 75d0d398b..d11281a6b 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -6,9 +6,14 @@ */ import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; import { parseStreamEvent } from "@/lib/api/agentStream"; import { logAgentDebug } from "@/lib/agentDebug"; +import { + executeCodexSlashCommand, + parseCodexSlashCommand, +} from "../commands"; import { defaultAgentRuntimeAdapter, type AgentRuntimeAdapter, @@ -94,6 +99,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { messages: session.messages, setMessages: session.setMessages, setThreadItems: session.setThreadItems, + refreshSessionReadModel: session.refreshSessionReadModel, }); resetPendingActionsRef.current = () => tools.setPendingActions([]); @@ -120,9 +126,118 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { queuedTurns: session.queuedTurns, setQueuedTurns: session.setQueuedTurns, setPendingActions: tools.setPendingActions, + refreshSessionReadModel: session.refreshSessionReadModel, }); + const setChatMessages = session.setMessages; + const clearChatMessages = session.clearMessages; + const createFreshSession = session.createFreshSession; + const currentTurnId = session.currentTurnId; + const activeSessionId = session.sessionId; + const queuedTurnsCount = session.queuedTurns.length; + const rawSendMessage = stream.sendMessage; + const compactCurrentSession = stream.compactSession; + const isStreamSending = stream.isSending; - sendMessageRef.current = stream.sendMessage; + const appendLocalAssistantMessage = useCallback( + (content: string) => { + setChatMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + role: "assistant", + content, + timestamp: new Date(), + }, + ]); + }, + [setChatMessages], + ); + + const sendMessage = useCallback( + async ( + content, + images, + webSearch, + thinking, + skipUserMessage, + executionStrategyOverride, + modelOverride, + autoContinue, + sendOptions, + ) => { + if (!skipUserMessage) { + const parsedCodexCommand = parseCodexSlashCommand(content); + if (parsedCodexCommand) { + const effectiveModel = + modelOverride?.trim() || context.modelRef.current; + const effectiveExecutionStrategy = + executionStrategyOverride || context.executionStrategy; + const handled = await executeCodexSlashCommand({ + command: parsedCodexCommand, + statusSnapshot: { + sessionId: activeSessionId, + currentTurnId, + providerType: context.providerTypeRef.current, + model: effectiveModel, + executionStrategy: effectiveExecutionStrategy, + queuedTurnsCount, + isSending: isStreamSending, + }, + sendPrompt: async (prompt) => { + await rawSendMessage( + prompt, + images, + webSearch, + thinking, + skipUserMessage, + executionStrategyOverride, + modelOverride, + autoContinue, + sendOptions, + ); + }, + compactSession: compactCurrentSession, + clearMessages: clearChatMessages, + createFreshSession, + appendAssistantMessage: appendLocalAssistantMessage, + notifyInfo: (message) => toast.info(message), + notifySuccess: (message) => toast.success(message), + }); + if (handled) { + return; + } + } + } + + await rawSendMessage( + content, + images, + webSearch, + thinking, + skipUserMessage, + executionStrategyOverride, + modelOverride, + autoContinue, + sendOptions, + ); + }, + [ + appendLocalAssistantMessage, + activeSessionId, + clearChatMessages, + compactCurrentSession, + context.executionStrategy, + context.modelRef, + context.providerTypeRef, + createFreshSession, + currentTurnId, + isStreamSending, + queuedTurnsCount, + rawSendMessage, + ], + ); + + sendMessageRef.current = sendMessage; topicsUpdaterRef.current = session.updateTopicExecutionStrategy; const hasActiveTopic = Boolean( @@ -406,9 +521,13 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { childSubagentSessions: session.childSubagentSessions, subagentParentContext: session.subagentParentContext, queuedTurns: session.queuedTurns, + threadRead: session.threadRead, isSending: stream.isSending, - sendMessage: stream.sendMessage, + sendMessage, + compactSession: stream.compactSession, stopSending: stream.stopSending, + resumeThread: stream.resumeThread, + replayPendingAction: tools.replayPendingAction, promoteQueuedTurn: stream.promoteQueuedTurn, removeQueuedTurn: stream.removeQueuedTurn, clearMessages: session.clearMessages, @@ -428,6 +547,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { updateTopicSnapshot: session.updateTopicSnapshot, pendingActions: tools.pendingActions, + submittedActionsInFlight: tools.submittedActionsInFlight, confirmAction: tools.confirmAction, workspacePathMissing: context.workspacePathMissing, diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index b09b654fb..00e9e5e81 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -42,6 +42,11 @@ const { mockSkillExecutionGetDetail, mockSkillsGetAll, mockSkillsGetLocal, + mockCloseAgentRuntimeSubagent, + mockGetAgentRuntimeToolInventory, + mockResumeAgentRuntimeSubagent, + mockSendAgentRuntimeSubagentInput, + mockWaitAgentRuntimeSubagents, mockCanvasWorkbenchLayoutState, mockCanvasWorkbenchLayout, mockLaunchBrowserSession, @@ -101,6 +106,11 @@ const { mockSkillExecutionGetDetail: vi.fn(), mockSkillsGetAll: vi.fn(), mockSkillsGetLocal: vi.fn(), + mockCloseAgentRuntimeSubagent: vi.fn(), + mockGetAgentRuntimeToolInventory: vi.fn(), + mockResumeAgentRuntimeSubagent: vi.fn(), + mockSendAgentRuntimeSubagentInput: vi.fn(), + mockWaitAgentRuntimeSubagents: vi.fn(), mockCanvasWorkbenchLayoutState: { renderPreview: false, }, @@ -630,6 +640,21 @@ vi.mock("@/lib/webview-api", () => ({ browserExecuteAction: mockBrowserExecuteAction, })); +vi.mock("@/lib/api/agentRuntime", async () => { + const actual = await vi.importActual( + "@/lib/api/agentRuntime", + ); + + return { + ...actual, + closeAgentRuntimeSubagent: mockCloseAgentRuntimeSubagent, + getAgentRuntimeToolInventory: mockGetAgentRuntimeToolInventory, + resumeAgentRuntimeSubagent: mockResumeAgentRuntimeSubagent, + sendAgentRuntimeSubagentInput: mockSendAgentRuntimeSubagentInput, + waitAgentRuntimeSubagents: mockWaitAgentRuntimeSubagents, + }; +}); + import * as configuredProvidersModule from "@/hooks/useConfiguredProviders"; import * as providerModelsModule from "@/hooks/useProviderModels"; import { AgentChatPage } from "./index"; @@ -653,6 +678,7 @@ type MockInputbarSendProps = { textOverride?: string, executionStrategy?: "react" | "code_orchestrated" | "auto", ) => void | Promise | Promise | boolean; + onStop?: () => void | Promise; }; const mountedRoots: MountedHarness[] = []; @@ -989,6 +1015,52 @@ beforeEach(async () => { }); mockSkillsGetAll.mockResolvedValue([]); mockSkillsGetLocal.mockResolvedValue([]); + mockCloseAgentRuntimeSubagent.mockResolvedValue({ + previous_status: { + session_id: "child-session-1", + kind: "running", + }, + cascade_session_ids: [], + changed_session_ids: ["child-session-1"], + }); + mockGetAgentRuntimeToolInventory.mockResolvedValue({ + surface: { + creator: false, + browser_assist: false, + }, + catalog: [], + registry: [], + extensions: { + surface_entries: [], + tool_entries: [], + }, + mcp_tools: [], + counts: { + catalog_total: 0, + catalog_current_total: 0, + catalog_compat_total: 0, + registry_total: 0, + extension_surface_total: 0, + extension_tool_total: 0, + mcp_total: 0, + visible_total: 0, + }, + }); + mockResumeAgentRuntimeSubagent.mockResolvedValue({ + status: { + session_id: "child-session-1", + kind: "closed", + }, + cascade_session_ids: [], + changed_session_ids: [], + }); + mockSendAgentRuntimeSubagentInput.mockResolvedValue({ + submission_id: "submission-1", + }); + mockWaitAgentRuntimeSubagents.mockResolvedValue({ + status: {}, + timed_out: false, + }); mockLaunchBrowserSession.mockResolvedValue({ profile: { success: true, @@ -1175,6 +1247,111 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("AgentChatPage 停止 Team 协作", () => { + it("点击停止时应一并暂停仍在运行或排队的 Team 子会话", async () => { + const stopSendingMock = vi.fn(async () => undefined); + + 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: [], + isSending: true, + sendMessage: sharedSendMessageMock, + stopSending: stopSendingMock, + 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", + childSubagentSessions: [ + { + id: "child-session-running", + name: "运行中成员", + created_at: 1700000000, + updated_at: 1700000001, + session_type: "sub_agent", + runtime_status: "running", + }, + { + id: "child-session-queued", + name: "排队中成员", + created_at: 1700000002, + updated_at: 1700000003, + session_type: "sub_agent", + runtime_status: "queued", + }, + { + id: "child-session-done", + name: "已完成成员", + created_at: 1700000004, + updated_at: 1700000005, + session_type: "sub_agent", + runtime_status: "completed", + }, + ], + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + workspacePathMissing: false, + fixWorkspacePathAndRetry: vi.fn(), + dismissWorkspacePathError: vi.fn(), + }; + }, + ); + mockCloseAgentRuntimeSubagent + .mockResolvedValueOnce({ + previous_status: { + session_id: "child-session-running", + kind: "running", + }, + cascade_session_ids: [], + changed_session_ids: ["child-session-running"], + }) + .mockResolvedValueOnce({ + previous_status: { + session_id: "child-session-queued", + kind: "queued", + }, + cascade_session_ids: [], + changed_session_ids: ["child-session-queued"], + }); + + renderPage(); + await flushEffects(); + + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | MockInputbarSendProps + | undefined; + + await latestInputbarProps?.onStop?.(); + await flushEffects(); + + expect(stopSendingMock).toHaveBeenCalledTimes(1); + expect(mockCloseAgentRuntimeSubagent).toHaveBeenCalledTimes(2); + expect(mockCloseAgentRuntimeSubagent).toHaveBeenNthCalledWith(1, { + id: "child-session-running", + }); + expect(mockCloseAgentRuntimeSubagent).toHaveBeenNthCalledWith(2, { + id: "child-session-queued", + }); + }); +}); + describe("AgentChatPage 话题切换项目恢复", () => { it("应先切换到话题绑定项目,再执行话题切换", async () => { localStorage.setItem( @@ -1564,7 +1741,7 @@ describe("AgentChatPage 通用工作台", () => { ).not.toBeNull(); }); - it("Team 组建中与仅完成编队时,应立即切到 Team 画布并展示本地调度预览", async () => { + it("Team 组建中先展示本地调度预览,编队完成后应携带 team_prepared metadata 再发送", async () => { let resolveGeneratedTeam: | ((value: { id: string; @@ -1631,6 +1808,7 @@ describe("AgentChatPage 通用工作台", () => { input: "请帮我拆解并推进这个修复任务", }), ); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); const latestMessageListProps = mockMessageList.mock.calls.at(-1)?.[0] as | { messages?: Array<{ @@ -1645,7 +1823,7 @@ describe("AgentChatPage 通用工作台", () => { mounted.container .querySelector('[data-testid="layout-transition"]') ?.getAttribute("data-mode"), - ).toBe("chat-canvas"); + ).toBe("chat"); expect( latestMessageListProps?.messages?.map((message) => ({ role: message.role, @@ -1664,9 +1842,11 @@ describe("AgentChatPage 通用工作台", () => { runtimeTitle: "正在组建 Team", }, ]); - expect( - mounted.container.querySelector('[data-testid="team-workspace-dock"]'), - ).toBeNull(); + const runtimeDock = mounted.container.querySelector( + '[data-testid="team-workspace-dock"]', + ); + expect(runtimeDock).not.toBeNull(); + expect(runtimeDock?.getAttribute("data-runtime-status")).toBe("forming"); expect( ( mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as @@ -1707,11 +1887,57 @@ describe("AgentChatPage 通用工作台", () => { }); await flushEffects(10); + expect(sharedSendMessageMock).toHaveBeenCalledTimes(1); + expect(sharedSendMessageMock).toHaveBeenCalledWith( + "请帮我拆解并推进这个修复任务", + [], + false, + false, + false, + "auto", + "mock-model", + undefined, + expect.objectContaining({ + assistantDraft: expect.objectContaining({ + content: expect.stringContaining("修复 Team"), + }), + requestMetadata: expect.objectContaining({ + harness: expect.objectContaining({ + turn_team_decision: "team_prepared", + turn_team_reason: "runtime_team_prepared", + turn_team_blueprint: expect.objectContaining({ + label: "修复 Team", + roles: expect.arrayContaining([ + expect.objectContaining({ + id: "explorer", + label: "分析", + }), + expect.objectContaining({ + id: "executor", + label: "执行", + }), + ]), + }), + }), + }), + }), + ); + expect( mounted.container .querySelector('[data-testid="layout-transition"]') ?.getAttribute("data-mode"), - ).toBe("chat-canvas"); + ).toBe("chat"); + const formedRuntimeDock = mounted.container.querySelector( + '[data-testid="team-workspace-dock"]', + ); + expect(formedRuntimeDock).not.toBeNull(); + expect(formedRuntimeDock?.getAttribute("data-runtime-status")).toBe( + "formed", + ); + expect(formedRuntimeDock?.getAttribute("data-runtime-member-count")).toBe( + "2", + ); expect( ( mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as @@ -1740,7 +1966,7 @@ describe("AgentChatPage 通用工作台", () => { ); }); - it("用户手动切回聊天后,同一轮 Team 组建与真实成员到达都不应再次抢焦点", async () => { + it("用户手动关闭真实 Team 画布后,同一轮后续成员更新不应再次抢焦点", async () => { let resolveGeneratedTeam: | ((value: { id: string; @@ -1797,6 +2023,7 @@ describe("AgentChatPage 通用工作台", () => { isSending: false, sendMessage: sharedSendMessageMock, stopSending: vi.fn(async () => undefined), + resumeThread: vi.fn(async () => false), promoteQueuedTurn: vi.fn(async () => false), removeQueuedTurn: vi.fn(async () => false), clearMessages: vi.fn(), @@ -1857,15 +2084,6 @@ describe("AgentChatPage 通用工作台", () => { }); await flushEffects(8); - expect( - mounted.container - .querySelector('[data-testid="layout-transition"]') - ?.getAttribute("data-mode"), - ).toBe("chat-canvas"); - - clickButton(mounted.container, "toggle-canvas"); - await flushEffects(8); - expect( mounted.container .querySelector('[data-testid="layout-transition"]') @@ -1921,6 +2139,37 @@ describe("AgentChatPage 通用工作台", () => { mounted.rerender(); await flushEffects(8); + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat-canvas"); + + clickButton(mounted.container, "toggle-canvas"); + await flushEffects(8); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat"); + + runtimeState.childSubagentSessions = [ + ...runtimeState.childSubagentSessions, + { + id: "child-2", + name: "分析成员", + created_at: Date.now(), + updated_at: Date.now(), + session_type: "sub_agent", + runtime_status: "running", + task_summary: "补充定位根因", + role_hint: "explorer", + }, + ]; + mounted.rerender(); + await flushEffects(8); + expect( mounted.container .querySelector('[data-testid="layout-transition"]') @@ -1963,6 +2212,7 @@ describe("AgentChatPage 通用工作台", () => { isSending: false, sendMessage: sharedSendMessageMock, stopSending: vi.fn(async () => undefined), + resumeThread: vi.fn(async () => false), promoteQueuedTurn: vi.fn(async () => false), removeQueuedTurn: vi.fn(async () => false), clearMessages: vi.fn(), @@ -2025,6 +2275,194 @@ describe("AgentChatPage 通用工作台", () => { .querySelector('[data-testid="layout-transition"]') ?.getAttribute("data-mode"), ).toBe("chat-canvas"); + expect( + ( + mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as + | { + teamView?: { + preferFixedPanel?: boolean; + preferFullscreenPreview?: boolean; + renderPanel?: () => unknown; + } | null; + } + | undefined + )?.teamView?.preferFixedPanel, + ).toBe(true); + expect( + ( + mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as + | { + teamView?: { + preferFixedPanel?: boolean; + preferFullscreenPreview?: boolean; + renderPanel?: () => unknown; + } | null; + } + | undefined + )?.teamView?.preferFullscreenPreview, + ).not.toBe(true); + expect( + typeof ( + mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as + | { + teamView?: { + preferFixedPanel?: boolean; + preferFullscreenPreview?: boolean; + renderPanel?: () => unknown; + } | null; + } + | undefined + )?.teamView?.renderPanel, + ).toBe("function"); + }); + + it("仅有 runtime 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-canvas-open", + theme: "general", + lockTheme: true, + }); + await flushEffects(10); + + let latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | MockInputbarSendProps + | undefined; + + act(() => { + latestInputbarProps?.onToolStatesChange?.((previous) => ({ + ...previous, + subagent: true, + })); + }); + await flushEffects(8); + + latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | MockInputbarSendProps + | undefined; + + act(() => { + void latestInputbarProps?.onSend?.( + [], + false, + false, + "请组织一个协作团队推进这项修复", + "auto", + ); + }); + await flushEffects(8); + + act(() => { + resolveGeneratedTeam?.({ + id: "ephemeral-team-manual-open", + source: "ephemeral", + label: "修复 Team", + description: "分析、执行、验证协作闭环。", + roles: [ + { + id: "explorer", + label: "分析", + summary: "负责定位问题边界。", + profileId: "code-explorer", + roleKey: "explorer", + skillIds: ["repo-exploration"], + }, + ], + }); + }); + await flushEffects(8); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat"); + + clickButton(mounted.container, "toggle-canvas"); + await flushEffects(8); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat-canvas"); + expect( + ( + mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as + | { + teamView?: { + title?: string; + renderPanel?: () => unknown; + } | null; + } + | undefined + )?.teamView?.title, + ).toBe("修复 Team"); + expect( + typeof ( + mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as + | { + teamView?: { + title?: string; + renderPanel?: () => unknown; + } | null; + } + | undefined + )?.teamView?.renderPanel, + ).toBe("function"); + + clickButton(mounted.container, "toggle-canvas"); + await flushEffects(8); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat"); + + clickButton(mounted.container, "team-workspace-dock-activate"); + await flushEffects(8); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat-canvas"); + expect( + ( + mockCanvasWorkbenchLayout.mock.calls.at(-1)?.[0] as + | { + teamView?: { + title?: string; + renderPanel?: () => unknown; + } | null; + } + | undefined + )?.teamView?.title, + ).toBe("修复 Team"); }); it("已安装 skills 但未显式激活时,通用工作台不应展示技能区块", async () => { @@ -2958,7 +3396,8 @@ describe("AgentChatPage 自动引导", () => { }); await flushEffects(12); - const selectedModelContainer = mountedRoots.at(-1)?.container as HTMLDivElement; + const selectedModelContainer = mountedRoots.at(-1) + ?.container as HTMLDivElement; expect(sharedSendMessageMock).not.toHaveBeenCalled(); clickButton(selectedModelContainer, "theme-workbench-entry-continue"); await flushEffects(12); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index ae47b29c2..c7eb10ce1 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -9,7 +9,7 @@ import { AgentChatHomeShell, type AgentChatWorkspaceBootstrap, } from "./AgentChatHomeShell"; -import type { AgentChatWorkspaceProps } from "./AgentChatWorkspace"; +import type { AgentChatWorkspaceProps } from "./agentChatWorkspaceContract"; const WORKSPACE_PREFETCH_IDLE_TIMEOUT_MS = 1_500; const WORKSPACE_PREFETCH_FALLBACK_DELAY_MS = 180; @@ -46,7 +46,7 @@ function scheduleWorkspacePrefetch(task: () => void): () => void { export type { AgentChatWorkspaceProps, WorkflowProgressSnapshot, -} from "./AgentChatWorkspace"; +} from "./agentChatWorkspaceContract"; export function AgentChatPage(props: AgentChatWorkspaceProps) { const { diff --git a/src/components/agent/chat/styles/index.ts b/src/components/agent/chat/styles/index.ts index cc1de0974..4a3d41bc1 100644 --- a/src/components/agent/chat/styles/index.ts +++ b/src/components/agent/chat/styles/index.ts @@ -39,12 +39,17 @@ export const MessageListContainer = styled(ScrollArea)` `; // Linear Layout Wrapper: Always Row, Left Aligned -export const MessageWrapper = styled.div<{ $isUser: boolean }>` +export const MessageWrapper = styled.div<{ + $isUser: boolean; + $compactLeadingSpacing?: boolean; +}>` display: flex; flex-direction: row; align-items: flex-start; - padding: 14px 8px; - gap: 14px; + padding: ${({ $compactLeadingSpacing }) => + $compactLeadingSpacing ? "14px 2px" : "14px 6px"}; + gap: ${({ $compactLeadingSpacing }) => + $compactLeadingSpacing ? "10px" : "12px"}; width: 100%; max-width: none; margin: 0; diff --git a/src/components/agent/chat/utils/agentThreadGrouping.ts b/src/components/agent/chat/utils/agentThreadGrouping.ts index e8d75f003..6f2405e52 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.ts @@ -293,7 +293,8 @@ function classifyItemKind(item: AgentThreadItem): AgentThreadGroupKind { if ( item.type === "plan" || item.type === "reasoning" || - item.type === "turn_summary" + item.type === "turn_summary" || + item.type === "context_compaction" ) { return "thinking"; } @@ -478,6 +479,13 @@ function summarizeThinkingItem(item: AgentThreadItem): string | null { return extractThinkingPreviewLine(item.text); } + if (item.type === "context_compaction") { + return shortenText( + item.detail || + (item.stage === "completed" ? "上下文已压缩" : "正在压缩上下文"), + ); + } + if (item.type === "reasoning") { return extractThinkingPreviewLine(item.summary?.join(";") || item.text); } diff --git a/src/components/agent/chat/utils/messageArtifacts.ts b/src/components/agent/chat/utils/messageArtifacts.ts index 0b099e7ce..ef2872ee4 100644 --- a/src/components/agent/chat/utils/messageArtifacts.ts +++ b/src/components/agent/chat/utils/messageArtifacts.ts @@ -259,6 +259,24 @@ export function resolveDefaultArtifactViewMode( return "source"; } +export function resolveArtifactFilePath( + artifact: Pick, +): string { + if ( + typeof artifact.meta.filePath === "string" && + artifact.meta.filePath.trim() + ) { + return artifact.meta.filePath.trim(); + } + if ( + typeof artifact.meta.filename === "string" && + artifact.meta.filename.trim() + ) { + return artifact.meta.filename.trim(); + } + return artifact.title; +} + export interface BuildArtifactInput { filePath: string; content: string; diff --git a/src/components/agent/chat/utils/threadReliabilityView.test.ts b/src/components/agent/chat/utils/threadReliabilityView.test.ts new file mode 100644 index 000000000..82c59efd6 --- /dev/null +++ b/src/components/agent/chat/utils/threadReliabilityView.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildThreadReliabilityView } from "./threadReliabilityView"; +import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; + +describe("buildThreadReliabilityView", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-23T10:00:00Z")); + }); + + it("应按 incident 严重级别排序,并优先使用最高优先级事故生成摘要", () => { + const threadRead: AgentRuntimeThreadReadModel = { + thread_id: "thread-1", + status: "failed", + active_turn_id: "turn-1", + pending_requests: [], + incidents: [ + { + id: "incident-low", + thread_id: "thread-1", + turn_id: "turn-1", + incident_type: "minor_warning", + severity: "low", + status: "active", + title: "低优先级提醒", + details: "仅需关注", + }, + { + id: "incident-high", + thread_id: "thread-1", + turn_id: "turn-1", + incident_type: "provider_failure", + severity: "high", + status: "active", + title: "高优先级故障", + details: "Provider 已连续失败", + }, + { + id: "incident-medium", + thread_id: "thread-1", + turn_id: "turn-1", + incident_type: "waiting_user_input", + severity: "warning", + status: "active", + title: "中优先级等待", + details: "等待人工确认", + }, + ], + }; + + const view = buildThreadReliabilityView({ + threadRead, + turns: [ + { + id: "turn-1", + thread_id: "thread-1", + prompt_text: "继续执行发布流程", + status: "failed", + error_message: "Provider 429", + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:10Z", + completed_at: "2026-03-23T09:00:12Z", + }, + ], + currentTurnId: "turn-1", + }); + + expect(view.incidents.map((incident) => incident.title)).toEqual([ + "高优先级故障", + "中优先级等待", + "低优先级提醒", + ]); + expect(view.summary).toContain("高优先级故障"); + expect(view.summary).toContain("Provider 已连续失败"); + }); + + it("应基于 timeout / stuck incident 生成主动治理建议", () => { + const threadRead: AgentRuntimeThreadReadModel = { + thread_id: "thread-1", + status: "running", + active_turn_id: "turn-2", + pending_requests: [], + incidents: [ + { + id: "incident-stuck", + thread_id: "thread-1", + turn_id: "turn-2", + incident_type: "turn_stuck", + severity: "high", + status: "active", + title: "当前回合长时间无进展", + details: "最近 3 分钟内没有新的线程更新", + }, + ], + last_outcome: { + thread_id: "thread-1", + turn_id: "turn-1", + outcome_type: "failed_provider", + summary: "最近一次 provider 请求失败", + primary_cause: "429", + retryable: true, + ended_at: "2026-03-23T09:58:00Z", + }, + }; + + const view = buildThreadReliabilityView({ + threadRead, + turns: [ + { + id: "turn-2", + thread_id: "thread-1", + prompt_text: "继续执行部署验证", + status: "running", + created_at: "2026-03-23T09:55:00Z", + started_at: "2026-03-23T09:55:00Z", + updated_at: "2026-03-23T09:56:00Z", + }, + ], + currentTurnId: "turn-2", + }); + + expect(view.summary).toContain("当前回合长时间无进展"); + expect(view.recommendations).toContain("当前回合长时间无进展,建议停止后恢复执行"); + expect(view.recommendations).toContain("Provider 故障通常可重试,建议稍后恢复或重发回合"); + expect(view.recommendations).toContain("最近结果支持重试,可恢复或重新发起新回合"); + }); + + it("应识别审批超时并给出优先处理建议", () => { + const threadRead: AgentRuntimeThreadReadModel = { + thread_id: "thread-1", + status: "waiting_request", + active_turn_id: "turn-3", + pending_requests: [ + { + id: "req-approval", + thread_id: "thread-1", + turn_id: "turn-3", + request_type: "tool_confirmation", + status: "pending", + title: "请确认是否执行 apply_patch", + created_at: "2026-03-23T09:50:00Z", + }, + ], + incidents: [ + { + id: "incident-approval", + thread_id: "thread-1", + turn_id: "turn-3", + incident_type: "approval_timeout", + severity: "high", + status: "active", + title: "审批等待超过阈值", + details: "工具确认已等待 10 分钟:请确认是否执行 apply_patch", + }, + ], + }; + + const view = buildThreadReliabilityView({ + threadRead, + turns: [ + { + id: "turn-3", + thread_id: "thread-1", + prompt_text: "继续修复发布脚本", + status: "running", + created_at: "2026-03-23T09:49:00Z", + started_at: "2026-03-23T09:49:00Z", + updated_at: "2026-03-23T09:50:00Z", + }, + ], + currentTurnId: "turn-3", + }); + + expect(view.pendingRequests[0]?.waitingLabel).toBe("已等待 10 分钟"); + expect(view.recommendations).toContain("优先响应当前待处理请求"); + expect(view.recommendations).toContain("审批等待过久,建议尽快处理或停止当前执行"); + }); +}); diff --git a/src/components/agent/chat/utils/threadReliabilityView.ts b/src/components/agent/chat/utils/threadReliabilityView.ts new file mode 100644 index 000000000..3073f2abc --- /dev/null +++ b/src/components/agent/chat/utils/threadReliabilityView.ts @@ -0,0 +1,888 @@ +import type { + AgentRuntimeIncidentView, + AgentRuntimeOutcomeView, + AgentRuntimeRequestView, + AgentRuntimeThreadReadModel, + QueuedTurnSnapshot, +} from "@/lib/api/agentRuntime"; +import type { ActionRequired, AgentThreadItem, AgentThreadTurn } from "../types"; + +export type ThreadReliabilityTone = + | "running" + | "waiting" + | "completed" + | "failed" + | "paused" + | "neutral"; + +export interface ThreadReliabilityRequestDisplay { + id: string; + title: string; + typeLabel: string; + statusLabel: string; + statusTone: ThreadReliabilityTone; + createdAtLabel?: string | null; + waitingLabel?: string | null; +} + +export interface ThreadReliabilityIncidentDisplay { + id: string; + incidentType: string; + title: string; + detail?: string | null; + statusLabel: string; + severityLabel: string; + tone: ThreadReliabilityTone; +} + +export interface ThreadReliabilityOutcomeDisplay { + label: string; + summary: string; + primaryCause?: string | null; + retryable: boolean; + endedAtLabel?: string | null; + tone: ThreadReliabilityTone; +} + +export interface ThreadReliabilityQueuedTurnDisplay { + id: string; + title: string; + positionLabel?: string | null; +} + +export interface ThreadReliabilityViewModel { + shouldRender: boolean; + statusLabel: string; + statusTone: ThreadReliabilityTone; + summary: string; + activeTurnLabel?: string | null; + updatedAtLabel?: string | null; + interruptStateLabel?: string | null; + pendingRequestCount: number; + activeIncidentCount: number; + queuedTurnCount: number; + pendingRequests: ThreadReliabilityRequestDisplay[]; + submittedRequests: ThreadReliabilityRequestDisplay[]; + incidents: ThreadReliabilityIncidentDisplay[]; + outcome: ThreadReliabilityOutcomeDisplay | null; + nextQueuedTurn: ThreadReliabilityQueuedTurnDisplay | null; + recommendations: string[]; +} + +interface BuildThreadReliabilityViewParams { + threadRead?: AgentRuntimeThreadReadModel | null; + turns?: AgentThreadTurn[]; + threadItems?: AgentThreadItem[]; + currentTurnId?: string | null; + pendingActions?: ActionRequired[]; + submittedActionsInFlight?: ActionRequired[]; + queuedTurns?: QueuedTurnSnapshot[]; +} + +function normalizeText(value?: string | null): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().replace(/\s+/g, " "); + return normalized || null; +} + +function shortenText(value?: string | null, maxLength = 52): string | null { + const normalized = normalizeText(value); + if (!normalized) { + return null; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + +function parseDateValue(value?: string | number | null): Date | null { + if (typeof value === "number") { + const normalizedValue = value > 10_000_000_000 ? value : value * 1000; + const date = new Date(normalizedValue); + return Number.isNaN(date.getTime()) ? null : date; + } + + if (typeof value === "string" && value.trim()) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + return null; +} + +function formatTimeLabel(value?: string | number | null): string | null { + const date = parseDateValue(value); + if (!date) { + return null; + } + + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatWaitingLabel(value?: string | number | null): string | null { + const date = parseDateValue(value); + if (!date) { + return null; + } + + const deltaMs = Math.max(0, Date.now() - date.getTime()); + const deltaMinutes = Math.floor(deltaMs / 60_000); + const deltaHours = Math.floor(deltaMinutes / 60); + + if (deltaMinutes < 1) { + return "刚刚产生"; + } + if (deltaMinutes < 60) { + return `已等待 ${deltaMinutes} 分钟`; + } + if (deltaHours < 24) { + return `已等待 ${deltaHours} 小时`; + } + return `已等待 ${Math.floor(deltaHours / 24)} 天`; +} + +function resolveRequestTypeLabel(requestType?: string): string { + const normalized = (requestType || "").toLowerCase(); + if (normalized.includes("tool") || normalized.includes("approval")) { + return "工具确认"; + } + if (normalized.includes("elicitation")) { + return "结构化输入"; + } + if (normalized.includes("ask") || normalized.includes("user")) { + return "人工输入"; + } + return "待处理请求"; +} + +function resolveRequestStatusMeta(status?: string): { + label: string; + tone: ThreadReliabilityTone; +} { + const normalized = (status || "").toLowerCase(); + + if ( + normalized.includes("submitted") || + normalized.includes("queued") || + normalized.includes("answer") + ) { + return { label: "已提交", tone: "waiting" }; + } + if ( + normalized.includes("resolved") || + normalized.includes("completed") || + normalized.includes("declined") + ) { + return { label: "已处理", tone: "completed" }; + } + if (normalized.includes("failed") || normalized.includes("error")) { + return { label: "处理失败", tone: "failed" }; + } + + return { label: "待处理", tone: "waiting" }; +} + +function isPendingRequest(request: AgentRuntimeRequestView): boolean { + if (request.resolved_at) { + return false; + } + const normalized = (request.status || "").toLowerCase(); + return !( + normalized.includes("resolved") || + normalized.includes("completed") || + normalized.includes("declined") || + normalized.includes("cancelled") + ); +} + +function requestTitleFromThreadRead(request: AgentRuntimeRequestView): string { + return ( + shortenText(request.title) || + shortenText( + typeof request.payload === "string" ? request.payload : undefined, + ) || + `${resolveRequestTypeLabel(request.request_type)} #${request.id.slice(0, 8)}` + ); +} + +function requestTitleFromAction(action: ActionRequired): string { + if (action.actionType === "tool_confirmation") { + return ( + shortenText(action.prompt) || + (action.toolName ? `等待确认工具:${action.toolName}` : "等待工具确认") + ); + } + + if (action.actionType === "elicitation") { + return shortenText(action.prompt) || "等待结构化输入"; + } + + return shortenText(action.prompt) || "等待人工输入"; +} + +function mergePendingRequests( + threadRead: AgentRuntimeThreadReadModel | null | undefined, + pendingActions: ActionRequired[], + submittedActionsInFlight: ActionRequired[], +): ThreadReliabilityRequestDisplay[] { + const merged = new Map(); + const submittedRequestIds = new Set( + submittedActionsInFlight.map((item) => item.requestId), + ); + + for (const request of threadRead?.pending_requests ?? []) { + if (submittedRequestIds.has(request.id)) { + continue; + } + if (!isPendingRequest(request)) { + continue; + } + const statusMeta = resolveRequestStatusMeta(request.status); + merged.set(request.id, { + id: request.id, + title: requestTitleFromThreadRead(request), + typeLabel: resolveRequestTypeLabel(request.request_type), + statusLabel: statusMeta.label, + statusTone: statusMeta.tone, + createdAtLabel: formatTimeLabel(request.created_at), + waitingLabel: formatWaitingLabel(request.created_at), + }); + } + + for (const action of pendingActions) { + if (merged.has(action.requestId)) { + continue; + } + const statusMeta = resolveRequestStatusMeta(action.status); + merged.set(action.requestId, { + id: action.requestId, + title: requestTitleFromAction(action), + typeLabel: resolveRequestTypeLabel(action.actionType), + statusLabel: statusMeta.label, + statusTone: statusMeta.tone, + }); + } + + return [...merged.values()]; +} + +function mergeSubmittedRequests( + submittedActionsInFlight: ActionRequired[], +): ThreadReliabilityRequestDisplay[] { + const merged = new Map(); + + for (const action of submittedActionsInFlight) { + merged.set(action.requestId, { + id: action.requestId, + title: requestTitleFromAction(action), + typeLabel: resolveRequestTypeLabel(action.actionType), + statusLabel: "已提交", + statusTone: "running", + }); + } + + return [...merged.values()]; +} + +function resolveLatestTurn( + turns: AgentThreadTurn[], + currentTurnId?: string | null, +): AgentThreadTurn | null { + if (currentTurnId) { + const currentTurn = turns.find((turn) => turn.id === currentTurnId); + if (currentTurn) { + return currentTurn; + } + } + + return turns.length > 0 ? turns[turns.length - 1] : null; +} + +export function resolveOutcomeTone(outcomeType?: string): ThreadReliabilityTone { + const normalized = (outcomeType || "").toLowerCase(); + if (normalized.includes("complete")) { + return "completed"; + } + if (normalized.includes("interrupt") || normalized.includes("abort")) { + return "paused"; + } + if (normalized.includes("wait")) { + return "waiting"; + } + if (normalized.includes("fail") || normalized.includes("error")) { + return "failed"; + } + return "neutral"; +} + +export function resolveOutcomeLabel(outcomeType?: string): string { + const normalized = (outcomeType || "").toLowerCase(); + if (normalized.includes("complete")) { + return "已完成"; + } + if (normalized.includes("interrupt") || normalized.includes("abort")) { + return "已中断"; + } + if (normalized.includes("provider")) { + return "Provider 失败"; + } + if (normalized.includes("tool")) { + return "工具失败"; + } + if (normalized.includes("wait") && normalized.includes("approval")) { + return "等待审批"; + } + if (normalized.includes("wait") && normalized.includes("user")) { + return "等待输入"; + } + if (normalized.includes("fail") || normalized.includes("error")) { + return "执行失败"; + } + return "最近结果"; +} + +function deriveOutcomeFromTurn( + latestTurn: AgentThreadTurn | null, +): ThreadReliabilityOutcomeDisplay | null { + if (!latestTurn) { + return null; + } + + if (latestTurn.status === "completed") { + return { + label: "已完成", + summary: "最近一次回合已稳定完成", + retryable: false, + endedAtLabel: formatTimeLabel(latestTurn.completed_at), + tone: "completed", + }; + } + + if (latestTurn.status === "failed") { + return { + label: "执行失败", + summary: normalizeText(latestTurn.error_message) || "最近一次回合执行失败", + primaryCause: normalizeText(latestTurn.error_message), + retryable: true, + endedAtLabel: formatTimeLabel(latestTurn.completed_at), + tone: "failed", + }; + } + + if (latestTurn.status === "aborted") { + return { + label: "已中断", + summary: "最近一次回合已被中断", + retryable: true, + endedAtLabel: formatTimeLabel(latestTurn.completed_at), + tone: "paused", + }; + } + + return null; +} + +function normalizeOutcome( + outcome: AgentRuntimeOutcomeView | null | undefined, + latestTurn: AgentThreadTurn | null, +): ThreadReliabilityOutcomeDisplay | null { + if (!outcome) { + return deriveOutcomeFromTurn(latestTurn); + } + + return { + label: resolveOutcomeLabel(outcome.outcome_type), + summary: + shortenText(outcome.summary, 72) || + shortenText(outcome.primary_cause, 72) || + "最近一次结果已更新", + primaryCause: shortenText(outcome.primary_cause, 72), + retryable: Boolean(outcome.retryable), + endedAtLabel: formatTimeLabel(outcome.ended_at), + tone: resolveOutcomeTone(outcome.outcome_type), + }; +} + +function describeIncidentDetails(details: unknown): string | null { + if (typeof details === "string") { + return shortenText(details, 80); + } + if (details && typeof details === "object") { + try { + return shortenText(JSON.stringify(details), 80); + } catch { + return null; + } + } + return null; +} + +export function resolveIncidentToneFromSeverity( + severity?: string, +): ThreadReliabilityTone { + const normalized = (severity || "").toLowerCase(); + if (normalized.includes("critical") || normalized.includes("high")) { + return "failed"; + } + if (normalized.includes("warn") || normalized.includes("medium")) { + return "waiting"; + } + return "neutral"; +} + +function normalizeIncident( + incident: AgentRuntimeIncidentView, +): ThreadReliabilityIncidentDisplay { + const severity = (incident.severity || "").toLowerCase(); + const tone = resolveIncidentToneFromSeverity(incident.severity); + let severityLabel = "低"; + + if (severity.includes("critical") || severity.includes("high")) { + severityLabel = "高"; + } else if (severity.includes("warn") || severity.includes("medium")) { + severityLabel = "中"; + } + + const statusLabel = + incident.status && incident.status.toLowerCase().includes("clear") + ? "已恢复" + : "进行中"; + + return { + id: incident.id, + incidentType: incident.incident_type, + title: + shortenText(incident.title, 56) || + shortenText(incident.incident_type, 56) || + "运行事故", + detail: describeIncidentDetails(incident.details), + statusLabel, + severityLabel, + tone, + }; +} + +function resolveIncidentPriority( + incident: ThreadReliabilityIncidentDisplay, +): number { + if (incident.tone === "failed") { + return 0; + } + if (incident.tone === "waiting") { + return 1; + } + return 2; +} + +function sortIncidentsByPriority( + incidents: ThreadReliabilityIncidentDisplay[], +): ThreadReliabilityIncidentDisplay[] { + return [...incidents].sort((left, right) => { + const priorityDelta = + resolveIncidentPriority(left) - resolveIncidentPriority(right); + if (priorityDelta !== 0) { + return priorityDelta; + } + + return left.title.localeCompare(right.title, "zh-CN"); + }); +} + +function deriveFallbackIncidents( + latestTurn: AgentThreadTurn | null, + threadItems: AgentThreadItem[], + pendingRequests: ThreadReliabilityRequestDisplay[], +): ThreadReliabilityIncidentDisplay[] { + if (pendingRequests.length > 0) { + return [ + { + id: `pending-request-${pendingRequests[0]?.id || "active"}`, + incidentType: "waiting_user_input", + title: "线程正在等待人工处理", + detail: pendingRequests[0]?.title || null, + statusLabel: "进行中", + severityLabel: "中", + tone: "waiting", + }, + ]; + } + + if (latestTurn?.status === "failed") { + return [ + { + id: `turn-failed-${latestTurn.id}`, + incidentType: "turn_failed", + title: "最近一次回合执行失败", + detail: shortenText(latestTurn.error_message, 80), + statusLabel: "进行中", + severityLabel: "高", + tone: "failed", + }, + ]; + } + + const issueItem = [...threadItems] + .reverse() + .find((item) => item.type === "warning" || item.type === "error"); + + if (!issueItem) { + return []; + } + + if (issueItem.type === "error") { + return [ + { + id: issueItem.id, + incidentType: "runtime_error", + title: "时间线记录到异常项", + detail: shortenText(issueItem.message, 80), + statusLabel: "进行中", + severityLabel: "高", + tone: "failed", + }, + ]; + } + + return [ + { + id: issueItem.id, + incidentType: "runtime_warning", + title: "时间线记录到警告项", + detail: shortenText(issueItem.message, 80), + statusLabel: "进行中", + severityLabel: "中", + tone: "waiting", + }, + ]; +} + +function normalizeIncidents( + threadRead: AgentRuntimeThreadReadModel | null | undefined, + latestTurn: AgentThreadTurn | null, + threadItems: AgentThreadItem[], + pendingRequests: ThreadReliabilityRequestDisplay[], + submittedActionsInFlight: ActionRequired[], +): ThreadReliabilityIncidentDisplay[] { + const submittedRequestIds = new Set( + submittedActionsInFlight.map((item) => item.requestId), + ); + const activeIncidents = (threadRead?.incidents ?? []).filter((incident) => { + const normalizedStatus = (incident.status || "").toLowerCase(); + if (normalizedStatus.includes("clear") || incident.cleared_at) { + return false; + } + if (submittedRequestIds.has(incident.id.replace(/^incident-/, ""))) { + return false; + } + return true; + }); + + if (activeIncidents.length > 0) { + return sortIncidentsByPriority(activeIncidents.map(normalizeIncident)); + } + + return sortIncidentsByPriority( + deriveFallbackIncidents(latestTurn, threadItems, pendingRequests), + ); +} + +function normalizeInterruptStateLabel( + interruptState?: string | null, +): string | null { + const normalized = (interruptState || "").toLowerCase(); + if (!normalized) { + return null; + } + if (normalized.includes("interrupt")) { + return "运行时已确认中断"; + } + return shortenText(interruptState, 32); +} + +function resolveNextQueuedTurn( + threadRead: AgentRuntimeThreadReadModel | null | undefined, + queuedTurns: QueuedTurnSnapshot[], +): ThreadReliabilityQueuedTurnDisplay | null { + const candidate = + threadRead?.queued_turns?.[0] ?? + (queuedTurns.length > 0 ? queuedTurns[0] : null); + + if (!candidate) { + return null; + } + + return { + id: candidate.queued_turn_id, + title: + shortenText(candidate.message_preview, 48) || + shortenText(candidate.message_text, 48) || + "继续执行排队回合", + positionLabel: + candidate.position > 0 ? `队列第 ${candidate.position} 位` : null, + }; +} + +function resolveStatusMeta(status?: string): { + label: string; + tone: ThreadReliabilityTone; +} { + const normalized = (status || "").toLowerCase(); + if (normalized.includes("interrupting")) { + return { label: "中断中", tone: "paused" }; + } + if (normalized.includes("wait") || normalized.includes("queue")) { + return { label: "等待处理", tone: "waiting" }; + } + if (normalized.includes("interrupt") || normalized.includes("abort")) { + return { label: "已中断", tone: "paused" }; + } + if (normalized.includes("run") || normalized.includes("active")) { + return { label: "执行中", tone: "running" }; + } + if ( + normalized.includes("complete") || + normalized.includes("done") || + normalized.includes("success") + ) { + return { label: "已完成", tone: "completed" }; + } + if (normalized.includes("fail") || normalized.includes("error")) { + return { label: "执行失败", tone: "failed" }; + } + return { label: "空闲", tone: "neutral" }; +} + +function deriveStatusFromRuntime(params: { + threadRead?: AgentRuntimeThreadReadModel | null; + latestTurn: AgentThreadTurn | null; + pendingRequests: ThreadReliabilityRequestDisplay[]; + submittedRequests: ThreadReliabilityRequestDisplay[]; + queuedTurnCount: number; +}): { label: string; tone: ThreadReliabilityTone } { + if (params.submittedRequests.length > 0) { + return { label: "处理中", tone: "running" }; + } + if (params.threadRead?.status) { + return resolveStatusMeta(params.threadRead.status); + } + + if (params.pendingRequests.length > 0) { + return { label: "等待处理", tone: "waiting" }; + } + + if (params.latestTurn?.status === "running") { + return { label: "执行中", tone: "running" }; + } + if (params.latestTurn?.status === "completed") { + return { label: "已完成", tone: "completed" }; + } + if (params.latestTurn?.status === "failed") { + return { label: "执行失败", tone: "failed" }; + } + if (params.latestTurn?.status === "aborted") { + return { label: "已中断", tone: "paused" }; + } + if (params.queuedTurnCount > 0) { + return { label: "等待处理", tone: "waiting" }; + } + + return { label: "空闲", tone: "neutral" }; +} + +function buildSummary(params: { + statusLabel: string; + latestTurn: AgentThreadTurn | null; + pendingRequests: ThreadReliabilityRequestDisplay[]; + submittedRequests: ThreadReliabilityRequestDisplay[]; + incidents: ThreadReliabilityIncidentDisplay[]; + outcome: ThreadReliabilityOutcomeDisplay | null; + queuedTurnCount: number; + interruptStateLabel?: string | null; + nextQueuedTurn: ThreadReliabilityQueuedTurnDisplay | null; +}): string { + if (params.pendingRequests.length > 0) { + return `当前线程正在等待人工处理:${params.pendingRequests[0]?.title || "请查看待处理请求"}`; + } + + if (params.submittedRequests.length > 0) { + return `已提交响应:${params.submittedRequests[0]?.title || "等待线程继续执行"},等待运行时回填最新状态`; + } + + if (params.incidents.length > 0) { + return params.incidents[0]?.detail + ? `${params.incidents[0].title}:${params.incidents[0].detail}` + : params.incidents[0].title; + } + + if (params.interruptStateLabel) { + if (params.nextQueuedTurn) { + return `${params.interruptStateLabel},可继续 ${params.nextQueuedTurn.title}`; + } + return `${params.interruptStateLabel},如需继续可重新发起下一回合。`; + } + + if (params.latestTurn?.status === "running") { + return `当前线程正在执行:${shortenText(params.latestTurn.prompt_text, 52) || "处理中"}`; + } + + if (params.outcome) { + return params.outcome.summary; + } + + if (params.queuedTurnCount > 0) { + return `当前有 ${params.queuedTurnCount} 个排队回合等待执行`; + } + + return `当前线程状态:${params.statusLabel}`; +} + +function buildRecommendations(params: { + pendingRequests: ThreadReliabilityRequestDisplay[]; + submittedRequests: ThreadReliabilityRequestDisplay[]; + incidents: ThreadReliabilityIncidentDisplay[]; + outcome: ThreadReliabilityOutcomeDisplay | null; + nextQueuedTurn: ThreadReliabilityQueuedTurnDisplay | null; + interruptStateLabel?: string | null; +}): string[] { + const recommendations = new Set(); + const incidentTypes = new Set( + params.incidents.map((incident) => incident.incidentType), + ); + + if (params.pendingRequests.length > 0) { + recommendations.add("优先响应当前待处理请求"); + } + if (params.submittedRequests.length > 0) { + recommendations.add("等待运行时回填最新状态"); + } + if (params.incidents.some((incident) => incident.tone === "failed")) { + recommendations.add("优先处理高优先级 incident"); + } + if (incidentTypes.has("approval_timeout")) { + recommendations.add("审批等待过久,建议尽快处理或停止当前执行"); + } + if (incidentTypes.has("user_input_timeout")) { + recommendations.add("人工输入等待过久,建议补充输入后继续线程"); + } + if (incidentTypes.has("turn_stuck")) { + recommendations.add("当前回合长时间无进展,建议停止后恢复执行"); + } + if (incidentTypes.has("provider_error")) { + recommendations.add("Provider 故障通常可重试,建议稍后恢复或重发回合"); + } + if (incidentTypes.has("tool_failed")) { + recommendations.add("请先检查失败工具的参数或环境,再尝试重试"); + } + if (params.interruptStateLabel) { + recommendations.add("当前执行已被运行时确认中断"); + } + if (params.nextQueuedTurn) { + recommendations.add(`可继续排队回合:${params.nextQueuedTurn.title}`); + } + if (params.outcome?.retryable) { + recommendations.add("最近结果支持重试,可恢复或重新发起新回合"); + } + if (params.outcome?.label.includes("Provider")) { + recommendations.add("Provider 故障通常可重试,建议稍后恢复或重发回合"); + } + if (params.outcome?.label.includes("工具")) { + recommendations.add("请先检查失败工具的参数或环境,再尝试重试"); + } + + return [...recommendations]; +} + +export function buildThreadReliabilityView( + params: BuildThreadReliabilityViewParams, +): ThreadReliabilityViewModel { + const turns = params.turns ?? []; + const threadItems = params.threadItems ?? []; + const pendingActions = params.pendingActions ?? []; + const submittedActionsInFlight = params.submittedActionsInFlight ?? []; + const latestTurn = resolveLatestTurn(turns, params.currentTurnId); + const pendingRequests = mergePendingRequests( + params.threadRead, + pendingActions, + submittedActionsInFlight, + ); + const submittedRequests = mergeSubmittedRequests(submittedActionsInFlight); + const queuedTurnCount = + params.threadRead?.queued_turns?.length ?? params.queuedTurns?.length ?? 0; + const outcome = normalizeOutcome(params.threadRead?.last_outcome, latestTurn); + const updatedAtLabel = formatTimeLabel(params.threadRead?.updated_at); + const interruptStateLabel = normalizeInterruptStateLabel( + params.threadRead?.interrupt_state, + ); + const nextQueuedTurn = resolveNextQueuedTurn( + params.threadRead, + params.queuedTurns ?? [], + ); + const incidents = normalizeIncidents( + params.threadRead, + latestTurn, + threadItems, + pendingRequests, + submittedActionsInFlight, + ); + const statusMeta = deriveStatusFromRuntime({ + threadRead: params.threadRead, + latestTurn, + pendingRequests, + submittedRequests, + queuedTurnCount, + }); + + return { + shouldRender: + Boolean(params.threadRead) || + turns.length > 0 || + pendingRequests.length > 0 || + submittedRequests.length > 0 || + incidents.length > 0 || + queuedTurnCount > 0, + statusLabel: statusMeta.label, + statusTone: statusMeta.tone, + summary: buildSummary({ + statusLabel: statusMeta.label, + latestTurn, + pendingRequests, + submittedRequests, + incidents, + outcome, + queuedTurnCount, + interruptStateLabel, + nextQueuedTurn, + }), + activeTurnLabel: + shortenText(latestTurn?.prompt_text, 56) || + params.threadRead?.active_turn_id || + latestTurn?.id || + null, + updatedAtLabel, + interruptStateLabel, + pendingRequestCount: pendingRequests.length, + activeIncidentCount: incidents.length, + queuedTurnCount, + pendingRequests, + submittedRequests, + incidents, + outcome, + nextQueuedTurn, + recommendations: buildRecommendations({ + pendingRequests, + submittedRequests, + incidents, + outcome, + nextQueuedTurn, + interruptStateLabel, + }), + }; +} diff --git a/src/components/agent/chat/workspace/ThemeWorkbenchSidebarSection.tsx b/src/components/agent/chat/workspace/ThemeWorkbenchSidebarSection.tsx new file mode 100644 index 000000000..107cb329e --- /dev/null +++ b/src/components/agent/chat/workspace/ThemeWorkbenchSidebarSection.tsx @@ -0,0 +1,106 @@ +import type { SkillDetailInfo } from "@/lib/api/skill-execution"; +import { ThemeWorkbenchSidebar } from "../components/ThemeWorkbenchSidebar"; +import type { + ThemeWorkbenchSidebarExecLogContract, + ThemeWorkbenchSidebarProps, +} from "../components/themeWorkbenchSidebarContract"; +import type { Message } from "../types"; + +type ThemeWorkbenchWorkflowProps = Pick< + ThemeWorkbenchSidebarProps, + | "branchMode" + | "onNewTopic" + | "onSwitchTopic" + | "onDeleteTopic" + | "branchItems" + | "onSetBranchStatus" + | "workflowSteps" + | "onAddImage" + | "onImportDocument" + | "activityLogs" + | "creationTaskEvents" + | "onViewRunDetail" + | "activeRunDetail" + | "activeRunDetailLoading" +>; + +type ThemeWorkbenchContextWorkspaceProps = { + contextSearchQuery: ThemeWorkbenchSidebarProps["contextSearchQuery"]; + setContextSearchQuery: ThemeWorkbenchSidebarProps["onContextSearchQueryChange"]; + contextSearchMode: ThemeWorkbenchSidebarProps["contextSearchMode"]; + setContextSearchMode: ThemeWorkbenchSidebarProps["onContextSearchModeChange"]; + contextSearchLoading: ThemeWorkbenchSidebarProps["contextSearchLoading"]; + contextSearchError?: ThemeWorkbenchSidebarProps["contextSearchError"]; + contextSearchBlockedReason?: ThemeWorkbenchSidebarProps["contextSearchBlockedReason"]; + submitContextSearch: ThemeWorkbenchSidebarProps["onSubmitContextSearch"]; + addTextContext?: ThemeWorkbenchSidebarProps["onAddTextContext"]; + addLinkContext?: ThemeWorkbenchSidebarProps["onAddLinkContext"]; + addFileContext?: ThemeWorkbenchSidebarProps["onAddFileContext"]; + sidebarContextItems: ThemeWorkbenchSidebarProps["contextItems"]; + toggleContextActive: ThemeWorkbenchSidebarProps["onToggleContextActive"]; + contextBudget: ThemeWorkbenchSidebarProps["contextBudget"]; +}; + +interface ThemeWorkbenchHistoryProps { + hasMore?: boolean; + loading?: boolean; + onLoadMore?: ThemeWorkbenchSidebarExecLogContract["onLoadMoreHistory"]; + skillDetailMap?: Record; + messages?: Message[]; +} + +interface ThemeWorkbenchSidebarSectionProps { + visible: boolean; + workflowProps: ThemeWorkbenchWorkflowProps; + contextWorkspace: ThemeWorkbenchContextWorkspaceProps; + onViewContextDetail?: ThemeWorkbenchSidebarProps["onViewContextDetail"]; + onRequestCollapse?: ThemeWorkbenchSidebarProps["onRequestCollapse"]; + headerActionSlot?: ThemeWorkbenchSidebarProps["headerActionSlot"]; + topSlot?: ThemeWorkbenchSidebarProps["topSlot"]; + historyProps?: ThemeWorkbenchHistoryProps; +} + +export function ThemeWorkbenchSidebarSection({ + visible, + workflowProps, + contextWorkspace, + onViewContextDetail, + onRequestCollapse, + headerActionSlot, + topSlot, + historyProps, +}: ThemeWorkbenchSidebarSectionProps) { + if (!visible) { + return null; + } + + return ( + + ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx b/src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx new file mode 100644 index 000000000..16d1fe268 --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx @@ -0,0 +1,45 @@ +import type { ComponentProps, ReactNode } from "react"; +import { CanvasWorkbenchLayout } from "../components/CanvasWorkbenchLayout"; + +interface WorkspaceCanvasContentProps { + liveCanvasPreview: ReactNode; + currentImageWorkbenchActive: boolean; + shouldShowCanvasLoadingState: boolean; + isBrowserAssistCanvasVisible: boolean; + teamWorkbenchView: ComponentProps["teamView"]; + canvasWorkbenchLayoutProps: Omit< + ComponentProps, + "teamView" + >; +} + +export function WorkspaceCanvasContent({ + liveCanvasPreview, + currentImageWorkbenchActive, + shouldShowCanvasLoadingState, + isBrowserAssistCanvasVisible, + teamWorkbenchView, + canvasWorkbenchLayoutProps, +}: WorkspaceCanvasContentProps) { + if (!liveCanvasPreview && !teamWorkbenchView) { + return null; + } + + if (currentImageWorkbenchActive) { + return liveCanvasPreview; + } + + if ( + !teamWorkbenchView && + (shouldShowCanvasLoadingState || isBrowserAssistCanvasVisible) + ) { + return liveCanvasPreview; + } + + return ( + + ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceChatContent.tsx b/src/components/agent/chat/workspace/WorkspaceChatContent.tsx new file mode 100644 index 000000000..bd949ae76 --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceChatContent.tsx @@ -0,0 +1,138 @@ +import type { ComponentProps, ReactNode } from "react"; +import { Info } from "lucide-react"; +import { StepProgress } from "@/components/content-creator/core/StepGuide/StepProgress"; +import { EmptyState } from "../components/EmptyState"; +import { MessageList } from "../components/MessageList"; +import { RuntimeStyleControlBar } from "../components/RuntimeStyleControlBar"; +import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; +import { + ChatContainer, + ChatContainerInner, + ChatContent, + EntryBanner, + EntryBannerClose, + MessageViewport, +} from "./WorkspaceStyles"; + +interface WorkspaceChatContentProps { + entryBannerVisible: boolean; + entryBannerMessage?: string; + onDismissEntryBanner: () => void; + stepProgressProps?: ComponentProps | null; + runtimeStyleControlBarProps?: ComponentProps< + typeof RuntimeStyleControlBar + > | null; + showChatLayout: boolean; + compactChrome: boolean; + contextWorkspaceEnabled: boolean; + themeWorkbenchMessageViewportBottomPadding?: string; + messageListProps: ComponentProps; + teamWorkspaceDockProps?: ComponentProps | null; + emptyStateProps: ComponentProps; + showWorkspaceAlert: boolean; + onSelectWorkspaceDirectory: () => void; + onDismissWorkspaceAlert: () => void; + showInlineInputbar: boolean; + inputbarNode: ReactNode; +} + +export function WorkspaceChatContent({ + entryBannerVisible, + entryBannerMessage, + onDismissEntryBanner, + stepProgressProps, + runtimeStyleControlBarProps, + showChatLayout, + compactChrome, + contextWorkspaceEnabled, + themeWorkbenchMessageViewportBottomPadding, + messageListProps, + teamWorkspaceDockProps, + emptyStateProps, + showWorkspaceAlert, + onSelectWorkspaceDirectory, + onDismissWorkspaceAlert, + showInlineInputbar, + inputbarNode, +}: WorkspaceChatContentProps) { + const messageListNode = ( + + ); + + return ( + + + {entryBannerVisible && entryBannerMessage ? ( + + + {entryBannerMessage} + + 关闭 + + + ) : null} + + {stepProgressProps ? : null} + {runtimeStyleControlBarProps ? ( + + ) : null} + + {showChatLayout ? ( + + <> + {contextWorkspaceEnabled ? ( + + {messageListNode} + + ) : ( + messageListNode + )} + {teamWorkspaceDockProps ? ( + + ) : null} + + + ) : ( + + )} + + {showChatLayout && ( + <> + {showWorkspaceAlert ? ( +
+ + 工作区目录不存在,请重新选择一个本地目录后继续 + + + +
+ ) : null} + {showInlineInputbar ? inputbarNode : null} + + )} +
+
+ ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceContentSyncNotice.tsx b/src/components/agent/chat/workspace/WorkspaceContentSyncNotice.tsx new file mode 100644 index 000000000..edea9de12 --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceContentSyncNotice.tsx @@ -0,0 +1,51 @@ +import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; +import type { SyncStatus } from "../hooks/useContentSync"; +import { ContentSyncNotice, ContentSyncNoticeText } from "./WorkspaceStyles"; + +interface WorkspaceContentSyncNoticeProps { + status: Exclude; +} + +function resolveContentSyncNoticeMeta( + status: Exclude, +): { + label: string; + Icon: typeof Loader2; + animated?: boolean; +} { + switch (status) { + case "syncing": + return { + label: "正在同步到当前内容…", + Icon: Loader2, + animated: true, + }; + case "success": + return { + label: "内容已同步", + Icon: CheckCircle2, + }; + case "error": + default: + return { + label: "同步失败,将自动重试", + Icon: AlertTriangle, + }; + } +} + +export function WorkspaceContentSyncNotice({ + status, +}: WorkspaceContentSyncNoticeProps) { + const notice = resolveContentSyncNoticeMeta(status); + const NoticeIcon = notice.Icon; + + return ( + + + {notice.label} + + ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx new file mode 100644 index 000000000..c61d3c9f3 --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx @@ -0,0 +1,348 @@ +import type { ComponentProps } from "react"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import { ChatNavbar } from "../components/ChatNavbar"; +import { EmptyState } from "../components/EmptyState"; +import { WorkspaceChatContent } from "./WorkspaceChatContent"; +import { WorkspaceMainScene } from "./WorkspaceMainScene"; +import { + buildWorkspaceEmptyStateProps, + buildWorkspaceNavbarProps, +} from "./chatSurfaceProps"; +import { isCanvasStateEmpty } from "./themeWorkbenchHelpers"; + +type WorkspaceMainSceneProps = Omit< + ComponentProps, + "chatContent" | "chatNavbarProps" +>; +type ChatToolPreferences = { + webSearch: boolean; + thinking: boolean; + task: boolean; + subagent: boolean; +}; +type ChatToolPreferenceKey = keyof ChatToolPreferences; + +interface WorkspaceConversationSceneProps extends WorkspaceMainSceneProps { + entryBannerVisible: boolean; + entryBannerMessage?: string; + onDismissEntryBanner: () => void; + stepProgressProps?: ComponentProps["stepProgressProps"]; + runtimeStyleControlBarProps?: ComponentProps< + typeof WorkspaceChatContent + >["runtimeStyleControlBarProps"]; + showChatLayout: boolean; + contextWorkspaceEnabled: boolean; + themeWorkbenchMessageViewportBottomPadding?: string; + messageListProps: ComponentProps["messageListProps"]; + teamWorkspaceDockProps?: ComponentProps< + typeof WorkspaceChatContent + >["teamWorkspaceDockProps"]; + workspaceAlertVisible: boolean; + onSelectWorkspaceDirectory: () => void; + onDismissWorkspaceAlert: () => void; + shouldHideThemeWorkbenchInputForTheme: boolean; + input: ComponentProps["input"]; + setInput: ComponentProps["setInput"]; + onSendMessage: ComponentProps["onSend"]; + providerType: ComponentProps["providerType"]; + setProviderType: ComponentProps["setProviderType"]; + model: ComponentProps["model"]; + setModel: ComponentProps["setModel"]; + executionStrategy: ComponentProps["executionStrategy"]; + setExecutionStrategy?: ComponentProps< + typeof EmptyState + >["setExecutionStrategy"]; + onManageProviders?: ComponentProps["onManageProviders"]; + toolPreferences: ChatToolPreferences; + onToolPreferenceChange: ( + key: ChatToolPreferenceKey, + enabled: boolean, + ) => void; + selectedTeam: ComponentProps["selectedTeam"]; + onSelectTeam?: ComponentProps["onSelectTeam"]; + onEnableSuggestedTeam?: ComponentProps< + typeof EmptyState + >["onEnableSuggestedTeam"]; + creationMode: ComponentProps["creationMode"]; + onCreationModeChange?: ComponentProps< + typeof EmptyState + >["onCreationModeChange"]; + activeTheme: ComponentProps["activeTheme"]; + onThemeChange?: NonNullable["onThemeChange"]>; + themeLocked: boolean; + artifactsCount: number; + generalCanvasContent?: string | null; + resolvedCanvasState: CanvasStateUnion | null; + selectedText: ComponentProps["selectedText"]; + onRecommendationClick?: ComponentProps< + typeof EmptyState + >["onRecommendationClick"]; + characters: NonNullable["characters"]>; + skills: NonNullable["skills"]>; + isSkillsLoading: boolean; + onNavigateToSettings?: ComponentProps< + typeof EmptyState + >["onNavigateToSettings"]; + onRefreshSkills?: ComponentProps["onRefreshSkills"]; + onLaunchBrowserAssist?: ComponentProps< + typeof EmptyState + >["onLaunchBrowserAssist"]; + browserAssistLoading: boolean; + projectId: string | null; + onProjectChange?: ComponentProps["onProjectChange"]; + onOpenSettings?: () => void; + navbarVisible: boolean; + isRunning: boolean; + navbarChrome: ComponentProps["chrome"]; + onToggleHistory: NonNullable["onToggleHistory"]>; + showHistoryToggle: boolean; + onBackToProjectManagement?: ComponentProps< + typeof ChatNavbar + >["onBackToProjectManagement"]; + onBackToResources?: ComponentProps["onBackToResources"]; + onToggleCanvas?: ComponentProps["onToggleCanvas"]; + onBackHome?: ComponentProps["onBackHome"]; + chatMode: string; + browserAssistAttentionLevel: ComponentProps< + typeof ChatNavbar + >["browserAssistAttentionLevel"]; + browserAssistLabel?: ComponentProps["browserAssistLabel"]; + onOpenBrowserAssist?: () => Promise | void; + showHarnessToggle: boolean; + harnessPanelVisible: boolean; + onToggleHarnessPanel?: ComponentProps["onToggleHarnessPanel"]; + harnessPendingCount: number; + harnessAttentionLevel: ComponentProps< + typeof ChatNavbar + >["harnessAttentionLevel"]; + showContextCompactionAction?: ComponentProps< + typeof ChatNavbar + >["showContextCompactionAction"]; + contextCompactionRunning?: ComponentProps< + typeof ChatNavbar + >["contextCompactionRunning"]; + onCompactContext?: ComponentProps["onCompactContext"]; + novelCanvasControls?: ComponentProps["novelCanvasControls"]; +} + +export function WorkspaceConversationScene({ + entryBannerVisible, + entryBannerMessage, + onDismissEntryBanner, + stepProgressProps, + runtimeStyleControlBarProps, + showChatLayout, + compactChrome, + contextWorkspaceEnabled, + themeWorkbenchMessageViewportBottomPadding, + messageListProps, + teamWorkspaceDockProps, + workspaceAlertVisible, + onSelectWorkspaceDirectory, + onDismissWorkspaceAlert, + shouldHideThemeWorkbenchInputForTheme, + inputbarNode, + input, + setInput, + onSendMessage, + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + onManageProviders, + toolPreferences, + onToolPreferenceChange, + selectedTeam, + onSelectTeam, + onEnableSuggestedTeam, + creationMode, + onCreationModeChange, + activeTheme, + onThemeChange, + themeLocked, + artifactsCount, + generalCanvasContent, + resolvedCanvasState, + contentId, + selectedText, + onRecommendationClick, + characters, + skills, + isSkillsLoading, + onNavigateToSettings, + onRefreshSkills, + onLaunchBrowserAssist, + browserAssistLoading, + projectId, + onProjectChange, + onOpenSettings, + navbarVisible, + isRunning, + navbarChrome, + onToggleHistory, + showHistoryToggle, + onBackToProjectManagement, + onBackToResources, + isThemeWorkbench, + layoutMode, + onToggleCanvas, + onBackHome, + chatMode, + isBrowserAssistCanvasVisible, + browserAssistAttentionLevel, + browserAssistLabel, + onOpenBrowserAssist, + showHarnessToggle, + harnessPanelVisible, + onToggleHarnessPanel, + harnessPendingCount, + harnessAttentionLevel, + showContextCompactionAction, + contextCompactionRunning, + onCompactContext, + novelCanvasControls, + syncStatus, + hasLiveCanvasPreviewContent, + liveCanvasPreview, + currentImageWorkbenchActive, + shouldShowCanvasLoadingState, + teamWorkbenchView, + canvasWorkbenchLayoutProps, + shellBottomInset, + chatPanelWidth, + chatPanelMinWidth, + generalWorkbenchDialog, + themeWorkbenchHarnessDialog, + showFloatingInputOverlay, + hasPendingA2UIForm, +}: WorkspaceConversationSceneProps) { + const emptyStateProps = buildWorkspaceEmptyStateProps({ + input, + setInput, + onSendMessage, + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + onManageProviders, + toolPreferences, + onToolPreferenceChange, + selectedTeam, + onSelectTeam, + onEnableSuggestedTeam, + creationMode, + onCreationModeChange, + activeTheme, + onThemeChange, + themeLocked, + hasCanvasContent: + activeTheme === "general" + ? artifactsCount > 0 || Boolean(generalCanvasContent?.trim()) + : !isCanvasStateEmpty(resolvedCanvasState), + hasContentId: Boolean(contentId), + selectedText, + onRecommendationClick, + characters, + skills, + isSkillsLoading, + onNavigateToSettings, + onRefreshSkills, + onLaunchBrowserAssist, + browserAssistLoading, + projectId, + onProjectChange, + onOpenSettings, + }); + + const chatContent = ( + + ); + + const chatNavbarProps = buildWorkspaceNavbarProps({ + visible: navbarVisible, + isRunning, + chrome: navbarChrome, + onToggleHistory, + showHistoryToggle, + onBackToProjectManagement, + onBackToResources, + showCanvasToggle: !isThemeWorkbench, + isCanvasOpen: layoutMode !== "chat", + onToggleCanvas, + projectId, + onProjectChange, + workspaceType: activeTheme, + onBackHome, + showBrowserAssistEntry: chatMode === "general" && !isThemeWorkbench, + browserAssistActive: isBrowserAssistCanvasVisible, + browserAssistLoading, + browserAssistAttentionLevel, + browserAssistLabel, + onOpenBrowserAssist, + showHarnessToggle, + harnessPanelVisible, + onToggleHarnessPanel, + harnessPendingCount, + harnessAttentionLevel, + harnessToggleLabel: + chatMode === "general" && !isThemeWorkbench ? "工作台" : undefined, + showContextCompactionAction, + contextCompactionRunning, + onCompactContext, + onOpenSettings, + novelCanvasControls, + }); + + return ( + + ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceHarnessDialog.tsx b/src/components/agent/chat/workspace/WorkspaceHarnessDialog.tsx new file mode 100644 index 000000000..44d02b4ed --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceHarnessDialog.tsx @@ -0,0 +1,30 @@ +import type { ComponentProps } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { HarnessStatusPanel } from "../components/HarnessStatusPanel"; + +interface WorkspaceHarnessDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + maxWidth: string; + panelProps: ComponentProps; +} + +export function WorkspaceHarnessDialog({ + open, + onOpenChange, + maxWidth, + panelProps, +}: WorkspaceHarnessDialogProps) { + return ( + + + + + + ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx b/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx new file mode 100644 index 000000000..33a122949 --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx @@ -0,0 +1,122 @@ +import type { ComponentProps } from "react"; +import { AgentRuntimeStrip } from "../components/AgentRuntimeStrip"; +import { HarnessStatusPanel } from "../components/HarnessStatusPanel"; +import { WorkspaceHarnessDialog } from "./WorkspaceHarnessDialog"; + +type HarnessPanelBaseProps = Pick< + ComponentProps, + | "harnessState" + | "compatSubagentRuntime" + | "environment" + | "childSubagentSessions" + | "selectedTeamLabel" + | "selectedTeamSummary" + | "selectedTeamRoles" + | "threadRead" + | "turns" + | "threadItems" + | "currentTurnId" + | "pendingActions" + | "submittedActionsInFlight" + | "queuedTurns" + | "canInterrupt" + | "onInterruptCurrentTurn" + | "onResumeThread" + | "onReplayPendingRequest" + | "onPromoteQueuedTurn" + | "toolInventory" + | "toolInventoryLoading" + | "toolInventoryError" + | "onRefreshToolInventory" + | "onOpenSubagentSession" + | "onLoadFilePreview" + | "onOpenFile" +>; + +interface ThemeWorkbenchHarnessDialogSectionProps extends HarnessPanelBaseProps { + enabled: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ThemeWorkbenchHarnessDialogSection({ + enabled, + open, + onOpenChange, + ...panelBaseProps +}: ThemeWorkbenchHarnessDialogSectionProps) { + if (!enabled) { + return null; + } + + return ( + + ); +} + +interface GeneralWorkbenchDialogSectionProps extends HarnessPanelBaseProps { + enabled: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + activeTheme: ComponentProps["activeTheme"]; + toolPreferences: ComponentProps["toolPreferences"]; + isSending: ComponentProps["isSending"]; + runtimeStatusTitle: ComponentProps["runtimeStatusTitle"]; + selectedTeamRoleCount: ComponentProps< + typeof AgentRuntimeStrip + >["selectedTeamRoleCount"]; +} + +export function GeneralWorkbenchDialogSection({ + enabled, + open, + onOpenChange, + activeTheme, + toolPreferences, + isSending, + runtimeStatusTitle, + selectedTeamRoleCount, + ...panelBaseProps +}: GeneralWorkbenchDialogSectionProps) { + if (!enabled) { + return null; + } + + return ( + + ), + }} + /> + ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceInputbar.tsx b/src/components/agent/chat/workspace/WorkspaceInputbar.tsx new file mode 100644 index 000000000..cbb7eaaf7 --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceInputbar.tsx @@ -0,0 +1,27 @@ +import { type ComponentProps, type ReactNode } from "react"; +import { Inputbar } from "../components/Inputbar"; +import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; + +interface WorkspaceInputbarProps { + inputbarProps: Omit, "overlayAccessory">; + accessory?: ReactNode; + teamWorkspaceDockProps?: ComponentProps | null; +} + +export function WorkspaceInputbar({ + inputbarProps, + accessory, + teamWorkspaceDockProps, +}: WorkspaceInputbarProps) { + const overlayAccessory = + accessory || teamWorkspaceDockProps ? ( + <> + {accessory} + {teamWorkspaceDockProps ? ( + + ) : null} + + ) : undefined; + + return ; +} diff --git a/src/components/agent/chat/workspace/WorkspaceMainArea.tsx b/src/components/agent/chat/workspace/WorkspaceMainArea.tsx new file mode 100644 index 000000000..cb70626f0 --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceMainArea.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; +import type { LayoutMode } from "@/components/content-creator/types"; +import { + LayoutTransitionRenderGate, + MainArea, + ThemeWorkbenchInputOverlay, + ThemeWorkbenchLayoutShell, +} from "./WorkspaceStyles"; + +interface WorkspaceMainAreaProps { + compactChrome: boolean; + navbarNode: ReactNode; + contentSyncNoticeNode: ReactNode; + shellBottomInset: string; + layoutMode: LayoutMode; + forceCanvasMode: boolean; + chatContent: ReactNode; + canvasContent: ReactNode; + chatPanelWidth?: string; + chatPanelMinWidth?: string; + generalWorkbenchDialog: ReactNode; + themeWorkbenchHarnessDialog: ReactNode; + showFloatingInputOverlay: boolean; + hasPendingA2UIForm: boolean; + inputbarNode: ReactNode; +} + +export function WorkspaceMainArea({ + compactChrome, + navbarNode, + contentSyncNoticeNode, + shellBottomInset, + layoutMode, + forceCanvasMode, + chatContent, + canvasContent, + chatPanelWidth, + chatPanelMinWidth, + generalWorkbenchDialog, + themeWorkbenchHarnessDialog, + showFloatingInputOverlay, + hasPendingA2UIForm, + inputbarNode, +}: WorkspaceMainAreaProps) { + return ( + + {navbarNode} + {contentSyncNoticeNode} + + + + {generalWorkbenchDialog} + {themeWorkbenchHarnessDialog} + {showFloatingInputOverlay ? ( + + {inputbarNode} + + ) : null} + + ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceMainScene.tsx b/src/components/agent/chat/workspace/WorkspaceMainScene.tsx new file mode 100644 index 000000000..eadcd4624 --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceMainScene.tsx @@ -0,0 +1,98 @@ +import type { ComponentProps, ReactNode } from "react"; +import type { SyncStatus } from "../hooks/useContentSync"; +import { ChatNavbar } from "../components/ChatNavbar"; +import { WorkspaceCanvasContent } from "./WorkspaceCanvasContent"; +import { WorkspaceContentSyncNotice } from "./WorkspaceContentSyncNotice"; +import { WorkspaceMainArea } from "./WorkspaceMainArea"; + +type WorkspaceCanvasContentProps = ComponentProps; +type WorkspaceMainAreaProps = ComponentProps; + +interface WorkspaceMainSceneProps { + chatNavbarProps: ComponentProps | null; + isThemeWorkbench: boolean; + contentId?: string; + syncStatus: SyncStatus; + hasLiveCanvasPreviewContent: boolean; + liveCanvasPreview: ReactNode; + currentImageWorkbenchActive: WorkspaceCanvasContentProps["currentImageWorkbenchActive"]; + shouldShowCanvasLoadingState: WorkspaceCanvasContentProps["shouldShowCanvasLoadingState"]; + isBrowserAssistCanvasVisible: WorkspaceCanvasContentProps["isBrowserAssistCanvasVisible"]; + teamWorkbenchView: WorkspaceCanvasContentProps["teamWorkbenchView"]; + canvasWorkbenchLayoutProps: WorkspaceCanvasContentProps["canvasWorkbenchLayoutProps"]; + compactChrome: WorkspaceMainAreaProps["compactChrome"]; + shellBottomInset: WorkspaceMainAreaProps["shellBottomInset"]; + layoutMode: WorkspaceMainAreaProps["layoutMode"]; + chatContent: WorkspaceMainAreaProps["chatContent"]; + chatPanelWidth?: WorkspaceMainAreaProps["chatPanelWidth"]; + chatPanelMinWidth?: WorkspaceMainAreaProps["chatPanelMinWidth"]; + generalWorkbenchDialog: WorkspaceMainAreaProps["generalWorkbenchDialog"]; + themeWorkbenchHarnessDialog: WorkspaceMainAreaProps["themeWorkbenchHarnessDialog"]; + showFloatingInputOverlay: WorkspaceMainAreaProps["showFloatingInputOverlay"]; + hasPendingA2UIForm: WorkspaceMainAreaProps["hasPendingA2UIForm"]; + inputbarNode: WorkspaceMainAreaProps["inputbarNode"]; +} + +export function WorkspaceMainScene({ + chatNavbarProps, + isThemeWorkbench, + contentId, + syncStatus, + hasLiveCanvasPreviewContent, + liveCanvasPreview, + currentImageWorkbenchActive, + shouldShowCanvasLoadingState, + isBrowserAssistCanvasVisible, + teamWorkbenchView, + canvasWorkbenchLayoutProps, + compactChrome, + shellBottomInset, + layoutMode, + chatContent, + chatPanelWidth, + chatPanelMinWidth, + generalWorkbenchDialog, + themeWorkbenchHarnessDialog, + showFloatingInputOverlay, + hasPendingA2UIForm, + inputbarNode, +}: WorkspaceMainSceneProps) { + const navbarNode = chatNavbarProps ? : null; + const contentSyncNoticeNode = + !isThemeWorkbench && contentId && syncStatus !== "idle" ? ( + + ) : null; + const canvasContent = ( + + ); + + return ( + + ); +} diff --git a/src/components/agent/chat/workspace/WorkspacePageShell.tsx b/src/components/agent/chat/workspace/WorkspacePageShell.tsx new file mode 100644 index 000000000..f26ec48ce --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspacePageShell.tsx @@ -0,0 +1,50 @@ +import type { ComponentProps, ReactNode } from "react"; +import { PanelLeftOpen } from "lucide-react"; +import { ChatSidebar } from "../components/ChatSidebar"; +import { PageContainer, ThemeWorkbenchLeftExpandButton } from "./WorkspaceStyles"; + +interface WorkspacePageShellProps { + compactChrome: boolean; + isThemeWorkbench: boolean; + themeWorkbenchSidebarNode: ReactNode; + showChatPanel: boolean; + showSidebar: boolean; + chatSidebarProps: ComponentProps | null; + showThemeWorkbenchLeftExpandButton: boolean; + onExpandThemeWorkbenchSidebar: () => void; + mainAreaNode: ReactNode; +} + +export function WorkspacePageShell({ + compactChrome, + isThemeWorkbench, + themeWorkbenchSidebarNode, + showChatPanel, + showSidebar, + chatSidebarProps, + showThemeWorkbenchLeftExpandButton, + onExpandThemeWorkbenchSidebar, + mainAreaNode, +}: WorkspacePageShellProps) { + return ( + + {isThemeWorkbench ? ( + themeWorkbenchSidebarNode + ) : showChatPanel && showSidebar && chatSidebarProps ? ( + + ) : null} + {showThemeWorkbenchLeftExpandButton ? ( + + + + ) : null} + + {mainAreaNode} + + ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceShellScene.tsx b/src/components/agent/chat/workspace/WorkspaceShellScene.tsx new file mode 100644 index 000000000..681096f22 --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceShellScene.tsx @@ -0,0 +1,99 @@ +import type { ComponentProps } from "react"; +import { ChatSidebar } from "../components/ChatSidebar"; +import { buildWorkspaceChatSidebarProps } from "./chatSurfaceProps"; +import { WorkspacePageShell } from "./WorkspacePageShell"; + +type WorkspacePageShellProps = Omit< + ComponentProps, + "chatSidebarProps" +>; + +interface WorkspaceShellSceneProps extends WorkspacePageShellProps { + currentTopicId: ComponentProps["currentTopicId"]; + topics: ComponentProps["topics"]; + onNewChat: ComponentProps["onNewChat"]; + onSwitchTopic: ComponentProps["onSwitchTopic"]; + onResumeTask: ComponentProps["onResumeTask"]; + onDeleteTopic: ComponentProps["onDeleteTopic"]; + onRenameTopic: ComponentProps["onRenameTopic"]; + currentMessages: ComponentProps["currentMessages"]; + isSending: ComponentProps["isSending"]; + pendingActionCount: number; + queuedTurnCount: number; + workspaceError: boolean; + childSubagentSessions: ComponentProps< + typeof ChatSidebar + >["childSubagentSessions"]; + subagentParentContext: ComponentProps< + typeof ChatSidebar + >["subagentParentContext"]; + onOpenSubagentSession: ComponentProps< + typeof ChatSidebar + >["onOpenSubagentSession"]; + onReturnToParentSession: ComponentProps< + typeof ChatSidebar + >["onReturnToParentSession"]; +} + +export function WorkspaceShellScene({ + compactChrome, + isThemeWorkbench, + themeWorkbenchSidebarNode, + showChatPanel, + showSidebar, + showThemeWorkbenchLeftExpandButton, + onExpandThemeWorkbenchSidebar, + mainAreaNode, + currentTopicId, + topics, + onNewChat, + onSwitchTopic, + onResumeTask, + onDeleteTopic, + onRenameTopic, + currentMessages, + isSending, + pendingActionCount, + queuedTurnCount, + workspaceError, + childSubagentSessions, + subagentParentContext, + onOpenSubagentSession, + onReturnToParentSession, +}: WorkspaceShellSceneProps) { + const chatSidebarProps = + !isThemeWorkbench && showChatPanel && showSidebar + ? buildWorkspaceChatSidebarProps({ + onNewChat, + topics, + currentTopicId, + onSwitchTopic, + onResumeTask, + onDeleteTopic, + onRenameTopic, + currentMessages, + isSending, + pendingActionCount, + queuedTurnCount, + workspaceError, + childSubagentSessions, + subagentParentContext, + onOpenSubagentSession, + onReturnToParentSession, + }) + : null; + + return ( + + ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceStyles.tsx b/src/components/agent/chat/workspace/WorkspaceStyles.tsx new file mode 100644 index 000000000..8b54e6b1b --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceStyles.tsx @@ -0,0 +1,314 @@ +import { memo, type ReactNode } from "react"; +import styled from "styled-components"; +import { LayoutTransition } from "@/components/content-creator/core/LayoutTransition/LayoutTransition"; +import type { LayoutMode } from "@/components/content-creator/types"; +import type { SyncStatus } from "../hooks/useContentSync"; + +export const PageContainer = styled.div<{ $compact?: boolean }>` + display: flex; + height: 100%; + width: 100%; + position: relative; + min-height: 0; + gap: ${({ $compact }) => ($compact ? "8px" : "14px")}; + padding: ${({ $compact }) => ($compact ? "4px 8px 8px" : "8px 14px 14px")}; + box-sizing: border-box; + overflow: hidden; + isolation: isolate; + background: + radial-gradient( + circle at 14% 18%, + rgba(56, 189, 248, 0.1), + transparent 30% + ), + radial-gradient( + circle at 86% 14%, + rgba(16, 185, 129, 0.08), + transparent 28% + ), + radial-gradient( + circle at 72% 84%, + rgba(245, 158, 11, 0.06), + transparent 24% + ), + linear-gradient( + 180deg, + rgba(248, 250, 252, 0.98) 0%, + rgba(248, 250, 252, 0.96) 42%, + rgba(242, 251, 247, 0.94) 100% + ); + + > * { + position: relative; + z-index: 1; + } +`; + +export const MainArea = styled.div<{ $compact?: boolean }>` + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + position: relative; + border: 1px solid rgba(226, 232, 240, 0.88); + border-radius: ${({ $compact }) => ($compact ? "24px" : "32px")}; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.96) 0%, + rgba(248, 250, 252, 0.94) 56%, + rgba(248, 250, 252, 0.88) 100% + ); + box-shadow: + 0 24px 72px -36px rgba(15, 23, 42, 0.18), + 0 16px 28px -24px rgba(15, 23, 42, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.76); + backdrop-filter: blur(18px); +`; + +function resolveContentSyncTone(status: SyncStatus): { + text: string; + background: string; + border: string; +} { + switch (status) { + case "syncing": + return { + text: "#475569", + background: + "linear-gradient(180deg, rgba(255,255,255,0.94) 0%, rgba(248,250,252,0.92) 100%)", + border: "rgba(226, 232, 240, 0.9)", + }; + case "success": + return { + text: "#047857", + background: + "linear-gradient(180deg, rgba(236,253,245,0.98) 0%, rgba(220,252,231,0.92) 100%)", + border: "rgba(167, 243, 208, 0.95)", + }; + case "error": + return { + text: "#be123c", + background: + "linear-gradient(180deg, rgba(255,241,242,0.98) 0%, rgba(255,228,230,0.92) 100%)", + border: "rgba(254, 205, 211, 0.95)", + }; + case "idle": + default: + return { + text: "#475569", + background: + "linear-gradient(180deg, rgba(255,255,255,0.94) 0%, rgba(248,250,252,0.9) 100%)", + border: "rgba(226, 232, 240, 0.88)", + }; + } +} + +export const ContentSyncNotice = styled.div<{ $status: SyncStatus }>` + ${({ $status }) => { + const tone = resolveContentSyncTone($status); + return ` + display: flex; + align-items: center; + gap: 8px; + margin: -2px 14px 10px; + padding: 8px 12px; + border: 1px solid ${tone.border}; + border-radius: 14px; + background: ${tone.background}; + color: ${tone.text}; + box-shadow: 0 10px 24px hsl(var(--foreground) / 0.03); + `; + }} +`; + +export const ContentSyncNoticeText = styled.span` + font-size: 12px; + font-weight: 500; + line-height: 1.4; +`; + +export const ChatContainer = styled.div` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: 100%; +`; + +export const ChatContainerInner = styled.div` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: 100%; + overflow: hidden; + background: linear-gradient( + 180deg, + rgba(248, 250, 252, 0.78) 0%, + rgba(255, 255, 255, 0.12) 18%, + rgba(255, 255, 255, 0) 100% + ); +`; + +export const EntryBanner = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin: 8px 12px 0; + padding: 10px 12px; + border-radius: 18px; + border: 1px solid rgba(191, 219, 254, 0.9); + background: linear-gradient( + 180deg, + rgba(239, 246, 255, 0.96) 0%, + rgba(248, 250, 252, 0.92) 100% + ); + color: #0f172a; + font-size: 13px; + box-shadow: 0 10px 22px -20px rgba(15, 23, 42, 0.16); +`; + +export const EntryBannerClose = styled.button` + margin-left: auto; + border: none; + background: transparent; + color: #64748b; + cursor: pointer; + font-size: 13px; +`; + +export const ChatContent = styled.div<{ $compact?: boolean }>` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: ${({ $compact }) => ($compact ? "0 6px 6px" : "0 10px 10px")}; + overflow: hidden; + height: 100%; + position: relative; +`; + +export const MessageViewport = styled.div<{ $bottomPadding?: string }>` + flex: 1; + min-height: 0; + overflow: hidden; + padding-bottom: ${({ $bottomPadding }) => $bottomPadding || "128px"}; +`; + +export const ThemeWorkbenchInputOverlay = styled.div<{ + $hasPendingA2UIForm?: boolean; +}>` + position: absolute; + left: 24px; + right: 24px; + bottom: 20px; + z-index: 25; + pointer-events: none; + display: flex; + justify-content: center; + box-sizing: border-box; + + > * { + pointer-events: auto; + width: ${({ $hasPendingA2UIForm }) => + $hasPendingA2UIForm + ? "min(calc(100% - 24px), 880px)" + : "min(calc(100% - 16px), 480px)"}; + max-width: 100%; + } +`; + +export const ThemeWorkbenchLayoutShell = styled.div<{ $bottomInset: string }>` + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + box-sizing: border-box; + padding-bottom: ${({ $bottomInset }) => $bottomInset}; + transition: padding-bottom 0.2s ease; +`; + +export const ThemeWorkbenchCanvasHost = styled.div` + flex: 1; + min-height: 0; + + > * { + height: 100%; + } +`; + +export const ThemeWorkbenchLeftExpandButton = styled.button` + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 78px; + border: 1px solid rgba(226, 232, 240, 0.92); + border-radius: 14px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.94) 0%, + rgba(248, 250, 252, 0.9) 100% + ); + color: #64748b; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 30; + box-shadow: 0 14px 28px -24px rgba(15, 23, 42, 0.2); + + &:hover { + color: #0f172a; + border-color: rgba(148, 163, 184, 0.84); + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(241, 245, 249, 0.92) 100% + ); + } +`; + +interface LayoutTransitionRenderGateProps { + mode: LayoutMode; + chatContent: ReactNode; + canvasContent: ReactNode; + chatPanelWidth?: string; + chatPanelMinWidth?: string; +} + +export const LayoutTransitionRenderGate = memo( + ({ + mode, + chatContent, + canvasContent, + chatPanelWidth, + chatPanelMinWidth, + }: LayoutTransitionRenderGateProps) => ( + + + + ), + (previous, next) => + previous.mode === next.mode && + previous.chatContent === next.chatContent && + previous.canvasContent === next.canvasContent && + previous.chatPanelWidth === next.chatPanelWidth && + previous.chatPanelMinWidth === next.chatPanelMinWidth, +); + +LayoutTransitionRenderGate.displayName = "LayoutTransitionRenderGate"; + +export const TEAM_PRIMARY_CHAT_PANEL_WIDTH = + "min(100%, clamp(420px, 34%, 560px))"; +export const TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH = "400px"; diff --git a/src/components/agent/chat/workspace/WorkspaceThemeSidebar.tsx b/src/components/agent/chat/workspace/WorkspaceThemeSidebar.tsx new file mode 100644 index 000000000..6f7dc649b --- /dev/null +++ b/src/components/agent/chat/workspace/WorkspaceThemeSidebar.tsx @@ -0,0 +1,139 @@ +import type { ComponentProps } from "react"; +import type { useThemeContextWorkspace } from "../hooks"; +import { SocialMediaHarnessCard } from "../components/SocialMediaHarnessCard"; +import { ThemeWorkbenchSidebarSection } from "./ThemeWorkbenchSidebarSection"; + +type ThemeWorkbenchSidebarSectionProps = ComponentProps< + typeof ThemeWorkbenchSidebarSection +>; +type ThemeWorkbenchSidebarWorkflowProps = + ThemeWorkbenchSidebarSectionProps["workflowProps"]; +type ThemeWorkbenchSidebarHistoryProps = NonNullable< + ThemeWorkbenchSidebarSectionProps["historyProps"] +>; +type SocialMediaHarnessSummary = Pick< + ComponentProps, + | "runState" + | "stageTitle" + | "stageDescription" + | "runTitle" + | "artifactCount" + | "updatedAt" + | "pendingCount" +>; + +interface WorkspaceThemeSidebarProps { + visible: boolean; + isThemeWorkbench: boolean; + enablePanelCollapse: boolean; + onRequestCollapse: NonNullable< + ThemeWorkbenchSidebarSectionProps["onRequestCollapse"] + >; + socialMediaHarnessSummary: SocialMediaHarnessSummary | null; + harnessPanelVisible: boolean; + onToggleHarnessPanel: NonNullable< + ComponentProps["onToggleHarnessPanel"] + >; + workflow: { + branchItems: ThemeWorkbenchSidebarWorkflowProps["branchItems"]; + onCreateVersionSnapshot: ThemeWorkbenchSidebarWorkflowProps["onNewTopic"]; + onSwitchBranchVersion: ThemeWorkbenchSidebarWorkflowProps["onSwitchTopic"]; + onDeleteTopic: ThemeWorkbenchSidebarWorkflowProps["onDeleteTopic"]; + onSetBranchStatus: ThemeWorkbenchSidebarWorkflowProps["onSetBranchStatus"]; + workflowSteps: ThemeWorkbenchSidebarWorkflowProps["workflowSteps"]; + onAddImage: ThemeWorkbenchSidebarWorkflowProps["onAddImage"]; + onImportDocument: ThemeWorkbenchSidebarWorkflowProps["onImportDocument"]; + activityLogs: ThemeWorkbenchSidebarWorkflowProps["activityLogs"]; + creationTaskEvents: ThemeWorkbenchSidebarWorkflowProps["creationTaskEvents"]; + onViewRunDetail: ThemeWorkbenchSidebarWorkflowProps["onViewRunDetail"]; + activeRunDetail: ThemeWorkbenchSidebarWorkflowProps["activeRunDetail"]; + activeRunDetailLoading: ThemeWorkbenchSidebarWorkflowProps["activeRunDetailLoading"]; + }; + contextWorkspace: ReturnType; + onViewContextDetail?: ThemeWorkbenchSidebarSectionProps["onViewContextDetail"]; + history?: { + hasMore?: ThemeWorkbenchSidebarHistoryProps["hasMore"]; + loading?: ThemeWorkbenchSidebarHistoryProps["loading"]; + onLoadMore?: ThemeWorkbenchSidebarHistoryProps["onLoadMore"]; + skillDetailMap?: ThemeWorkbenchSidebarHistoryProps["skillDetailMap"]; + messages?: ThemeWorkbenchSidebarHistoryProps["messages"]; + }; +} + +export function WorkspaceThemeSidebar({ + visible, + isThemeWorkbench, + enablePanelCollapse, + onRequestCollapse, + socialMediaHarnessSummary, + harnessPanelVisible, + onToggleHarnessPanel, + workflow, + contextWorkspace, + onViewContextDetail, + history, +}: WorkspaceThemeSidebarProps) { + const headerActionSlot = + isThemeWorkbench && socialMediaHarnessSummary ? ( + + ) : null; + + return ( + + ); +} diff --git a/src/components/agent/chat/workspace/browserAssistArtifact.ts b/src/components/agent/chat/workspace/browserAssistArtifact.ts new file mode 100644 index 000000000..6e0cd276e --- /dev/null +++ b/src/components/agent/chat/workspace/browserAssistArtifact.ts @@ -0,0 +1,219 @@ +import type { Artifact } from "@/lib/artifact/types"; +import type { TaskStatusReason } from "../hooks/agentChatShared"; +import { mergeArtifacts } from "../utils/messageArtifacts"; + +export const GENERAL_BROWSER_ASSIST_ARTIFACT_ID = "browser-assist:general"; + +export function isResumableBrowserTaskReason( + statusReason?: TaskStatusReason, +): boolean { + return ( + statusReason === "browser_launching" || + statusReason === "browser_awaiting_user" || + statusReason === "browser_failed" + ); +} + +function shouldPreserveGeneralArtifact(artifact: Artifact): boolean { + return artifact.meta.persistOutsideMessages === true; +} + +export function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +export function readFirstString( + candidates: Array | null | undefined>, + keys: string[], +): string | undefined { + for (const candidate of candidates) { + if (!candidate) { + continue; + } + for (const key of keys) { + const value = candidate[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + } + + return undefined; +} + +export function resolveBrowserAssistArtifactScopeKey( + artifact: Pick | null | undefined, +): string | null { + if (!artifact || artifact.type !== "browser_assist") { + return null; + } + + const meta = asRecord(artifact.meta); + return ( + readFirstString(meta ? [meta] : [], [ + "browserAssistScopeKey", + "browser_assist_scope_key", + ]) || null + ); +} + +export function buildBrowserAssistArtifact(params: { + scopeKey: string; + profileKey: string; + browserSessionId: string; + url: string; + title?: string; + targetId?: string; + transportKind?: string; + lifecycleState?: string; + controlMode?: string; +}): Artifact { + const now = Date.now(); + + return { + id: GENERAL_BROWSER_ASSIST_ARTIFACT_ID, + type: "browser_assist", + title: params.title?.trim() || "浏览器协助", + content: "", + status: "complete", + error: undefined, + meta: { + persistOutsideMessages: true, + browserAssistScopeKey: params.scopeKey, + profileKey: params.profileKey, + sessionId: params.browserSessionId, + url: params.url, + launchState: "ready", + launchHint: undefined, + launchError: undefined, + ...(params.targetId ? { targetId: params.targetId } : {}), + ...(params.transportKind ? { transportKind: params.transportKind } : {}), + ...(params.lifecycleState + ? { lifecycleState: params.lifecycleState } + : {}), + ...(params.controlMode ? { controlMode: params.controlMode } : {}), + }, + position: { start: 0, end: 0 }, + createdAt: now, + updatedAt: now, + }; +} + +export function buildPendingBrowserAssistArtifact(params: { + scopeKey: string; + profileKey: string; + url: string; + title?: string; +}): Artifact { + const now = Date.now(); + + return { + id: GENERAL_BROWSER_ASSIST_ARTIFACT_ID, + type: "browser_assist", + title: params.title?.trim() || "浏览器协助", + content: "", + status: "pending", + error: undefined, + meta: { + persistOutsideMessages: true, + browserAssistScopeKey: params.scopeKey, + profileKey: params.profileKey, + url: params.url, + launchState: "launching", + launchHint: + "正在启动 Chrome、连接调试通道并等待首帧画面,通常需要 3–8 秒。", + launchError: undefined, + }, + position: { start: 0, end: 0 }, + createdAt: now, + updatedAt: now, + }; +} + +export function buildFailedBrowserAssistArtifact(params: { + scopeKey: string; + profileKey: string; + url: string; + title?: string; + error: string; +}): Artifact { + const now = Date.now(); + + return { + id: GENERAL_BROWSER_ASSIST_ARTIFACT_ID, + type: "browser_assist", + title: params.title?.trim() || "浏览器协助", + content: "", + status: "error", + error: params.error, + meta: { + persistOutsideMessages: true, + browserAssistScopeKey: params.scopeKey, + profileKey: params.profileKey, + url: params.url, + launchState: "failed", + launchHint: undefined, + launchError: params.error, + }, + position: { start: 0, end: 0 }, + createdAt: now, + updatedAt: now, + }; +} + +export function mergeMessageArtifactsIntoStore( + messageArtifacts: Artifact[], + currentArtifacts: Artifact[], + browserAssistScopeKey: string | null, +): Artifact[] { + const preservedArtifacts = currentArtifacts.filter( + (artifact) => + shouldPreserveGeneralArtifact(artifact) && + (artifact.type !== "browser_assist" || + resolveBrowserAssistArtifactScopeKey(artifact) === + browserAssistScopeKey), + ); + + if (messageArtifacts.length === 0) { + return mergeArtifacts(preservedArtifacts); + } + + const currentArtifactsById = new Map( + currentArtifacts.map((artifact) => [artifact.id, artifact]), + ); + + return mergeArtifacts([ + ...messageArtifacts.map((artifact) => { + const existing = currentArtifactsById.get(artifact.id); + if (!existing) { + return artifact; + } + + const shouldReuseExistingContent = + existing.content.length > 0 && + (artifact.content.length === 0 || + (artifact.status === "streaming" && + artifact.content.length < existing.content.length && + existing.content.startsWith(artifact.content))); + + return { + ...existing, + ...artifact, + content: shouldReuseExistingContent + ? existing.content + : artifact.content, + meta: { + ...existing.meta, + ...artifact.meta, + }, + createdAt: Math.min(existing.createdAt, artifact.createdAt), + updatedAt: Math.max(existing.updatedAt, artifact.updatedAt), + }; + }), + ...preservedArtifacts, + ]); +} diff --git a/src/components/agent/chat/workspace/canvasWorkbenchDefaultPreview.ts b/src/components/agent/chat/workspace/canvasWorkbenchDefaultPreview.ts new file mode 100644 index 000000000..cdccb5669 --- /dev/null +++ b/src/components/agent/chat/workspace/canvasWorkbenchDefaultPreview.ts @@ -0,0 +1,88 @@ +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { ThemeType } from "@/components/content-creator/types"; +import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge"; +import type { TaskFile } from "../components/TaskFiles"; +import type { CanvasWorkbenchDefaultPreview } from "../components/CanvasWorkbenchLayout"; +import { + isCanvasStateEmpty, + serializeCanvasStateForSync, +} from "./themeWorkbenchHelpers"; +import { resolvePreviousDocumentVersionContent } from "./workbenchPreviewHelpers"; +import { + extractFileNameFromPath, + resolveAbsoluteWorkspacePath, +} from "./workspacePath"; + +interface BuildCanvasWorkbenchDefaultPreviewParams { + workspaceRoot: string | null; + canvasRenderTheme: ThemeType; + generalCanvasState: Pick; + resolvedCanvasState: CanvasStateUnion | null; + activeCanvasTaskFile: TaskFile | null; +} + +export function buildCanvasWorkbenchDefaultPreview({ + workspaceRoot, + canvasRenderTheme, + generalCanvasState, + resolvedCanvasState, + activeCanvasTaskFile, +}: BuildCanvasWorkbenchDefaultPreviewParams): CanvasWorkbenchDefaultPreview | null { + if (canvasRenderTheme === "general") { + if (!generalCanvasState.isOpen || !generalCanvasState.content.trim()) { + return null; + } + + const filePath = generalCanvasState.filename?.trim() || undefined; + return { + title: filePath ? extractFileNameFromPath(filePath) : "当前画布草稿", + content: generalCanvasState.content, + filePath, + absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), + previousContent: null, + }; + } + + if (!resolvedCanvasState || isCanvasStateEmpty(resolvedCanvasState)) { + return null; + } + + const taskSelectionKey = activeCanvasTaskFile + ? `task:${activeCanvasTaskFile.id}` + : undefined; + + if (resolvedCanvasState.type === "document") { + const currentVersion = + resolvedCanvasState.versions.find( + (item) => item.id === resolvedCanvasState.currentVersionId, + ) || + resolvedCanvasState.versions[resolvedCanvasState.versions.length - 1] || + null; + const filePath = + activeCanvasTaskFile?.name || currentVersion?.metadata?.sourceFileName; + + return { + selectionKey: + taskSelectionKey || + (currentVersion ? `version:${currentVersion.id}` : undefined), + title: filePath ? extractFileNameFromPath(filePath) : "当前文稿", + content: resolvedCanvasState.content, + filePath, + absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), + previousContent: resolvePreviousDocumentVersionContent( + currentVersion, + resolvedCanvasState.versions, + ), + }; + } + + const filePath = activeCanvasTaskFile?.name; + return { + selectionKey: taskSelectionKey, + title: filePath ? extractFileNameFromPath(filePath) : "当前画布", + content: serializeCanvasStateForSync(resolvedCanvasState), + filePath, + absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), + previousContent: null, + }; +} diff --git a/src/components/agent/chat/workspace/chatSurfaceProps.ts b/src/components/agent/chat/workspace/chatSurfaceProps.ts new file mode 100644 index 000000000..e5143f77f --- /dev/null +++ b/src/components/agent/chat/workspace/chatSurfaceProps.ts @@ -0,0 +1,394 @@ +import type { ComponentProps } from "react"; +import { StepProgress } from "@/components/content-creator/core/StepGuide/StepProgress"; +import { ChatNavbar } from "../components/ChatNavbar"; +import { ChatSidebar } from "../components/ChatSidebar"; +import { EmptyState } from "../components/EmptyState"; +import { MessageList } from "../components/MessageList"; +import { RuntimeStyleControlBar } from "../components/RuntimeStyleControlBar"; +import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; +import type { TeamWorkbenchSurfaceProps } from "./teamWorkbenchPresentation"; + +type ChatToolPreferences = { + webSearch: boolean; + thinking: boolean; + task: boolean; + subagent: boolean; +}; + +type ChatToolPreferenceKey = keyof ChatToolPreferences; + +interface BuildStepProgressPropsParams { + hidden: boolean; + isContentCreationMode: boolean; + hasMessages: boolean; + steps: ComponentProps["steps"]; + currentIndex: ComponentProps["currentIndex"]; + onStepClick: NonNullable["onStepClick"]>; +} + +export function buildStepProgressProps({ + hidden, + isContentCreationMode, + hasMessages, + steps, + currentIndex, + onStepClick, +}: BuildStepProgressPropsParams): ComponentProps | null { + if (hidden || !isContentCreationMode || !hasMessages || steps.length === 0) { + return null; + } + + return { + steps, + currentIndex, + onStepClick, + }; +} + +interface BuildRuntimeStyleControlBarPropsParams { + enabled: boolean; + projectId: string | null | undefined; + activeTheme: ComponentProps["activeTheme"]; + projectStyleGuide: ComponentProps< + typeof RuntimeStyleControlBar + >["projectStyleGuide"]; + selection: ComponentProps["selection"]; + onSelectionChange: ComponentProps< + typeof RuntimeStyleControlBar + >["onSelectionChange"]; + onRewrite: NonNullable["onRewrite"]>; + onAudit: NonNullable["onAudit"]>; + actionsDisabled: boolean; +} + +export function buildRuntimeStyleControlBarProps({ + enabled, + projectId, + activeTheme, + projectStyleGuide, + selection, + onSelectionChange, + onRewrite, + onAudit, + actionsDisabled, +}: BuildRuntimeStyleControlBarPropsParams): + | ComponentProps + | null { + if (!enabled || !projectId) { + return null; + } + + return { + projectId, + activeTheme, + projectStyleGuide, + selection, + onSelectionChange, + onRewrite, + onAudit, + actionsDisabled, + }; +} + +export function buildWorkspaceMessageListProps( + params: ComponentProps, +): ComponentProps { + return params; +} + +interface BuildTeamWorkspaceDockPropsParams { + enabled: boolean; + shouldShowFloatingInputOverlay: boolean; + layoutMode: "chat" | "chat-canvas"; + onActivateWorkbench: NonNullable< + ComponentProps["onActivateWorkbench"] + >; + withBottomOverlay: boolean; + surfaceProps: TeamWorkbenchSurfaceProps; +} + +export function buildTeamWorkspaceDockProps({ + enabled, + shouldShowFloatingInputOverlay, + layoutMode, + onActivateWorkbench, + withBottomOverlay, + surfaceProps, +}: BuildTeamWorkspaceDockPropsParams): + | ComponentProps + | null { + if (!enabled || shouldShowFloatingInputOverlay || layoutMode !== "chat") { + return null; + } + + return { + onActivateWorkbench, + withBottomOverlay, + ...surfaceProps, + }; +} + +interface BuildWorkspaceEmptyStatePropsParams { + input: ComponentProps["input"]; + setInput: ComponentProps["setInput"]; + onSendMessage: ComponentProps["onSend"]; + providerType: ComponentProps["providerType"]; + setProviderType: ComponentProps["setProviderType"]; + model: ComponentProps["model"]; + setModel: ComponentProps["setModel"]; + executionStrategy: ComponentProps["executionStrategy"]; + setExecutionStrategy: ComponentProps< + typeof EmptyState + >["setExecutionStrategy"]; + onManageProviders?: ComponentProps["onManageProviders"]; + toolPreferences: ChatToolPreferences; + onToolPreferenceChange: ( + key: ChatToolPreferenceKey, + enabled: boolean, + ) => void; + selectedTeam: ComponentProps["selectedTeam"]; + onSelectTeam?: ComponentProps["onSelectTeam"]; + onEnableSuggestedTeam?: ComponentProps< + typeof EmptyState + >["onEnableSuggestedTeam"]; + creationMode: ComponentProps["creationMode"]; + onCreationModeChange?: ComponentProps< + typeof EmptyState + >["onCreationModeChange"]; + activeTheme: ComponentProps["activeTheme"]; + onThemeChange?: NonNullable["onThemeChange"]>; + themeLocked: boolean; + hasCanvasContent: boolean; + hasContentId: boolean; + selectedText: ComponentProps["selectedText"]; + onRecommendationClick?: ComponentProps< + typeof EmptyState + >["onRecommendationClick"]; + characters: NonNullable["characters"]>; + skills: NonNullable["skills"]>; + isSkillsLoading: boolean; + onNavigateToSettings?: ComponentProps< + typeof EmptyState + >["onNavigateToSettings"]; + onRefreshSkills?: ComponentProps["onRefreshSkills"]; + onLaunchBrowserAssist?: ComponentProps< + typeof EmptyState + >["onLaunchBrowserAssist"]; + browserAssistLoading: boolean; + projectId: string | null; + onProjectChange?: ComponentProps["onProjectChange"]; + onOpenSettings?: ComponentProps["onOpenSettings"]; +} + +export function buildWorkspaceEmptyStateProps({ + input, + setInput, + onSendMessage, + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + onManageProviders, + toolPreferences, + onToolPreferenceChange, + selectedTeam, + onSelectTeam, + onEnableSuggestedTeam, + creationMode, + onCreationModeChange, + activeTheme, + onThemeChange, + themeLocked, + hasCanvasContent, + hasContentId, + selectedText, + onRecommendationClick, + characters, + skills, + isSkillsLoading, + onNavigateToSettings, + onRefreshSkills, + onLaunchBrowserAssist, + browserAssistLoading, + projectId, + onProjectChange, + onOpenSettings, +}: BuildWorkspaceEmptyStatePropsParams): ComponentProps { + return { + input, + setInput, + onSend: onSendMessage, + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + onManageProviders, + webSearchEnabled: toolPreferences.webSearch, + onWebSearchEnabledChange: (enabled) => + onToolPreferenceChange("webSearch", enabled), + thinkingEnabled: toolPreferences.thinking, + onThinkingEnabledChange: (enabled) => + onToolPreferenceChange("thinking", enabled), + taskEnabled: toolPreferences.task, + onTaskEnabledChange: (enabled) => onToolPreferenceChange("task", enabled), + subagentEnabled: toolPreferences.subagent, + onSubagentEnabledChange: (enabled) => + onToolPreferenceChange("subagent", enabled), + selectedTeam, + onSelectTeam, + onEnableSuggestedTeam, + creationMode, + onCreationModeChange, + activeTheme, + onThemeChange: themeLocked + ? undefined + : (theme) => { + onThemeChange?.(theme); + }, + showThemeTabs: false, + hasCanvasContent, + hasContentId, + selectedText, + onRecommendationClick, + characters, + skills, + isSkillsLoading, + onNavigateToSettings, + onRefreshSkills, + onLaunchBrowserAssist, + browserAssistLoading, + projectId, + onProjectChange, + onOpenSettings, + }; +} + +interface BuildWorkspaceNavbarPropsParams { + visible: boolean; + isRunning: boolean; + chrome: ComponentProps["chrome"]; + onToggleHistory: NonNullable["onToggleHistory"]>; + showHistoryToggle: boolean; + onBackToProjectManagement?: ComponentProps< + typeof ChatNavbar + >["onBackToProjectManagement"]; + onBackToResources?: ComponentProps["onBackToResources"]; + showCanvasToggle: boolean; + isCanvasOpen: boolean; + onToggleCanvas?: ComponentProps["onToggleCanvas"]; + projectId: string | null; + onProjectChange?: ComponentProps["onProjectChange"]; + workspaceType?: ComponentProps["workspaceType"]; + onBackHome?: ComponentProps["onBackHome"]; + showBrowserAssistEntry: boolean; + browserAssistActive: boolean; + browserAssistLoading: boolean; + browserAssistAttentionLevel: ComponentProps< + typeof ChatNavbar + >["browserAssistAttentionLevel"]; + browserAssistLabel?: ComponentProps["browserAssistLabel"]; + onOpenBrowserAssist?: () => Promise | void; + showHarnessToggle: boolean; + harnessPanelVisible: boolean; + onToggleHarnessPanel?: ComponentProps["onToggleHarnessPanel"]; + harnessPendingCount: number; + harnessAttentionLevel: ComponentProps< + typeof ChatNavbar + >["harnessAttentionLevel"]; + harnessToggleLabel?: ComponentProps["harnessToggleLabel"]; + showContextCompactionAction?: ComponentProps< + typeof ChatNavbar + >["showContextCompactionAction"]; + contextCompactionRunning?: ComponentProps< + typeof ChatNavbar + >["contextCompactionRunning"]; + onCompactContext?: ComponentProps["onCompactContext"]; + onOpenSettings?: () => void; + novelCanvasControls?: ComponentProps["novelCanvasControls"]; +} + +export function buildWorkspaceNavbarProps({ + visible, + isRunning, + chrome, + onToggleHistory, + showHistoryToggle, + onBackToProjectManagement, + onBackToResources, + showCanvasToggle, + isCanvasOpen, + onToggleCanvas, + projectId, + onProjectChange, + workspaceType, + onBackHome, + showBrowserAssistEntry, + browserAssistActive, + browserAssistLoading, + browserAssistAttentionLevel, + browserAssistLabel, + onOpenBrowserAssist, + showHarnessToggle, + harnessPanelVisible, + onToggleHarnessPanel, + harnessPendingCount, + harnessAttentionLevel, + harnessToggleLabel, + showContextCompactionAction, + contextCompactionRunning, + onCompactContext, + onOpenSettings, + novelCanvasControls, +}: BuildWorkspaceNavbarPropsParams): ComponentProps | null { + if (!visible) { + return null; + } + + return { + isRunning, + chrome, + onToggleHistory, + showHistoryToggle, + onToggleFullscreen: () => undefined, + onBackToProjectManagement, + onBackToResources, + showCanvasToggle, + isCanvasOpen, + onToggleCanvas, + projectId, + onProjectChange, + workspaceType, + onBackHome, + showBrowserAssistEntry, + browserAssistActive, + browserAssistLoading, + browserAssistAttentionLevel, + browserAssistLabel, + onOpenBrowserAssist: onOpenBrowserAssist + ? () => { + void onOpenBrowserAssist(); + } + : undefined, + showHarnessToggle, + harnessPanelVisible, + onToggleHarnessPanel, + harnessPendingCount, + harnessAttentionLevel, + harnessToggleLabel, + showContextCompactionAction, + contextCompactionRunning, + onCompactContext, + onToggleSettings: onOpenSettings, + novelCanvasControls, + }; +} + +export function buildWorkspaceChatSidebarProps( + params: ComponentProps, +): ComponentProps { + return params; +} diff --git a/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts b/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts new file mode 100644 index 000000000..b1c96d9c6 --- /dev/null +++ b/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts @@ -0,0 +1,326 @@ +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { PlatformType } from "@/components/content-creator/canvas/document/types"; +import type { + CanvasImageInsertAnchorHint, + CanvasImageTargetType, +} from "@/lib/canvasImageInsertBus"; +import type { Message, MessageImage } from "../types"; +import type { + ImageWorkbenchOutputView, + ImageWorkbenchTaskMode, + ImageWorkbenchTaskView, + ImageWorkbenchViewport, +} from "../components/ImageWorkbenchCanvas"; + +export interface ImageWorkbenchTask extends ImageWorkbenchTaskView { + sessionId: string; + hookImageIds: string[]; + applyTarget: ImageWorkbenchApplyTarget | null; +} + +export interface ImageWorkbenchOutput extends ImageWorkbenchOutputView { + hookImageId: string; + applyTarget: ImageWorkbenchApplyTarget | null; +} + +export type ImageWorkbenchApplyTarget = + | { + kind: "canvas-insert"; + canvasType: CanvasImageTargetType; + anchorHint?: CanvasImageInsertAnchorHint; + projectId?: string | null; + contentId?: string | null; + actionLabel: string; + dispatchLabel: string; + } + | { + kind: "document-cover"; + placeholder: string; + actionLabel: string; + successLabel: string; + }; + +export interface SessionImageWorkbenchState { + active: boolean; + viewport: ImageWorkbenchViewport; + tasks: ImageWorkbenchTask[]; + outputs: ImageWorkbenchOutput[]; + selectedOutputId: string | null; + nextOutputIndex: number; +} + +export function createInitialSessionImageWorkbenchState(): SessionImageWorkbenchState { + return { + active: false, + viewport: { x: 0, y: 0, scale: 1 }, + tasks: [], + outputs: [], + selectedOutputId: null, + nextOutputIndex: 1, + }; +} + +export function buildImageWorkbenchDispatchMessages(params: { + rawText: string; + images: MessageImage[]; + taskId: string; + prompt: string; + mode: ImageWorkbenchTaskMode; + count: number; +}): Message[] { + const timestamp = new Date(); + const modeLabel = + params.mode === "edit" + ? "图片编辑" + : params.mode === "variation" + ? "图片变体" + : "图片生成"; + + return [ + { + id: `image-workbench:${params.taskId}:user`, + role: "user", + content: params.rawText, + images: params.images.length > 0 ? params.images : undefined, + timestamp, + }, + { + id: `image-workbench:${params.taskId}:assistant`, + role: "assistant", + content: `已创建${modeLabel}任务,正在准备 ${params.count} 张结果。`, + timestamp: new Date(timestamp.getTime() + 1), + runtimeStatus: { + phase: "routing", + title: `${modeLabel}已进入工作台`, + detail: params.prompt.trim() + ? `主画布已接管当前任务:${params.prompt.trim()}` + : "主画布已接管当前任务,正在准备图片服务与结果卡片。", + checkpoints: ["记录当前调度", "创建画布任务卡", "等待结果回填"], + }, + }, + ]; +} + +export function buildImageWorkbenchCompletionMessage(params: { + taskId: string; + successCount: number; + failedCount: number; + mode: ImageWorkbenchTaskMode; +}): Message { + const timestamp = new Date(); + const modeLabel = + params.mode === "edit" + ? "图片编辑" + : params.mode === "variation" + ? "图片变体" + : "图片生成"; + const detail = + params.failedCount > 0 + ? `${modeLabel}完成 ${params.successCount} 张,失败 ${params.failedCount} 张。` + : `${modeLabel}已完成,共生成 ${params.successCount} 张。`; + + return { + id: `image-workbench:${params.taskId}:complete`, + role: "assistant", + content: detail, + timestamp, + }; +} + +export function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function extractImagePromptSnippet(content: string, maxLength = 120): string { + const normalized = collapseWhitespace( + content + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/[#>*`~\-|]/g, " ") + .replace(/\d+\.\s+/g, " ") + .replace(/[^\S\r\n]+/g, " "), + ); + + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength).trim()}...`; +} + +function resolveDocumentPlatformLabel(platform: PlatformType): string { + switch (platform) { + case "wechat": + return "微信"; + case "xiaohongshu": + return "小红书"; + case "zhihu": + return "知乎"; + case "markdown": + default: + return "文稿"; + } +} + +export function resolveCoverAspectRatio(platform?: PlatformType): string { + if (platform === "xiaohongshu") { + return "1:1"; + } + return "16:9"; +} + +export function resolveClosestImageAspectRatio( + width: number, + height: number, +): string | undefined { + if (width <= 0 || height <= 0) { + return undefined; + } + + const currentRatio = width / height; + const candidates: Array<[string, number]> = [ + ["1:1", 1], + ["16:9", 16 / 9], + ["9:16", 9 / 16], + ["4:3", 4 / 3], + ["3:4", 3 / 4], + ["3:2", 3 / 2], + ["2:3", 2 / 3], + ["21:9", 21 / 9], + ["4:5", 4 / 5], + ["5:4", 5 / 4], + ]; + + return candidates.reduce( + (closest, candidate) => { + if (!closest) { + return candidate; + } + return Math.abs(candidate[1] - currentRatio) < + Math.abs(closest[1] - currentRatio) + ? candidate + : closest; + }, + null as [string, number] | null, + )?.[0]; +} + +export function buildImageWorkbenchCommandText( + prompt: string, + options?: { + aspectRatio?: string; + count?: number; + }, +): string { + const normalizedPrompt = collapseWhitespace(prompt) || "生成一张主题配图"; + const ratioSuffix = options?.aspectRatio?.trim() + ? `,${options.aspectRatio.trim()}` + : ""; + const countSuffix = + options?.count && options.count > 1 + ? `,出 ${Math.trunc(options.count)} 张` + : ""; + return `@配图 生成 ${normalizedPrompt}${ratioSuffix}${countSuffix}`; +} + +export function buildDocumentImageWorkbenchPrompt(params: { + projectName?: string | null; + platform: PlatformType; + content: string; +}): string { + const platformLabel = resolveDocumentPlatformLabel(params.platform); + const subject = + extractImagePromptSnippet(params.content) || + collapseWhitespace(params.projectName || "") || + "当前主题"; + return `为当前${platformLabel}文稿补一张主视觉配图,重点内容:${subject}`; +} + +export function buildPosterImageWorkbenchPrompt(params: { + projectName?: string | null; + width: number; + height: number; +}): string { + const subject = + collapseWhitespace(params.projectName || "") || "当前海报主题"; + return `为当前海报生成一张主视觉图片,主题:${subject},画布尺寸约 ${params.width}x${params.height}`; +} + +function findDocumentCoverPlaceholder(content: string): string | null { + const match = content.match( + /!\[[^\]]*]\((pending-cover:\/\/[^)\s]+|【img:[^】]+】|cover-generation-failed)\)/, + ); + return match?.[1]?.trim() || null; +} + +export function buildDefaultCanvasImageApplyTarget(params: { + canvasState: CanvasStateUnion | null; + projectId?: string | null; + contentId?: string | null; +}): ImageWorkbenchApplyTarget | null { + if (!params.canvasState) { + return null; + } + + if (params.canvasState.type === "document") { + return { + kind: "canvas-insert", + canvasType: "document", + anchorHint: "section_end", + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + actionLabel: "插入文稿", + dispatchLabel: "已切回文稿,正在插入图片", + }; + } + + if (params.canvasState.type === "poster") { + return { + kind: "canvas-insert", + canvasType: "poster", + anchorHint: "poster_center", + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + actionLabel: "插入海报", + dispatchLabel: "已切回海报,正在插入图片", + }; + } + + return null; +} + +export function resolveScopedImageWorkbenchApplyTarget(params: { + canvasState: CanvasStateUnion | null; + projectId?: string | null; + contentId?: string | null; + requestedTarget?: "generate" | "cover"; +}): ImageWorkbenchApplyTarget | null { + if ( + params.requestedTarget === "cover" && + params.canvasState?.type === "document" + ) { + const placeholder = findDocumentCoverPlaceholder( + params.canvasState.content, + ); + if (placeholder) { + return { + kind: "document-cover", + placeholder, + actionLabel: "设为封面", + successLabel: "已设为封面", + }; + } + } + + return buildDefaultCanvasImageApplyTarget(params); +} + +export function resolveImageWorkbenchActionLabel( + target: ImageWorkbenchApplyTarget | null | undefined, +): string { + if (!target) { + return "应用到画布"; + } + return target.actionLabel; +} diff --git a/src/components/agent/chat/workspace/inputbarPresentation.ts b/src/components/agent/chat/workspace/inputbarPresentation.ts new file mode 100644 index 000000000..9bf2ab6de --- /dev/null +++ b/src/components/agent/chat/workspace/inputbarPresentation.ts @@ -0,0 +1,40 @@ +import type { ComponentProps } from "react"; +import { Inputbar } from "../components/Inputbar"; +import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; +import type { TeamWorkbenchSurfaceProps } from "./teamWorkbenchPresentation"; + +interface BuildFloatingTeamWorkspaceDockPropsParams { + enabled: boolean; + layoutMode: "chat" | "chat-canvas"; + showFloatingInputOverlay: boolean; + onActivateWorkbench: NonNullable< + ComponentProps["onActivateWorkbench"] + >; + surfaceProps: TeamWorkbenchSurfaceProps; +} + +export function buildFloatingTeamWorkspaceDockProps({ + enabled, + layoutMode, + showFloatingInputOverlay, + onActivateWorkbench, + surfaceProps, +}: BuildFloatingTeamWorkspaceDockPropsParams): + | ComponentProps + | null { + if (!enabled || !showFloatingInputOverlay || layoutMode !== "chat") { + return null; + } + + return { + placement: "inline", + onActivateWorkbench, + ...surfaceProps, + }; +} + +export function buildWorkspaceInputbarProps( + params: Omit, "overlayAccessory">, +): Omit, "overlayAccessory"> { + return params; +} diff --git a/src/components/agent/chat/workspace/runtimeTeamPreview.ts b/src/components/agent/chat/workspace/runtimeTeamPreview.ts new file mode 100644 index 000000000..298eb8586 --- /dev/null +++ b/src/components/agent/chat/workspace/runtimeTeamPreview.ts @@ -0,0 +1,158 @@ +import type { AssistantDraftState } from "../hooks/agentChatShared"; +import type { TeamWorkspaceRuntimeFormationState } from "../teamWorkspaceRuntime"; +import type { AgentRuntimeStatus, Message, MessageImage } from "../types"; + +export interface RuntimeTeamDispatchPreviewSnapshot { + key: string; + prompt: string; + images: MessageImage[]; + baseMessageCount: number; + status: "forming" | "formed" | "failed"; + formationState?: TeamWorkspaceRuntimeFormationState | null; + failureMessage?: string | null; +} + +function buildRuntimeTeamMemberPlanLines( + state: TeamWorkspaceRuntimeFormationState, +): string[] { + const members = state.members.slice(0, 3); + const lines = members.map((member, index) => { + const label = member.label.trim() || `成员 ${index + 1}`; + const summary = member.summary.trim() || "负责分担当前任务中的一部分工作。"; + return `${index + 1}. ${label}:${summary}`; + }); + + if (state.members.length > members.length) { + lines.push( + `另外还有 ${state.members.length - members.length} 位成员会继续配合处理。`, + ); + } + + return lines; +} + +export function buildRuntimeTeamAssistantDraft( + state: TeamWorkspaceRuntimeFormationState | null | undefined, +): AssistantDraftState | undefined { + if (!state || state.status !== "formed") { + return undefined; + } + + const teamLabel = + state.label?.trim() || state.blueprint?.label?.trim() || "当前协作方案"; + const summary = + state.summary?.trim() || state.blueprint?.summary?.trim() || ""; + const planLines = buildRuntimeTeamMemberPlanLines(state); + const contentSections = [ + `我已经为这项任务准备了「${teamLabel}」。`, + summary ? `会先按“${summary}”来推进。` : null, + planLines.length > 0 ? `分工如下:\n${planLines.join("\n")}` : null, + "接下来我会让他们分别处理,再把关键进展、风险和需要你确认的事项汇总给你。", + ].filter(Boolean); + + const initialRuntimeStatus: AgentRuntimeStatus = { + phase: "routing", + title: "协作分工已准备好", + detail: + summary || "已整理好当前任务的分工,接下来会分别展开处理并同步结果。", + checkpoints: [ + `当前方案:${teamLabel}`, + `已安排 ${Math.max(state.members.length, 1)} 位协作成员`, + "主对话会持续同步关键进展", + ], + }; + + const waitingRuntimeStatus: AgentRuntimeStatus = { + phase: "routing", + title: "协作成员开始接手", + detail: + summary || "分工已经确认,协作成员会按各自职责继续处理并回传关键结果。", + checkpoints: [ + `当前方案:${teamLabel}`, + planLines[0] || "成员会分别接手自己的部分", + "主对话会持续同步关键进展", + ], + }; + + return { + content: contentSections.join("\n\n"), + initialRuntimeStatus, + waitingRuntimeStatus, + }; +} + +export function buildRuntimeTeamDispatchPreviewMessages( + snapshot: RuntimeTeamDispatchPreviewSnapshot, +): Message[] { + const normalizedPrompt = snapshot.prompt.trim(); + const timestamp = new Date(); + const formedAssistantDraft = + snapshot.status === "formed" + ? buildRuntimeTeamAssistantDraft(snapshot.formationState) + : undefined; + const formedTeamLabel = + snapshot.formationState?.label?.trim() || + snapshot.formationState?.blueprint?.label?.trim() || + "当前协作方案"; + const formedSummary = + snapshot.formationState?.summary?.trim() || + snapshot.formationState?.blueprint?.summary?.trim() || + ""; + const assistantRuntimeStatus = + snapshot.status === "failed" + ? { + phase: "failed" as const, + title: "Team 调度准备失败", + detail: + snapshot.failureMessage?.trim() || + "这次 Team 组建失败,已回退到普通对话发送。", + } + : snapshot.status === "formed" + ? formedAssistantDraft?.initialRuntimeStatus || { + phase: "routing" as const, + title: "协作分工已准备好", + detail: + formedSummary || + "已整理好当前任务的分工,接下来会分别展开处理并同步结果。", + checkpoints: [ + `当前方案:${formedTeamLabel}`, + "协作成员会按分工开始接手", + "主对话会持续同步关键进展", + ], + } + : { + phase: "routing" as const, + title: "正在组建 Team", + detail: + "系统正在根据当前任务安排分工,会先接入合适的成员,再把关键进展持续汇总回主对话。", + checkpoints: [ + "确认当前任务目标", + "安排协作分工", + "等待成员接手处理", + ], + }; + + return [ + { + id: `runtime-team-dispatch:${snapshot.key}:user`, + role: "user", + content: normalizedPrompt, + images: snapshot.images.length > 0 ? snapshot.images : undefined, + timestamp, + }, + { + id: `runtime-team-dispatch:${snapshot.key}:assistant`, + role: "assistant", + content: + snapshot.status === "failed" + ? "这次 Team 调度准备失败,已回退到普通执行。" + : snapshot.status === "formed" + ? formedAssistantDraft?.content || + `我已经为这项任务准备了「${formedTeamLabel}」。\n\n接下来我会让他们分别处理,再把关键进展和结果汇总给你。` + : "我会先安排协作分工,再把关键进展和结果汇总给你。", + timestamp: new Date(timestamp.getTime() + 1), + isThinking: snapshot.status === "forming", + runtimeStatus: assistantRuntimeStatus, + }, + ]; +} diff --git a/src/components/agent/chat/workspace/teamWorkbenchPresentation.tsx b/src/components/agent/chat/workspace/teamWorkbenchPresentation.tsx new file mode 100644 index 000000000..a5fb5d31e --- /dev/null +++ b/src/components/agent/chat/workspace/teamWorkbenchPresentation.tsx @@ -0,0 +1,221 @@ +import { + useCallback, + useMemo, + type ComponentProps, + type ReactNode, +} from "react"; +import type { CanvasWorkbenchTeamView } from "../components/CanvasWorkbenchLayout"; +import { TeamWorkbenchSummaryPanel } from "../components/TeamWorkbenchSummaryPanel"; +import { TeamWorkspaceBoard } from "../components/TeamWorkspaceBoard"; +import { + summarizeTeamWorkspaceExecution, + type TeamWorkspaceActivityEntry, + type TeamWorkspaceControlSummary, + type TeamWorkspaceLiveRuntimeState, + type TeamWorkspaceRuntimeFormationState, + type TeamWorkspaceWaitSummary, +} from "../teamWorkspaceRuntime"; +import { TeamWorkbenchPreview } from "./workbenchPreview"; + +export type TeamWorkbenchSurfaceProps = Omit< + ComponentProps, + "className" | "embedded" | "defaultShellExpanded" +>; + +export interface UseTeamWorkbenchPresentationParams { + enabled: boolean; + surfaceProps: TeamWorkbenchSurfaceProps; + hasRealTeamGraph: boolean; + autoFocusToken?: string | number | null; + runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null; + liveActivityBySessionId?: Record; + teamWaitSummary?: TeamWorkspaceWaitSummary | null; + teamControlSummary?: TeamWorkspaceControlSummary | null; +} + +function resolveTeamWorkbenchTriggerState(params: { + enabled: boolean; + hasRealTeamGraph: boolean; + runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null; + liveActivityBySessionId?: Record; + teamWaitSummary?: TeamWorkspaceWaitSummary | null; + teamControlSummary?: TeamWorkspaceControlSummary | null; + executionSummary: ReturnType; +}): CanvasWorkbenchTeamView["triggerState"] { + if (!params.enabled) { + return null; + } + + if (params.runtimeTeamState?.status === "failed") { + return { tone: "error", label: "失败" }; + } + + if (params.runtimeTeamState?.status === "forming") { + return { tone: "active", label: "组建中" }; + } + + if (params.executionSummary.runningSessionCount > 0) { + return { + tone: "active", + label: + params.executionSummary.runningSessionCount > 1 + ? `${params.executionSummary.runningSessionCount} 处理中` + : "处理中", + }; + } + + if (params.executionSummary.queuedSessionCount > 0) { + return { + tone: "active", + label: + params.executionSummary.queuedSessionCount > 1 + ? `${params.executionSummary.queuedSessionCount} 稍后开始` + : "稍后开始", + }; + } + + if (params.runtimeTeamState?.status === "formed" && !params.hasRealTeamGraph) { + return { tone: "active", label: "已就绪" }; + } + + if ( + Object.values(params.liveActivityBySessionId ?? {}).some( + (entries) => (entries?.length ?? 0) > 0, + ) || + Boolean(params.teamWaitSummary) || + Boolean(params.teamControlSummary) + ) { + return { tone: "active", label: "有更新" }; + } + + return { tone: "idle", label: null }; +} + +export function useTeamWorkbenchPresentation({ + enabled, + surfaceProps, + hasRealTeamGraph, + autoFocusToken, + runtimeTeamState = null, + liveActivityBySessionId = {}, + teamWaitSummary = null, + teamControlSummary = null, +}: UseTeamWorkbenchPresentationParams) { + const boardProps = useMemo>( + () => ({ + ...surfaceProps, + embedded: true, + defaultShellExpanded: true, + }), + [surfaceProps], + ); + + const renderTeamWorkbenchPreview = useCallback( + (stackedWorkbenchTrigger?: ReactNode) => ( + + ), + [boardProps], + ); + + const summaryPanelProps = useMemo>( + () => ({ + currentSessionId: surfaceProps.currentSessionId, + currentSessionRuntimeStatus: surfaceProps.currentSessionRuntimeStatus, + currentSessionLatestTurnStatus: surfaceProps.currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: surfaceProps.currentSessionQueuedTurnCount, + childSubagentSessions: surfaceProps.childSubagentSessions, + subagentParentContext: surfaceProps.subagentParentContext, + liveRuntimeBySessionId: surfaceProps.liveRuntimeBySessionId, + liveActivityBySessionId, + teamWaitSummary, + teamControlSummary, + selectedTeamLabel: surfaceProps.selectedTeamLabel, + selectedTeamSummary: surfaceProps.selectedTeamSummary, + selectedTeamRoles: surfaceProps.selectedTeamRoles, + runtimeTeamState: surfaceProps.runtimeTeamState, + }), + [liveActivityBySessionId, surfaceProps, teamControlSummary, teamWaitSummary], + ); + + const teamWorkbenchSummaryPanel = useMemo( + () => , + [summaryPanelProps], + ); + + const executionSummary = useMemo( + () => + summarizeTeamWorkspaceExecution({ + currentSessionId: surfaceProps.currentSessionId, + currentSessionRuntimeStatus: surfaceProps.currentSessionRuntimeStatus, + currentSessionLatestTurnStatus: surfaceProps.currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: + surfaceProps.currentSessionQueuedTurnCount, + childSubagentSessions: surfaceProps.childSubagentSessions, + subagentParentContext: surfaceProps.subagentParentContext, + liveRuntimeBySessionId: surfaceProps.liveRuntimeBySessionId as + | Record + | undefined, + }), + [surfaceProps], + ); + + const triggerState = useMemo( + () => + resolveTeamWorkbenchTriggerState({ + enabled, + hasRealTeamGraph, + runtimeTeamState, + liveActivityBySessionId, + teamWaitSummary, + teamControlSummary, + executionSummary, + }), + [ + enabled, + executionSummary, + hasRealTeamGraph, + liveActivityBySessionId, + runtimeTeamState, + teamControlSummary, + teamWaitSummary, + ], + ); + + const teamWorkbenchView = useMemo(() => { + if (!enabled) { + return null; + } + + return { + enabled: true, + title: + runtimeTeamState?.label?.trim() || + runtimeTeamState?.blueprint?.label?.trim() || + surfaceProps.selectedTeamLabel || + "团队工作台", + subtitle: "主对话保留调度记录,画布按角色分别展示执行过程与结果。", + autoFocusToken, + preferFixedPanel: true, + triggerState, + renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => + renderTeamWorkbenchPreview(options?.stackedWorkbenchTrigger), + renderPanel: () => teamWorkbenchSummaryPanel, + }; + }, [ + autoFocusToken, + enabled, + renderTeamWorkbenchPreview, + runtimeTeamState, + surfaceProps.selectedTeamLabel, + teamWorkbenchSummaryPanel, + triggerState, + ]); + + return { + renderTeamWorkbenchPreview, + teamWorkbenchView, + }; +} diff --git a/src/components/agent/chat/workspace/themeWorkbenchHelpers.ts b/src/components/agent/chat/workspace/themeWorkbenchHelpers.ts new file mode 100644 index 000000000..d0c4c14f3 --- /dev/null +++ b/src/components/agent/chat/workspace/themeWorkbenchHelpers.ts @@ -0,0 +1,1178 @@ +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { DocumentVersion } from "@/components/content-creator/canvas/document/types"; +import type { StepStatus } from "@/components/content-creator/types"; +import type { + ThemeWorkbenchRunTodoItem, + ThemeWorkbenchRunTerminalItem, + ThemeWorkbenchRunState as BackendThemeWorkbenchRunState, +} from "@/lib/api/executionRun"; +import type { ThemeWorkbenchDocumentState } from "@/lib/api/project"; +import type { ToolCallState } from "@/lib/api/agentStream"; +import type { SkillDetailInfo } from "@/lib/api/skill-execution"; +import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace"; +import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard"; +import { parseSkillSlashCommand } from "../hooks/skillCommand"; +import type { Message } from "../types"; +import type { TaskFile } from "../components/TaskFiles"; + +export const THEME_WORKBENCH_DOCUMENT_META_KEY = + "theme_workbench_document_v1"; +export const MAX_PERSISTED_DOCUMENT_VERSIONS = 40; +export const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; +export const THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS = 45 * 1000; +export const THEME_WORKBENCH_HISTORY_PAGE_SIZE = 20; + +function resolveThemeWorkbenchRunStepStatus( + status: "queued" | "running" | "success" | "error" | "canceled" | "timeout", +): StepStatus { + if (status === "running") { + return "active"; + } + if (status === "queued") { + return "pending"; + } + if (status === "success") { + return "completed"; + } + return "error"; +} + +function parseThemeWorkbenchToolArguments( + argumentsJson?: string, +): Record { + if (!argumentsJson) { + return {}; + } + + try { + const parsed = JSON.parse(argumentsJson); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function truncateThemeWorkbenchLabel(value: string, limit = 28): string { + return value.length > limit ? `${value.slice(0, limit)}…` : value; +} + +function resolveThemeWorkbenchTextArg( + args: Record, + keys: string[], +): string { + for (const key of keys) { + const value = args[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (Array.isArray(value)) { + const firstString = value.find( + (item): item is string => + typeof item === "string" && item.trim().length > 0, + ); + if (firstString) { + return firstString.trim(); + } + } + } + return ""; +} + +function getThemeWorkbenchFileLabel(pathValue: string): string { + const normalized = pathValue.trim(); + if (!normalized) { + return "主稿文件"; + } + const segments = normalized.split(/[/\\]/).filter(Boolean); + if (segments.length >= 2) { + return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`; + } + return segments[0] || normalized; +} + +function resolveThemeWorkbenchToolTaskTitle(toolCall: ToolCallState): string { + const normalized = toolCall.name.trim().toLowerCase(); + const args = parseThemeWorkbenchToolArguments(toolCall.arguments); + const queryValue = resolveThemeWorkbenchTextArg(args, [ + "query", + "q", + "keyword", + "pattern", + "text", + ]); + const urlValue = resolveThemeWorkbenchTextArg(args, ["url", "href"]); + const elementValue = resolveThemeWorkbenchTextArg(args, [ + "element", + "name", + "label", + "ref", + ]); + + if (normalized.includes("social_generate_cover_image")) { + const size = resolveThemeWorkbenchTextArg(args, ["size"]); + return size ? `生成封面图(${size})` : "生成封面图"; + } + if (normalized.includes("write_file") || normalized.includes("create_file")) { + const pathValue = resolveThemeWorkbenchTextArg(args, [ + "path", + "file_path", + "filePath", + ]); + return pathValue + ? `写入 ${getThemeWorkbenchFileLabel(pathValue)}` + : "写入主稿文件"; + } + if (normalized.includes("websearch")) { + return queryValue + ? `检索 ${truncateThemeWorkbenchLabel(queryValue)}` + : "检索参考资料"; + } + if ( + normalized.includes("browser_navigate") || + (normalized.includes("navigate") && urlValue) + ) { + return urlValue + ? `打开 ${truncateThemeWorkbenchLabel(urlValue, 36)}` + : "打开网页"; + } + if (normalized.includes("browser_click") || normalized === "click") { + return elementValue + ? `点击「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : "点击页面元素"; + } + if (normalized.includes("browser_hover") || normalized === "hover") { + return elementValue + ? `定位「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : "定位页面元素"; + } + if (normalized.includes("browser_type") || normalized === "type") { + return elementValue + ? `填写「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : queryValue + ? `填写 ${truncateThemeWorkbenchLabel(queryValue, 18)}` + : "填写页面内容"; + } + if ( + normalized.includes("browser_select_option") || + normalized.includes("select_option") + ) { + const value = resolveThemeWorkbenchTextArg(args, [ + "value", + "values", + "option", + ]); + return value + ? `选择 ${truncateThemeWorkbenchLabel(value, 20)}` + : elementValue + ? `选择「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : "选择页面选项"; + } + if ( + normalized.includes("browser_press_key") || + normalized.includes("press_key") + ) { + const keyValue = resolveThemeWorkbenchTextArg(args, ["key"]); + return keyValue ? `触发按键 ${keyValue}` : "触发页面快捷键"; + } + if (normalized.includes("browser_drag") || normalized.includes("drag")) { + const endValue = resolveThemeWorkbenchTextArg(args, [ + "endElement", + "endRef", + ]); + return endValue + ? `拖拽到「${truncateThemeWorkbenchLabel(endValue, 18)}」` + : "拖拽页面元素"; + } + if ( + normalized.includes("browser_snapshot") || + normalized.includes("screenshot") + ) { + return elementValue + ? `分析页面区域:${truncateThemeWorkbenchLabel(elementValue, 20)}` + : urlValue + ? `分析页面 ${truncateThemeWorkbenchLabel(urlValue, 30)}` + : "分析页面内容"; + } + if (normalized.includes("bash") || normalized.includes("shell")) { + const commandValue = resolveThemeWorkbenchTextArg(args, ["command", "cmd"]); + const commandProbe = commandValue.toLowerCase(); + if (commandProbe.includes("ffmpeg")) { + return "处理音视频素材"; + } + if (commandProbe.includes("curl") || commandProbe.includes("wget")) { + return "下载远程资源"; + } + if ( + commandProbe.includes("python") || + commandProbe.includes("node") || + commandProbe.includes("tsx") || + commandProbe.includes("npm") + ) { + return "执行自动化脚本"; + } + return commandValue + ? `执行命令:${truncateThemeWorkbenchLabel(commandValue, 22)}` + : "执行终端命令"; + } + if (normalized.includes("browser")) { + return urlValue + ? `采集 ${truncateThemeWorkbenchLabel(urlValue, 36)}` + : elementValue + ? `处理页面元素:${truncateThemeWorkbenchLabel(elementValue, 20)}` + : "采集网页信息"; + } + return toolCall.name.replace(/[_-]+/g, " ").trim() || "执行工具"; +} + +function resolveThemeWorkbenchPrimaryTaskTitle( + skillName: string, + detail?: SkillDetailInfo | null, +): string { + if (skillName === SOCIAL_ARTICLE_SKILL_KEY) { + return "生成社媒主稿"; + } + + const displayName = detail?.display_name?.trim(); + if (displayName) { + return displayName; + } + + return skillName.replace(/[_-]+/g, " ").trim() || "执行任务"; +} + +function extractThemeWorkbenchWorkflowMarkerIndex( + content: string, +): number | null { + const matches = [...content.matchAll(/\*\*步骤\s+(\d+)\/(\d+):/g)]; + if (matches.length === 0) { + return null; + } + const last = matches[matches.length - 1]; + const value = Number(last[1]); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return value - 1; +} + +function findLatestThemeWorkbenchExecution(messages: Message[]): { + assistantMessage: Message; + skillName: string | null; +} | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "assistant") { + continue; + } + + const hasToolCalls = (message.toolCalls?.length || 0) > 0; + const hasPendingAction = + message.actionRequests?.some( + (request) => request.status !== "submitted", + ) || false; + if (!message.isThinking && !hasToolCalls && !hasPendingAction) { + continue; + } + + let skillName: string | null = null; + for (let userIndex = index - 1; userIndex >= 0; userIndex -= 1) { + const candidate = messages[userIndex]; + if (candidate.role !== "user") { + continue; + } + skillName = parseSkillSlashCommand(candidate.content)?.skillName || null; + break; + } + + return { + assistantMessage: message, + skillName, + }; + } + + return null; +} + +function buildThemeWorkbenchLiveWorkflowSteps( + messages: Message[], + skillDetailMap: Record, + isSending: boolean, +): Array<{ id: string; title: string; status: StepStatus }> { + const activeExecution = findLatestThemeWorkbenchExecution(messages); + if (!activeExecution) { + return []; + } + + const { assistantMessage, skillName } = activeExecution; + if (!skillName) { + return []; + } + + const skillDetail = skillDetailMap[skillName] || null; + const workflowSteps = skillDetail?.workflow_steps || []; + if (workflowSteps.length > 0) { + const latestAssistantContent = + messages + .slice() + .reverse() + .find((m) => m.role === "assistant")?.content || ""; + const activeIndex = + extractThemeWorkbenchWorkflowMarkerIndex(latestAssistantContent) ?? 0; + return workflowSteps.map((step, index) => ({ + id: step.id, + title: step.name, + status: + index < activeIndex + ? ("completed" as StepStatus) + : index === activeIndex + ? ("active" as StepStatus) + : ("pending" as StepStatus), + })); + } + + const toolCalls = assistantMessage.toolCalls || []; + const steps: Array<{ id: string; title: string; status: StepStatus }> = []; + const primaryTaskTitle = resolveThemeWorkbenchPrimaryTaskTitle( + skillName, + skillDetail, + ); + const hasRunningTool = toolCalls.some( + (toolCall) => toolCall.status === "running", + ); + const hasFailedTool = toolCalls.some( + (toolCall) => toolCall.status === "failed", + ); + const hasCompletedPrimaryWrite = toolCalls.some((toolCall) => { + if (toolCall.status !== "completed") { + return false; + } + const normalizedName = toolCall.name.trim().toLowerCase(); + return ( + normalizedName.includes("write_file") || + normalizedName.includes("create_file") + ); + }); + + steps.push({ + id: `${skillName}:primary`, + title: primaryTaskTitle, + status: hasCompletedPrimaryWrite + ? ("completed" as StepStatus) + : hasFailedTool + ? ("error" as StepStatus) + : toolCalls.length > 0 + ? ("completed" as StepStatus) + : assistantMessage.isThinking || isSending + ? ("active" as StepStatus) + : ("pending" as StepStatus), + }); + + toolCalls.forEach((toolCall, index) => { + steps.push({ + id: toolCall.id || `${skillName}:tool:${index}`, + title: resolveThemeWorkbenchToolTaskTitle(toolCall), + status: + toolCall.status === "running" + ? ("active" as StepStatus) + : toolCall.status === "completed" + ? ("completed" as StepStatus) + : ("error" as StepStatus), + }); + }); + + if (isSending && toolCalls.length > 0 && !hasRunningTool) { + steps.push({ + id: `${skillName}:finalize`, + title: "整理最终结果", + status: "active", + }); + } + + return steps; +} + +function resolveThemeWorkbenchQueueItemTitle( + item: ThemeWorkbenchRunTodoItem, + skillDetailMap: Record, +): string { + const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); + if (sourceRef) { + return resolveThemeWorkbenchPrimaryTaskTitle( + sourceRef, + skillDetailMap[sourceRef], + ); + } + return item.title?.trim() || "执行任务"; +} + +export function resolveThemeWorkbenchSkillSourceRef( + item: + | ThemeWorkbenchRunTodoItem + | ThemeWorkbenchRunTerminalItem + | { source?: string | null; source_ref?: string | null }, +): string | null { + if ((item.source || "").trim() !== "skill") { + return null; + } + const sourceRef = item.source_ref?.trim(); + return sourceRef || null; +} + +interface PersistedThemeWorkbenchDocument { + versions: DocumentVersion[]; + currentVersionId: string; + versionStatusMap: Record; +} + +function isTopicBranchStatus(value: unknown): value is TopicBranchStatus { + return ( + value === "in_progress" || + value === "pending" || + value === "merged" || + value === "candidate" + ); +} + +function normalizeDocumentVersion(value: unknown): DocumentVersion | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as Record; + const id = typeof candidate.id === "string" ? candidate.id.trim() : ""; + const content = + typeof candidate.content === "string" ? candidate.content : ""; + const createdAt = + typeof candidate.createdAt === "number" + ? candidate.createdAt + : typeof candidate.created_at === "number" + ? candidate.created_at + : NaN; + const description = + typeof candidate.description === "string" + ? candidate.description + : undefined; + const metadata = + candidate.metadata && typeof candidate.metadata === "object" + ? (candidate.metadata as DocumentVersion["metadata"]) + : undefined; + + if (!id || Number.isNaN(createdAt)) { + return null; + } + + return { + id, + content, + createdAt, + description, + metadata, + }; +} + +export function buildPersistedThemeWorkbenchDocument( + state: CanvasStateUnion, + statusMap: Record, +): PersistedThemeWorkbenchDocument | null { + if (state.type !== "document" || state.versions.length === 0) { + return null; + } + + const normalizedVersions = state.versions + .map((version) => normalizeDocumentVersion(version)) + .filter((version): version is DocumentVersion => !!version); + + if (normalizedVersions.length === 0) { + return null; + } + + const latestVersions = normalizedVersions.slice( + -MAX_PERSISTED_DOCUMENT_VERSIONS, + ); + const versionIdSet = new Set(latestVersions.map((version) => version.id)); + let currentVersionId = state.currentVersionId; + + if (!versionIdSet.has(currentVersionId)) { + currentVersionId = + latestVersions[latestVersions.length - 1]?.id || latestVersions[0].id; + } + + const persistedVersions = latestVersions.map((version) => + version.id === currentVersionId ? { ...version, content: "" } : version, + ); + + const versionStatusMap = Object.fromEntries( + Object.entries(statusMap).filter( + ([versionId, status]) => + versionIdSet.has(versionId) && isTopicBranchStatus(status), + ), + ) as Record; + + return { + versions: persistedVersions, + currentVersionId, + versionStatusMap, + }; +} + +export function readPersistedThemeWorkbenchDocument( + metadata?: Record, +): PersistedThemeWorkbenchDocument | null { + const raw = metadata?.[THEME_WORKBENCH_DOCUMENT_META_KEY]; + if (!raw || typeof raw !== "object") { + return null; + } + const candidate = raw as Record; + const versionsRaw = Array.isArray(candidate.versions) + ? candidate.versions + : []; + const versions = versionsRaw + .map((version) => normalizeDocumentVersion(version)) + .filter((version): version is DocumentVersion => !!version) + .slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); + if (versions.length === 0) { + return null; + } + + const versionIdSet = new Set(versions.map((version) => version.id)); + const currentVersionIdRaw = candidate.currentVersionId; + const currentVersionId = + typeof currentVersionIdRaw === "string" && + versionIdSet.has(currentVersionIdRaw) + ? currentVersionIdRaw + : versions[versions.length - 1]?.id || versions[0].id; + + const statusRaw = candidate.versionStatusMap; + const statusEntries = + statusRaw && typeof statusRaw === "object" ? statusRaw : {}; + const versionStatusMap = Object.fromEntries( + Object.entries(statusEntries).filter( + ([versionId, status]) => + versionIdSet.has(versionId) && isTopicBranchStatus(status), + ), + ) as Record; + + return { + versions, + currentVersionId, + versionStatusMap, + }; +} + +export function applyBackendThemeWorkbenchDocumentState( + state: CanvasStateUnion, + backendState: ThemeWorkbenchDocumentState, + currentBody: string, +): { + state: CanvasStateUnion; + statusMap: Record; +} | null { + if (state.type !== "document" || backendState.versions.length === 0) { + return null; + } + + const versions = backendState.versions + .map((version, index) => ({ + id: version.id, + content: version.is_current ? currentBody : "", + createdAt: version.created_at, + description: version.description?.trim() || `版本 ${index + 1}`, + })) + .slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); + + if (versions.length === 0) { + return null; + } + + const currentVersion = + versions.find( + (version) => version.id === backendState.current_version_id, + ) || versions[versions.length - 1]; + + const statusMap = Object.fromEntries( + backendState.versions + .filter( + ( + version, + ): version is ThemeWorkbenchDocumentState["versions"][number] & { + status: TopicBranchStatus; + } => isTopicBranchStatus(version.status), + ) + .map((version) => [version.id, version.status]), + ) as Record; + + return { + state: { + ...state, + versions, + currentVersionId: currentVersion.id, + content: currentVersion.content, + }, + statusMap, + }; +} + +export function inferThemeWorkbenchGateFromQueueItem( + queueItem: ThemeWorkbenchRunTodoItem | null, +): { + key: "topic_select" | "write_mode" | "publish_confirm"; + title: string; + description: string; +} { + const gateKey = queueItem?.gate_key; + if (gateKey === "publish_confirm") { + return { + key: "publish_confirm", + title: "发布闸门", + description: queueItem?.title || "正在准备发布前检查与平台适配结果。", + }; + } + if (gateKey === "topic_select") { + return { + key: "topic_select", + title: "选题闸门", + description: queueItem?.title || "正在整理选题方向并生成可确认方案。", + }; + } + if (gateKey === "write_mode") { + return { + key: "write_mode", + title: "写作闸门", + description: queueItem?.title || "正在执行主稿写作与插图生成流程。", + }; + } + + if (!queueItem) { + return { + key: "topic_select", + title: "选题闸门", + description: "正在整理选题方向并生成可确认方案。", + }; + } + + const probe = + `${queueItem.title} ${queueItem.source_ref || ""} ${queueItem.source}`.toLowerCase(); + const looksLikePublish = + /publish|adapt|distribution|release|发布|分发|平台适配/.test(probe); + if (looksLikePublish) { + return { + key: "publish_confirm", + title: "发布闸门", + description: queueItem.title || "正在准备发布前检查与平台适配结果。", + }; + } + + const looksLikeTopic = /topic|research|trend|idea|选题|方向|调研|洞察/.test( + probe, + ); + if (looksLikeTopic) { + return { + key: "topic_select", + title: "选题闸门", + description: queueItem.title || "正在整理选题方向并生成可确认方案。", + }; + } + + return { + key: "write_mode", + title: "写作闸门", + description: queueItem.title || "正在执行主稿写作与插图生成流程。", + }; +} + +export function resolveThemeWorkbenchGateByKey( + gateKey: "topic_select" | "write_mode" | "publish_confirm", + fallbackTitle?: string, +): { + key: "topic_select" | "write_mode" | "publish_confirm"; + title: string; + description: string; +} { + if (gateKey === "publish_confirm") { + return { + key: "publish_confirm", + title: "发布闸门", + description: fallbackTitle || "正在准备发布前检查与平台适配结果。", + }; + } + if (gateKey === "topic_select") { + return { + key: "topic_select", + title: "选题闸门", + description: fallbackTitle || "正在整理选题方向并生成可确认方案。", + }; + } + return { + key: "write_mode", + title: "写作闸门", + description: fallbackTitle || "正在执行主稿写作与插图生成流程。", + }; +} + +export function formatThemeWorkbenchRunTimeLabel( + raw: string | null | undefined, +): string { + if (!raw) { + return "--:--"; + } + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return "--:--"; + } + return parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +export function formatThemeWorkbenchRunDurationLabel( + startedAt: string | null | undefined, + finishedAt: string | null | undefined, +): string | undefined { + if (!startedAt || !finishedAt) { + return undefined; + } + + const started = new Date(startedAt); + const finished = new Date(finishedAt); + if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) { + return undefined; + } + + const durationMs = finished.getTime() - started.getTime(); + if (durationMs < 0) { + return undefined; + } + if (durationMs < 1000) { + return `${durationMs}ms`; + } + if (durationMs < 60000) { + return `${(durationMs / 1000).toFixed(1)}s`; + } + return `${Math.floor(durationMs / 60000)}m${Math.round( + (durationMs % 60000) / 1000, + )}s`; +} + +export function resolveThemeWorkbenchApplyTargetByGateKey( + gateKey: "topic_select" | "write_mode" | "publish_confirm" | "idle", +): string { + if (gateKey === "topic_select") { + return "选题池"; + } + if (gateKey === "publish_confirm") { + return "发布产物"; + } + if (gateKey === "write_mode") { + return "版本主稿"; + } + return "主稿内容"; +} + +function extractExecutionIdFromSocialToolId(toolCallId: string): string | null { + const normalized = toolCallId.trim(); + if (!normalized.startsWith("social-write-")) { + return null; + } + const match = normalized.match(/^social-write-(.+)-[0-9a-f]{8}$/i); + const executionId = match?.[1]?.trim(); + if (!executionId) { + return null; + } + return executionId; +} + +export function resolveExecutionIdCandidatesForActivityLog( + log: SidebarActivityLog, +): string[] { + const candidates: string[] = []; + const pushCandidate = (value?: string | null) => { + const normalized = value?.trim(); + if (!normalized) { + return; + } + if (!candidates.includes(normalized)) { + candidates.push(normalized); + } + }; + + pushCandidate(log.executionId); + pushCandidate(log.messageId); + + const normalizedLogId = log.id.trim(); + if (normalizedLogId) { + let toolCallIdProbe = normalizedLogId; + if (log.messageId) { + const messagePrefix = `${log.messageId}-`; + if (normalizedLogId.startsWith(messagePrefix)) { + toolCallIdProbe = normalizedLogId.slice(messagePrefix.length); + } + } + pushCandidate(extractExecutionIdFromSocialToolId(toolCallIdProbe)); + } + + return candidates; +} + +export function isThemeWorkbenchPrimaryDocumentArtifact( + fileName: string, +): boolean { + const normalized = fileName.trim().toLowerCase(); + if (!normalized) { + return false; + } + return normalized.endsWith(".md") || normalized.endsWith(".markdown"); +} + +function inferTaskFileType(fileName: string): TaskFile["type"] { + const normalized = fileName.trim().toLowerCase(); + const extension = normalized.split(".").pop() || ""; + + if (extension === "md" || extension === "markdown" || extension === "txt") { + return "document"; + } + if ( + ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "ico"].includes( + extension, + ) + ) { + return "image"; + } + if ( + ["mp3", "wav", "aac", "flac", "m4a", "ogg", "mid", "midi"].includes( + extension, + ) + ) { + return "audio"; + } + if (["mp4", "mov", "avi", "mkv", "webm"].includes(extension)) { + return "video"; + } + return "other"; +} + +export function looksLikeSocialPublishPayload(content: string): boolean { + const trimmed = content.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return false; + } + + try { + const parsed = JSON.parse(trimmed) as Record; + return ( + typeof parsed.article_path === "string" || + typeof parsed.cover_meta_path === "string" || + Array.isArray(parsed.pipeline) || + Array.isArray(parsed.recommended_channels) + ); + } catch { + return false; + } +} + +function looksLikeThemeWorkbenchErrorPayload(content: string): boolean { + const normalized = content.trim().toLowerCase(); + if (!normalized) { + return false; + } + + return ( + normalized.startsWith("ran into this error:") || + normalized.startsWith("request failed:") || + normalized.includes( + "please retry if you think this is a transient or recoverable error.", + ) || + normalized.includes("api key not valid") + ); +} + +export function isCorruptedThemeWorkbenchDocumentContent( + content?: string | null, +): boolean { + if (typeof content !== "string") { + return false; + } + + return ( + looksLikeSocialPublishPayload(content) || + looksLikeThemeWorkbenchErrorPayload(content) + ); +} + +export function resolveTaskFileType( + fileName: string, + content?: string | null, +): TaskFile["type"] { + const inferredType = inferTaskFileType(fileName); + if ( + inferredType === "document" && + isCorruptedThemeWorkbenchDocumentContent(content) + ) { + return "other"; + } + return inferredType; +} + +export function normalizeSessionTaskFileType( + fileType: string, + fileName: string, + content?: string | null, +): TaskFile["type"] { + const normalized = fileType.trim().toLowerCase(); + if ( + normalized === "document" || + normalized === "image" || + normalized === "audio" || + normalized === "video" || + normalized === "other" + ) { + const resolvedByContent = resolveTaskFileType(fileName, content); + if (normalized === "document" && resolvedByContent !== "document") { + return resolvedByContent; + } + return normalized; + } + return resolveTaskFileType(fileName, content); +} + +export function isRenderableTaskFile( + file: Pick, + isThemeWorkbench: boolean, +): boolean { + if (file.type !== "document") { + return false; + } + if (!isThemeWorkbench) { + return true; + } + return isThemeWorkbenchPrimaryDocumentArtifact(file.name); +} + +export function buildThemeWorkbenchWorkflowSteps( + messages: Message[], + backendRunState: BackendThemeWorkbenchRunState | null, + isSending: boolean, + skillDetailMap: Record, +): Array<{ id: string; title: string; status: StepStatus }> { + const liveSteps = buildThemeWorkbenchLiveWorkflowSteps( + messages, + skillDetailMap, + isSending, + ); + if (liveSteps.length > 0) { + return liveSteps; + } + + const queueItems = backendRunState?.queue_items || []; + if (queueItems.length > 0) { + if (queueItems.length === 1) { + const item = queueItems[0]; + const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); + const workflowSteps = sourceRef + ? skillDetailMap[sourceRef]?.workflow_steps || [] + : []; + if (workflowSteps.length > 0) { + const latestAssistantContent = + messages + .slice() + .reverse() + .find((m) => m.role === "assistant")?.content || ""; + const activeIndex = + extractThemeWorkbenchWorkflowMarkerIndex(latestAssistantContent) ?? 0; + return workflowSteps.map((step, index) => ({ + id: `${item.run_id}-${step.id}`, + title: step.name, + status: + index < activeIndex + ? ("completed" as StepStatus) + : index === activeIndex + ? ("active" as StepStatus) + : ("pending" as StepStatus), + })); + } + } + return queueItems.map((item) => ({ + id: item.run_id, + title: resolveThemeWorkbenchQueueItemTitle(item, skillDetailMap), + status: resolveThemeWorkbenchRunStepStatus(item.status), + })); + } + + const latestTerminal = backendRunState?.latest_terminal; + if (latestTerminal && backendRunState?.run_state !== "auto_running") { + return [ + { + id: latestTerminal.run_id, + title: resolveThemeWorkbenchQueueItemTitle( + latestTerminal, + skillDetailMap, + ), + status: resolveThemeWorkbenchRunStepStatus(latestTerminal.status), + }, + ]; + } + + return []; +} + +export function loadPersistedBoolean(key: string, fallback = false): boolean { + try { + const stored = localStorage.getItem(key); + if (stored == null) { + return fallback; + } + + try { + const parsed = JSON.parse(stored); + return typeof parsed === "boolean" ? parsed : fallback; + } catch { + return stored === "true"; + } + } catch { + return fallback; + } +} + +export function savePersistedBoolean(key: string, value: boolean) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // ignore write errors + } +} + +export function isCanvasStateEmpty(state: CanvasStateUnion | null): boolean { + if (!state) return true; + + switch (state.type) { + case "document": + return !state.content || state.content.trim() === ""; + case "novel": + return ( + state.chapters.length === 0 || + !state.chapters[0].content || + state.chapters[0].content.trim() === "" + ); + case "script": + return ( + state.scenes.length === 0 || + (state.scenes.length === 1 && + state.scenes[0].dialogues.length === 0 && + !state.scenes[0].description) + ); + case "music": + return !state.sections || state.sections.length === 0; + case "poster": + return ( + state.pages.length === 0 || + (state.pages.length === 1 && state.pages[0].layers.length === 0) + ); + default: + return true; + } +} + +export function serializeCanvasStateForSync(state: CanvasStateUnion): string { + switch (state.type) { + case "document": + return state.content || ""; + case "novel": + return JSON.stringify(state.chapters); + case "script": + return JSON.stringify(state.scenes); + case "music": + return JSON.stringify(state.sections); + case "poster": + return JSON.stringify(state.pages); + default: + return JSON.stringify(state); + } +} + +export function isSyncContentEmpty(content: string): boolean { + return !content || content === "[]" || content === "{}"; +} + +export function resolveThemeWorkbenchRecentTerminals( + state: BackendThemeWorkbenchRunState | null, +): ThemeWorkbenchRunTerminalItem[] { + if (!state) { + return []; + } + + const rawTerminals = + Array.isArray(state.recent_terminals) && state.recent_terminals.length > 0 + ? state.recent_terminals + : state.latest_terminal + ? [state.latest_terminal] + : []; + + const seenRunIds = new Set(); + return rawTerminals.filter((item) => { + const runId = item.run_id?.trim(); + if (!runId || seenRunIds.has(runId)) { + return false; + } + seenRunIds.add(runId); + return true; + }); +} + +export function mergeThemeWorkbenchTerminalItems( + ...groups: ThemeWorkbenchRunTerminalItem[][] +): ThemeWorkbenchRunTerminalItem[] { + const merged: ThemeWorkbenchRunTerminalItem[] = []; + const seenRunIds = new Set(); + + groups.forEach((items) => { + items.forEach((item) => { + const runId = item.run_id?.trim(); + if (!runId || seenRunIds.has(runId)) { + return; + } + seenRunIds.add(runId); + merged.push(item); + }); + }); + + return merged; +} + +export function buildThemeWorkbenchRunStateSignature( + state: BackendThemeWorkbenchRunState | null, +): string { + if (!state) { + return "null"; + } + + const queueSignature = (state.queue_items || []) + .map((item) => + [ + item.run_id, + item.execution_id || "", + item.status, + item.gate_key || "", + item.source || "", + item.source_ref || "", + ].join(":"), + ) + .join("|"); + + const terminalSignature = resolveThemeWorkbenchRecentTerminals(state) + .map((item) => + [ + item.run_id, + item.execution_id || "", + item.status, + item.gate_key || "", + item.source || "", + item.source_ref || "", + ].join(":"), + ) + .join("|"); + + return [ + state.run_state, + state.current_gate_key || "", + queueSignature, + terminalSignature, + ].join("||"); +} diff --git a/src/components/agent/chat/workspace/useThemeWorkbenchSidebarPresentation.tsx b/src/components/agent/chat/workspace/useThemeWorkbenchSidebarPresentation.tsx new file mode 100644 index 000000000..8f289a3ac --- /dev/null +++ b/src/components/agent/chat/workspace/useThemeWorkbenchSidebarPresentation.tsx @@ -0,0 +1,89 @@ +import { useCallback, type ComponentProps, type ReactNode } from "react"; +import { WorkspaceThemeSidebar } from "./WorkspaceThemeSidebar"; +import { ThemeWorkbenchHarnessDialogSection } from "./WorkspaceHarnessDialogs"; + +type WorkspaceThemeSidebarProps = ComponentProps; + +type ThemeWorkbenchSidebarWorkflowParams = Omit< + WorkspaceThemeSidebarProps["workflow"], + "onDeleteTopic" +>; + +interface UseThemeWorkbenchSidebarPresentationParams { + showChatPanel: boolean; + showSidebar: boolean; + isThemeWorkbench: boolean; + shouldUseCompactThemeWorkbench: boolean; + enablePanelCollapse: boolean; + sidebarCollapsed: boolean; + onSidebarCollapsedChange: (collapsed: boolean) => void; + sidebar: Omit< + WorkspaceThemeSidebarProps, + "visible" | "isThemeWorkbench" | "enablePanelCollapse" | "onRequestCollapse" | "workflow" + > & { + workflow: ThemeWorkbenchSidebarWorkflowParams; + }; + harnessDialog: ComponentProps; +} + +interface ThemeWorkbenchSidebarPresentationResult { + themeWorkbenchHarnessDialog: ReactNode; + themeWorkbenchSidebarNode: ReactNode; + showThemeWorkbenchLeftExpandButton: boolean; + onExpandThemeWorkbenchSidebar: () => void; +} + +export function useThemeWorkbenchSidebarPresentation({ + showChatPanel, + showSidebar, + isThemeWorkbench, + shouldUseCompactThemeWorkbench, + enablePanelCollapse, + sidebarCollapsed, + onSidebarCollapsedChange, + sidebar, + harnessDialog, +}: UseThemeWorkbenchSidebarPresentationParams): ThemeWorkbenchSidebarPresentationResult { + const shouldShowThemeWorkbenchSidebarForTheme = + !shouldUseCompactThemeWorkbench; + const showThemeWorkbenchSidebar = + showChatPanel && + showSidebar && + isThemeWorkbench && + shouldShowThemeWorkbenchSidebarForTheme && + (!enablePanelCollapse || !sidebarCollapsed); + const showThemeWorkbenchLeftExpandButton = + showChatPanel && + showSidebar && + isThemeWorkbench && + shouldShowThemeWorkbenchSidebarForTheme && + enablePanelCollapse && + sidebarCollapsed; + + const handleThemeWorkbenchDeleteTopic = useCallback(() => undefined, []); + const handleThemeWorkbenchSidebarCollapse = useCallback(() => { + onSidebarCollapsedChange(true); + }, [onSidebarCollapsedChange]); + const handleExpandThemeWorkbenchSidebar = useCallback(() => { + onSidebarCollapsedChange(false); + }, [onSidebarCollapsedChange]); + + return { + themeWorkbenchHarnessDialog: , + themeWorkbenchSidebarNode: ( + + ), + showThemeWorkbenchLeftExpandButton, + onExpandThemeWorkbenchSidebar: handleExpandThemeWorkbenchSidebar, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts new file mode 100644 index 000000000..ddc8a7665 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts @@ -0,0 +1,223 @@ +import { useEffect, useMemo } from "react"; +import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; +import type { A2UIResponse } from "@/components/content-creator/a2ui/types"; +import { + buildActionRequestA2UI, + isActionRequestA2UICompatible, + summarizeActionRequestSubmission, +} from "../utils/actionRequestA2UI"; +import { buildLegacyQuestionnaireA2UI } from "../utils/legacyQuestionnaireA2UI"; +import type { ActionRequired, Message } from "../types"; + +interface A2UISubmissionNotice { + title: string; + summary: string; +} + +interface UseWorkspaceA2UIRuntimeParams { + messages: Message[]; +} + +export function useWorkspaceA2UIRuntime({ + messages, +}: UseWorkspaceA2UIRuntimeParams): { + a2uiSubmissionNotice: A2UISubmissionNotice | null; + pendingA2UIForm: A2UIResponse | null; + pendingActionRequest: ActionRequired | null; + pendingLegacyQuestionnaireA2UIForm: A2UIResponse | null; + pendingPromotedA2UIActionRequest: ActionRequired | null; +} { + const pendingActionRequest = useMemo(() => { + const latestPendingMessage = [...messages] + .reverse() + .find((message) => + message.actionRequests?.some((request) => request.status === "pending"), + ); + + if (!latestPendingMessage?.actionRequests) { + return null; + } + + return ( + [...latestPendingMessage.actionRequests] + .reverse() + .find((request) => request.status === "pending") || null + ); + }, [messages]); + + const pendingMessageA2UIForm = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + + if (message.role === "user") { + return null; + } + + if (message.role !== "assistant" || !message.content) { + continue; + } + + try { + const parsed = parseAIResponse(message.content, false); + if (!parsed.hasA2UI) { + continue; + } + + for (let j = parsed.parts.length - 1; j >= 0; j -= 1) { + const part = parsed.parts[j]; + if (part.type === "a2ui" && typeof part.content !== "string") { + return part.content; + } + } + } catch { + // 解析失败时忽略,继续向前寻找最近可用表单 + } + } + + return null; + }, [messages]); + + const pendingPromotedA2UIActionRequest = useMemo(() => { + if (pendingMessageA2UIForm) { + return null; + } + + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + const pendingRequest = [...(message.actionRequests || [])] + .reverse() + .find( + (request) => + request.status === "pending" && + isActionRequestA2UICompatible(request), + ); + + if (pendingRequest) { + return pendingRequest; + } + } + + return null; + }, [messages, pendingMessageA2UIForm]); + + const pendingLegacyQuestionnaireA2UIForm = useMemo(() => { + if (pendingMessageA2UIForm || pendingActionRequest) { + return null; + } + + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + + if (message.role === "user") { + return null; + } + + if (message.role !== "assistant") { + continue; + } + + if ((message.actionRequests || []).length > 0) { + return null; + } + + return buildLegacyQuestionnaireA2UI(message.content || ""); + } + + return null; + }, [messages, pendingActionRequest, pendingMessageA2UIForm]); + + const pendingA2UIForm = useMemo(() => { + if (pendingMessageA2UIForm) { + return pendingMessageA2UIForm; + } + + if (pendingPromotedA2UIActionRequest) { + return buildActionRequestA2UI(pendingPromotedA2UIActionRequest); + } + + return pendingLegacyQuestionnaireA2UIForm; + }, [ + pendingLegacyQuestionnaireA2UIForm, + pendingMessageA2UIForm, + pendingPromotedA2UIActionRequest, + ]); + + const a2uiSubmissionNotice = useMemo(() => { + if (pendingA2UIForm) { + return null; + } + + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message.role === "assistant") { + const submittedActionRequest = [...(message.actionRequests || [])] + .reverse() + .find( + (request) => + request.status === "submitted" && + isActionRequestA2UICompatible(request), + ); + + if (submittedActionRequest) { + return { + title: "补充信息已确认", + summary: + summarizeActionRequestSubmission(submittedActionRequest) || + "已收到你的补充信息,正在继续推进下一步。", + }; + } + + continue; + } + + if (message.role !== "user") { + continue; + } + + const content = message.content.trim(); + if (!content.startsWith("我的选择:")) { + return null; + } + + const summary = content + .split("\n") + .slice(1) + .map((line) => line.replace(/^[-•]\s*/, "").trim()) + .filter(Boolean) + .slice(0, 3) + .join(" · "); + + return { + title: "需求已确认", + summary: summary || "已收到你的补充信息,正在继续推进下一步。", + }; + } + + return null; + }, [messages, pendingA2UIForm]); + + useEffect(() => { + if ( + !pendingActionRequest || + pendingA2UIForm || + !isActionRequestA2UICompatible(pendingActionRequest) + ) { + return; + } + + console.warn("[AgentChatPage] 待处理 action_required 未生成输入区 A2UI", { + requestId: pendingActionRequest.requestId, + actionType: pendingActionRequest.actionType, + prompt: pendingActionRequest.prompt, + scope: pendingActionRequest.scope, + }); + }, [pendingA2UIForm, pendingActionRequest]); + + return { + a2uiSubmissionNotice, + pendingA2UIForm, + pendingActionRequest, + pendingLegacyQuestionnaireA2UIForm, + pendingPromotedA2UIActionRequest, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceA2UISubmitActions.ts b/src/components/agent/chat/workspace/useWorkspaceA2UISubmitActions.ts new file mode 100644 index 000000000..ab8e20cbe --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceA2UISubmitActions.ts @@ -0,0 +1,113 @@ +import { useCallback } from "react"; +import { toast } from "sonner"; +import type { A2UIFormData, A2UIResponse } from "@/components/content-creator/a2ui/types"; +import type { ConfirmResponse } from "../types"; +import type { SendMessageFn } from "../hooks/agentChatShared"; +import { + buildActionRequestSubmissionPayload, +} from "../utils/actionRequestA2UI"; +import { buildLegacyQuestionnaireSubmissionPayload } from "../utils/legacyQuestionnaireA2UI"; + +interface PendingPromotedActionRequest { + requestId: string; + actionType: ConfirmResponse["actionType"]; +} + +interface UseWorkspaceA2UISubmitActionsParams { + handlePermissionResponseWithBrowserPreflight: ( + response: ConfirmResponse, + ) => Promise; + pendingLegacyQuestionnaireA2UIForm: A2UIResponse | null; + pendingPromotedA2UIActionRequest: + | (PendingPromotedActionRequest & Record) + | null; + sendMessage: SendMessageFn; +} + +export function useWorkspaceA2UISubmitActions({ + handlePermissionResponseWithBrowserPreflight, + pendingLegacyQuestionnaireA2UIForm, + pendingPromotedA2UIActionRequest, + sendMessage, +}: UseWorkspaceA2UISubmitActionsParams) { + const handleA2UISubmit = useCallback( + async (formData: A2UIFormData, _messageId: string) => { + console.log("[AgentChatPage] A2UI 表单提交:", formData); + + const formattedData = Object.entries(formData) + .map(([key, value]) => { + if (Array.isArray(value)) { + return `- ${key}: ${value.join(", ")}`; + } + return `- ${key}: ${value}`; + }) + .join("\n"); + + await sendMessage(`我的选择:\n${formattedData}`, [], false, false); + }, + [sendMessage], + ); + + const handleInputbarA2UISubmit = useCallback( + (formData: A2UIFormData) => { + if (pendingPromotedA2UIActionRequest) { + const payload = buildActionRequestSubmissionPayload( + pendingPromotedA2UIActionRequest as Parameters< + typeof buildActionRequestSubmissionPayload + >[0], + formData, + ); + + void handlePermissionResponseWithBrowserPreflight({ + requestId: pendingPromotedA2UIActionRequest.requestId, + confirmed: true, + actionType: pendingPromotedA2UIActionRequest.actionType, + response: payload.responseText, + userData: payload.userData, + }); + return; + } + + if (pendingLegacyQuestionnaireA2UIForm) { + const submissionPayload = buildLegacyQuestionnaireSubmissionPayload( + pendingLegacyQuestionnaireA2UIForm, + formData, + ); + + if (!submissionPayload) { + toast.info("请至少补充一项信息后再继续"); + return; + } + + void sendMessage( + submissionPayload.formattedMessage, + [], + false, + false, + false, + undefined, + undefined, + undefined, + { + requestMetadata: submissionPayload.requestMetadata, + }, + ); + return; + } + + void handleA2UISubmit(formData, ""); + }, + [ + handleA2UISubmit, + handlePermissionResponseWithBrowserPreflight, + pendingLegacyQuestionnaireA2UIForm, + pendingPromotedA2UIActionRequest, + sendMessage, + ], + ); + + return { + handleA2UISubmit, + handleInputbarA2UISubmit, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts b/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts new file mode 100644 index 000000000..97aa104f5 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts @@ -0,0 +1,435 @@ +import { + useCallback, + useMemo, + type Dispatch, + type SetStateAction, +} from "react"; +import { toast } from "sonner"; +import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import { createInitialMusicState } from "@/components/content-creator/canvas/music/types"; +import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser"; +import type { LayoutMode, ThemeType } from "@/components/content-creator/types"; +import { readFilePreview } from "@/lib/api/fileBrowser"; +import type { SessionFile } from "@/lib/api/session-files"; +import type { Artifact } from "@/lib/artifact/types"; +import type { TaskFile } from "../components/TaskFiles"; +import type { HarnessFilePreviewResult } from "../components/HarnessStatusPanel"; +import { useArtifactAutoPreviewSync } from "../hooks/useArtifactAutoPreviewSync"; +import { + buildArtifactFromWrite, + resolveArtifactFilePath, + resolveDefaultArtifactViewMode, +} from "../utils/messageArtifacts"; +import { + isRenderableTaskFile, + looksLikeSocialPublishPayload, + resolveTaskFileType, +} from "./themeWorkbenchHelpers"; +import { extractFileNameFromPath } from "./workspacePath"; + +function buildCanvasStateFromContent(params: { + previous: CanvasStateUnion | null; + mappedTheme: ThemeType; + content: string; + upsertNovelCanvasState: ( + previous: CanvasStateUnion | null, + content: string, + ) => CanvasStateUnion; +}): CanvasStateUnion { + const { previous, mappedTheme, content, upsertNovelCanvasState } = params; + + if (mappedTheme === "music") { + const sections = parseLyrics(content); + if (!previous || previous.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + const titleMatch = content.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + return musicState; + } + return { ...previous, sections }; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(previous, content); + } + + if (!previous || previous.type !== "document") { + return createInitialDocumentState(content); + } + + return { + ...previous, + content, + }; +} + +interface UseWorkspaceArtifactPreviewActionsParams { + activeTheme: ThemeType; + mappedTheme: ThemeType; + layoutMode: LayoutMode; + isThemeWorkbench: boolean; + isGeneralCanvasOpen: boolean; + artifacts: Artifact[]; + currentCanvasArtifact: Artifact | null; + taskFiles: TaskFile[]; + sessionFiles: SessionFile[]; + readSessionFile: (fileName: string) => Promise; + upsertGeneralArtifact: (artifact: Artifact) => void; + setSelectedArtifactId: (artifactId: string | null) => void; + setArtifactViewMode: Dispatch>; + setLayoutMode: Dispatch>; + setTaskFiles: Dispatch>; + setSelectedFileId: Dispatch>; + setCanvasState: Dispatch>; + upsertNovelCanvasState: ( + previous: CanvasStateUnion | null, + content: string, + ) => CanvasStateUnion; +} + +interface WorkspaceArtifactPreviewActionsResult { + handleHarnessLoadFilePreview: ( + path: string, + ) => Promise; + handleArtifactClick: (artifact: Artifact) => void; + handleFileClick: (fileName: string, content: string) => void; + handleCodeBlockClick: (language: string, code: string) => void; + shouldCollapseCodeBlocks: boolean; + shouldCollapseCodeBlockInChat: (language: string, code: string) => boolean; + handleTaskFileClick: (file: TaskFile) => void; +} + +export function useWorkspaceArtifactPreviewActions({ + activeTheme, + mappedTheme, + layoutMode, + isThemeWorkbench, + isGeneralCanvasOpen, + artifacts, + currentCanvasArtifact, + taskFiles, + sessionFiles, + readSessionFile, + upsertGeneralArtifact, + setSelectedArtifactId, + setArtifactViewMode, + setLayoutMode, + setTaskFiles, + setSelectedFileId, + setCanvasState, + upsertNovelCanvasState, +}: UseWorkspaceArtifactPreviewActionsParams): WorkspaceArtifactPreviewActionsResult { + const handleHarnessLoadFilePreview = useCallback( + async (path: string): Promise => { + const normalizedPath = path.trim(); + const createFallbackResult = ( + overrides: Partial = {}, + ): HarnessFilePreviewResult => ({ + path: normalizedPath, + content: null, + isBinary: false, + size: 0, + error: null, + ...overrides, + }); + + if (!normalizedPath) { + return createFallbackResult({ error: "文件路径为空" }); + } + + const fileName = extractFileNameFromPath(normalizedPath); + const candidateNames = [...new Set([normalizedPath, fileName])]; + + const matchedTaskFile = taskFiles.find((file) => + candidateNames.includes(file.name), + ); + if (matchedTaskFile) { + const content = matchedTaskFile.content ?? ""; + return createFallbackResult({ + path: matchedTaskFile.name, + content, + size: content.length, + }); + } + + const matchedSessionFile = sessionFiles.find((file) => + candidateNames.includes(file.name), + ); + if (matchedSessionFile) { + const content = await readSessionFile(matchedSessionFile.name); + if (content !== null) { + return createFallbackResult({ + path: matchedSessionFile.name, + content, + size: content.length, + }); + } + } + + try { + const result = await readFilePreview(normalizedPath, 64 * 1024); + + return createFallbackResult({ + path: result.path || normalizedPath, + content: result.content ?? null, + isBinary: result.isBinary ?? false, + size: result.size ?? 0, + error: result.error ?? null, + }); + } catch (error) { + return createFallbackResult({ + error: error instanceof Error ? error.message : String(error), + }); + } + }, + [readSessionFile, sessionFiles, taskFiles], + ); + + useArtifactAutoPreviewSync({ + enabled: activeTheme === "general", + artifact: currentCanvasArtifact, + loadPreview: handleHarnessLoadFilePreview, + onSyncArtifact: upsertGeneralArtifact, + }); + + const openArtifactInWorkbench = useCallback( + async (artifact: Artifact) => { + let nextArtifact = artifact; + const artifactPath = resolveArtifactFilePath(artifact); + const shouldLoadPreview = artifact.content.length === 0 && artifactPath; + + if (shouldLoadPreview) { + const preview = await handleHarnessLoadFilePreview(artifactPath); + if (preview.error) { + toast.error(`读取产物失败: ${preview.error}`); + } else if (preview.isBinary) { + toast.info("该产物为二进制文件,暂不支持在工作台预览"); + } else if (typeof preview.content === "string") { + nextArtifact = { + ...artifact, + content: preview.content, + meta: { + ...artifact.meta, + filePath: preview.path || artifactPath, + filename: + artifact.meta.filename || + extractFileNameFromPath(preview.path || artifactPath), + }, + updatedAt: Date.now(), + }; + upsertGeneralArtifact(nextArtifact); + } + } + + setSelectedArtifactId(nextArtifact.id); + setArtifactViewMode(resolveDefaultArtifactViewMode(nextArtifact)); + setLayoutMode("chat-canvas"); + }, + [ + handleHarnessLoadFilePreview, + setArtifactViewMode, + setLayoutMode, + setSelectedArtifactId, + upsertGeneralArtifact, + ], + ); + + const handleArtifactClick = useCallback( + (artifact: Artifact) => { + void openArtifactInWorkbench(artifact); + }, + [openArtifactInWorkbench], + ); + + const findArtifactForCodeBlock = useCallback( + (code: string) => { + const normalizedCode = code.replace(/\r\n/g, "\n").trimEnd(); + if (!normalizedCode) { + return undefined; + } + + return artifacts.find((artifact) => { + if (typeof artifact.content !== "string") { + return false; + } + return ( + artifact.content.replace(/\r\n/g, "\n").trimEnd() === normalizedCode + ); + }); + }, + [artifacts], + ); + + const applyContentToCanvas = useCallback( + (content: string) => { + setCanvasState((previous) => + buildCanvasStateFromContent({ + previous, + mappedTheme, + content, + upsertNovelCanvasState, + }), + ); + setLayoutMode("chat-canvas"); + }, + [mappedTheme, setCanvasState, setLayoutMode, upsertNovelCanvasState], + ); + + const handleFileClick = useCallback( + (fileName: string, content: string) => { + console.log("[AgentChatPage] 文件点击:", fileName, "主题:", activeTheme); + + if (activeTheme === "general") { + const matchingArtifact = artifacts.find((artifact) => { + const artifactPath = resolveArtifactFilePath(artifact); + return ( + artifactPath === fileName || + artifact.title === extractFileNameFromPath(fileName) || + (content.trim().length > 0 && artifact.content === content) + ); + }); + const nextArtifact = + matchingArtifact || + buildArtifactFromWrite({ + filePath: fileName, + content, + context: { + source: "message_content", + status: content.length > 0 ? "complete" : "pending", + }, + }); + + if (!matchingArtifact) { + upsertGeneralArtifact(nextArtifact); + } + + void openArtifactInWorkbench(nextArtifact); + return; + } + + const nextFileType = resolveTaskFileType(fileName, content); + setTaskFiles((previous) => { + const existingFile = previous.find((file) => file.name === fileName); + if (existingFile) { + setSelectedFileId(existingFile.id); + return previous; + } + + const nextFile: TaskFile = { + id: crypto.randomUUID(), + name: fileName, + type: nextFileType, + content, + version: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + setSelectedFileId(nextFile.id); + return [...previous, nextFile]; + }); + + if ( + !isRenderableTaskFile( + { name: fileName, type: nextFileType }, + isThemeWorkbench, + ) + ) { + toast.info("该文件为辅助产物,暂不在主稿画布渲染"); + return; + } + + applyContentToCanvas(content); + }, + [ + activeTheme, + applyContentToCanvas, + artifacts, + isThemeWorkbench, + openArtifactInWorkbench, + setSelectedFileId, + setTaskFiles, + upsertGeneralArtifact, + ], + ); + + const handleCodeBlockClick = useCallback( + (language: string, code: string) => { + console.log("[AgentChatPage] 代码块点击:", language); + + const matchingArtifact = findArtifactForCodeBlock(code); + if (!matchingArtifact) { + console.warn( + "[AgentChatPage] 代码块未匹配到 artifact,保持内联渲染:", + language, + ); + return; + } + + console.log("[AgentChatPage] 找到匹配的 artifact:", matchingArtifact.id); + void openArtifactInWorkbench(matchingArtifact); + }, + [findArtifactForCodeBlock, openArtifactInWorkbench], + ); + + const shouldCollapseCodeBlocks = useMemo(() => { + if (activeTheme !== "general") { + return false; + } + if (layoutMode === "chat") { + return false; + } + return artifacts.length > 0 || isGeneralCanvasOpen; + }, [activeTheme, artifacts.length, isGeneralCanvasOpen, layoutMode]); + + const shouldCollapseCodeBlockInChat = useCallback( + (language: string, code: string) => { + if (!shouldCollapseCodeBlocks) { + return false; + } + + const normalizedLanguage = language.trim().toLowerCase(); + if ( + ["", "text", "plaintext", "plain", "txt", "markdown", "md"].includes( + normalizedLanguage, + ) + ) { + return false; + } + + return Boolean(findArtifactForCodeBlock(code)); + }, + [findArtifactForCodeBlock, shouldCollapseCodeBlocks], + ); + + const handleTaskFileClick = useCallback( + (file: TaskFile) => { + setSelectedFileId(file.id); + + if ( + !isRenderableTaskFile(file, isThemeWorkbench) || + looksLikeSocialPublishPayload(file.content || "") || + !file.content?.trim() + ) { + toast.info("该文件为辅助产物,暂不在主稿画布渲染"); + return; + } + + applyContentToCanvas(file.content ?? ""); + }, + [applyContentToCanvas, isThemeWorkbench, setSelectedFileId], + ); + + return { + handleHarnessLoadFilePreview, + handleArtifactClick, + handleFileClick, + handleCodeBlockClick, + shouldCollapseCodeBlocks, + shouldCollapseCodeBlockInChat, + handleTaskFileClick, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts new file mode 100644 index 000000000..33116020c --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts @@ -0,0 +1,290 @@ +import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import { contentWorkflowApi } from "@/lib/api/content-workflow"; +import { getDefaultGuidePromptByTheme } from "../utils/defaultGuidePrompt"; +import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt"; +import type { ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { MessageImage } from "../types"; +import type { CreationMode, ThemeType } from "@/components/content-creator/types"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import { isCanvasStateEmpty } from "./themeWorkbenchHelpers"; +import type { WorkspaceHandleSend } from "./useWorkspaceSendActions"; + +interface UseWorkspaceAutoGuideRuntimeParams { + contentId?: string | null; + sessionId?: string; + initialUserPrompt?: string; + initialUserImages?: MessageImage[]; + initialDispatchKey: string | null; + messagesCount: number; + projectReady: boolean; + systemPromptReady: boolean; + isSending: boolean; + canvasState: CanvasStateUnion | null; + isThemeWorkbench: boolean; + mappedTheme: ThemeType; + creationMode: CreationMode; + shouldUseCompactThemeWorkbench: boolean; + shouldSkipThemeWorkbenchAutoGuideWithoutPrompt: boolean; + themeWorkbenchEntryCheckPending: boolean; + themeWorkbenchEntryPrompt: ThemeWorkbenchEntryPromptState | null; + chatToolPreferences: Pick; + setInput: Dispatch>; + handleSend: WorkspaceHandleSend; + triggerAIGuide: () => void; + onInitialUserPromptConsumed?: () => void; + hasTriggeredGuideRef: MutableRefObject; + consumedInitialPromptRef: MutableRefObject; +} + +export function useWorkspaceAutoGuideRuntime({ + contentId, + sessionId, + initialUserPrompt, + initialUserImages, + initialDispatchKey, + messagesCount, + projectReady, + systemPromptReady, + isSending, + canvasState, + isThemeWorkbench, + mappedTheme, + creationMode, + shouldUseCompactThemeWorkbench, + shouldSkipThemeWorkbenchAutoGuideWithoutPrompt, + themeWorkbenchEntryCheckPending, + themeWorkbenchEntryPrompt, + chatToolPreferences, + setInput, + handleSend, + triggerAIGuide, + onInitialUserPromptConsumed, + hasTriggeredGuideRef, + consumedInitialPromptRef, +}: UseWorkspaceAutoGuideRuntimeParams) { + const triggerAIGuideRef = useRef(triggerAIGuide); + triggerAIGuideRef.current = triggerAIGuide; + + useEffect(() => { + if (shouldUseCompactThemeWorkbench) { + return; + } + + const canvasEmpty = isCanvasStateEmpty(canvasState); + const pendingInitialPrompt = (initialUserPrompt || "").trim(); + const pendingInitialImages = initialUserImages || []; + const defaultGuidePrompt = + contentId && canvasEmpty && !isThemeWorkbench + ? getDefaultGuidePromptByTheme(mappedTheme) + : undefined; + + if ( + !contentId || + messagesCount > 0 || + !projectReady || + !systemPromptReady || + isSending || + !canvasEmpty + ) { + return; + } + + if (!initialDispatchKey && themeWorkbenchEntryCheckPending) { + return; + } + + if (initialDispatchKey) { + if (isThemeWorkbench && pendingInitialImages.length === 0) { + return; + } + if (consumedInitialPromptRef.current === initialDispatchKey) { + return; + } + + let disposed = false; + consumedInitialPromptRef.current = initialDispatchKey; + hasTriggeredGuideRef.current = true; + console.log("[AgentChatPage] 自动发送首条创作意图消息"); + + void (async () => { + const started = await handleSend( + pendingInitialImages, + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + pendingInitialPrompt, + ); + if (disposed) { + return; + } + if (!started) { + consumedInitialPromptRef.current = null; + return; + } + onInitialUserPromptConsumed?.(); + })(); + + return () => { + disposed = true; + }; + } + + if (hasTriggeredGuideRef.current) { + return; + } + + if (themeWorkbenchEntryPrompt?.kind === "resume") { + return; + } + + if (defaultGuidePrompt) { + hasTriggeredGuideRef.current = true; + setInput((previous) => previous.trim() || defaultGuidePrompt); + return; + } + + if (isThemeWorkbench) { + if (shouldSkipThemeWorkbenchAutoGuideWithoutPrompt) { + return; + } + + hasTriggeredGuideRef.current = true; + console.log("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流"); + + void (async () => { + try { + await contentWorkflowApi.create(contentId, mappedTheme, creationMode); + console.log("[AgentChatPage] 后端工作流创建成功"); + } catch (error) { + console.warn( + "[AgentChatPage] 后端工作流创建失败(不影响主流程):", + error, + ); + } + })(); + + triggerAIGuideRef.current(); + return; + } + + hasTriggeredGuideRef.current = true; + console.log("[AgentChatPage] 自动触发 AI 创作引导"); + triggerAIGuideRef.current(); + }, [ + canvasState, + chatToolPreferences.thinking, + chatToolPreferences.webSearch, + contentId, + creationMode, + handleSend, + initialDispatchKey, + initialUserImages, + initialUserPrompt, + isSending, + isThemeWorkbench, + mappedTheme, + messagesCount, + onInitialUserPromptConsumed, + projectReady, + setInput, + shouldSkipThemeWorkbenchAutoGuideWithoutPrompt, + shouldUseCompactThemeWorkbench, + systemPromptReady, + themeWorkbenchEntryCheckPending, + themeWorkbenchEntryPrompt, + consumedInitialPromptRef, + hasTriggeredGuideRef, + ]); + + useEffect(() => { + const pendingInitialPrompt = (initialUserPrompt || "").trim(); + const pendingInitialImages = initialUserImages || []; + + if ( + shouldUseCompactThemeWorkbench || + !initialDispatchKey || + contentId || + !sessionId || + messagesCount > 0 || + isSending + ) { + return; + } + + if (consumedInitialPromptRef.current === initialDispatchKey) { + return; + } + + let disposed = false; + consumedInitialPromptRef.current = initialDispatchKey; + + void (async () => { + const started = await handleSend( + pendingInitialImages, + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + pendingInitialPrompt, + ); + if (disposed) { + return; + } + if (!started) { + consumedInitialPromptRef.current = null; + return; + } + onInitialUserPromptConsumed?.(); + })(); + + return () => { + disposed = true; + }; + }, [ + chatToolPreferences.thinking, + chatToolPreferences.webSearch, + contentId, + handleSend, + initialDispatchKey, + initialUserImages, + initialUserPrompt, + isSending, + messagesCount, + onInitialUserPromptConsumed, + sessionId, + shouldUseCompactThemeWorkbench, + consumedInitialPromptRef, + ]); + + useEffect(() => { + hasTriggeredGuideRef.current = false; + consumedInitialPromptRef.current = null; + }, [contentId, consumedInitialPromptRef, hasTriggeredGuideRef]); + + useEffect(() => { + if (!contentId || !isThemeWorkbench) { + return; + } + + let disposed = false; + + void (async () => { + try { + const workflow = await contentWorkflowApi.getByContent(contentId); + if (!workflow || disposed) { + return; + } + + const completedCount = workflow.steps.filter( + (step) => step.status === "completed" || step.status === "skipped", + ).length; + console.log( + `[AgentChatPage] 找到已有工作流: ${workflow.id},已完成步骤 ${completedCount}/${workflow.steps.length}`, + ); + } catch (error) { + console.debug("[AgentChatPage] 查询后端工作流失败:", error); + } + })(); + + return () => { + disposed = true; + }; + }, [contentId, isThemeWorkbench]); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceBrowserAssistRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceBrowserAssistRuntime.ts new file mode 100644 index 000000000..2bfd734f9 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceBrowserAssistRuntime.ts @@ -0,0 +1,915 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import { toast } from "sonner"; +import type { LayoutMode, ThemeType } from "@/components/content-creator/types"; +import { browserExecuteAction, launchBrowserSession } from "@/lib/webview-api"; +import type { Artifact } from "@/lib/artifact/types"; +import type { + BrowserAssistSessionState, + Message, +} from "../types"; +import { + areBrowserAssistSessionStatesEqual, + clearBrowserAssistSessionState, + createBrowserAssistSessionState, + extractBrowserAssistSessionFromArtifact, + findLatestBrowserAssistSessionInMessages, + loadBrowserAssistSessionState, + mergeBrowserAssistSessionStates, + resolveBrowserAssistSessionScopeKey, + saveBrowserAssistSessionState, +} from "../utils/browserAssistSession"; +import { + extractExplicitUrlFromText, + resolveBrowserAssistLaunchUrl, +} from "../utils/browserAssistIntent"; +import { + GENERAL_BROWSER_ASSIST_ARTIFACT_ID, + asRecord, + buildBrowserAssistArtifact, + buildFailedBrowserAssistArtifact, + buildPendingBrowserAssistArtifact, + readFirstString, + resolveBrowserAssistArtifactScopeKey, +} from "./browserAssistArtifact"; + +function hasActiveBrowserAssistSession( + sessionState: BrowserAssistSessionState | null, +): boolean { + if (!sessionState) { + return false; + } + + if (!sessionState.sessionId && !sessionState.profileKey) { + return false; + } + + const lifecycleState = sessionState.lifecycleState?.trim().toLowerCase(); + return !["failed", "closed", "terminated"].includes(lifecycleState || ""); +} + +type EnsureBrowserAssistCanvasHandler = ( + sourceText: string, + options?: { + silent?: boolean; + navigationMode?: "none" | "explicit-url" | "best-effort"; + }, +) => Promise; + +interface UseWorkspaceBrowserAssistRuntimeParams { + activeTheme: ThemeType; + projectId?: string | null; + sessionId?: string | null; + input: string; + initialUserPrompt?: string; + openBrowserAssistOnMount: boolean; + artifacts: Artifact[]; + messages: Message[]; + currentCanvasArtifact: Artifact | null; + layoutMode: LayoutMode; + setLayoutMode: Dispatch>; + setSelectedArtifactId: (artifactId: string | null) => void; + upsertGeneralArtifact: (artifact: Artifact) => void; + generalBrowserAssistProfileKey: string; +} + +interface WorkspaceBrowserAssistRuntimeResult { + browserAssistLaunching: boolean; + isBrowserAssistReady: boolean; + isBrowserAssistCanvasVisible: boolean; + currentBrowserAssistScopeKey: string | null; + ensureBrowserAssistCanvas: EnsureBrowserAssistCanvasHandler; + handleOpenBrowserAssistInCanvas: () => Promise; + suppressBrowserAssistCanvasAutoOpen: () => void; + suppressGeneralCanvasArtifactAutoOpen: () => void; +} + +export function useWorkspaceBrowserAssistRuntime({ + activeTheme, + projectId, + sessionId, + input, + initialUserPrompt, + openBrowserAssistOnMount, + artifacts, + messages, + currentCanvasArtifact, + layoutMode, + setLayoutMode, + setSelectedArtifactId, + upsertGeneralArtifact, + generalBrowserAssistProfileKey, +}: UseWorkspaceBrowserAssistRuntimeParams): WorkspaceBrowserAssistRuntimeResult { + const [browserAssistLaunching, setBrowserAssistLaunching] = useState(false); + const [browserAssistSessionState, setBrowserAssistSessionState] = + useState(null); + const openBrowserAssistOnMountHandledRef = useRef(false); + const autoOpenedBrowserAssistSessionIdRef = useRef(""); + const autoLaunchingBrowserAssistKeyRef = useRef(""); + const browserAssistLaunchRequestIdRef = useRef(0); + const browserAssistAutoOpenDismissedScopeRef = useRef(null); + const browserAssistScopeTrackerRef = useRef(null); + const dismissedGeneralCanvasAutoOpenFingerprintRef = useRef( + null, + ); + const previousGeneralCanvasAutoOpenSessionIdRef = useRef( + sessionId ?? null, + ); + + const currentBrowserAssistScopeKey = useMemo( + () => + activeTheme === "general" + ? resolveBrowserAssistSessionScopeKey(projectId, sessionId) + : null, + [activeTheme, projectId, sessionId], + ); + + const browserAssistArtifact = useMemo( + () => + artifacts.find( + (artifact) => + artifact.id === GENERAL_BROWSER_ASSIST_ARTIFACT_ID && + artifact.type === "browser_assist" && + resolveBrowserAssistArtifactScopeKey(artifact) === + currentBrowserAssistScopeKey, + ) || null, + [artifacts, currentBrowserAssistScopeKey], + ); + + const latestBrowserAssistSessionFromMessages = useMemo( + () => findLatestBrowserAssistSessionInMessages(messages), + [messages], + ); + + const browserAssistSessionFromArtifact = useMemo( + () => extractBrowserAssistSessionFromArtifact(browserAssistArtifact), + [browserAssistArtifact], + ); + + const browserAssistStorageKey = useMemo( + () => + activeTheme === "general" + ? `${projectId || "global"}:${sessionId || "active"}` + : null, + [activeTheme, projectId, sessionId], + ); + + const latestGeneralCanvasAutoOpenFingerprint = useMemo(() => { + if (activeTheme !== "general") { + return null; + } + + const latestArtifact = [...artifacts] + .reverse() + .find((artifact) => artifact.type !== "browser_assist"); + if (!latestArtifact) { + return null; + } + + return `${latestArtifact.id}:${latestArtifact.updatedAt}:${latestArtifact.status}`; + }, [activeTheme, artifacts]); + + const isBrowserAssistReady = useMemo( + () => hasActiveBrowserAssistSession(browserAssistSessionState), + [browserAssistSessionState], + ); + + const isBrowserAssistCanvasVisible = + activeTheme === "general" && + layoutMode !== "chat" && + currentCanvasArtifact?.type === "browser_assist"; + + const openBrowserAssistCanvas = useCallback( + (artifactId = GENERAL_BROWSER_ASSIST_ARTIFACT_ID) => { + browserAssistAutoOpenDismissedScopeRef.current = null; + setSelectedArtifactId(artifactId); + setLayoutMode("chat-canvas"); + }, + [setLayoutMode, 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, setLayoutMode, setSelectedArtifactId], + ); + + const suppressBrowserAssistCanvasAutoOpen = useCallback(() => { + if (activeTheme !== "general") { + return; + } + + browserAssistAutoOpenDismissedScopeRef.current = "__dismissed__"; + }, [activeTheme]); + + const suppressGeneralCanvasArtifactAutoOpen = useCallback(() => { + if (activeTheme !== "general" || !latestGeneralCanvasAutoOpenFingerprint) { + return; + } + + dismissedGeneralCanvasAutoOpenFingerprintRef.current = + latestGeneralCanvasAutoOpenFingerprint; + }, [activeTheme, latestGeneralCanvasAutoOpenFingerprint]); + + useEffect(() => { + const normalizedSessionId = sessionId ?? null; + if ( + previousGeneralCanvasAutoOpenSessionIdRef.current === normalizedSessionId + ) { + return; + } + + previousGeneralCanvasAutoOpenSessionIdRef.current = normalizedSessionId; + dismissedGeneralCanvasAutoOpenFingerprintRef.current = null; + }, [sessionId]); + + 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]); + + useEffect(() => { + if (activeTheme !== "general") { + return; + } + if (artifacts.length === 0) { + return; + } + + const hasNonBrowserAssistArtifact = artifacts.some( + (artifact) => artifact.type !== "browser_assist", + ); + const hasBoundBrowserAssistSession = Boolean( + browserAssistSessionState?.sessionId || + browserAssistSessionState?.profileKey, + ); + if (!hasNonBrowserAssistArtifact && !hasBoundBrowserAssistSession) { + return; + } + + if ( + !hasNonBrowserAssistArtifact && + browserAssistAutoOpenDismissedScopeRef.current + ) { + return; + } + + if ( + hasNonBrowserAssistArtifact && + latestGeneralCanvasAutoOpenFingerprint && + dismissedGeneralCanvasAutoOpenFingerprintRef.current === + latestGeneralCanvasAutoOpenFingerprint + ) { + return; + } + + setLayoutMode("chat-canvas"); + }, [ + activeTheme, + artifacts, + browserAssistSessionState?.profileKey, + browserAssistSessionState?.sessionId, + latestGeneralCanvasAutoOpenFingerprint, + setLayoutMode, + ]); + + const commitBrowserAssistSessionState = useCallback( + (candidate: BrowserAssistSessionState | null) => { + if (activeTheme !== "general" || !candidate) { + return; + } + + setBrowserAssistSessionState((current) => { + const next = mergeBrowserAssistSessionStates(current, candidate); + return areBrowserAssistSessionStatesEqual(current, next) + ? current + : next; + }); + }, + [activeTheme], + ); + + useEffect(() => { + if (activeTheme !== "general") { + setBrowserAssistSessionState(null); + return; + } + + setBrowserAssistSessionState( + loadBrowserAssistSessionState(projectId, sessionId), + ); + }, [activeTheme, browserAssistStorageKey, projectId, sessionId]); + + useEffect(() => { + if (activeTheme !== "general") { + return; + } + + commitBrowserAssistSessionState(browserAssistSessionFromArtifact); + }, [ + activeTheme, + browserAssistSessionFromArtifact, + commitBrowserAssistSessionState, + ]); + + useEffect(() => { + if (activeTheme !== "general") { + return; + } + + commitBrowserAssistSessionState(latestBrowserAssistSessionFromMessages); + }, [ + activeTheme, + commitBrowserAssistSessionState, + latestBrowserAssistSessionFromMessages, + ]); + + useEffect(() => { + if (activeTheme !== "general") { + return; + } + + if (browserAssistSessionState) { + saveBrowserAssistSessionState( + projectId, + sessionId, + browserAssistSessionState, + ); + return; + } + + clearBrowserAssistSessionState(projectId, sessionId); + }, [ + activeTheme, + browserAssistSessionState, + browserAssistStorageKey, + projectId, + sessionId, + ]); + + const navigateBrowserAssistCanvasToUrl = useCallback( + async (url: string, options?: { silent?: boolean }): Promise => { + if (activeTheme !== "general" || !url.trim()) { + return false; + } + + const artifactMeta = asRecord(browserAssistArtifact?.meta); + const profileKey = + browserAssistSessionState?.profileKey || + readFirstString(artifactMeta ? [artifactMeta] : [], [ + "profileKey", + "profile_key", + ]) || + generalBrowserAssistProfileKey; + const currentUrl = + browserAssistSessionState?.url || + readFirstString(artifactMeta ? [artifactMeta] : [], [ + "url", + "launchUrl", + ]) || + ""; + const fallbackTitle = + browserAssistSessionState?.title || + browserAssistArtifact?.title?.trim() || + "浏览器协助"; + + if (currentUrl === url) { + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + return true; + } + + setBrowserAssistLaunching(true); + + try { + const result = await browserExecuteAction({ + profile_key: profileKey, + backend: "cdp_direct", + action: "navigate", + args: { + action: "goto", + url, + wait_for_page_info: true, + }, + timeout_ms: 20000, + }); + + if (!result.success) { + throw new Error(result.error || "浏览器导航失败"); + } + + const resultData = asRecord(result.data); + const pageInfo = + asRecord(resultData?.page_info) || asRecord(resultData?.pageInfo); + const nextUrl = + readFirstString( + [pageInfo, resultData], + ["url", "target_url", "targetUrl"], + ) || url; + const nextTitle = + readFirstString( + [pageInfo, resultData], + ["title", "target_title", "targetTitle"], + ) || fallbackTitle; + + commitBrowserAssistSessionState( + createBrowserAssistSessionState({ + sessionId: + result.session_id || + browserAssistSessionState?.sessionId || + undefined, + profileKey, + url: nextUrl, + title: nextTitle, + targetId: + result.target_id || + browserAssistSessionState?.targetId || + undefined, + transportKind: browserAssistSessionState?.transportKind, + lifecycleState: browserAssistSessionState?.lifecycleState || "live", + controlMode: browserAssistSessionState?.controlMode, + source: "runtime_launch", + updatedAt: Date.now(), + }), + ); + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + + if (!options?.silent) { + toast.success(`已切换浏览器页面:${nextTitle}`); + } + return true; + } catch (error) { + if (!options?.silent) { + toast.error( + `切换浏览器页面失败: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return false; + } finally { + setBrowserAssistLaunching(false); + } + }, + [ + activeTheme, + browserAssistArtifact, + browserAssistSessionState, + commitBrowserAssistSessionState, + generalBrowserAssistProfileKey, + openBrowserAssistCanvas, + ], + ); + + const ensureBrowserAssistCanvas = useCallback( + async ( + sourceText: string, + options?: { + silent?: boolean; + navigationMode?: "none" | "explicit-url" | "best-effort"; + }, + ): Promise => { + if (activeTheme !== "general") { + return false; + } + + const navigationMode = options?.navigationMode || "best-effort"; + const targetUrl = + navigationMode === "explicit-url" + ? extractExplicitUrlFromText(sourceText) + : navigationMode === "best-effort" + ? resolveBrowserAssistLaunchUrl(sourceText) + : null; + const artifactMeta = asRecord(browserAssistArtifact?.meta); + const hasSessionContext = Boolean( + browserAssistSessionState?.sessionId || + browserAssistSessionState?.profileKey || + readFirstString(artifactMeta ? [artifactMeta] : [], [ + "sessionId", + "session_id", + "profileKey", + "profile_key", + ]) || + browserAssistArtifact, + ); + + if (hasSessionContext) { + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + if (!targetUrl) { + return true; + } + return navigateBrowserAssistCanvasToUrl(targetUrl, options); + } + + if (!targetUrl) { + return false; + } + + const browserAssistScopeKey = + currentBrowserAssistScopeKey || + resolveBrowserAssistSessionScopeKey(projectId, sessionId); + const launchKey = `${generalBrowserAssistProfileKey}:${targetUrl}`; + if (autoLaunchingBrowserAssistKeyRef.current === launchKey) { + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + return true; + } + autoLaunchingBrowserAssistKeyRef.current = launchKey; + upsertGeneralArtifact( + buildPendingBrowserAssistArtifact({ + scopeKey: browserAssistScopeKey, + profileKey: generalBrowserAssistProfileKey, + url: targetUrl, + title: "浏览器协助", + }), + ); + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + setBrowserAssistLaunching(true); + + try { + const result = await launchBrowserSession({ + profile_key: generalBrowserAssistProfileKey, + url: targetUrl, + open_window: false, + stream_mode: "both", + }); + + commitBrowserAssistSessionState( + createBrowserAssistSessionState({ + sessionId: result.session.session_id, + profileKey: result.session.profile_key, + url: + result.session.last_page_info?.url?.trim() || + result.session.target_url?.trim() || + targetUrl, + title: + result.session.last_page_info?.title?.trim() || + result.session.target_title?.trim() || + "浏览器协助", + targetId: result.session.target_id, + transportKind: result.session.transport_kind, + lifecycleState: result.session.lifecycle_state, + controlMode: result.session.control_mode, + source: "runtime_launch", + updatedAt: Date.now(), + }), + ); + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + + if (!options?.silent) { + toast.success( + `浏览器协助已启动:${ + result.session.target_title || + result.session.target_url || + targetUrl + }`, + ); + } + return true; + } catch (error) { + upsertGeneralArtifact( + buildFailedBrowserAssistArtifact({ + scopeKey: browserAssistScopeKey, + profileKey: generalBrowserAssistProfileKey, + url: targetUrl, + title: "浏览器协助", + error: error instanceof Error ? error.message : String(error), + }), + ); + autoLaunchingBrowserAssistKeyRef.current = ""; + if (!options?.silent) { + toast.error( + `启动浏览器协助失败: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return false; + } finally { + setBrowserAssistLaunching(false); + } + }, + [ + activeTheme, + browserAssistArtifact, + browserAssistSessionState?.profileKey, + browserAssistSessionState?.sessionId, + commitBrowserAssistSessionState, + currentBrowserAssistScopeKey, + generalBrowserAssistProfileKey, + navigateBrowserAssistCanvasToUrl, + openBrowserAssistCanvas, + projectId, + sessionId, + upsertGeneralArtifact, + ], + ); + + const handleOpenBrowserAssistInCanvas = useCallback(async () => { + await ensureBrowserAssistCanvas(input, { + navigationMode: "best-effort", + }); + }, [ensureBrowserAssistCanvas, input]); + + useEffect(() => { + if ( + !openBrowserAssistOnMount || + openBrowserAssistOnMountHandledRef.current + ) { + return; + } + + openBrowserAssistOnMountHandledRef.current = true; + void ensureBrowserAssistCanvas(initialUserPrompt || "", { + navigationMode: "best-effort", + }); + }, [ensureBrowserAssistCanvas, initialUserPrompt, openBrowserAssistOnMount]); + + useEffect(() => { + if (activeTheme !== "general") { + autoOpenedBrowserAssistSessionIdRef.current = ""; + autoLaunchingBrowserAssistKeyRef.current = ""; + browserAssistLaunchRequestIdRef.current += 1; + return; + } + + if ( + !browserAssistSessionState?.sessionId && + !browserAssistSessionState?.profileKey + ) { + return; + } + + const artifactMeta = asRecord(browserAssistArtifact?.meta); + const currentSessionId = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["sessionId", "session_id"], + ); + const currentProfileKey = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["profileKey", "profile_key"], + ); + const currentUrl = readFirstString(artifactMeta ? [artifactMeta] : [], [ + "url", + "launchUrl", + ]); + const currentTargetId = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["targetId", "target_id"], + ); + const currentTransportKind = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["transportKind", "transport_kind"], + ); + const currentLifecycleState = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["lifecycleState", "lifecycle_state"], + ); + const currentControlMode = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["controlMode", "control_mode"], + ); + const currentTitle = browserAssistArtifact?.title?.trim(); + + const nextArtifact = buildBrowserAssistArtifact({ + scopeKey: + currentBrowserAssistScopeKey || + resolveBrowserAssistSessionScopeKey(projectId, sessionId), + profileKey: + browserAssistSessionState.profileKey || + currentProfileKey || + generalBrowserAssistProfileKey, + browserSessionId: + browserAssistSessionState.sessionId || currentSessionId || "", + url: + browserAssistSessionState.url || currentUrl || "https://www.google.com", + title: browserAssistSessionState.title || currentTitle || "浏览器协助", + targetId: browserAssistSessionState.targetId || currentTargetId, + transportKind: + browserAssistSessionState.transportKind || currentTransportKind, + lifecycleState: + browserAssistSessionState.lifecycleState || currentLifecycleState, + controlMode: browserAssistSessionState.controlMode || currentControlMode, + }); + + const nextMeta = asRecord(nextArtifact.meta); + const nextSessionId = readFirstString(nextMeta ? [nextMeta] : [], [ + "sessionId", + "session_id", + ]); + const nextProfileKey = readFirstString(nextMeta ? [nextMeta] : [], [ + "profileKey", + "profile_key", + ]); + const nextUrl = readFirstString(nextMeta ? [nextMeta] : [], [ + "url", + "launchUrl", + ]); + const nextTargetId = readFirstString(nextMeta ? [nextMeta] : [], [ + "targetId", + "target_id", + ]); + const nextTransportKind = readFirstString(nextMeta ? [nextMeta] : [], [ + "transportKind", + "transport_kind", + ]); + const nextLifecycleState = readFirstString(nextMeta ? [nextMeta] : [], [ + "lifecycleState", + "lifecycle_state", + ]); + const nextControlMode = readFirstString(nextMeta ? [nextMeta] : [], [ + "controlMode", + "control_mode", + ]); + const currentScopeKey = resolveBrowserAssistArtifactScopeKey( + browserAssistArtifact, + ); + const nextScopeKey = resolveBrowserAssistArtifactScopeKey(nextArtifact); + + const shouldUpsertArtifact = + !browserAssistArtifact || + currentScopeKey !== nextScopeKey || + currentSessionId !== nextSessionId || + currentProfileKey !== nextProfileKey || + currentUrl !== nextUrl || + currentTargetId !== nextTargetId || + currentTransportKind !== nextTransportKind || + currentLifecycleState !== nextLifecycleState || + currentControlMode !== nextControlMode || + currentTitle !== nextArtifact.title; + + if (shouldUpsertArtifact) { + upsertGeneralArtifact(nextArtifact); + } + + const autoOpenKey = + browserAssistSessionState.sessionId || + `${ + browserAssistSessionState.profileKey || + generalBrowserAssistProfileKey + }:${browserAssistSessionState.url || currentUrl || "pending"}`; + if (autoOpenedBrowserAssistSessionIdRef.current !== autoOpenKey) { + autoOpenedBrowserAssistSessionIdRef.current = autoOpenKey; + autoOpenBrowserAssistCanvas(nextArtifact.id); + } + }, [ + activeTheme, + autoOpenBrowserAssistCanvas, + browserAssistArtifact, + browserAssistSessionState, + currentBrowserAssistScopeKey, + generalBrowserAssistProfileKey, + projectId, + sessionId, + upsertGeneralArtifact, + ]); + + useEffect(() => { + if (activeTheme !== "general") { + autoLaunchingBrowserAssistKeyRef.current = ""; + browserAssistLaunchRequestIdRef.current += 1; + return; + } + + if ( + !browserAssistSessionState?.sessionId && + !browserAssistSessionState?.profileKey + ) { + return; + } + + const nextSessionId = browserAssistSessionState.sessionId || ""; + const nextProfileKey = + browserAssistSessionState.profileKey || generalBrowserAssistProfileKey; + const nextUrl = browserAssistSessionState.url || "https://www.google.com"; + const nextTitle = browserAssistSessionState.title || "浏览器协助"; + + if (nextSessionId || !nextProfileKey || !nextUrl) { + return; + } + + const launchKey = `${nextProfileKey}:${nextUrl}`; + if (autoLaunchingBrowserAssistKeyRef.current === launchKey) { + return; + } + autoLaunchingBrowserAssistKeyRef.current = launchKey; + const browserAssistScopeKey = + currentBrowserAssistScopeKey || + resolveBrowserAssistSessionScopeKey(projectId, sessionId); + upsertGeneralArtifact( + buildPendingBrowserAssistArtifact({ + scopeKey: browserAssistScopeKey, + profileKey: nextProfileKey, + url: nextUrl, + title: nextTitle, + }), + ); + autoOpenBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + const launchRequestId = browserAssistLaunchRequestIdRef.current + 1; + browserAssistLaunchRequestIdRef.current = launchRequestId; + void (async () => { + try { + setBrowserAssistLaunching(true); + const result = await launchBrowserSession({ + profile_key: nextProfileKey, + url: nextUrl, + open_window: false, + stream_mode: "both", + }); + if (browserAssistLaunchRequestIdRef.current !== launchRequestId) { + return; + } + + commitBrowserAssistSessionState( + createBrowserAssistSessionState({ + sessionId: result.session.session_id, + profileKey: result.session.profile_key, + url: + result.session.last_page_info?.url?.trim() || + result.session.target_url?.trim() || + nextUrl, + title: + result.session.last_page_info?.title?.trim() || + result.session.target_title?.trim() || + nextTitle, + targetId: result.session.target_id, + transportKind: result.session.transport_kind, + lifecycleState: result.session.lifecycle_state, + controlMode: result.session.control_mode, + source: "runtime_launch", + updatedAt: Date.now(), + }), + ); + autoOpenBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + } catch (error) { + upsertGeneralArtifact( + buildFailedBrowserAssistArtifact({ + scopeKey: browserAssistScopeKey, + profileKey: nextProfileKey, + url: nextUrl, + title: nextTitle, + error: error instanceof Error ? error.message : String(error), + }), + ); + autoLaunchingBrowserAssistKeyRef.current = ""; + console.warn("[AgentChatPage] 自动拉起浏览器协助实时会话失败:", error); + } finally { + if (browserAssistLaunchRequestIdRef.current === launchRequestId) { + setBrowserAssistLaunching(false); + } + } + })(); + }, [ + activeTheme, + autoOpenBrowserAssistCanvas, + browserAssistSessionState, + commitBrowserAssistSessionState, + currentBrowserAssistScopeKey, + generalBrowserAssistProfileKey, + projectId, + sessionId, + upsertGeneralArtifact, + ]); + + return { + browserAssistLaunching, + isBrowserAssistReady, + isBrowserAssistCanvasVisible, + currentBrowserAssistScopeKey, + ensureBrowserAssistCanvas, + handleOpenBrowserAssistInCanvas, + suppressBrowserAssistCanvasAutoOpen, + suppressGeneralCanvasArtifactAutoOpen, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceBrowserPreflightRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceBrowserPreflightRuntime.ts new file mode 100644 index 000000000..bf8631a99 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceBrowserPreflightRuntime.ts @@ -0,0 +1,358 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from "react"; +import { toast } from "sonner"; +import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; +import type { + BrowserTaskPreflight, + HandleSendOptions, +} from "../hooks/handleSendTypes"; +import type { ConfirmResponse, Message, MessageImage } from "../types"; + +type BrowserAssistAttentionLevel = "idle" | "info" | "warning"; + +type WorkspaceSendHandler = ( + images?: MessageImage[], + webSearch?: boolean, + thinking?: boolean, + textOverride?: string, + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", + autoContinuePayload?: AutoContinueRequestPayload, + sendOptions?: HandleSendOptions, +) => Promise; + +type EnsureBrowserAssistCanvasHandler = ( + sourceText: string, + options?: { + silent?: boolean; + navigationMode?: "none" | "explicit-url" | "best-effort"; + }, +) => Promise; + +interface UseWorkspaceBrowserPreflightRuntimeParams { + browserTaskPreflight: BrowserTaskPreflight | null; + setBrowserTaskPreflight: Dispatch< + SetStateAction + >; + browserAssistLaunching: boolean; + isBrowserAssistReady: boolean; + ensureBrowserAssistCanvas: EnsureBrowserAssistCanvasHandler; + handlePermissionResponse: (response: ConfirmResponse) => Promise; + sendRef: MutableRefObject; +} + +interface WorkspaceBrowserPreflightRuntimeResult { + browserAssistEntryLabel: string; + browserAssistAttentionLevel: BrowserAssistAttentionLevel; + browserPreflightMessages: Message[] | null; + handlePermissionResponseWithBrowserPreflight: ( + response: ConfirmResponse, + ) => Promise; +} + +function buildBrowserPreflightMessages( + preflight: BrowserTaskPreflight, +): Message[] { + const timestamp = new Date(preflight.createdAt); + const actionRequired = { + requestId: preflight.requestId, + actionType: "ask_user" as const, + uiKind: "browser_preflight" as const, + browserRequirement: preflight.requirement, + browserPrepState: preflight.phase, + prompt: preflight.reason, + detail: preflight.detail, + allowCapabilityFallback: false, + }; + + return [ + { + id: `${preflight.requestId}:user`, + role: "user", + content: preflight.sourceText, + images: preflight.images.length > 0 ? preflight.images : undefined, + timestamp, + }, + { + id: `${preflight.requestId}:assistant`, + role: "assistant", + content: "", + timestamp: new Date(preflight.createdAt + 1), + actionRequests: [actionRequired], + contentParts: [{ type: "action_required", actionRequired }], + }, + ]; +} + +export function useWorkspaceBrowserPreflightRuntime({ + browserTaskPreflight, + setBrowserTaskPreflight, + browserAssistLaunching, + isBrowserAssistReady, + ensureBrowserAssistCanvas, + handlePermissionResponse, + sendRef, +}: UseWorkspaceBrowserPreflightRuntimeParams): WorkspaceBrowserPreflightRuntimeResult { + const browserTaskPreflightLaunchIdRef = useRef(""); + + const browserAssistEntryLabel = useMemo(() => { + if (browserTaskPreflight?.phase === "launching" || browserAssistLaunching) { + return "浏览器启动中"; + } + if ( + browserTaskPreflight?.phase === "awaiting_user" || + browserTaskPreflight?.phase === "ready_to_resume" + ) { + return "等待登录"; + } + if (browserTaskPreflight?.phase === "failed") { + return "浏览器未连接"; + } + if (isBrowserAssistReady) { + return "浏览器已就绪"; + } + return "浏览器协助"; + }, [ + browserAssistLaunching, + browserTaskPreflight?.phase, + isBrowserAssistReady, + ]); + + const browserAssistAttentionLevel = useMemo( + () => { + if ( + browserTaskPreflight?.phase === "launching" || + browserAssistLaunching + ) { + return "info"; + } + + if ( + browserTaskPreflight?.phase === "awaiting_user" || + browserTaskPreflight?.phase === "ready_to_resume" || + browserTaskPreflight?.phase === "failed" + ) { + return "warning"; + } + + return "idle"; + }, + [browserAssistLaunching, browserTaskPreflight?.phase], + ); + + const browserPreflightMessages = useMemo( + () => + browserTaskPreflight + ? buildBrowserPreflightMessages(browserTaskPreflight) + : null, + [browserTaskPreflight], + ); + + const runBrowserTaskPreflight = useCallback( + async (preflight: BrowserTaskPreflight) => { + setBrowserTaskPreflight((current) => + current?.requestId === preflight.requestId + ? { + ...current, + phase: "launching", + detail: current.detail, + } + : current, + ); + + const launchInput = preflight.launchUrl || preflight.sourceText; + const navigationMode = + preflight.launchUrl && preflight.launchUrl !== preflight.sourceText + ? ("explicit-url" as const) + : ("best-effort" as const); + + try { + const launched = await ensureBrowserAssistCanvas(launchInput, { + silent: false, + navigationMode, + }); + + setBrowserTaskPreflight((current) => { + if (current?.requestId !== preflight.requestId) { + return current; + } + + if (!launched) { + return { + ...current, + phase: "failed", + detail: + "还没有建立可用的浏览器会话。请确认本机浏览器/CDP 可用后重试。", + }; + } + + return { + ...current, + phase: "awaiting_user", + detail: + preflight.requirement === "required_with_user_step" + ? `已为你打开${preflight.platformLabel || "浏览器协助"}。请先在右侧浏览器完成登录、扫码、验证码或授权,再继续当前任务。` + : "浏览器已经准备好。请确认右侧页面可操作后继续当前任务。", + }; + }); + } catch (error) { + setBrowserTaskPreflight((current) => { + if (current?.requestId !== preflight.requestId) { + return current; + } + + return { + ...current, + phase: "failed", + detail: + error instanceof Error && error.message + ? error.message + : "启动浏览器协助失败,请稍后重试。", + }; + }); + } + }, + [ensureBrowserAssistCanvas, setBrowserTaskPreflight], + ); + + useEffect(() => { + if (!browserTaskPreflight) { + return; + } + + if (isBrowserAssistReady) { + if ( + browserTaskPreflight.phase === "launching" || + browserTaskPreflight.phase === "failed" + ) { + setBrowserTaskPreflight((current) => + current?.requestId === browserTaskPreflight.requestId + ? { + ...current, + phase: "awaiting_user", + detail: + current.requirement === "required_with_user_step" + ? `浏览器已经连接。请先在右侧完成${current.platformLabel || "目标站点"}登录、扫码或验证码,然后继续当前任务。` + : "浏览器已经连接,请确认页面可操作后继续当前任务。", + } + : current, + ); + } + return; + } + + if ( + browserTaskPreflight.phase === "awaiting_user" || + browserTaskPreflight.phase === "ready_to_resume" + ) { + setBrowserTaskPreflight((current) => + current?.requestId === browserTaskPreflight.requestId + ? { + ...current, + phase: "failed", + detail: "浏览器会话已断开,请重新启动浏览器后再继续。", + } + : current, + ); + } + }, [browserTaskPreflight, isBrowserAssistReady, setBrowserTaskPreflight]); + + const handlePermissionResponseWithBrowserPreflight = useCallback( + async (response: ConfirmResponse) => { + if ( + !browserTaskPreflight || + response.requestId !== browserTaskPreflight.requestId + ) { + await handlePermissionResponse(response); + return; + } + + const userData = + response.userData && typeof response.userData === "object" + ? (response.userData as Record) + : null; + const browserAction = + typeof userData?.browserAction === "string" + ? userData.browserAction + : ""; + + if (browserAction === "launch") { + await runBrowserTaskPreflight(browserTaskPreflight); + return; + } + + if (browserAction === "continue") { + if (!isBrowserAssistReady) { + setBrowserTaskPreflight((current) => + current?.requestId === browserTaskPreflight.requestId + ? { + ...current, + phase: "failed", + detail: "尚未检测到可用的浏览器会话,请先启动或恢复浏览器。", + } + : current, + ); + toast.error("浏览器还没有准备好,请先完成启动或恢复浏览器"); + return; + } + + const pending = browserTaskPreflight; + setBrowserTaskPreflight(null); + await sendRef.current( + pending.images, + pending.webSearch, + pending.thinking, + pending.sourceText, + pending.sendExecutionStrategy, + pending.autoContinuePayload, + { + ...(pending.sendOptions || {}), + browserPreflightConfirmed: true, + }, + ); + return; + } + + await handlePermissionResponse(response); + }, + [ + browserTaskPreflight, + handlePermissionResponse, + isBrowserAssistReady, + runBrowserTaskPreflight, + sendRef, + setBrowserTaskPreflight, + ], + ); + + useEffect(() => { + if (!browserTaskPreflight || browserTaskPreflight.phase !== "launching") { + if (!browserTaskPreflight) { + browserTaskPreflightLaunchIdRef.current = ""; + } + return; + } + + if ( + browserTaskPreflightLaunchIdRef.current === browserTaskPreflight.requestId + ) { + return; + } + + browserTaskPreflightLaunchIdRef.current = browserTaskPreflight.requestId; + void runBrowserTaskPreflight(browserTaskPreflight); + }, [browserTaskPreflight, runBrowserTaskPreflight]); + + return { + browserAssistEntryLabel, + browserAssistAttentionLevel, + browserPreflightMessages, + handlePermissionResponseWithBrowserPreflight, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts new file mode 100644 index 000000000..32135b88f --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts @@ -0,0 +1,354 @@ +import { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import { createInitialCanvasState, type CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; +import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge"; +import type { LayoutMode, ThemeType } from "@/components/content-creator/types"; +import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt"; +import type { CanvasWorkbenchLayoutMode } from "../components/CanvasWorkbenchLayout"; + +const FALLBACK_CANVAS_CONTENT = "# 新文档\n\n在这里开始编写内容..."; + +interface UseWorkspaceCanvasLayoutRuntimeParams { + activeTheme: string; + isThemeWorkbench: boolean; + layoutMode: LayoutMode; + showChatPanel: boolean; + showSidebar: boolean; + defaultTopicSidebarVisible: boolean; + hasMessages: boolean; + canvasWorkbenchLayoutMode: CanvasWorkbenchLayoutMode; + autoCollapsedTopicSidebarRef: MutableRefObject; + mappedTheme: ThemeType; + normalizedEntryTheme: ThemeType; + shouldBootstrapCanvasOnEntry: boolean; + canvasState: CanvasStateUnion | null; + showTeamWorkspaceBoard: boolean; + hasCurrentCanvasArtifact: boolean; + currentCanvasArtifactType?: string | null; + currentImageWorkbenchActive: boolean; + isBrowserAssistCanvasVisible: boolean; + onHasMessagesChange?: (hasMessages: boolean) => void; + dismissActiveTeamWorkbenchAutoOpen: () => void; + suppressGeneralCanvasArtifactAutoOpen: () => void; + suppressBrowserAssistCanvasAutoOpen: () => void; + setShowSidebar: Dispatch>; + setLayoutMode: Dispatch>; + setGeneralCanvasState: Dispatch>; + setCanvasState: Dispatch>; + setCanvasWorkbenchLayoutMode: Dispatch< + SetStateAction + >; + setNovelChapterListCollapsed: Dispatch>; +} + +export function useWorkspaceCanvasLayoutRuntime({ + activeTheme, + isThemeWorkbench, + layoutMode, + showChatPanel, + showSidebar, + defaultTopicSidebarVisible, + hasMessages, + canvasWorkbenchLayoutMode, + autoCollapsedTopicSidebarRef, + mappedTheme, + normalizedEntryTheme, + shouldBootstrapCanvasOnEntry, + canvasState, + showTeamWorkspaceBoard, + hasCurrentCanvasArtifact, + currentCanvasArtifactType, + currentImageWorkbenchActive, + isBrowserAssistCanvasVisible, + onHasMessagesChange, + dismissActiveTeamWorkbenchAutoOpen, + suppressGeneralCanvasArtifactAutoOpen, + suppressBrowserAssistCanvasAutoOpen, + setShowSidebar, + setLayoutMode, + setGeneralCanvasState, + setCanvasState, + setCanvasWorkbenchLayoutMode, + setNovelChapterListCollapsed, +}: UseWorkspaceCanvasLayoutRuntimeParams) { + useEffect(() => { + if (!canvasState || canvasState.type !== "novel") { + setNovelChapterListCollapsed(false); + } + }, [canvasState, setNovelChapterListCollapsed]); + + useEffect(() => { + autoCollapsedTopicSidebarRef.current = false; + setShowSidebar(defaultTopicSidebarVisible); + }, [ + autoCollapsedTopicSidebarRef, + defaultTopicSidebarVisible, + setShowSidebar, + ]); + + useEffect(() => { + if (showChatPanel) { + setLayoutMode((previous) => + previous === "canvas" ? "chat-canvas" : previous, + ); + return; + } + + setShowSidebar(false); + + if (layoutMode === "canvas") { + return; + } + + if (layoutMode === "chat-canvas") { + setLayoutMode("canvas"); + return; + } + + if (activeTheme === "general") { + setGeneralCanvasState((previous) => ({ + ...previous, + isOpen: true, + contentType: + previous.contentType === "empty" ? "markdown" : previous.contentType, + content: previous.content || FALLBACK_CANVAS_CONTENT, + })); + } else if (!canvasState) { + const initialState = + createInitialCanvasState(mappedTheme, FALLBACK_CANVAS_CONTENT) || + createInitialDocumentState(FALLBACK_CANVAS_CONTENT); + setCanvasState(initialState); + } + + setLayoutMode("canvas"); + }, [ + activeTheme, + canvasState, + layoutMode, + mappedTheme, + setCanvasState, + setGeneralCanvasState, + setLayoutMode, + setShowSidebar, + showChatPanel, + ]); + + useEffect(() => { + if ( + isThemeWorkbench || + activeTheme !== "general" || + layoutMode !== "chat-canvas" + ) { + setCanvasWorkbenchLayoutMode("split"); + } + }, [ + activeTheme, + isThemeWorkbench, + layoutMode, + setCanvasWorkbenchLayoutMode, + ]); + + useEffect(() => { + const shouldAutoHideTopicSidebar = + showChatPanel && + !isThemeWorkbench && + activeTheme === "general" && + layoutMode === "chat-canvas" && + canvasWorkbenchLayoutMode === "stacked"; + + if (shouldAutoHideTopicSidebar) { + if (showSidebar) { + autoCollapsedTopicSidebarRef.current = true; + setShowSidebar(false); + } + return; + } + + if (autoCollapsedTopicSidebarRef.current) { + autoCollapsedTopicSidebarRef.current = false; + setShowSidebar(true); + } + }, [ + activeTheme, + autoCollapsedTopicSidebarRef, + canvasWorkbenchLayoutMode, + isThemeWorkbench, + layoutMode, + setShowSidebar, + showChatPanel, + showSidebar, + ]); + + useEffect(() => { + onHasMessagesChange?.(hasMessages); + }, [hasMessages, onHasMessagesChange]); + + const handleToggleSidebar = useCallback(() => { + if (!showChatPanel) { + return; + } + setShowSidebar((previous) => !previous); + }, [setShowSidebar, showChatPanel]); + + const handleToggleNovelChapterList = useCallback(() => { + setNovelChapterListCollapsed((previous) => !previous); + }, [setNovelChapterListCollapsed]); + + const handleAddNovelChapter = useCallback(() => { + setCanvasState((previous) => { + if (!previous || previous.type !== "novel") { + return previous; + } + + const now = Date.now(); + const chapterNumber = previous.chapters.length + 1; + const title = `第${chapterNumber}章`; + const newChapter = { + id: crypto.randomUUID(), + number: chapterNumber, + title, + content: `# ${title}\n\n`, + wordCount: 0, + status: "draft" as const, + createdAt: now, + updatedAt: now, + }; + + return { + ...previous, + chapters: [...previous.chapters, newChapter], + currentChapterId: newChapter.id, + }; + }); + setNovelChapterListCollapsed(false); + }, [setCanvasState, setNovelChapterListCollapsed]); + + const handleToggleCanvas = useCallback(() => { + if (activeTheme === "general") { + const shouldManageStandaloneGeneralCanvas = + !showTeamWorkspaceBoard && + !hasCurrentCanvasArtifact && + !currentImageWorkbenchActive; + + if (layoutMode !== "chat") { + dismissActiveTeamWorkbenchAutoOpen(); + suppressGeneralCanvasArtifactAutoOpen(); + if (isBrowserAssistCanvasVisible) { + suppressBrowserAssistCanvasAutoOpen(); + } + if (shouldManageStandaloneGeneralCanvas) { + setGeneralCanvasState((previous) => ({ ...previous, isOpen: false })); + } + setLayoutMode("chat"); + return; + } + + if (shouldManageStandaloneGeneralCanvas) { + setGeneralCanvasState((previous) => ({ + ...previous, + isOpen: true, + contentType: + previous.contentType === "empty" ? "markdown" : previous.contentType, + content: previous.content || FALLBACK_CANVAS_CONTENT, + })); + } + setLayoutMode("chat-canvas"); + return; + } + + setLayoutMode((previous) => { + if (previous === "chat") { + if (!canvasState) { + const initialState = + createInitialCanvasState(mappedTheme, FALLBACK_CANVAS_CONTENT) || + createInitialDocumentState(FALLBACK_CANVAS_CONTENT); + setCanvasState(initialState); + } + return "chat-canvas"; + } + + return "chat"; + }); + }, [ + activeTheme, + canvasState, + currentImageWorkbenchActive, + dismissActiveTeamWorkbenchAutoOpen, + hasCurrentCanvasArtifact, + isBrowserAssistCanvasVisible, + layoutMode, + mappedTheme, + setCanvasState, + setGeneralCanvasState, + setLayoutMode, + showTeamWorkspaceBoard, + suppressBrowserAssistCanvasAutoOpen, + suppressGeneralCanvasArtifactAutoOpen, + ]); + + const handleCloseCanvas = useCallback(() => { + if (activeTheme === "general") { + dismissActiveTeamWorkbenchAutoOpen(); + suppressGeneralCanvasArtifactAutoOpen(); + if (currentCanvasArtifactType === "browser_assist") { + suppressBrowserAssistCanvasAutoOpen(); + } + } + setLayoutMode("chat"); + setNovelChapterListCollapsed(false); + if (activeTheme === "general") { + setGeneralCanvasState((previous) => ({ ...previous, isOpen: false })); + } + }, [ + activeTheme, + currentCanvasArtifactType, + dismissActiveTeamWorkbenchAutoOpen, + setGeneralCanvasState, + setLayoutMode, + setNovelChapterListCollapsed, + suppressBrowserAssistCanvasAutoOpen, + suppressGeneralCanvasArtifactAutoOpen, + ]); + + const resolvedCanvasState = useMemo(() => { + if (canvasState) { + return canvasState; + } + + if (shouldBootstrapCanvasOnEntry) { + return ( + createInitialCanvasState(normalizedEntryTheme, "") || + createInitialDocumentState("") + ); + } + + if (isThemeWorkbench && isContentCreationTheme(activeTheme)) { + return ( + createInitialCanvasState(mappedTheme, "") || + createInitialDocumentState("") + ); + } + + return null; + }, [ + activeTheme, + canvasState, + isThemeWorkbench, + mappedTheme, + normalizedEntryTheme, + shouldBootstrapCanvasOnEntry, + ]); + + const showNovelNavbarControls = + layoutMode !== "chat" && resolvedCanvasState?.type === "novel"; + + return { + handleToggleSidebar, + handleToggleNovelChapterList, + handleAddNovelChapter, + handleToggleCanvas, + handleCloseCanvas, + resolvedCanvasState, + showNovelNavbarControls, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasMessageSyncRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasMessageSyncRuntime.ts new file mode 100644 index 000000000..72d8a4cc5 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasMessageSyncRuntime.ts @@ -0,0 +1,213 @@ +import { + useCallback, + useEffect, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from "react"; +import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import { + createInitialNovelState, + countWords as countNovelWords, +} from "@/components/content-creator/canvas/novel/types"; +import type { LayoutMode, ThemeType } from "@/components/content-creator/types"; +import type { Message } from "../types"; +import { isCanvasStateEmpty } from "./themeWorkbenchHelpers"; + +interface UseWorkspaceCanvasMessageSyncRuntimeParams { + canvasState: CanvasStateUnion | null; + isContentCreationMode: boolean; + isThemeWorkbench: boolean; + mappedTheme: ThemeType; + messages: Message[]; + processedMessageIdsRef: MutableRefObject>; + setCanvasState: Dispatch>; + setLayoutMode: Dispatch>; +} + +function extractDocumentContent( + content: string, + isThemeWorkbench: boolean, +): string | null { + const documentMatch = content.match(/([\s\S]*?)<\/document>/); + if (documentMatch) { + return documentMatch[1].trim(); + } + + const markdownMatch = content.match(/```(?:markdown|md)\n([\s\S]*?)```/); + if (markdownMatch) { + return markdownMatch[1].trim(); + } + + if (isThemeWorkbench) { + return null; + } + + if (content.trim().startsWith("#") && content.length > 200) { + return content.trim(); + } + + return null; +} + +function looksLikeSerializedNovelState(content: string): boolean { + const trimmed = content.trim(); + if (!trimmed) { + return false; + } + + const jsonCandidate = + trimmed.match(/^```json\s*([\s\S]*?)```$/i)?.[1] || trimmed; + + if (!(jsonCandidate.startsWith("[") || jsonCandidate.startsWith("{"))) { + return false; + } + + return ( + jsonCandidate.includes('"title"') && + (jsonCandidate.includes('"number"') || + jsonCandidate.includes('"chapters"')) + ); +} + +export function useWorkspaceCanvasMessageSyncRuntime({ + canvasState, + isContentCreationMode, + isThemeWorkbench, + mappedTheme, + messages, + processedMessageIdsRef, + setCanvasState, + setLayoutMode, +}: UseWorkspaceCanvasMessageSyncRuntimeParams) { + const upsertNovelCanvasState = useCallback( + (previous: CanvasStateUnion | null, content: string): CanvasStateUnion => { + if (!previous || previous.type !== "novel") { + return createInitialNovelState(content); + } + + if (looksLikeSerializedNovelState(content)) { + return createInitialNovelState(content); + } + + const targetChapterId = + previous.currentChapterId || previous.chapters[0]?.id || crypto.randomUUID(); + const now = Date.now(); + + if (previous.chapters.length === 0) { + const initialized = createInitialNovelState(content); + return { + ...initialized, + currentChapterId: initialized.chapters[0]?.id || targetChapterId, + }; + } + + return { + ...previous, + chapters: previous.chapters.map((chapter) => + chapter.id === targetChapterId + ? { + ...chapter, + content, + wordCount: countNovelWords(content), + updatedAt: now, + } + : chapter, + ), + }; + }, + [], + ); + + useEffect(() => { + if (!isContentCreationMode) { + return; + } + + const lastAssistantMessage = [...messages] + .reverse() + .find( + (message) => + message.role === "assistant" && + !message.isThinking && + message.content && + message.purpose !== "content_review" && + message.purpose !== "style_audit", + ); + + if (!lastAssistantMessage) { + return; + } + + if (isThemeWorkbench) { + const hasWriteFileToolCall = lastAssistantMessage.toolCalls?.some( + (toolCall) => { + const name = (toolCall.name || "").toLowerCase(); + return name.includes("write") || name.includes("create_file"); + }, + ); + if (hasWriteFileToolCall) { + return; + } + if (canvasState && !isCanvasStateEmpty(canvasState)) { + return; + } + } + + if (processedMessageIdsRef.current.has(lastAssistantMessage.id)) { + return; + } + + const documentContent = extractDocumentContent( + lastAssistantMessage.content, + isThemeWorkbench, + ); + if (!documentContent) { + return; + } + + processedMessageIdsRef.current.add(lastAssistantMessage.id); + setCanvasState((previous) => { + if (mappedTheme === "poster") { + return previous; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(previous, documentContent); + } + + if (!previous || previous.type !== "document") { + return createInitialDocumentState(documentContent); + } + + const newVersion = { + id: crypto.randomUUID(), + content: documentContent, + createdAt: Date.now(), + description: `AI 生成 - 版本 ${previous.versions.length + 1}`, + }; + return { + ...previous, + content: documentContent, + versions: [...previous.versions, newVersion], + currentVersionId: newVersion.id, + }; + }); + setLayoutMode("chat-canvas"); + }, [ + canvasState, + isContentCreationMode, + isThemeWorkbench, + mappedTheme, + messages, + processedMessageIdsRef, + setCanvasState, + setLayoutMode, + upsertNovelCanvasState, + ]); + + return { + upsertNovelCanvasState, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx new file mode 100644 index 000000000..dc601b306 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx @@ -0,0 +1,516 @@ +import { + useCallback, + useMemo, + type ComponentProps, + type ReactNode, +} from "react"; +import { toast } from "sonner"; +import { + ArtifactCanvasOverlay, + ArtifactToolbar, +} from "@/components/artifact"; +import { CanvasFactory } from "@/components/content-creator/canvas/CanvasFactory"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { ThemeType } from "@/components/content-creator/types"; +import { + CanvasPanel as GeneralCanvasPanel, + type CanvasState as GeneralCanvasState, +} from "@/components/general-chat/bridge"; +import { openPathWithDefaultApp, revealPathInFinder } from "@/lib/api/fileSystem"; +import type { Artifact } from "@/lib/artifact/types"; +import { ImageWorkbenchCanvas } from "../components/ImageWorkbenchCanvas"; +import type { + CanvasWorkbenchDefaultPreview, + CanvasWorkbenchPreviewTarget, + CanvasWorkbenchTeamView, +} from "../components/CanvasWorkbenchLayout"; +import type { TaskFile } from "../components/TaskFiles"; +import { + ArtifactWorkbenchPreview, + WorkspaceLiveCanvasPreview, +} from "./workbenchPreview"; +import { renderCanvasWorkbenchPreviewTarget } from "./workbenchPreviewHelpers"; +import { buildCanvasWorkbenchDefaultPreview } from "./canvasWorkbenchDefaultPreview"; +import { + useTeamWorkbenchPresentation, + type TeamWorkbenchSurfaceProps, + type UseTeamWorkbenchPresentationParams, +} from "./teamWorkbenchPresentation"; + +type ArtifactPreviewBaseProps = Omit< + ComponentProps, + "artifact" | "stackedWorkbenchTrigger" +>; +type ImageWorkbenchCanvasProps = ComponentProps; +type GeneralCanvasPanelProps = Omit< + ComponentProps, + "toolbarActions" +>; + +interface WorkspaceCanvasDefaultPreviewParams { + workspaceRoot: string | null; + canvasRenderTheme: ThemeType; + generalCanvasState: GeneralCanvasState; + resolvedCanvasState: CanvasStateUnion | null; + activeCanvasTaskFile: TaskFile | null; +} + +interface WorkspaceCanvasPreviewArtifactParams { + currentCanvasArtifact: Artifact | null; + displayedCanvasArtifact: Artifact | null; + artifactOverlay: ComponentProps["overlay"] | null; + showPreviousVersionBadge: boolean; + artifactViewMode: ComponentProps["viewMode"]; + onArtifactViewModeChange: NonNullable< + ComponentProps["onViewModeChange"] + >; + artifactPreviewSize: ComponentProps["previewSize"]; + onArtifactPreviewSizeChange: NonNullable< + ComponentProps["onPreviewSizeChange"] + >; + onCloseCanvas: () => void; +} + +interface WorkspaceCanvasPreviewImageWorkbenchParams { + active: boolean; + tasks: ImageWorkbenchCanvasProps["tasks"]; + outputs: ImageWorkbenchCanvasProps["outputs"]; + selectedOutputId: ImageWorkbenchCanvasProps["selectedOutputId"]; + viewport: ImageWorkbenchCanvasProps["viewport"]; + preferenceSummary: ImageWorkbenchCanvasProps["preferenceSummary"]; + preferenceWarning: ImageWorkbenchCanvasProps["preferenceWarning"]; + availableProviders: ImageWorkbenchCanvasProps["availableProviders"]; + selectedProviderId: ImageWorkbenchCanvasProps["selectedProviderId"]; + onProviderChange: ImageWorkbenchCanvasProps["onProviderChange"]; + availableModels: ImageWorkbenchCanvasProps["availableModels"]; + selectedModelId: ImageWorkbenchCanvasProps["selectedModelId"]; + onModelChange: ImageWorkbenchCanvasProps["onModelChange"]; + selectedSize: ImageWorkbenchCanvasProps["selectedSize"]; + onSizeChange: ImageWorkbenchCanvasProps["onSizeChange"]; + generating: ImageWorkbenchCanvasProps["generating"]; + savingToResource: ImageWorkbenchCanvasProps["savingToResource"]; + onStopGeneration: ImageWorkbenchCanvasProps["onStopGeneration"]; + onViewportChange: ImageWorkbenchCanvasProps["onViewportChange"]; + onSelectOutput: ImageWorkbenchCanvasProps["onSelectOutput"]; + onSaveSelectedToLibrary: ImageWorkbenchCanvasProps["onSaveSelectedToLibrary"]; + applySelectedOutputLabel: ImageWorkbenchCanvasProps["applySelectedOutputLabel"]; + onApplySelectedOutput?: ImageWorkbenchCanvasProps["onApplySelectedOutput"]; + onSeedFollowUpCommand: ImageWorkbenchCanvasProps["onSeedFollowUpCommand"]; + onOpenImage: ImageWorkbenchCanvasProps["onOpenImage"]; +} + +interface WorkspaceCanvasPreviewGeneralCanvasParams { + state: GeneralCanvasState; + onCloseCanvas: () => void; + onContentChange: (content: string) => void; +} + +interface WorkspaceCanvasPreviewLoadingParams { + isInitialContentLoading: boolean; + initialContentLoadError?: string | null; + shouldShowCanvasLoadingState: boolean; +} + +interface WorkspaceCanvasPreviewFactoryParams { + canvasRenderTheme: ThemeType; + resolvedCanvasState: CanvasStateUnion | null; + onStateChange: ComponentProps["onStateChange"]; + onBackHome: NonNullable["onBackHome"]>; + onCloseCanvas: NonNullable["onClose"]>; + isStreaming: ComponentProps["isStreaming"]; + onSelectionTextChange: ComponentProps< + typeof CanvasFactory + >["onSelectionTextChange"]; + projectId: string | null; + contentId: string | null; + autoImageTopic?: string; + autoContinueProviderType: ComponentProps< + typeof CanvasFactory + >["autoContinueProviderType"]; + onAutoContinueProviderTypeChange: ComponentProps< + typeof CanvasFactory + >["onAutoContinueProviderTypeChange"]; + autoContinueModel: ComponentProps["autoContinueModel"]; + onAutoContinueModelChange: ComponentProps< + typeof CanvasFactory + >["onAutoContinueModelChange"]; + autoContinueThinkingEnabled: boolean; + onAutoContinueThinkingEnabledChange: NonNullable< + ComponentProps["onAutoContinueThinkingEnabledChange"] + >; + onAutoContinueRun: NonNullable< + ComponentProps["onAutoContinueRun"] + >; + onAddImage: ComponentProps["onAddImage"]; + onImportDocument: ComponentProps["onImportDocument"]; + onContentReviewRun: NonNullable< + ComponentProps["onContentReviewRun"] + >; + onTextStylizeRun: NonNullable< + ComponentProps["onTextStylizeRun"] + >; + preferContentReviewInRightRail: boolean; + novelChapterListCollapsed: boolean; + onNovelChapterListCollapsedChange: (collapsed: boolean) => void; +} + +interface WorkspaceCanvasPreviewTeamWorkbenchParams + extends Omit { + surfaceProps: TeamWorkbenchSurfaceProps; +} + +interface UseWorkspaceCanvasPreviewPresentationParams { + defaultPreview: WorkspaceCanvasDefaultPreviewParams; + artifactPreview: WorkspaceCanvasPreviewArtifactParams; + imageWorkbench: WorkspaceCanvasPreviewImageWorkbenchParams; + generalCanvas: WorkspaceCanvasPreviewGeneralCanvasParams; + loading: WorkspaceCanvasPreviewLoadingParams; + canvasFactory: WorkspaceCanvasPreviewFactoryParams; + teamWorkbench: WorkspaceCanvasPreviewTeamWorkbenchParams; +} + +interface WorkspaceCanvasPreviewPresentationResult { + canvasWorkbenchDefaultPreview: CanvasWorkbenchDefaultPreview; + handleOpenCanvasWorkbenchPath: (path: string) => Promise; + handleRevealCanvasWorkbenchPath: (path: string) => Promise; + liveCanvasPreview: ReactNode; + hasLiveCanvasPreviewContent: boolean; + teamWorkbenchView: CanvasWorkbenchTeamView | null; + renderCanvasWorkbenchPreview: ( + target: CanvasWorkbenchPreviewTarget, + options?: { stackedWorkbenchTrigger?: ReactNode }, + ) => ReactNode; +} + +export function useWorkspaceCanvasPreviewPresentation({ + defaultPreview, + artifactPreview, + imageWorkbench, + generalCanvas, + loading, + canvasFactory, + teamWorkbench, +}: UseWorkspaceCanvasPreviewPresentationParams): WorkspaceCanvasPreviewPresentationResult { + const canvasWorkbenchDefaultPreview = useMemo( + () => + buildCanvasWorkbenchDefaultPreview({ + workspaceRoot: defaultPreview.workspaceRoot, + canvasRenderTheme: defaultPreview.canvasRenderTheme, + generalCanvasState: defaultPreview.generalCanvasState, + resolvedCanvasState: defaultPreview.resolvedCanvasState, + activeCanvasTaskFile: defaultPreview.activeCanvasTaskFile, + }), + [ + defaultPreview.activeCanvasTaskFile, + defaultPreview.canvasRenderTheme, + defaultPreview.generalCanvasState, + defaultPreview.resolvedCanvasState, + defaultPreview.workspaceRoot, + ], + ); + + const handleOpenCanvasWorkbenchPath = useCallback(async (path: string) => { + try { + await openPathWithDefaultApp(path); + } catch (error) { + toast.error( + `打开文件失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, []); + + const handleRevealCanvasWorkbenchPath = useCallback(async (path: string) => { + try { + await revealPathInFinder(path); + } catch (error) { + toast.error( + `定位文件失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, []); + + const artifactWorkbenchPreviewBaseProps = useMemo( + () => ({ + currentCanvasArtifact: artifactPreview.currentCanvasArtifact, + displayedCanvasArtifact: artifactPreview.displayedCanvasArtifact, + artifactOverlay: artifactPreview.artifactOverlay, + showPreviousVersionBadge: artifactPreview.showPreviousVersionBadge, + artifactViewMode: artifactPreview.artifactViewMode, + onArtifactViewModeChange: artifactPreview.onArtifactViewModeChange, + artifactPreviewSize: artifactPreview.artifactPreviewSize, + onArtifactPreviewSizeChange: artifactPreview.onArtifactPreviewSizeChange, + onCloseCanvas: artifactPreview.onCloseCanvas, + }), + [ + artifactPreview.artifactOverlay, + artifactPreview.artifactPreviewSize, + artifactPreview.artifactViewMode, + artifactPreview.currentCanvasArtifact, + artifactPreview.displayedCanvasArtifact, + artifactPreview.onArtifactPreviewSizeChange, + artifactPreview.onArtifactViewModeChange, + artifactPreview.onCloseCanvas, + artifactPreview.showPreviousVersionBadge, + ], + ); + + const imageWorkbenchCanvasProps = useMemo( + () => ({ + tasks: imageWorkbench.tasks, + outputs: imageWorkbench.outputs, + selectedOutputId: imageWorkbench.selectedOutputId, + viewport: imageWorkbench.viewport, + preferenceSummary: imageWorkbench.preferenceSummary, + preferenceWarning: imageWorkbench.preferenceWarning, + availableProviders: imageWorkbench.availableProviders, + selectedProviderId: imageWorkbench.selectedProviderId, + onProviderChange: imageWorkbench.onProviderChange, + availableModels: imageWorkbench.availableModels, + selectedModelId: imageWorkbench.selectedModelId, + onModelChange: imageWorkbench.onModelChange, + selectedSize: imageWorkbench.selectedSize, + onSizeChange: imageWorkbench.onSizeChange, + generating: imageWorkbench.generating, + savingToResource: imageWorkbench.savingToResource, + onStopGeneration: imageWorkbench.onStopGeneration, + onViewportChange: imageWorkbench.onViewportChange, + onSelectOutput: imageWorkbench.onSelectOutput, + onSaveSelectedToLibrary: imageWorkbench.onSaveSelectedToLibrary, + applySelectedOutputLabel: imageWorkbench.applySelectedOutputLabel, + onApplySelectedOutput: imageWorkbench.onApplySelectedOutput, + onSeedFollowUpCommand: imageWorkbench.onSeedFollowUpCommand, + onOpenImage: imageWorkbench.onOpenImage, + }), + [ + imageWorkbench.applySelectedOutputLabel, + imageWorkbench.availableModels, + imageWorkbench.availableProviders, + imageWorkbench.generating, + imageWorkbench.onApplySelectedOutput, + imageWorkbench.onModelChange, + imageWorkbench.onOpenImage, + imageWorkbench.onProviderChange, + imageWorkbench.onSaveSelectedToLibrary, + imageWorkbench.onSeedFollowUpCommand, + imageWorkbench.onSelectOutput, + imageWorkbench.onSizeChange, + imageWorkbench.onStopGeneration, + imageWorkbench.onViewportChange, + imageWorkbench.outputs, + imageWorkbench.preferenceSummary, + imageWorkbench.preferenceWarning, + imageWorkbench.savingToResource, + imageWorkbench.selectedModelId, + imageWorkbench.selectedOutputId, + imageWorkbench.selectedProviderId, + imageWorkbench.selectedSize, + imageWorkbench.tasks, + imageWorkbench.viewport, + ], + ); + + const generalCanvasPanelProps = useMemo( + () => ({ + state: generalCanvas.state, + onClose: generalCanvas.onCloseCanvas, + onContentChange: generalCanvas.onContentChange, + }), + [generalCanvas.onCloseCanvas, generalCanvas.onContentChange, generalCanvas.state], + ); + + const canvasLoadingLabel = useMemo( + () => + loading.isInitialContentLoading + ? "正在加载文稿内容..." + : loading.initialContentLoadError || "正在准备文稿画布...", + [loading.initialContentLoadError, loading.isInitialContentLoading], + ); + + const canvasFactoryProps = useMemo | null>( + () => + canvasFactory.resolvedCanvasState + ? { + theme: canvasFactory.canvasRenderTheme, + state: canvasFactory.resolvedCanvasState, + onStateChange: canvasFactory.onStateChange, + onBackHome: canvasFactory.onBackHome, + onClose: canvasFactory.onCloseCanvas, + isStreaming: canvasFactory.isStreaming, + onSelectionTextChange: canvasFactory.onSelectionTextChange, + projectId: canvasFactory.projectId, + contentId: canvasFactory.contentId, + autoImageTopic: canvasFactory.autoImageTopic, + autoContinueProviderType: + canvasFactory.autoContinueProviderType, + onAutoContinueProviderTypeChange: + canvasFactory.onAutoContinueProviderTypeChange, + autoContinueModel: canvasFactory.autoContinueModel, + onAutoContinueModelChange: canvasFactory.onAutoContinueModelChange, + autoContinueThinkingEnabled: + canvasFactory.autoContinueThinkingEnabled, + onAutoContinueThinkingEnabledChange: + canvasFactory.onAutoContinueThinkingEnabledChange, + onAutoContinueRun: canvasFactory.onAutoContinueRun, + onAddImage: canvasFactory.onAddImage, + onImportDocument: canvasFactory.onImportDocument, + onContentReviewRun: canvasFactory.onContentReviewRun, + onTextStylizeRun: canvasFactory.onTextStylizeRun, + documentContentReviewPlacement: + canvasFactory.preferContentReviewInRightRail + ? ("external-rail" as const) + : ("inline" as const), + novelControls: + canvasFactory.resolvedCanvasState.type === "novel" + ? { + useExternalToolbar: true, + chapterListCollapsed: + canvasFactory.novelChapterListCollapsed, + onChapterListCollapsedChange: + canvasFactory.onNovelChapterListCollapsedChange, + } + : null, + } + : null, + [ + canvasFactory.autoContinueModel, + canvasFactory.autoContinueProviderType, + canvasFactory.autoContinueThinkingEnabled, + canvasFactory.autoImageTopic, + canvasFactory.canvasRenderTheme, + canvasFactory.contentId, + canvasFactory.isStreaming, + canvasFactory.novelChapterListCollapsed, + canvasFactory.onAddImage, + canvasFactory.onAutoContinueModelChange, + canvasFactory.onAutoContinueProviderTypeChange, + canvasFactory.onAutoContinueRun, + canvasFactory.onAutoContinueThinkingEnabledChange, + canvasFactory.onBackHome, + canvasFactory.onCloseCanvas, + canvasFactory.onContentReviewRun, + canvasFactory.onImportDocument, + canvasFactory.onNovelChapterListCollapsedChange, + canvasFactory.onSelectionTextChange, + canvasFactory.onStateChange, + canvasFactory.onTextStylizeRun, + canvasFactory.preferContentReviewInRightRail, + canvasFactory.projectId, + canvasFactory.resolvedCanvasState, + ], + ); + + const renderArtifactWorkbenchPreview = useCallback( + (artifact: Artifact, stackedWorkbenchTrigger?: ReactNode) => ( + + ), + [artifactWorkbenchPreviewBaseProps], + ); + + const hasLiveCanvasPreviewContent = useMemo(() => { + if (imageWorkbench.active) { + return true; + } + + if (defaultPreview.canvasRenderTheme === "general") { + return Boolean( + (artifactPreview.currentCanvasArtifact && + artifactPreview.displayedCanvasArtifact) || + defaultPreview.generalCanvasState.isOpen, + ); + } + + return Boolean( + loading.shouldShowCanvasLoadingState || + defaultPreview.resolvedCanvasState, + ); + }, [ + artifactPreview.currentCanvasArtifact, + artifactPreview.displayedCanvasArtifact, + defaultPreview.canvasRenderTheme, + defaultPreview.generalCanvasState.isOpen, + defaultPreview.resolvedCanvasState, + imageWorkbench.active, + loading.shouldShowCanvasLoadingState, + ]); + + const renderLiveCanvasPreview = useCallback( + (stackedWorkbenchTrigger?: ReactNode) => + hasLiveCanvasPreviewContent ? ( + + ) : null, + [ + artifactPreview.currentCanvasArtifact, + artifactPreview.displayedCanvasArtifact, + canvasFactoryProps, + canvasLoadingLabel, + defaultPreview.canvasRenderTheme, + generalCanvasPanelProps, + hasLiveCanvasPreviewContent, + imageWorkbench.active, + imageWorkbenchCanvasProps, + loading.shouldShowCanvasLoadingState, + renderArtifactWorkbenchPreview, + ], + ); + + const { renderTeamWorkbenchPreview, teamWorkbenchView } = + useTeamWorkbenchPresentation({ + enabled: teamWorkbench.enabled, + surfaceProps: teamWorkbench.surfaceProps, + hasRealTeamGraph: teamWorkbench.hasRealTeamGraph, + autoFocusToken: teamWorkbench.autoFocusToken, + runtimeTeamState: teamWorkbench.runtimeTeamState, + liveActivityBySessionId: teamWorkbench.liveActivityBySessionId, + teamWaitSummary: teamWorkbench.teamWaitSummary, + teamControlSummary: teamWorkbench.teamControlSummary, + }); + + const renderCanvasWorkbenchPreview = useCallback( + ( + target: CanvasWorkbenchPreviewTarget, + options?: { + stackedWorkbenchTrigger?: ReactNode; + }, + ) => + renderCanvasWorkbenchPreviewTarget({ + target, + stackedWorkbenchTrigger: options?.stackedWorkbenchTrigger, + renderDefaultCanvasPreview: renderLiveCanvasPreview, + renderArtifactPreview: renderArtifactWorkbenchPreview, + renderTeamWorkbenchPreview, + }), + [ + renderArtifactWorkbenchPreview, + renderLiveCanvasPreview, + renderTeamWorkbenchPreview, + ], + ); + + return { + canvasWorkbenchDefaultPreview, + handleOpenCanvasWorkbenchPath, + handleRevealCanvasWorkbenchPath, + liveCanvasPreview: renderLiveCanvasPreview(), + hasLiveCanvasPreviewContent, + teamWorkbenchView, + renderCanvasWorkbenchPreview, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasScenePresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasScenePresentation.tsx new file mode 100644 index 000000000..28adc1b51 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasScenePresentation.tsx @@ -0,0 +1,132 @@ +import { useMemo } from "react"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { ThemeType } from "@/components/content-creator/types"; +import type { TaskFile } from "../components/TaskFiles"; +import { useWorkspaceCanvasPreviewPresentation } from "./useWorkspaceCanvasPreviewPresentation"; + +type WorkspaceCanvasPreviewPresentationParams = Parameters< + typeof useWorkspaceCanvasPreviewPresentation +>[0]; + +interface CanvasPreviewProviderSummary { + id: string; + name: string; +} + +interface UseWorkspaceCanvasScenePresentationParams { + shouldBootstrapCanvasOnEntry: boolean; + normalizedEntryTheme: ThemeType; + mappedTheme: ThemeType; + canvasState: CanvasStateUnion | null; + resolvedCanvasState: CanvasStateUnion | null; + isInitialContentLoading: boolean; + initialContentLoadError?: string | null; + imageWorkbenchProviders: CanvasPreviewProviderSummary[]; + activeCanvasTaskFile: TaskFile | null; + canvasPreviewPresentation: { + defaultPreview: Omit< + WorkspaceCanvasPreviewPresentationParams["defaultPreview"], + "canvasRenderTheme" | "resolvedCanvasState" | "activeCanvasTaskFile" + >; + artifactPreview: WorkspaceCanvasPreviewPresentationParams["artifactPreview"]; + imageWorkbench: Omit< + WorkspaceCanvasPreviewPresentationParams["imageWorkbench"], + "availableProviders" + >; + generalCanvas: WorkspaceCanvasPreviewPresentationParams["generalCanvas"]; + loading: Omit< + WorkspaceCanvasPreviewPresentationParams["loading"], + "shouldShowCanvasLoadingState" + >; + canvasFactory: Omit< + WorkspaceCanvasPreviewPresentationParams["canvasFactory"], + "canvasRenderTheme" | "resolvedCanvasState" + >; + teamWorkbench: WorkspaceCanvasPreviewPresentationParams["teamWorkbench"]; + }; +} + +interface WorkspaceCanvasScenePresentationResult + extends ReturnType { + canvasRenderTheme: ThemeType; + shouldShowCanvasLoadingState: boolean; +} + +export function useWorkspaceCanvasScenePresentation({ + shouldBootstrapCanvasOnEntry, + normalizedEntryTheme, + mappedTheme, + canvasState, + resolvedCanvasState, + isInitialContentLoading, + initialContentLoadError, + imageWorkbenchProviders, + activeCanvasTaskFile, + canvasPreviewPresentation, +}: UseWorkspaceCanvasScenePresentationParams): WorkspaceCanvasScenePresentationResult { + const canvasRenderTheme = useMemo( + () => + (shouldBootstrapCanvasOnEntry + ? normalizedEntryTheme + : mappedTheme) as ThemeType, + [mappedTheme, normalizedEntryTheme, shouldBootstrapCanvasOnEntry], + ); + + const shouldShowCanvasLoadingState = useMemo( + () => + (!canvasState && + (shouldBootstrapCanvasOnEntry || + isInitialContentLoading || + Boolean(initialContentLoadError))) || + (resolvedCanvasState?.type === "document" && + !resolvedCanvasState.content.trim() && + (isInitialContentLoading || Boolean(initialContentLoadError))), + [ + canvasState, + initialContentLoadError, + isInitialContentLoading, + resolvedCanvasState, + shouldBootstrapCanvasOnEntry, + ], + ); + + const canvasPreviewImageWorkbenchProviders = useMemo( + () => + imageWorkbenchProviders.map((provider) => ({ + id: provider.id, + name: provider.name, + })), + [imageWorkbenchProviders], + ); + + const previewPresentation = useWorkspaceCanvasPreviewPresentation({ + defaultPreview: { + ...canvasPreviewPresentation.defaultPreview, + canvasRenderTheme, + resolvedCanvasState, + activeCanvasTaskFile, + }, + artifactPreview: canvasPreviewPresentation.artifactPreview, + imageWorkbench: { + ...canvasPreviewPresentation.imageWorkbench, + availableProviders: canvasPreviewImageWorkbenchProviders, + }, + generalCanvas: canvasPreviewPresentation.generalCanvas, + loading: { + ...canvasPreviewPresentation.loading, + shouldShowCanvasLoadingState, + }, + canvasFactory: { + ...canvasPreviewPresentation.canvasFactory, + canvasRenderTheme, + resolvedCanvasState, + }, + teamWorkbench: canvasPreviewPresentation.teamWorkbench, + }); + + return { + canvasRenderTheme, + shouldShowCanvasLoadingState, + ...previewPresentation, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx new file mode 100644 index 000000000..93f6b29f2 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx @@ -0,0 +1,251 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useImageGen } from "@/components/image-gen/useImageGen"; +import type { ArtifactDisplayState } from "../hooks/useArtifactDisplayState"; +import type { SessionImageWorkbenchState } from "./imageWorkbenchHelpers"; +import { useWorkspaceCanvasScenePresentation } from "./useWorkspaceCanvasScenePresentation"; +import { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; +import { useWorkspaceImageWorkbenchActionRuntime } from "./useWorkspaceImageWorkbenchActionRuntime"; +import { useWorkspaceTeamSessionControlRuntime } from "./useWorkspaceTeamSessionControlRuntime"; +import { useWorkspaceTeamSessionRuntime } from "./useWorkspaceTeamSessionRuntime"; + +type CanvasScenePresentationParams = Parameters< + typeof useWorkspaceCanvasScenePresentation +>[0]; +type CanvasPreviewPresentationParams = + CanvasScenePresentationParams["canvasPreviewPresentation"]; +type ArtifactPreviewParams = CanvasPreviewPresentationParams["artifactPreview"]; +type ImageWorkbenchParams = CanvasPreviewPresentationParams["imageWorkbench"]; +type CanvasFactoryParams = CanvasPreviewPresentationParams["canvasFactory"]; +type TeamWorkbenchParams = CanvasPreviewPresentationParams["teamWorkbench"]; +type InputbarScene = ReturnType; +type ImageWorkbenchGenerationRuntime = ReturnType; +type ImageWorkbenchActionRuntime = ReturnType< + typeof useWorkspaceImageWorkbenchActionRuntime +>; +type TeamSessionRuntime = ReturnType; +type TeamSessionControlRuntime = ReturnType< + typeof useWorkspaceTeamSessionControlRuntime +>; + +interface UseWorkspaceCanvasSceneRuntimeParams { + shouldBootstrapCanvasOnEntry: CanvasScenePresentationParams["shouldBootstrapCanvasOnEntry"]; + normalizedEntryTheme: CanvasScenePresentationParams["normalizedEntryTheme"]; + mappedTheme: CanvasScenePresentationParams["mappedTheme"]; + canvasState: CanvasScenePresentationParams["canvasState"]; + resolvedCanvasState: CanvasScenePresentationParams["resolvedCanvasState"]; + isInitialContentLoading: CanvasScenePresentationParams["isInitialContentLoading"]; + initialContentLoadError: CanvasScenePresentationParams["initialContentLoadError"]; + imageWorkbenchGenerationRuntime: ImageWorkbenchGenerationRuntime; + imageWorkbenchActionRuntime: ImageWorkbenchActionRuntime; + inputbarScene: InputbarScene; + projectRootPath: CanvasPreviewPresentationParams["defaultPreview"]["workspaceRoot"]; + generalCanvasState: CanvasPreviewPresentationParams["defaultPreview"]["generalCanvasState"]; + setGeneralCanvasState: Dispatch< + SetStateAction + >; + currentCanvasArtifact: ArtifactPreviewParams["currentCanvasArtifact"]; + displayedCanvasArtifact: ArtifactPreviewParams["displayedCanvasArtifact"]; + artifactDisplayState: Pick< + ArtifactDisplayState, + "overlay" | "showPreviousVersionBadge" + >; + artifactViewMode: ArtifactPreviewParams["artifactViewMode"]; + setArtifactViewMode: ArtifactPreviewParams["onArtifactViewModeChange"]; + artifactPreviewSize: ArtifactPreviewParams["artifactPreviewSize"]; + setArtifactPreviewSize: ArtifactPreviewParams["onArtifactPreviewSizeChange"]; + handleCloseCanvas: ArtifactPreviewParams["onCloseCanvas"]; + currentImageWorkbenchState: SessionImageWorkbenchState; + imageWorkbenchPreferenceSummary: ImageWorkbenchParams["preferenceSummary"]; + imageWorkbenchPreferenceWarning: ImageWorkbenchParams["preferenceWarning"]; + setCanvasState: CanvasFactoryParams["onStateChange"]; + handleBackHome: CanvasFactoryParams["onBackHome"]; + isSending: CanvasFactoryParams["isStreaming"]; + handleCanvasSelectionTextChange: CanvasFactoryParams["onSelectionTextChange"]; + projectId: CanvasFactoryParams["projectId"]; + contentId: CanvasFactoryParams["contentId"]; + projectName?: CanvasFactoryParams["autoImageTopic"]; + providerType: CanvasFactoryParams["autoContinueProviderType"]; + setProviderType: CanvasFactoryParams["onAutoContinueProviderTypeChange"]; + model: CanvasFactoryParams["autoContinueModel"]; + setModel: CanvasFactoryParams["onAutoContinueModelChange"]; + documentThinkingEnabled: CanvasFactoryParams["autoContinueThinkingEnabled"]; + handleDocumentThinkingEnabledChange: CanvasFactoryParams["onAutoContinueThinkingEnabledChange"]; + handleDocumentAutoContinueRun: CanvasFactoryParams["onAutoContinueRun"]; + handleAddImage: CanvasFactoryParams["onAddImage"]; + handleImportDocument: CanvasFactoryParams["onImportDocument"]; + handleDocumentContentReviewRun: CanvasFactoryParams["onContentReviewRun"]; + handleDocumentTextStylizeRun: CanvasFactoryParams["onTextStylizeRun"]; + preferContentReviewInRightRail: CanvasFactoryParams["preferContentReviewInRightRail"]; + novelChapterListCollapsed: CanvasFactoryParams["novelChapterListCollapsed"]; + setNovelChapterListCollapsed: CanvasFactoryParams["onNovelChapterListCollapsedChange"]; + teamSessionRuntime: TeamSessionRuntime; + teamSessionControlRuntime: TeamSessionControlRuntime; + teamWorkbenchAutoFocusToken: TeamWorkbenchParams["autoFocusToken"]; + runtimeTeamState: TeamWorkbenchParams["runtimeTeamState"]; +} + +export function useWorkspaceCanvasSceneRuntime({ + shouldBootstrapCanvasOnEntry, + normalizedEntryTheme, + mappedTheme, + canvasState, + resolvedCanvasState, + isInitialContentLoading, + initialContentLoadError, + imageWorkbenchGenerationRuntime, + imageWorkbenchActionRuntime, + inputbarScene, + projectRootPath, + generalCanvasState, + setGeneralCanvasState, + currentCanvasArtifact, + displayedCanvasArtifact, + artifactDisplayState, + artifactViewMode, + setArtifactViewMode, + artifactPreviewSize, + setArtifactPreviewSize, + handleCloseCanvas, + currentImageWorkbenchState, + imageWorkbenchPreferenceSummary, + imageWorkbenchPreferenceWarning, + setCanvasState, + handleBackHome, + isSending, + handleCanvasSelectionTextChange, + projectId, + contentId, + projectName, + providerType, + setProviderType, + model, + setModel, + documentThinkingEnabled, + handleDocumentThinkingEnabledChange, + handleDocumentAutoContinueRun, + handleAddImage, + handleImportDocument, + handleDocumentContentReviewRun, + handleDocumentTextStylizeRun, + preferContentReviewInRightRail, + novelChapterListCollapsed, + setNovelChapterListCollapsed, + teamSessionRuntime, + teamSessionControlRuntime, + teamWorkbenchAutoFocusToken, + runtimeTeamState, +}: UseWorkspaceCanvasSceneRuntimeParams) { + return useWorkspaceCanvasScenePresentation({ + shouldBootstrapCanvasOnEntry, + normalizedEntryTheme, + mappedTheme, + canvasState, + resolvedCanvasState, + isInitialContentLoading, + initialContentLoadError, + imageWorkbenchProviders: + imageWorkbenchGenerationRuntime.availableProviders.map((provider) => ({ + id: provider.id, + name: provider.name, + })), + activeCanvasTaskFile: inputbarScene.activeCanvasTaskFile, + canvasPreviewPresentation: { + defaultPreview: { + workspaceRoot: projectRootPath, + generalCanvasState, + }, + artifactPreview: { + currentCanvasArtifact, + displayedCanvasArtifact, + artifactOverlay: artifactDisplayState.overlay, + showPreviousVersionBadge: + artifactDisplayState.showPreviousVersionBadge, + artifactViewMode, + onArtifactViewModeChange: setArtifactViewMode, + artifactPreviewSize, + onArtifactPreviewSizeChange: setArtifactPreviewSize, + onCloseCanvas: handleCloseCanvas, + }, + imageWorkbench: { + active: currentImageWorkbenchState.active, + tasks: currentImageWorkbenchState.tasks, + outputs: currentImageWorkbenchState.outputs, + selectedOutputId: currentImageWorkbenchState.selectedOutputId, + viewport: currentImageWorkbenchState.viewport, + preferenceSummary: imageWorkbenchPreferenceSummary, + preferenceWarning: imageWorkbenchPreferenceWarning, + selectedProviderId: imageWorkbenchGenerationRuntime.selectedProviderId, + onProviderChange: imageWorkbenchGenerationRuntime.setSelectedProviderId, + availableModels: imageWorkbenchGenerationRuntime.availableModels, + selectedModelId: imageWorkbenchGenerationRuntime.selectedModelId, + onModelChange: imageWorkbenchGenerationRuntime.setSelectedModelId, + selectedSize: imageWorkbenchGenerationRuntime.selectedSize, + onSizeChange: imageWorkbenchGenerationRuntime.setSelectedSize, + generating: imageWorkbenchGenerationRuntime.generating, + savingToResource: imageWorkbenchGenerationRuntime.savingToResource, + onStopGeneration: + imageWorkbenchActionRuntime.handleStopImageWorkbenchGeneration, + onViewportChange: + imageWorkbenchActionRuntime.handleImageWorkbenchViewportChange, + onSelectOutput: + imageWorkbenchActionRuntime.handleSelectImageWorkbenchOutput, + onSaveSelectedToLibrary: + imageWorkbenchActionRuntime.handleSaveSelectedImageWorkbenchOutput, + applySelectedOutputLabel: + imageWorkbenchActionRuntime.imageWorkbenchPrimaryActionLabel, + onApplySelectedOutput: + currentImageWorkbenchState.outputs.length > 0 + ? imageWorkbenchActionRuntime.handleApplySelectedImageWorkbenchOutput + : undefined, + onSeedFollowUpCommand: + imageWorkbenchActionRuntime.handleSeedImageWorkbenchFollowUp, + onOpenImage: imageWorkbenchActionRuntime.handleOpenImageWorkbenchAsset, + }, + generalCanvas: { + state: generalCanvasState, + onCloseCanvas: handleCloseCanvas, + onContentChange: (content: string) => + setGeneralCanvasState((previous) => ({ ...previous, content })), + }, + loading: { + isInitialContentLoading, + initialContentLoadError, + }, + canvasFactory: { + onStateChange: setCanvasState, + onBackHome: handleBackHome, + onCloseCanvas: handleCloseCanvas, + isStreaming: isSending, + onSelectionTextChange: handleCanvasSelectionTextChange, + projectId, + contentId, + autoImageTopic: projectName, + autoContinueProviderType: providerType, + onAutoContinueProviderTypeChange: setProviderType, + autoContinueModel: model, + onAutoContinueModelChange: setModel, + autoContinueThinkingEnabled: documentThinkingEnabled, + onAutoContinueThinkingEnabledChange: + handleDocumentThinkingEnabledChange, + onAutoContinueRun: handleDocumentAutoContinueRun, + onAddImage: handleAddImage, + onImportDocument: handleImportDocument, + onContentReviewRun: handleDocumentContentReviewRun, + onTextStylizeRun: handleDocumentTextStylizeRun, + preferContentReviewInRightRail, + novelChapterListCollapsed, + onNovelChapterListCollapsedChange: setNovelChapterListCollapsed, + }, + teamWorkbench: { + enabled: teamSessionRuntime.showTeamWorkspaceBoard, + surfaceProps: inputbarScene.teamWorkbenchSurfaceProps, + hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph, + autoFocusToken: teamWorkbenchAutoFocusToken, + runtimeTeamState, + liveActivityBySessionId: teamSessionRuntime.liveActivityBySessionId, + teamWaitSummary: teamSessionControlRuntime.teamWaitSummary, + teamControlSummary: teamSessionControlRuntime.teamControlSummary, + }, + }, + }); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasTaskFileSync.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasTaskFileSync.ts new file mode 100644 index 000000000..99703ba1c --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasTaskFileSync.ts @@ -0,0 +1,111 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; +import { createInitialMusicState } from "@/components/content-creator/canvas/music/types"; +import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser"; +import type { LayoutMode, ThemeType } from "@/components/content-creator/types"; +import type { TaskFile } from "../components/TaskFiles"; +import { resolveCanvasTaskFileTarget, shouldDeferCanvasSyncWhileEditing } from "../utils/taskFileCanvasSync"; +import { isRenderableTaskFile } from "./themeWorkbenchHelpers"; + +interface UseWorkspaceCanvasTaskFileSyncParams { + taskFiles: TaskFile[]; + isThemeWorkbench: boolean; + selectedFileId?: string; + canvasState: CanvasStateUnion | null; + mappedTheme: ThemeType; + documentEditorFocusedRef: MutableRefObject; + setSelectedFileId: Dispatch>; + setCanvasState: Dispatch>; + setLayoutMode: Dispatch>; + upsertNovelCanvasState: ( + previous: CanvasStateUnion | null, + content: string, + ) => CanvasStateUnion | null; +} + +export function useWorkspaceCanvasTaskFileSync({ + taskFiles, + isThemeWorkbench, + selectedFileId, + canvasState, + mappedTheme, + documentEditorFocusedRef, + setSelectedFileId, + setCanvasState, + setLayoutMode, + upsertNovelCanvasState, +}: UseWorkspaceCanvasTaskFileSyncParams) { + useEffect(() => { + const renderableFiles = taskFiles.filter((file) => + isRenderableTaskFile(file, isThemeWorkbench), + ); + if (renderableFiles.length === 0) { + return; + } + + const { targetFile, nextSelectedFileId } = resolveCanvasTaskFileTarget( + renderableFiles, + selectedFileId, + ); + if (!targetFile?.content) { + return; + } + + if (nextSelectedFileId) { + setSelectedFileId((previous) => + previous === nextSelectedFileId ? previous : nextSelectedFileId, + ); + } + + if ( + shouldDeferCanvasSyncWhileEditing({ + canvasType: canvasState?.type ?? null, + editorFocused: documentEditorFocusedRef.current, + }) + ) { + return; + } + + const targetContent = targetFile.content; + setCanvasState((previous) => { + if (mappedTheme === "music") { + const sections = parseLyrics(targetContent); + if (!previous || previous.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + const titleMatch = targetContent.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + return musicState; + } + return { ...previous, sections }; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(previous, targetContent); + } + + if (!previous || previous.type !== "document") { + return createInitialDocumentState(targetContent); + } + if (previous.content === targetContent) { + return previous; + } + return { ...previous, content: targetContent }; + }); + setLayoutMode("chat-canvas"); + }, [ + canvasState?.type, + documentEditorFocusedRef, + isThemeWorkbench, + mappedTheme, + selectedFileId, + setCanvasState, + setLayoutMode, + setSelectedFileId, + taskFiles, + upsertNovelCanvasState, + ]); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.ts new file mode 100644 index 000000000..f0ea2c626 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.ts @@ -0,0 +1,388 @@ +import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { toast } from "sonner"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { + AutoContinueRunPayload, + ContentReviewRunPayload, + TextStylizeRunPayload, +} from "@/components/content-creator/canvas/document/types"; +import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; +import { importDocument } from "@/lib/api/session-files"; +import type { HandleSendOptions } from "../hooks/handleSendTypes"; +import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard"; +import type { MessageImage } from "../types"; +import { + buildDefaultCanvasImageApplyTarget, + buildDocumentImageWorkbenchPrompt, + buildImageWorkbenchCommandText, + buildPosterImageWorkbenchPrompt, + resolveClosestImageAspectRatio, + type ImageWorkbenchApplyTarget, +} from "./imageWorkbenchHelpers"; +import { + parseImageWorkbenchCommand, + type ParsedImageWorkbenchCommand, +} from "../utils/imageWorkbenchCommand"; + +type WorkspaceSendHandler = ( + images?: MessageImage[], + webSearch?: boolean, + thinking?: boolean, + textOverride?: string, + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", + autoContinuePayload?: AutoContinueRequestPayload, + sendOptions?: HandleSendOptions, +) => Promise; + +interface ThinkingPreferenceState { + thinking: boolean; +} + +interface RunImageWorkbenchCommandParams { + rawText: string; + parsedCommand: ParsedImageWorkbenchCommand; + images: MessageImage[]; + applyTarget?: ImageWorkbenchApplyTarget | null; +} + +interface UseWorkspaceCanvasWorkflowActionsParams< + TToolPreferences extends ThinkingPreferenceState, +> { + setChatToolPreferences: Dispatch>; + sendRef: MutableRefObject; + webSearchPreferenceRef: MutableRefObject; + setCanvasState: Dispatch>; + setTopicStatus: (topicId: string, status: TopicBranchStatus) => void; + projectId?: string | null; + projectName?: string; + canvasState: CanvasStateUnion | null; + contentId?: string | null; + onRunImageWorkbenchCommand: ( + params: RunImageWorkbenchCommandParams, + ) => Promise; +} + +interface WorkspaceCanvasWorkflowActionsResult { + handleDocumentThinkingEnabledChange: (enabled: boolean) => void; + handleDocumentAutoContinueRun: ( + payload: AutoContinueRunPayload, + ) => Promise; + handleDocumentContentReviewRun: ( + payload: ContentReviewRunPayload, + ) => Promise; + handleDocumentTextStylizeRun: ( + payload: TextStylizeRunPayload, + ) => Promise; + handleSwitchBranchVersion: (versionId: string) => void; + handleCreateVersionSnapshot: () => void; + handleSetBranchStatus: ( + topicId: string, + status: TopicBranchStatus, + ) => void; + handleAddImage: () => Promise; + handleImportDocument: () => Promise; +} + +export function useWorkspaceCanvasWorkflowActions< + TToolPreferences extends ThinkingPreferenceState, +>({ + setChatToolPreferences, + sendRef, + webSearchPreferenceRef, + setCanvasState, + setTopicStatus, + projectId, + projectName, + canvasState, + contentId, + onRunImageWorkbenchCommand, +}: UseWorkspaceCanvasWorkflowActionsParams): WorkspaceCanvasWorkflowActionsResult { + const handleDocumentThinkingEnabledChange = useCallback( + (enabled: boolean) => { + setChatToolPreferences((previous) => + previous.thinking === enabled + ? previous + : { + ...previous, + thinking: enabled, + }, + ); + }, + [setChatToolPreferences], + ); + + const handleDocumentAutoContinueRun = useCallback( + async (payload: AutoContinueRunPayload) => { + await sendRef.current( + [], + webSearchPreferenceRef.current, + payload.thinkingEnabled, + payload.prompt, + undefined, + { + enabled: payload.settings.enabled, + fast_mode_enabled: payload.settings.fastModeEnabled, + continuation_length: payload.settings.continuationLength, + sensitivity: payload.settings.sensitivity, + source: "theme_workbench_document_auto_continue", + }, + ); + }, + [sendRef, webSearchPreferenceRef], + ); + + const handleDocumentContentReviewRun = useCallback( + async (payload: ContentReviewRunPayload) => + await new Promise((resolve, reject) => { + void sendRef + .current( + [], + webSearchPreferenceRef.current, + payload.thinkingEnabled, + payload.prompt, + undefined, + undefined, + { + skipThemeSkillPrefix: true, + purpose: "content_review", + observer: { + onComplete: resolve, + onError: (message) => reject(new Error(message)), + }, + }, + ) + .catch((error) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }), + [sendRef, webSearchPreferenceRef], + ); + + const handleDocumentTextStylizeRun = useCallback( + async (payload: TextStylizeRunPayload) => + await new Promise((resolve, reject) => { + void sendRef + .current( + [], + webSearchPreferenceRef.current, + payload.thinkingEnabled, + payload.prompt, + undefined, + undefined, + { + skipThemeSkillPrefix: true, + purpose: "text_stylize", + observer: { + onComplete: resolve, + onError: (message) => reject(new Error(message)), + }, + }, + ) + .catch((error) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }), + [sendRef, webSearchPreferenceRef], + ); + + const handleSwitchBranchVersion = useCallback( + (versionId: string) => { + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + return previous; + } + + const targetVersion = previous.versions.find( + (version) => version.id === versionId, + ); + if (!targetVersion) { + return previous; + } + + return { + ...previous, + currentVersionId: targetVersion.id, + content: targetVersion.content, + }; + }); + }, + [setCanvasState], + ); + + const handleCreateVersionSnapshot = useCallback(() => { + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + toast.info("当前没有可管理的文稿版本"); + return previous; + } + + const content = previous.content.trim(); + if (!content) { + toast.info("主稿为空,无法创建版本快照"); + return previous; + } + + const nextIndex = previous.versions.length + 1; + const newVersion = { + id: crypto.randomUUID(), + content: previous.content, + createdAt: Date.now(), + description: `手动快照 - 版本 ${nextIndex}`, + }; + + toast.success("已创建版本快照"); + return { + ...previous, + versions: [...previous.versions, newVersion], + currentVersionId: newVersion.id, + }; + }); + }, [setCanvasState]); + + const handleSetBranchStatus = useCallback( + (topicId: string, status: TopicBranchStatus) => { + setTopicStatus(topicId, status); + if (status === "merged") { + toast.success("已将该版本标记为主稿"); + } else if (status === "pending") { + toast.info("已将该版本标记为待评审"); + } + }, + [setTopicStatus], + ); + + const handleAddImage = useCallback(async () => { + if (!projectId) { + toast.error("请先选择项目后再开始配图"); + return; + } + + if (!canvasState) { + toast.info("当前没有可用画布"); + return; + } + + let rawText = ""; + let applyTarget: ImageWorkbenchApplyTarget | null = null; + + if (canvasState.type === "document") { + rawText = buildImageWorkbenchCommandText( + buildDocumentImageWorkbenchPrompt({ + projectName, + platform: canvasState.platform, + content: canvasState.content, + }), + ); + applyTarget = buildDefaultCanvasImageApplyTarget({ + canvasState, + projectId, + contentId: contentId ?? null, + }); + } else if (canvasState.type === "poster") { + const currentPage = + canvasState.pages[canvasState.currentPageIndex] || canvasState.pages[0]; + if (!currentPage) { + toast.error("海报画布缺少有效页面"); + return; + } + rawText = buildImageWorkbenchCommandText( + buildPosterImageWorkbenchPrompt({ + projectName, + width: currentPage.width, + height: currentPage.height, + }), + { + aspectRatio: resolveClosestImageAspectRatio( + currentPage.width, + currentPage.height, + ), + }, + ); + applyTarget = buildDefaultCanvasImageApplyTarget({ + canvasState, + projectId, + contentId: contentId ?? null, + }); + } else { + toast.info("当前画布暂未接入配图工作台"); + return; + } + + const parsedCommand = parseImageWorkbenchCommand(rawText); + if (!parsedCommand) { + toast.error("配图任务初始化失败"); + return; + } + + await onRunImageWorkbenchCommand({ + rawText, + parsedCommand, + images: [], + applyTarget, + }); + }, [ + canvasState, + contentId, + onRunImageWorkbenchCommand, + projectId, + projectName, + ]); + + const handleImportDocument = useCallback(async () => { + try { + const selected = await openDialog({ + multiple: false, + filters: [ + { + name: "文档", + extensions: ["md", "txt"], + }, + ], + }); + + if (!selected) { + return; + } + + const filePath = selected; + if (!filePath) { + toast.error("未选择文件"); + return; + } + + toast.info("正在导入文稿..."); + + const content = await importDocument(filePath); + + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + toast.error("当前不在文档编辑模式"); + return previous; + } + + return { + ...previous, + content, + }; + }); + + toast.success("文稿已导入"); + } catch (error) { + console.error("导入文稿失败:", error); + toast.error(error instanceof Error ? error.message : "导入文稿失败"); + } + }, [setCanvasState]); + + return { + handleDocumentThinkingEnabledChange, + handleDocumentAutoContinueRun, + handleDocumentContentReviewRun, + handleDocumentTextStylizeRun, + handleSwitchBranchVersion, + handleCreateVersionSnapshot, + handleSetBranchStatus, + handleAddImage, + handleImportDocument, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceContextDetailActions.tsx b/src/components/agent/chat/workspace/useWorkspaceContextDetailActions.tsx new file mode 100644 index 000000000..d527a1ebe --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceContextDetailActions.tsx @@ -0,0 +1,61 @@ +import { useCallback } from "react"; +import { toast } from "sonner"; +import type { ThemeContextWorkspaceState } from "../hooks/useThemeContextWorkspace"; + +interface UseWorkspaceContextDetailActionsParams { + contextWorkspace: ThemeContextWorkspaceState; +} + +export function useWorkspaceContextDetailActions({ + contextWorkspace, +}: UseWorkspaceContextDetailActionsParams) { + const handleViewContextDetail = useCallback( + (contextId: string) => { + const detail = contextWorkspace.getContextDetail(contextId); + if (!detail) { + toast.error("无法找到上下文详情"); + return; + } + + const sourceLabel = + detail.source === "material" + ? "素材库" + : detail.source === "content" + ? "历史内容" + : "搜索结果"; + + toast.info( +
+
+ {detail.name} +
+
+ 来源: {sourceLabel} · 约 {detail.estimatedTokens} tokens +
+
+ {detail.bodyText || detail.previewText} +
+
, + { duration: 10000 }, + ); + }, + [contextWorkspace], + ); + + return { + handleViewContextDetail, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceContextHarnessRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceContextHarnessRuntime.ts new file mode 100644 index 000000000..d186d50e4 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceContextHarnessRuntime.ts @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ProjectMemory } from "@/lib/api/memory"; +import type { ThemeType } from "@/components/content-creator/types"; +import type { Message } from "../types"; +import { useThemeContextWorkspace } from "../hooks"; +import { collectConversationSkillNames } from "../utils/harnessSkills"; +import { + loadPersistedBoolean, + savePersistedBoolean, +} from "./themeWorkbenchHelpers"; + +const HARNESS_PANEL_VISIBILITY_KEY = "lime.chat.harness-panel.visible.v1"; + +interface UseWorkspaceContextHarnessRuntimeParams { + projectId?: string; + activeTheme: string; + messages: Message[]; + providerType: string; + model: string; + mappedTheme: ThemeType; + chatMode: "agent" | "general" | "creator"; + isSending: boolean; + projectMemory: ProjectMemory | null; + harnessState: { + pendingApprovals: unknown[]; + hasSignals: boolean; + }; + compatSubagentRuntime: { + isRunning: boolean; + }; +} + +export function useWorkspaceContextHarnessRuntime({ + projectId, + activeTheme, + messages, + providerType, + model, + mappedTheme, + chatMode, + isSending, + projectMemory, + harnessState, + compatSubagentRuntime, +}: UseWorkspaceContextHarnessRuntimeParams) { + const [harnessPanelVisible, setHarnessPanelVisible] = useState(() => + loadPersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, false), + ); + + useEffect(() => { + savePersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, harnessPanelVisible); + }, [harnessPanelVisible]); + + const contextWorkspace = useThemeContextWorkspace({ + projectId, + activeTheme, + messages, + providerType, + model, + }); + const isThemeWorkbench = contextWorkspace.enabled; + const harnessSkillNames = useMemo( + () => collectConversationSkillNames(messages), + [messages], + ); + const harnessPendingCount = harnessState.pendingApprovals.length; + const shouldAlwaysShowHarnessToggle = + contextWorkspace.enabled && mappedTheme === "social-media"; + const shouldAlwaysShowGeneralWorkbenchToggle = + chatMode === "general" && !contextWorkspace.enabled; + const hasHarnessActivity = + harnessPanelVisible || + harnessState.hasSignals || + compatSubagentRuntime.isRunning; + const showHarnessToggle = + shouldAlwaysShowHarnessToggle || + shouldAlwaysShowGeneralWorkbenchToggle || + hasHarnessActivity; + const harnessAttentionLevel = + harnessPendingCount > 0 + ? "warning" + : hasHarnessActivity + ? "active" + : "idle"; + const navbarHarnessPanelVisible = harnessPanelVisible; + const visibleContextItems = useMemo(() => { + const activeItems = contextWorkspace.sidebarContextItems.filter( + (item) => item.active, + ); + return activeItems.length > 0 + ? activeItems + : contextWorkspace.sidebarContextItems; + }, [contextWorkspace.sidebarContextItems]); + const harnessEnvironment = useMemo( + () => ({ + skillsCount: harnessSkillNames.length, + skillNames: harnessSkillNames.slice(0, 4), + memorySignals: [ + projectMemory?.characters.length ? "角色" : null, + projectMemory?.world_building ? "世界观" : null, + projectMemory?.style_guide ? "风格" : null, + projectMemory?.outline.length ? "大纲" : null, + ].filter((item): item is string => item !== null), + contextItemsCount: contextWorkspace.sidebarContextItems.length, + activeContextCount: contextWorkspace.sidebarContextItems.filter( + (item) => item.active, + ).length, + contextItemNames: visibleContextItems + .map((item) => item.name) + .filter((name) => !!name.trim()) + .slice(0, 4), + contextEnabled: contextWorkspace.enabled, + }), + [ + contextWorkspace.enabled, + contextWorkspace.sidebarContextItems, + harnessSkillNames, + projectMemory?.characters.length, + projectMemory?.outline.length, + projectMemory?.style_guide, + projectMemory?.world_building, + visibleContextItems, + ], + ); + const handleToggleHarnessPanel = useCallback(() => { + setHarnessPanelVisible((current) => !current); + }, []); + const activeRuntimeStatusTitle = useMemo(() => { + if (!isSending) { + return null; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role === "assistant" && message.runtimeStatus?.title) { + return message.runtimeStatus.title; + } + } + + return "正在准备处理"; + }, [isSending, messages]); + + return { + contextWorkspace, + isThemeWorkbench, + harnessPanelVisible, + setHarnessPanelVisible, + harnessPendingCount, + showHarnessToggle, + harnessAttentionLevel, + navbarHarnessPanelVisible, + harnessEnvironment, + handleToggleHarnessPanel, + activeRuntimeStatusTitle, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationScenePresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationScenePresentation.tsx new file mode 100644 index 000000000..e3f23493d --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceConversationScenePresentation.tsx @@ -0,0 +1,141 @@ +import type { ComponentProps, ReactNode } from "react"; +import { StepProgress } from "@/components/content-creator/core/StepGuide/StepProgress"; +import { WorkspaceConversationScene } from "./WorkspaceConversationScene"; +import { MessageList } from "../components/MessageList"; +import { RuntimeStyleControlBar } from "../components/RuntimeStyleControlBar"; +import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; +import { + buildRuntimeStyleControlBarProps, + buildStepProgressProps, + buildTeamWorkspaceDockProps, + buildWorkspaceMessageListProps, +} from "./chatSurfaceProps"; +import type { TeamWorkbenchSurfaceProps } from "./teamWorkbenchPresentation"; + +type WorkspaceConversationSceneProps = ComponentProps< + typeof WorkspaceConversationScene +>; +type CanvasWorkbenchLayoutProps = NonNullable< + WorkspaceConversationSceneProps["canvasWorkbenchLayoutProps"] +>; +type NovelCanvasControls = NonNullable< + WorkspaceConversationSceneProps["novelCanvasControls"] +>; + +interface UseWorkspaceConversationScenePresentationParams { + scene: Omit< + WorkspaceConversationSceneProps, + | "workspaceAlertVisible" + | "projectId" + | "novelCanvasControls" + | "canvasWorkbenchLayoutProps" + | "stepProgressProps" + | "runtimeStyleControlBarProps" + | "messageListProps" + | "teamWorkspaceDockProps" + > & { + projectId: string | null | undefined; + }; + stepProgress: { + hidden: boolean; + isContentCreationMode: boolean; + hasMessages: boolean; + steps: ComponentProps["steps"]; + currentIndex: ComponentProps["currentIndex"]; + onStepClick: NonNullable["onStepClick"]>; + }; + runtimeStyleControlBar: { + enabled: boolean; + projectId: string | null | undefined; + activeTheme: ComponentProps["activeTheme"]; + projectStyleGuide: ComponentProps< + typeof RuntimeStyleControlBar + >["projectStyleGuide"]; + selection: ComponentProps["selection"]; + onSelectionChange: ComponentProps< + typeof RuntimeStyleControlBar + >["onSelectionChange"]; + onRewrite: NonNullable["onRewrite"]>; + onAudit: NonNullable["onAudit"]>; + actionsDisabled: boolean; + }; + messageList: ComponentProps; + teamWorkspaceDock: { + enabled: boolean; + shouldShowFloatingInputOverlay: boolean; + layoutMode: "chat" | "chat-canvas"; + onActivateWorkbench: NonNullable< + ComponentProps["onActivateWorkbench"] + >; + withBottomOverlay: boolean; + surfaceProps: TeamWorkbenchSurfaceProps; + }; + workspaceAlert: { + workspacePathMissing: boolean; + workspaceHealthError: boolean; + }; + novelCanvas: { + visible: boolean; + chapterListCollapsed: NovelCanvasControls["chapterListCollapsed"]; + onToggleChapterList: NovelCanvasControls["onToggleChapterList"]; + onAddChapter: NovelCanvasControls["onAddChapter"]; + onCloseCanvas: NovelCanvasControls["onCloseCanvas"]; + }; + canvasWorkbenchLayout: Omit; +} + +interface WorkspaceConversationScenePresentationResult { + workspaceAlertVisible: boolean; + mainAreaNode: ReactNode; +} + +export function useWorkspaceConversationScenePresentation({ + scene, + stepProgress, + runtimeStyleControlBar, + messageList, + teamWorkspaceDock, + workspaceAlert, + novelCanvas, + canvasWorkbenchLayout, +}: UseWorkspaceConversationScenePresentationParams): WorkspaceConversationScenePresentationResult { + const stepProgressProps = buildStepProgressProps(stepProgress); + const runtimeStyleControlBarProps = + buildRuntimeStyleControlBarProps(runtimeStyleControlBar); + const messageListProps = buildWorkspaceMessageListProps(messageList); + const teamWorkspaceDockProps = buildTeamWorkspaceDockProps(teamWorkspaceDock); + const workspaceAlertVisible = Boolean( + workspaceAlert.workspacePathMissing || workspaceAlert.workspaceHealthError, + ); + + const novelCanvasControls = novelCanvas.visible + ? { + chapterListCollapsed: novelCanvas.chapterListCollapsed, + onToggleChapterList: novelCanvas.onToggleChapterList, + onAddChapter: novelCanvas.onAddChapter, + onCloseCanvas: novelCanvas.onCloseCanvas, + } + : null; + + const canvasWorkbenchLayoutProps: CanvasWorkbenchLayoutProps = { + ...canvasWorkbenchLayout, + workspaceUnavailable: workspaceAlertVisible, + }; + + return { + workspaceAlertVisible, + mainAreaNode: ( + + ), + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx new file mode 100644 index 000000000..b3a1b6dd0 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx @@ -0,0 +1,428 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useWorkspaceConversationScenePresentation } from "./useWorkspaceConversationScenePresentation"; +import { useWorkspaceConversationSendRuntime } from "./useWorkspaceConversationSendRuntime"; +import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions"; +import { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; +import { useWorkspaceCanvasSceneRuntime } from "./useWorkspaceCanvasSceneRuntime"; +import { useWorkspaceShellChromeRuntime } from "./useWorkspaceShellChromeRuntime"; +import type { ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { CreationMode } from "../components/types"; +import type { WriteArtifactContext } from "../types"; +import type { ThemeType } from "@/components/content-creator/types"; +import type { Artifact } from "@/lib/artifact/types"; +import type { Character } from "@/lib/api/memory"; +import type { TaskFile } from "../components/TaskFiles"; + +type NavigationActions = ReturnType; +type InputbarScene = ReturnType; +type CanvasScene = ReturnType; +type ConversationSendRuntime = ReturnType< + typeof useWorkspaceConversationSendRuntime +>; +type ShellChromeRuntime = ReturnType; +type ConversationScenePresentationParams = Parameters< + typeof useWorkspaceConversationScenePresentation +>[0]; + +interface UseWorkspaceConversationSceneRuntimeParams { + navigationActions: NavigationActions; + inputbarScene: InputbarScene; + canvasScene: CanvasScene; + conversationSendRuntime: ConversationSendRuntime; + shellChromeRuntime: ShellChromeRuntime; + themeWorkbenchHarnessDialog: ConversationScenePresentationParams["scene"]["themeWorkbenchHarnessDialog"]; + entryBannerVisible: ConversationScenePresentationParams["scene"]["entryBannerVisible"]; + entryBannerMessage: ConversationScenePresentationParams["scene"]["entryBannerMessage"]; + contextWorkspaceEnabled: boolean; + input: ConversationScenePresentationParams["scene"]["input"]; + setInput: ConversationScenePresentationParams["scene"]["setInput"]; + providerType: ConversationScenePresentationParams["scene"]["providerType"]; + setProviderType: ConversationScenePresentationParams["scene"]["setProviderType"]; + model: ConversationScenePresentationParams["scene"]["model"]; + setModel: ConversationScenePresentationParams["scene"]["setModel"]; + executionStrategy: ConversationScenePresentationParams["scene"]["executionStrategy"]; + setExecutionStrategy: ConversationScenePresentationParams["scene"]["setExecutionStrategy"]; + chatToolPreferences: ChatToolPreferences; + setChatToolPreferences: Dispatch>; + selectedTeam: ConversationScenePresentationParams["scene"]["selectedTeam"]; + handleSelectTeam: ConversationScenePresentationParams["scene"]["onSelectTeam"]; + handleEnableSuggestedTeam: ConversationScenePresentationParams["scene"]["onEnableSuggestedTeam"]; + creationMode: CreationMode; + setCreationMode: Dispatch>; + activeTheme: string; + setActiveTheme: Dispatch>; + lockTheme: boolean; + artifacts: Artifact[]; + generalCanvasContent: string; + resolvedCanvasState: ConversationScenePresentationParams["scene"]["resolvedCanvasState"]; + contentId: ConversationScenePresentationParams["scene"]["contentId"]; + selectedText: ConversationScenePresentationParams["scene"]["selectedText"]; + handleRecommendationClick: ConversationScenePresentationParams["scene"]["onRecommendationClick"]; + projectCharacters: Character[]; + skills: ConversationScenePresentationParams["scene"]["skills"]; + skillsLoading: ConversationScenePresentationParams["scene"]["isSkillsLoading"]; + handleNavigateToSkillSettings: ConversationScenePresentationParams["scene"]["onNavigateToSettings"]; + handleRefreshSkills: ConversationScenePresentationParams["scene"]["onRefreshSkills"]; + handleOpenBrowserAssistInCanvas: ConversationScenePresentationParams["scene"]["onLaunchBrowserAssist"]; + browserAssistLaunching: ConversationScenePresentationParams["scene"]["browserAssistLoading"]; + projectId: string | null; + hideHistoryToggle: boolean; + showChatPanel: boolean; + topBarChrome: ConversationScenePresentationParams["scene"]["navbarChrome"]; + onBackToProjectManagement?: ConversationScenePresentationParams["scene"]["onBackToProjectManagement"]; + fromResources: boolean; + handleBackHome: ConversationScenePresentationParams["scene"]["onBackHome"]; + handleToggleSidebar: ConversationScenePresentationParams["scene"]["onToggleHistory"]; + chatMode: ConversationScenePresentationParams["scene"]["chatMode"]; + isBrowserAssistCanvasVisible: ConversationScenePresentationParams["scene"]["isBrowserAssistCanvasVisible"]; + browserAssistAttentionLevel: ConversationScenePresentationParams["scene"]["browserAssistAttentionLevel"]; + browserAssistEntryLabel: ConversationScenePresentationParams["scene"]["browserAssistLabel"]; + showHarnessToggle: ConversationScenePresentationParams["scene"]["showHarnessToggle"]; + navbarHarnessPanelVisible: ConversationScenePresentationParams["scene"]["harnessPanelVisible"]; + handleToggleHarnessPanel: ConversationScenePresentationParams["scene"]["onToggleHarnessPanel"]; + harnessPendingCount: ConversationScenePresentationParams["scene"]["harnessPendingCount"]; + harnessAttentionLevel: ConversationScenePresentationParams["scene"]["harnessAttentionLevel"]; + sessionId: string | undefined; + syncStatus: ConversationScenePresentationParams["scene"]["syncStatus"]; + pendingA2UIForm: unknown; + handleToggleCanvas: ConversationScenePresentationParams["scene"]["onToggleCanvas"]; + currentImageWorkbenchActive: ConversationScenePresentationParams["scene"]["currentImageWorkbenchActive"]; + hideInlineStepProgress: ConversationScenePresentationParams["stepProgress"]["hidden"]; + isContentCreationMode: ConversationScenePresentationParams["stepProgress"]["isContentCreationMode"]; + hasMessages: ConversationScenePresentationParams["stepProgress"]["hasMessages"]; + steps: ConversationScenePresentationParams["stepProgress"]["steps"]; + currentStepIndex: ConversationScenePresentationParams["stepProgress"]["currentIndex"]; + goToStep: ConversationScenePresentationParams["stepProgress"]["onStepClick"]; + displayMessages: ConversationScenePresentationParams["messageList"]["messages"]; + turns: ConversationScenePresentationParams["messageList"]["turns"]; + effectiveThreadItems: ConversationScenePresentationParams["messageList"]["threadItems"]; + currentTurnId: ConversationScenePresentationParams["messageList"]["currentTurnId"]; + threadRead: ConversationScenePresentationParams["messageList"]["threadRead"]; + pendingActions: ConversationScenePresentationParams["messageList"]["pendingActions"]; + submittedActionsInFlight: ConversationScenePresentationParams["messageList"]["submittedActionsInFlight"]; + queuedTurns: ConversationScenePresentationParams["messageList"]["queuedTurns"]; + isSending: ConversationScenePresentationParams["messageList"]["isSending"]; + stopSending: ConversationScenePresentationParams["messageList"]["onInterruptCurrentTurn"]; + resumeThread: ConversationScenePresentationParams["messageList"]["onResumeThread"]; + replayPendingAction: ConversationScenePresentationParams["messageList"]["onReplayPendingRequest"]; + promoteQueuedTurn: ConversationScenePresentationParams["messageList"]["onPromoteQueuedTurn"]; + deleteMessage: ConversationScenePresentationParams["messageList"]["onDeleteMessage"]; + editMessage: ConversationScenePresentationParams["messageList"]["onEditMessage"]; + handleA2UISubmit: ConversationScenePresentationParams["messageList"]["onA2UISubmit"]; + handleWriteFile: ( + content: string, + fileName: string, + context?: WriteArtifactContext, + ) => void | Promise; + handleFileClick: ConversationScenePresentationParams["messageList"]["onFileClick"]; + handleArtifactClick: ConversationScenePresentationParams["messageList"]["onArtifactClick"]; + handleOpenSubagentSession: ConversationScenePresentationParams["messageList"]["onOpenSubagentSession"]; + handlePermissionResponseWithBrowserPreflight: ConversationScenePresentationParams["messageList"]["onPermissionResponse"]; + pendingPromotedA2UIActionRequest: unknown; + shouldCollapseCodeBlocks: ConversationScenePresentationParams["messageList"]["collapseCodeBlocks"]; + shouldCollapseCodeBlockInChat: ConversationScenePresentationParams["messageList"]["shouldCollapseCodeBlock"]; + handleCodeBlockClick: ConversationScenePresentationParams["messageList"]["onCodeBlockClick"]; + showTeamWorkspaceBoard: ConversationScenePresentationParams["teamWorkspaceDock"]["enabled"]; + layoutMode: ConversationScenePresentationParams["teamWorkspaceDock"]["layoutMode"]; + handleActivateTeamWorkbench: ConversationScenePresentationParams["teamWorkspaceDock"]["onActivateWorkbench"]; + isThemeWorkbench: boolean; + showNovelNavbarControls: ConversationScenePresentationParams["novelCanvas"]["visible"]; + novelChapterListCollapsed: ConversationScenePresentationParams["novelCanvas"]["chapterListCollapsed"]; + handleToggleNovelChapterList: ConversationScenePresentationParams["novelCanvas"]["onToggleChapterList"]; + handleAddNovelChapter: ConversationScenePresentationParams["novelCanvas"]["onAddChapter"]; + handleCloseCanvas: ConversationScenePresentationParams["novelCanvas"]["onCloseCanvas"]; + settledWorkbenchArtifacts: ConversationScenePresentationParams["canvasWorkbenchLayout"]["artifacts"]; + taskFiles: TaskFile[]; + selectedFileId: string | undefined; + projectRootPath: string | null; + handleHarnessLoadFilePreview: ConversationScenePresentationParams["canvasWorkbenchLayout"]["loadFilePreview"]; + setCanvasWorkbenchLayoutMode: ConversationScenePresentationParams["canvasWorkbenchLayout"]["onLayoutModeChange"]; + workspacePathMissing: boolean; + workspaceHealthError: boolean; +} + +export function useWorkspaceConversationSceneRuntime({ + navigationActions, + inputbarScene, + canvasScene, + conversationSendRuntime, + shellChromeRuntime, + themeWorkbenchHarnessDialog, + entryBannerVisible, + entryBannerMessage, + contextWorkspaceEnabled, + input, + setInput, + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + chatToolPreferences, + setChatToolPreferences, + selectedTeam, + handleSelectTeam, + handleEnableSuggestedTeam, + creationMode, + setCreationMode, + activeTheme, + setActiveTheme, + lockTheme, + artifacts, + generalCanvasContent, + resolvedCanvasState, + contentId, + selectedText, + handleRecommendationClick, + projectCharacters, + skills, + skillsLoading, + handleNavigateToSkillSettings, + handleRefreshSkills, + handleOpenBrowserAssistInCanvas, + browserAssistLaunching, + projectId, + hideHistoryToggle, + showChatPanel, + topBarChrome, + onBackToProjectManagement, + fromResources, + handleBackHome, + handleToggleSidebar, + chatMode, + isBrowserAssistCanvasVisible, + browserAssistAttentionLevel, + browserAssistEntryLabel, + showHarnessToggle, + navbarHarnessPanelVisible, + handleToggleHarnessPanel, + harnessPendingCount, + harnessAttentionLevel, + sessionId, + syncStatus, + pendingA2UIForm, + handleToggleCanvas, + currentImageWorkbenchActive, + hideInlineStepProgress, + isContentCreationMode, + hasMessages, + steps, + currentStepIndex, + goToStep, + displayMessages, + turns, + effectiveThreadItems, + currentTurnId, + threadRead, + pendingActions, + submittedActionsInFlight, + queuedTurns, + isSending, + stopSending, + resumeThread, + replayPendingAction, + promoteQueuedTurn, + deleteMessage, + editMessage, + handleA2UISubmit, + handleWriteFile, + handleFileClick, + handleArtifactClick, + handleOpenSubagentSession, + handlePermissionResponseWithBrowserPreflight, + pendingPromotedA2UIActionRequest, + shouldCollapseCodeBlocks, + shouldCollapseCodeBlockInChat, + handleCodeBlockClick, + showTeamWorkspaceBoard, + layoutMode, + handleActivateTeamWorkbench, + isThemeWorkbench, + showNovelNavbarControls, + novelChapterListCollapsed, + handleToggleNovelChapterList, + handleAddNovelChapter, + handleCloseCanvas, + settledWorkbenchArtifacts, + taskFiles, + selectedFileId, + projectRootPath, + handleHarnessLoadFilePreview, + setCanvasWorkbenchLayoutMode, + workspacePathMissing, + workspaceHealthError, +}: UseWorkspaceConversationSceneRuntimeParams) { + return useWorkspaceConversationScenePresentation({ + scene: { + entryBannerVisible, + entryBannerMessage, + onDismissEntryBanner: navigationActions.handleDismissEntryBanner, + showChatLayout: shellChromeRuntime.showChatLayout, + compactChrome: shellChromeRuntime.isWorkspaceCompactChrome, + contextWorkspaceEnabled, + themeWorkbenchMessageViewportBottomPadding: + shellChromeRuntime.themeWorkbenchLayoutBottomSpacing.messageViewportBottomPadding, + onSelectWorkspaceDirectory: + navigationActions.handleWorkspaceAlertSelectDirectory, + onDismissWorkspaceAlert: navigationActions.handleDismissWorkspaceAlert, + shouldHideThemeWorkbenchInputForTheme: + shellChromeRuntime.shouldHideThemeWorkbenchInputForTheme, + inputbarNode: inputbarScene.inputbarNode, + input, + setInput, + onSendMessage: conversationSendRuntime.handleSendFromEmptyState, + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + onManageProviders: navigationActions.handleManageProviders, + toolPreferences: chatToolPreferences, + onToolPreferenceChange: (key, enabled) => + setChatToolPreferences((previous) => ({ + ...previous, + [key]: enabled, + })), + selectedTeam, + onSelectTeam: handleSelectTeam, + onEnableSuggestedTeam: handleEnableSuggestedTeam, + creationMode, + onCreationModeChange: setCreationMode, + activeTheme: activeTheme as ThemeType, + onThemeChange: setActiveTheme, + themeLocked: lockTheme, + artifactsCount: artifacts.length, + generalCanvasContent, + resolvedCanvasState, + contentId, + selectedText, + onRecommendationClick: handleRecommendationClick, + characters: projectCharacters, + skills, + isSkillsLoading: skillsLoading, + onNavigateToSettings: handleNavigateToSkillSettings, + onRefreshSkills: handleRefreshSkills, + onLaunchBrowserAssist: handleOpenBrowserAssistInCanvas, + browserAssistLoading: browserAssistLaunching, + projectId, + onProjectChange: navigationActions.handleProjectChange, + onOpenSettings: navigationActions.handleOpenAppearanceSettings, + navbarVisible: shellChromeRuntime.shouldRenderTopBar, + isRunning: Boolean(isSending), + navbarChrome: topBarChrome, + onToggleHistory: handleToggleSidebar, + showHistoryToggle: !hideHistoryToggle && showChatPanel, + onBackToProjectManagement, + onBackToResources: fromResources + ? navigationActions.handleBackToResources + : undefined, + isThemeWorkbench, + layoutMode, + onToggleCanvas: handleToggleCanvas, + onBackHome: handleBackHome, + chatMode, + isBrowserAssistCanvasVisible, + browserAssistAttentionLevel, + browserAssistLabel: browserAssistEntryLabel, + onOpenBrowserAssist: handleOpenBrowserAssistInCanvas, + showHarnessToggle, + harnessPanelVisible: navbarHarnessPanelVisible, + onToggleHarnessPanel: handleToggleHarnessPanel, + harnessPendingCount, + harnessAttentionLevel, + showContextCompactionAction: Boolean(sessionId), + contextCompactionRunning: isSending, + onCompactContext: navigationActions.handleCompactContext, + syncStatus, + hasLiveCanvasPreviewContent: canvasScene.hasLiveCanvasPreviewContent, + liveCanvasPreview: canvasScene.liveCanvasPreview, + currentImageWorkbenchActive, + shouldShowCanvasLoadingState: canvasScene.shouldShowCanvasLoadingState, + teamWorkbenchView: canvasScene.teamWorkbenchView, + shellBottomInset: + shellChromeRuntime.themeWorkbenchLayoutBottomSpacing.shellBottomInset, + chatPanelWidth: shellChromeRuntime.layoutTransitionChatPanelWidth, + chatPanelMinWidth: shellChromeRuntime.layoutTransitionChatPanelMinWidth, + generalWorkbenchDialog: inputbarScene.generalWorkbenchDialog, + themeWorkbenchHarnessDialog, + showFloatingInputOverlay: + shellChromeRuntime.shouldShowThemeWorkbenchFloatingInputOverlay, + hasPendingA2UIForm: Boolean(pendingA2UIForm), + }, + stepProgress: { + hidden: hideInlineStepProgress, + isContentCreationMode, + hasMessages, + steps, + currentIndex: currentStepIndex, + onStepClick: goToStep, + }, + runtimeStyleControlBar: conversationSendRuntime.runtimeStyleControlBar, + messageList: { + messages: displayMessages, + turns, + threadItems: effectiveThreadItems, + currentTurnId, + threadRead, + pendingActions, + submittedActionsInFlight, + queuedTurns, + isSending, + onInterruptCurrentTurn: stopSending, + onResumeThread: resumeThread, + onReplayPendingRequest: replayPendingAction, + onPromoteQueuedTurn: promoteQueuedTurn, + onDeleteMessage: deleteMessage, + onEditMessage: editMessage, + onA2UISubmit: handleA2UISubmit, + onWriteFile: handleWriteFile, + onFileClick: handleFileClick, + onArtifactClick: handleArtifactClick, + onOpenSubagentSession: handleOpenSubagentSession, + onPermissionResponse: handlePermissionResponseWithBrowserPreflight, + promoteActionRequestsToA2UI: Boolean(pendingPromotedA2UIActionRequest), + renderA2UIInline: shellChromeRuntime.shouldRenderInlineA2UI, + collapseCodeBlocks: shouldCollapseCodeBlocks, + shouldCollapseCodeBlock: shouldCollapseCodeBlockInChat, + onCodeBlockClick: handleCodeBlockClick, + }, + teamWorkspaceDock: { + enabled: showTeamWorkspaceBoard, + shouldShowFloatingInputOverlay: + shellChromeRuntime.shouldShowThemeWorkbenchFloatingInputOverlay, + layoutMode, + onActivateWorkbench: handleActivateTeamWorkbench, + withBottomOverlay: + isThemeWorkbench && + shellChromeRuntime.showChatLayout && + !shellChromeRuntime.shouldHideThemeWorkbenchInputForTheme, + surfaceProps: inputbarScene.teamWorkbenchSurfaceProps, + }, + workspaceAlert: { + workspacePathMissing, + workspaceHealthError, + }, + novelCanvas: { + visible: showNovelNavbarControls, + chapterListCollapsed: novelChapterListCollapsed, + onToggleChapterList: handleToggleNovelChapterList, + onAddChapter: handleAddNovelChapter, + onCloseCanvas: handleCloseCanvas, + }, + canvasWorkbenchLayout: { + artifacts: settledWorkbenchArtifacts, + canvasState: resolvedCanvasState, + taskFiles, + selectedFileId, + workspaceRoot: projectRootPath, + defaultPreview: canvasScene.canvasWorkbenchDefaultPreview, + loadFilePreview: handleHarnessLoadFilePreview, + onOpenPath: canvasScene.handleOpenCanvasWorkbenchPath, + onRevealPath: canvasScene.handleRevealCanvasWorkbenchPath, + renderPreview: canvasScene.renderCanvasWorkbenchPreview, + onLayoutModeChange: setCanvasWorkbenchLayoutMode, + }, + }); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSendRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceConversationSendRuntime.ts new file mode 100644 index 000000000..30e146114 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSendRuntime.ts @@ -0,0 +1,74 @@ +import { useCallback } from "react"; +import type { MessageImage } from "../types"; +import type { ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { WorkspaceHandleSend } from "./useWorkspaceSendActions"; +import { useWorkspaceStyleActionPresentation } from "./useWorkspaceStyleActionPresentation"; + +type StyleActionPresentationParams = Parameters< + typeof useWorkspaceStyleActionPresentation +>[0]; + +interface UseWorkspaceConversationSendRuntimeParams { + chatToolPreferences: ChatToolPreferences; + handleSend: WorkspaceHandleSend; + styleAction: Omit; +} + +export function useWorkspaceConversationSendRuntime({ + chatToolPreferences, + handleSend, + styleAction, +}: UseWorkspaceConversationSendRuntimeParams) { + const handleSendFromEmptyState = useCallback( + ( + text: string, + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", + images?: MessageImage[], + ) => { + void handleSend( + images || [], + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + text, + sendExecutionStrategy, + ); + }, + [ + chatToolPreferences.thinking, + chatToolPreferences.webSearch, + handleSend, + ], + ); + + const handleDispatchStylePrompt = useCallback( + (prompt: string, purpose: "style_rewrite" | "style_audit") => { + void handleSend( + [], + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + prompt, + undefined, + undefined, + { + skipThemeSkillPrefix: true, + purpose, + }, + ); + }, + [ + chatToolPreferences.thinking, + chatToolPreferences.webSearch, + handleSend, + ], + ); + + const { runtimeStyleControlBar } = useWorkspaceStyleActionPresentation({ + ...styleAction, + onDispatchPrompt: handleDispatchStylePrompt, + }); + + return { + handleSendFromEmptyState, + runtimeStyleControlBar, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx new file mode 100644 index 000000000..167588d5d --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx @@ -0,0 +1,167 @@ +import { useWorkspaceCanvasSceneRuntime } from "./useWorkspaceCanvasSceneRuntime"; +import { useWorkspaceContextHarnessRuntime } from "./useWorkspaceContextHarnessRuntime"; +import { useWorkspaceConversationSceneRuntime } from "./useWorkspaceConversationSceneRuntime"; +import { useWorkspaceConversationSendRuntime } from "./useWorkspaceConversationSendRuntime"; +import { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; +import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions"; +import { useWorkspaceShellChromeRuntime } from "./useWorkspaceShellChromeRuntime"; +import { useWorkspaceShellSceneRuntime } from "./useWorkspaceShellSceneRuntime"; +import { useWorkspaceTeamSessionRuntime } from "./useWorkspaceTeamSessionRuntime"; +import { useWorkspaceThemeWorkbenchShellRuntime } from "./useWorkspaceThemeWorkbenchShellRuntime"; + +type NavigationActions = ReturnType; +type InputbarScene = ReturnType; +type CanvasScene = ReturnType; +type ShellChromeRuntime = ReturnType; +type ThemeWorkbenchShellRuntime = ReturnType< + typeof useWorkspaceThemeWorkbenchShellRuntime +>; +type ContextHarnessRuntime = ReturnType; +type TeamSessionRuntime = ReturnType; +type ConversationSendRuntimeParams = Parameters< + typeof useWorkspaceConversationSendRuntime +>[0]; +type ConversationSceneRuntimeParams = Parameters< + typeof useWorkspaceConversationSceneRuntime +>[0]; +type ShellSceneRuntimeParams = Parameters[0]; +type ConversationSceneBridgeParams = Omit< + ConversationSceneRuntimeParams, + | "navigationActions" + | "inputbarScene" + | "canvasScene" + | "conversationSendRuntime" + | "shellChromeRuntime" + | "themeWorkbenchHarnessDialog" + | "generalCanvasContent" + | "projectId" + | "projectCharacters" + | "handleToggleHarnessPanel" + | "showTeamWorkspaceBoard" + | "currentImageWorkbenchActive" + | "projectRootPath" +>; + +interface UseWorkspaceConversationShellSceneRuntimeParams + extends ConversationSceneBridgeParams { + navigationActions: NavigationActions; + inputbarScene: InputbarScene; + canvasScene: CanvasScene; + shellChromeRuntime: ShellChromeRuntime; + themeWorkbenchShellRuntime: ThemeWorkbenchShellRuntime; + contextHarnessRuntime: Pick; + teamSessionRuntime: Pick; + currentImageWorkbenchState: { active: boolean }; + project: { rootPath?: string | null } | null; + projectId: string | null | undefined; + projectMemory: { + style_guide?: ConversationSendRuntimeParams["styleAction"]["projectStyleGuide"]; + characters?: ConversationSceneRuntimeParams["projectCharacters"]; + } | null; + handleSend: ConversationSendRuntimeParams["handleSend"]; + mappedTheme: ConversationSendRuntimeParams["styleAction"]["activeTheme"]; + runtimeStyleSelection: ConversationSendRuntimeParams["styleAction"]["selection"]; + setRuntimeStyleSelection: ConversationSendRuntimeParams["styleAction"]["onSelectionChange"]; + generalCanvasState: ConversationSendRuntimeParams["styleAction"]["generalCanvasState"]; + runtimeStylePrompt: ConversationSendRuntimeParams["styleAction"]["runtimeStylePrompt"]; + showSidebar: ShellSceneRuntimeParams["showSidebar"]; + topics: ShellSceneRuntimeParams["topics"]; + switchTopic: ShellSceneRuntimeParams["switchTopic"]; + handleResumeSidebarTask: ShellSceneRuntimeParams["handleResumeSidebarTask"]; + deleteTopic: ShellSceneRuntimeParams["deleteTopic"]; + renameTopic: ShellSceneRuntimeParams["renameTopic"]; + childSubagentSessions: ShellSceneRuntimeParams["childSubagentSessions"]; + subagentParentContext: ShellSceneRuntimeParams["subagentParentContext"]; + handleReturnToParentSession: ShellSceneRuntimeParams["handleReturnToParentSession"]; +} + +export function useWorkspaceConversationShellSceneRuntime({ + navigationActions, + inputbarScene, + canvasScene, + shellChromeRuntime, + themeWorkbenchShellRuntime, + contextHarnessRuntime, + teamSessionRuntime, + currentImageWorkbenchState, + project, + projectId, + projectMemory, + handleSend, + mappedTheme, + runtimeStyleSelection, + setRuntimeStyleSelection, + generalCanvasState, + runtimeStylePrompt, + showSidebar, + topics, + switchTopic, + handleResumeSidebarTask, + deleteTopic, + renameTopic, + childSubagentSessions, + subagentParentContext, + handleReturnToParentSession, + ...conversationScene +}: UseWorkspaceConversationShellSceneRuntimeParams) { + const conversationSendRuntime = useWorkspaceConversationSendRuntime({ + chatToolPreferences: conversationScene.chatToolPreferences, + handleSend, + styleAction: { + enabled: conversationScene.isContentCreationMode, + projectId, + activeTheme: mappedTheme, + projectStyleGuide: projectMemory?.style_guide, + selection: runtimeStyleSelection, + onSelectionChange: setRuntimeStyleSelection, + generalCanvasState, + resolvedCanvasState: conversationScene.resolvedCanvasState, + taskFiles: inputbarScene.visibleTaskFiles, + selectedFileId: inputbarScene.visibleSelectedFileId, + runtimeStylePrompt, + }, + }); + + const conversationSceneRuntime = useWorkspaceConversationSceneRuntime({ + ...conversationScene, + navigationActions, + inputbarScene, + canvasScene, + conversationSendRuntime, + shellChromeRuntime, + themeWorkbenchHarnessDialog: + themeWorkbenchShellRuntime.themeWorkbenchHarnessDialog, + generalCanvasContent: generalCanvasState.content, + projectId: projectId ?? null, + projectCharacters: projectMemory?.characters || [], + handleToggleHarnessPanel: + contextHarnessRuntime.handleToggleHarnessPanel, + showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard, + currentImageWorkbenchActive: currentImageWorkbenchState.active, + projectRootPath: project?.rootPath || null, + }); + + return useWorkspaceShellSceneRuntime({ + compactChrome: shellChromeRuntime.isWorkspaceCompactChrome, + isThemeWorkbench: conversationScene.isThemeWorkbench, + showChatPanel: conversationScene.showChatPanel, + showSidebar, + themeWorkbenchShellRuntime, + conversationSceneRuntime, + sessionId: conversationScene.sessionId, + topics, + handleBackHome: conversationScene.handleBackHome, + switchTopic, + handleResumeSidebarTask, + deleteTopic, + renameTopic, + displayMessages: conversationScene.displayMessages, + isSending: conversationScene.isSending, + pendingActionCount: conversationScene.pendingActions.length, + queuedTurnCount: conversationScene.queuedTurns.length, + childSubagentSessions, + subagentParentContext, + handleOpenSubagentSession: conversationScene.handleOpenSubagentSession, + handleReturnToParentSession, + }); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts new file mode 100644 index 000000000..e19d02785 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts @@ -0,0 +1,138 @@ +import { useEffect, useMemo } from "react"; +import { + buildLiveTaskSnapshot, +} from "../hooks/agentChatShared"; +import { buildLegacyQuestionnaireA2UI } from "../utils/legacyQuestionnaireA2UI"; +import { buildRuntimeTeamDispatchPreviewMessages } from "./runtimeTeamPreview"; +import type { RuntimeTeamDispatchPreviewSnapshot } from "./runtimeTeamPreview"; +import type { Message } from "../types"; + +interface UseWorkspaceDisplayMessagesRuntimeParams { + bootstrapDispatchPreviewMessages: Message[]; + browserPreflightMessages?: Message[] | null; + isSending: boolean; + messages: Message[]; + pendingActionCount: number; + queuedTurnCount: number; + runtimeTeamDispatchPreview: RuntimeTeamDispatchPreviewSnapshot | null; + sessionId?: string; + updateTopicSnapshot: ( + sessionId: string, + snapshot: ReturnType, + ) => void; + workspaceError: boolean; +} + +function isLegacyQuestionnaireSummaryMessage(message?: Message): boolean { + return ( + message?.role === "user" && message.content.trim().startsWith("我的选择:") + ); +} + +function collapseLegacyQuestionnaireMessages(messages: Message[]): Message[] { + let mutated = false; + const collapsedMessages = messages.map((message, index) => { + if (message.role !== "assistant") { + return message; + } + + if ((message.actionRequests || []).length > 0) { + return message; + } + + const legacyForm = buildLegacyQuestionnaireA2UI(message.content || ""); + if (!legacyForm) { + return message; + } + + const nextMessage = messages[index + 1]; + const isPendingQuestionnaire = index === messages.length - 1; + const hasSubmittedSummary = + isLegacyQuestionnaireSummaryMessage(nextMessage); + + if (!isPendingQuestionnaire && !hasSubmittedSummary) { + return message; + } + + mutated = true; + return { + ...message, + content: hasSubmittedSummary + ? "补充信息表单已提交。" + : "已整理为补充信息表单,请在输入区完成填写。", + }; + }); + + return mutated ? collapsedMessages : messages; +} + +export function useWorkspaceDisplayMessagesRuntime({ + bootstrapDispatchPreviewMessages, + browserPreflightMessages, + isSending, + messages, + pendingActionCount, + queuedTurnCount, + runtimeTeamDispatchPreview, + sessionId, + updateTopicSnapshot, + workspaceError, +}: UseWorkspaceDisplayMessagesRuntimeParams) { + const displayMessages = useMemo(() => { + const collapsedMessages = collapseLegacyQuestionnaireMessages(messages); + const runtimeTeamDispatchPreviewMessages = runtimeTeamDispatchPreview + ? buildRuntimeTeamDispatchPreviewMessages(runtimeTeamDispatchPreview) + : []; + + if (browserPreflightMessages) { + return [...collapsedMessages, ...browserPreflightMessages]; + } + + if (runtimeTeamDispatchPreviewMessages.length > 0) { + return [...collapsedMessages, ...runtimeTeamDispatchPreviewMessages]; + } + + if ( + collapsedMessages.length === 0 && + bootstrapDispatchPreviewMessages.length > 0 + ) { + return bootstrapDispatchPreviewMessages; + } + + return collapsedMessages; + }, [ + bootstrapDispatchPreviewMessages, + browserPreflightMessages, + messages, + runtimeTeamDispatchPreview, + ]); + + useEffect(() => { + if (!sessionId) { + return; + } + + updateTopicSnapshot( + sessionId, + buildLiveTaskSnapshot({ + messages: displayMessages, + isSending, + pendingActionCount, + queuedTurnCount, + workspaceError, + }), + ); + }, [ + displayMessages, + isSending, + pendingActionCount, + queuedTurnCount, + sessionId, + updateTopicSnapshot, + workspaceError, + ]); + + return { + displayMessages, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceGeneralResourceSync.ts b/src/components/agent/chat/workspace/useWorkspaceGeneralResourceSync.ts new file mode 100644 index 000000000..53d92b559 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceGeneralResourceSync.ts @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useRef } from "react"; +import { resolveFilePath as resolveSessionFilePath } from "@/lib/api/session-files"; +import { listMaterials, uploadMaterial } from "@/lib/api/materials"; +import { setStoredResourceProjectId } from "@/lib/resourceProjectSelection"; +import { normalizeProjectId } from "../utils/topicProjectResolution"; +import { + buildGeneralChatResourceDescription, + buildGeneralChatResourceHash, + buildGeneralChatResourceTags, + extractGeneralChatResourceHash, + inferGeneralChatResourceMaterialType, +} from "../utils/generalResourceSync"; +import { + extractFileNameFromPath, + resolveAbsoluteWorkspacePath, +} from "./workspacePath"; + +interface UseWorkspaceGeneralResourceSyncParams { + activeTheme: string; + projectId?: string; + sessionId?: string; + projectRootPath?: string | null; +} + +export function useWorkspaceGeneralResourceSync({ + activeTheme, + projectId, + sessionId, + projectRootPath, +}: UseWorkspaceGeneralResourceSyncParams) { + const generalResourceHashesRef = useRef>>(new Map()); + const generalResourceSyncInFlightRef = useRef>(new Set()); + + const syncResourceProjectSelection = useCallback( + (targetProjectId: string | null | undefined) => { + const normalizedProjectId = normalizeProjectId(targetProjectId); + if (!normalizedProjectId) { + return; + } + + setStoredResourceProjectId(normalizedProjectId, { + source: "general-chat", + emitEvent: true, + }); + }, + [], + ); + + const ensureGeneralResourceHashes = useCallback(async (targetProjectId: string) => { + const existingHashes = generalResourceHashesRef.current.get(targetProjectId); + if (existingHashes) { + return existingHashes; + } + + const nextHashes = new Set(); + + try { + const materials = await listMaterials(targetProjectId); + materials.forEach((material) => { + const hash = extractGeneralChatResourceHash(material); + if (hash) { + nextHashes.add(hash); + } + }); + } catch (error) { + console.warn("[AgentChatPage] 读取资源去重缓存失败:", error); + } + + generalResourceHashesRef.current.set(targetProjectId, nextHashes); + return nextHashes; + }, []); + + const resolveGeneralArtifactSyncPath = useCallback( + async (rawFilePath: string): Promise => { + const normalizedFilePath = rawFilePath.trim(); + if (!normalizedFilePath) { + return null; + } + + if ( + normalizedFilePath.startsWith("/") || + normalizedFilePath.startsWith("~/") || + normalizedFilePath.startsWith("\\\\") || + /^[A-Za-z]:[\\/]/.test(normalizedFilePath) + ) { + return normalizedFilePath; + } + + if (sessionId) { + try { + return await resolveSessionFilePath(sessionId, normalizedFilePath); + } catch (error) { + console.warn("[AgentChatPage] 解析会话文件路径失败:", error); + } + } + + return ( + resolveAbsoluteWorkspacePath(projectRootPath, normalizedFilePath) || null + ); + }, + [projectRootPath, sessionId], + ); + + const syncGeneralArtifactToResource = useCallback( + async (input: { rawFilePath: string; preferredName?: string }) => { + if (activeTheme !== "general") { + return; + } + + const normalizedProjectId = normalizeProjectId(projectId); + const normalizedRawFilePath = input.rawFilePath.trim(); + if (!normalizedProjectId || !normalizedRawFilePath) { + return; + } + + const materialType = inferGeneralChatResourceMaterialType( + normalizedRawFilePath, + ); + if (!materialType) { + return; + } + + const resolvedFilePath = await resolveGeneralArtifactSyncPath( + normalizedRawFilePath, + ); + const normalizedResolvedFilePath = resolvedFilePath?.trim(); + if (!normalizedResolvedFilePath) { + return; + } + + const pathHash = buildGeneralChatResourceHash(normalizedResolvedFilePath); + const dedupeKey = `${normalizedProjectId}:${pathHash}`; + if (generalResourceSyncInFlightRef.current.has(dedupeKey)) { + return; + } + + const knownHashes = await ensureGeneralResourceHashes(normalizedProjectId); + if (knownHashes.has(pathHash)) { + return; + } + + generalResourceSyncInFlightRef.current.add(dedupeKey); + try { + await uploadMaterial({ + projectId: normalizedProjectId, + name: + input.preferredName?.trim() || + extractFileNameFromPath(normalizedResolvedFilePath), + type: materialType, + filePath: normalizedResolvedFilePath, + tags: buildGeneralChatResourceTags( + normalizedResolvedFilePath, + sessionId, + ), + description: buildGeneralChatResourceDescription(sessionId), + }); + + knownHashes.add(pathHash); + syncResourceProjectSelection(normalizedProjectId); + } catch (error) { + console.warn("[AgentChatPage] 自动补录资源失败:", error); + } finally { + generalResourceSyncInFlightRef.current.delete(dedupeKey); + } + }, + [ + activeTheme, + ensureGeneralResourceHashes, + projectId, + resolveGeneralArtifactSyncPath, + sessionId, + syncResourceProjectSelection, + ], + ); + + useEffect(() => { + if (activeTheme !== "general") { + return; + } + + syncResourceProjectSelection(projectId); + }, [activeTheme, projectId, syncResourceProjectSelection]); + + return { + syncGeneralArtifactToResource, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.ts new file mode 100644 index 000000000..527da6b06 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + getAgentRuntimeToolInventory, + type AgentRuntimeToolInventory, +} from "@/lib/api/agentRuntime"; + +interface HarnessRunSummary { + title?: string | null; + started_at?: number | null; + artifact_paths?: string[] | null; +} + +interface HarnessTerminalSummary extends HarnessRunSummary { + finished_at?: number | null; +} + +interface HarnessBackendRunStateSummary { + updated_at?: number | null; + latest_terminal?: HarnessTerminalSummary | null; +} + +interface UseWorkspaceHarnessInventoryRuntimeParams { + chatMode: "agent" | "general" | "creator"; + mappedTheme: string; + harnessPanelVisible: boolean; + harnessRequestMetadata: Record; + isThemeWorkbench: boolean; + themeWorkbenchRunState: "idle" | "auto_running" | "await_user_decision"; + currentGate: { + title: string; + description: string; + }; + themeWorkbenchBackendRunState: HarnessBackendRunStateSummary | null; + themeWorkbenchActiveQueueItem: HarnessRunSummary | null | undefined; + harnessPendingCount: number; +} + +export function useWorkspaceHarnessInventoryRuntime({ + chatMode, + mappedTheme, + harnessPanelVisible, + harnessRequestMetadata, + isThemeWorkbench, + themeWorkbenchRunState, + currentGate, + themeWorkbenchBackendRunState, + themeWorkbenchActiveQueueItem, + harnessPendingCount, +}: UseWorkspaceHarnessInventoryRuntimeParams) { + const [toolInventory, setToolInventory] = + useState(null); + const [toolInventoryLoading, setToolInventoryLoading] = useState(false); + const [toolInventoryError, setToolInventoryError] = useState( + null, + ); + const toolInventoryRequestIdRef = useRef(0); + + const refreshToolInventory = useCallback(async () => { + const requestId = toolInventoryRequestIdRef.current + 1; + toolInventoryRequestIdRef.current = requestId; + setToolInventoryLoading(true); + setToolInventoryError(null); + + try { + const nextInventory = await getAgentRuntimeToolInventory({ + caller: "assistant", + creator: chatMode === "creator", + browserAssist: mappedTheme === "general", + metadata: { + harness: harnessRequestMetadata, + }, + }); + + if (toolInventoryRequestIdRef.current !== requestId) { + return; + } + + setToolInventory(nextInventory); + } catch (error) { + if (toolInventoryRequestIdRef.current !== requestId) { + return; + } + + setToolInventoryError( + error instanceof Error ? error.message : "读取工具库存失败", + ); + } finally { + if (toolInventoryRequestIdRef.current === requestId) { + setToolInventoryLoading(false); + } + } + }, [chatMode, harnessRequestMetadata, mappedTheme]); + + useEffect(() => { + if (!harnessPanelVisible) { + return; + } + + void refreshToolInventory(); + }, [harnessPanelVisible, refreshToolInventory]); + + const socialMediaHarnessSummary = useMemo(() => { + if (!isThemeWorkbench || mappedTheme !== "social-media") { + return null; + } + + const latestTerminal = + themeWorkbenchBackendRunState?.latest_terminal ?? null; + const activeRun = themeWorkbenchActiveQueueItem ?? latestTerminal; + const artifactPaths = + Array.isArray(themeWorkbenchActiveQueueItem?.artifact_paths) && + themeWorkbenchActiveQueueItem.artifact_paths.length > 0 + ? themeWorkbenchActiveQueueItem.artifact_paths + : Array.isArray(latestTerminal?.artifact_paths) && + latestTerminal.artifact_paths.length > 0 + ? latestTerminal.artifact_paths + : []; + + return { + runState: themeWorkbenchRunState, + stageTitle: currentGate.title, + stageDescription: currentGate.description, + runTitle: activeRun?.title || null, + artifactCount: artifactPaths.length, + updatedAt: + themeWorkbenchBackendRunState?.updated_at || + latestTerminal?.finished_at || + latestTerminal?.started_at || + themeWorkbenchActiveQueueItem?.started_at || + null, + pendingCount: harnessPendingCount, + }; + }, [ + currentGate.description, + currentGate.title, + harnessPendingCount, + isThemeWorkbench, + mappedTheme, + themeWorkbenchActiveQueueItem, + themeWorkbenchBackendRunState?.latest_terminal, + themeWorkbenchBackendRunState?.updated_at, + themeWorkbenchRunState, + ]); + + return { + toolInventory, + toolInventoryLoading, + toolInventoryError, + refreshToolInventory, + socialMediaHarnessSummary, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts new file mode 100644 index 000000000..10846ece6 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts @@ -0,0 +1,530 @@ +import { + useCallback, + useMemo, + type Dispatch, + type SetStateAction, +} from "react"; +import { toast } from "sonner"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { LayoutMode } from "@/components/content-creator/types"; +import { IMAGE_GENERATION_CANCELED_MESSAGE } from "@/components/image-gen/useImageGen"; +import type { GeneratedImage } from "@/components/image-gen/types"; +import type { Character } from "@/lib/api/memory"; +import { emitCanvasImageInsertRequest } from "@/lib/canvasImageInsertBus"; +import type { Message, MessageImage } from "../types"; +import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; +import { + buildImageWorkbenchCompletionMessage, + buildImageWorkbenchDispatchMessages, + collapseWhitespace, + resolveImageWorkbenchActionLabel, + type ImageWorkbenchApplyTarget, + type SessionImageWorkbenchState, +} from "./imageWorkbenchHelpers"; + +interface SaveImagesToResourceResult { + saved: number; + skipped: number; + errors: string[]; +} + +interface UseWorkspaceImageWorkbenchActionRuntimeParams { + appendLocalDispatchMessages: (messages: Message[]) => void; + cancelImageWorkbenchGeneration: () => void; + contentId?: string | null; + currentImageWorkbenchState: SessionImageWorkbenchState; + imageWorkbenchSelectedSize: string; + imageWorkbenchSessionKey: string; + projectId?: string; + runImageWorkbenchGeneration: ( + prompt: string, + options: { + imageCount?: number; + referenceImages?: string[]; + size?: string; + }, + ) => Promise; + saveImageWorkbenchImagesToResource: ( + imageIds: string[], + targetProjectId: string, + ) => Promise; + setCanvasState: Dispatch>; + setInput: Dispatch>; + setLayoutMode: Dispatch>; + setMentionedCharacters: Dispatch>; + updateCurrentImageWorkbenchState: ( + updater: ( + current: SessionImageWorkbenchState, + ) => SessionImageWorkbenchState, + ) => void; +} + +export function useWorkspaceImageWorkbenchActionRuntime({ + appendLocalDispatchMessages, + cancelImageWorkbenchGeneration, + contentId, + currentImageWorkbenchState, + imageWorkbenchSelectedSize, + imageWorkbenchSessionKey, + projectId, + runImageWorkbenchGeneration, + saveImageWorkbenchImagesToResource, + setCanvasState, + setInput, + setLayoutMode, + setMentionedCharacters, + updateCurrentImageWorkbenchState, +}: UseWorkspaceImageWorkbenchActionRuntimeParams) { + const handleImageWorkbenchViewportChange = useCallback( + (viewport: SessionImageWorkbenchState["viewport"]) => { + updateCurrentImageWorkbenchState((current) => ({ + ...current, + active: true, + viewport, + })); + }, + [updateCurrentImageWorkbenchState], + ); + + const handleSelectImageWorkbenchOutput = useCallback( + (outputId: string) => { + updateCurrentImageWorkbenchState((current) => ({ + ...current, + active: true, + selectedOutputId: outputId, + })); + }, + [updateCurrentImageWorkbenchState], + ); + + const handleSeedImageWorkbenchFollowUp = useCallback( + (command: string) => { + setInput(command); + toast.info("已在输入框填入配图命令"); + }, + [setInput], + ); + + const handleOpenImageWorkbenchAsset = useCallback((url: string) => { + if (!url.trim()) { + return; + } + + window.open(url, "_blank", "noopener,noreferrer"); + }, []); + + const handleStopImageWorkbenchGeneration = useCallback(() => { + cancelImageWorkbenchGeneration(); + updateCurrentImageWorkbenchState((current) => ({ + ...current, + active: true, + tasks: current.tasks.map((task) => + task.status === "routing" || task.status === "running" + ? { + ...task, + status: "error", + failureMessage: IMAGE_GENERATION_CANCELED_MESSAGE, + } + : task, + ), + })); + toast.info(IMAGE_GENERATION_CANCELED_MESSAGE); + }, [cancelImageWorkbenchGeneration, updateCurrentImageWorkbenchState]); + + const handleSaveSelectedImageWorkbenchOutput = useCallback(async () => { + const selectedOutput = currentImageWorkbenchState.outputs.find( + (item) => item.id === currentImageWorkbenchState.selectedOutputId, + ); + if (!selectedOutput) { + toast.info("请先选择一张图片"); + return; + } + if (!projectId) { + toast.error("请先选择项目后再保存到素材库"); + return; + } + + const result = await saveImageWorkbenchImagesToResource( + [selectedOutput.hookImageId], + projectId, + ); + if (result.saved > 0) { + updateCurrentImageWorkbenchState((current) => ({ + ...current, + outputs: current.outputs.map((item) => + item.id === selectedOutput.id + ? { ...item, resourceSaved: true } + : item, + ), + })); + toast.success("已保存到素材库"); + return; + } + + if (result.skipped > 0) { + toast.info("该图片已在当前素材库中"); + return; + } + + toast.error(result.errors[0] || "保存到素材库失败"); + }, [ + currentImageWorkbenchState.outputs, + currentImageWorkbenchState.selectedOutputId, + projectId, + saveImageWorkbenchImagesToResource, + updateCurrentImageWorkbenchState, + ]); + + const handleApplySelectedImageWorkbenchOutput = useCallback(() => { + const selectedOutput = currentImageWorkbenchState.outputs.find( + (item) => item.id === currentImageWorkbenchState.selectedOutputId, + ); + if (!selectedOutput) { + toast.info("请先选择一张图片"); + return; + } + + const applyTarget = selectedOutput.applyTarget; + if (!applyTarget) { + toast.info("当前结果还没有绑定落位目标"); + return; + } + + if (applyTarget.kind === "document-cover") { + let replaced = false; + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + return previous; + } + + const updatedContent = previous.content + .split(applyTarget.placeholder) + .join(selectedOutput.url); + if (updatedContent === previous.content) { + return previous; + } + + replaced = true; + return { + ...previous, + content: updatedContent, + }; + }); + + if (!replaced) { + toast.error("未找到待替换的封面占位"); + return; + } + + updateCurrentImageWorkbenchState((current) => ({ + ...current, + active: false, + })); + setLayoutMode("chat-canvas"); + toast.success(applyTarget.successLabel); + return; + } + + emitCanvasImageInsertRequest({ + projectId: applyTarget.projectId ?? projectId ?? null, + contentId: applyTarget.contentId ?? contentId ?? null, + canvasType: applyTarget.canvasType, + anchorHint: applyTarget.anchorHint, + source: "manual", + image: { + id: selectedOutput.id, + previewUrl: selectedOutput.url, + contentUrl: selectedOutput.url, + title: collapseWhitespace(selectedOutput.prompt) || selectedOutput.refId, + provider: selectedOutput.providerName, + }, + }); + + updateCurrentImageWorkbenchState((current) => ({ + ...current, + active: false, + })); + setLayoutMode("chat-canvas"); + toast.info(applyTarget.dispatchLabel); + }, [ + contentId, + currentImageWorkbenchState.outputs, + currentImageWorkbenchState.selectedOutputId, + projectId, + setCanvasState, + setLayoutMode, + updateCurrentImageWorkbenchState, + ]); + + const imageWorkbenchPrimaryActionLabel = useMemo(() => { + const selectedOutput = currentImageWorkbenchState.outputs.find( + (item) => item.id === currentImageWorkbenchState.selectedOutputId, + ); + return resolveImageWorkbenchActionLabel(selectedOutput?.applyTarget); + }, [ + currentImageWorkbenchState.outputs, + currentImageWorkbenchState.selectedOutputId, + ]); + + const handleImageWorkbenchCommand = useCallback( + async (params: { + rawText: string; + parsedCommand: NonNullable>; + images: MessageImage[]; + applyTarget?: ImageWorkbenchApplyTarget | null; + }): Promise => { + if (!projectId) { + toast.error("请先选择项目后再开始配图"); + return false; + } + + const { rawText, parsedCommand, images } = params; + const targetOutput = parsedCommand.targetRef + ? currentImageWorkbenchState.outputs.find( + (item) => + item.refId.toLowerCase() === + parsedCommand.targetRef?.toLowerCase(), + ) || null + : null; + const effectiveApplyTarget = + params.applyTarget ?? targetOutput?.applyTarget ?? null; + + if ( + (parsedCommand.mode === "edit" || parsedCommand.mode === "variation") && + !targetOutput && + images.length === 0 + ) { + toast.error("编辑或变体任务需要选择已有图片,或先附加参考图"); + return false; + } + + const effectivePrompt = + parsedCommand.prompt.trim() || + (parsedCommand.mode === "generate" + ? "" + : "请基于参考图继续优化画面表现"); + if (!effectivePrompt) { + toast.error("请补充清晰的配图描述后再提交"); + return false; + } + + const taskId = `image-task-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`; + const referenceImages = [ + ...(targetOutput?.url ? [targetOutput.url] : []), + ...images.map((image) => image.data).filter(Boolean), + ]; + const now = Date.now(); + + updateCurrentImageWorkbenchState((current) => ({ + ...current, + active: true, + tasks: [ + { + sessionId: imageWorkbenchSessionKey, + id: taskId, + mode: parsedCommand.mode, + status: "routing", + prompt: effectivePrompt, + rawText, + expectedCount: parsedCommand.count, + outputIds: [], + targetOutputId: targetOutput?.id ?? null, + createdAt: now, + hookImageIds: [], + applyTarget: effectiveApplyTarget, + }, + ...current.tasks, + ], + selectedOutputId: targetOutput?.id ?? current.selectedOutputId, + })); + + appendLocalDispatchMessages( + buildImageWorkbenchDispatchMessages({ + rawText, + images, + taskId, + prompt: effectivePrompt, + mode: parsedCommand.mode, + count: parsedCommand.count, + }), + ); + + setLayoutMode("chat-canvas"); + setInput(""); + setMentionedCharacters([]); + + updateCurrentImageWorkbenchState((current) => ({ + ...current, + active: true, + tasks: current.tasks.map((task) => + task.id === taskId ? { ...task, status: "running" } : task, + ), + })); + + try { + const generatedImages = await runImageWorkbenchGeneration( + effectivePrompt, + { + imageCount: parsedCommand.count, + referenceImages, + size: parsedCommand.size || imageWorkbenchSelectedSize, + }, + ); + const hookImageIds = generatedImages.map((image) => image.id); + + let successCount = 0; + updateCurrentImageWorkbenchState((current) => { + let nextOutputIndex = current.nextOutputIndex; + const nextOutputs = [...current.outputs]; + const createdOutputIds: string[] = []; + + for (const image of generatedImages) { + if (image.status !== "complete" || !image.url) { + continue; + } + + successCount += 1; + const outputId = `${taskId}:${image.id}`; + const refId = `img-${nextOutputIndex}`; + nextOutputIndex += 1; + nextOutputs.unshift({ + id: outputId, + taskId, + hookImageId: image.id, + refId, + url: image.url, + prompt: image.prompt, + createdAt: image.createdAt, + providerName: image.providerName, + modelName: image.model, + size: image.size, + parentOutputId: targetOutput?.refId ?? null, + resourceSaved: Boolean(image.resourceMaterialId), + applyTarget: effectiveApplyTarget, + }); + createdOutputIds.push(outputId); + } + + const failedCount = Math.max(0, parsedCommand.count - successCount); + const nextStatus = + successCount === 0 + ? "error" + : failedCount > 0 + ? "partial" + : "complete"; + + return { + ...current, + active: true, + outputs: nextOutputs, + selectedOutputId: + createdOutputIds[0] || + current.selectedOutputId || + targetOutput?.id || + null, + nextOutputIndex, + tasks: current.tasks.map((task) => + task.id === taskId + ? { + ...task, + status: nextStatus, + outputIds: createdOutputIds, + hookImageIds, + failureMessage: + successCount === 0 + ? "图片服务未返回可用结果" + : failedCount > 0 + ? `有 ${failedCount} 张结果生成失败` + : undefined, + } + : task, + ), + }; + }); + + appendLocalDispatchMessages([ + buildImageWorkbenchCompletionMessage({ + taskId, + successCount, + failedCount: Math.max(0, parsedCommand.count - successCount), + mode: parsedCommand.mode, + }), + ]); + + if (successCount === 0) { + toast.error("图片任务失败,未生成可用结果"); + } else if (parsedCommand.count - successCount > 0) { + toast.warning( + `图片任务已完成 ${successCount} 张,失败 ${Math.max( + 0, + parsedCommand.count - successCount, + )} 张`, + ); + } else { + toast.success(`图片任务已完成,共生成 ${successCount} 张`); + } + return true; + } catch (error) { + const failureMessage = + error instanceof Error ? error.message : "图片任务执行失败"; + const canceled = failureMessage === IMAGE_GENERATION_CANCELED_MESSAGE; + updateCurrentImageWorkbenchState((current) => ({ + ...current, + active: true, + tasks: current.tasks.map((task) => + task.id === taskId + ? { + ...task, + status: "error", + failureMessage, + } + : task, + ), + })); + if (!canceled) { + appendLocalDispatchMessages([ + { + id: `image-workbench:${taskId}:failed`, + role: "assistant", + content: `当前图片任务失败:${failureMessage}`, + timestamp: new Date(), + runtimeStatus: { + phase: "failed", + title: "图片任务失败", + detail: failureMessage, + }, + }, + ]); + toast.error(failureMessage); + } + return true; + } + }, + [ + appendLocalDispatchMessages, + currentImageWorkbenchState.outputs, + imageWorkbenchSelectedSize, + imageWorkbenchSessionKey, + projectId, + runImageWorkbenchGeneration, + setInput, + setLayoutMode, + setMentionedCharacters, + updateCurrentImageWorkbenchState, + ], + ); + + return { + handleApplySelectedImageWorkbenchOutput, + handleImageWorkbenchCommand, + handleImageWorkbenchViewportChange, + handleOpenImageWorkbenchAsset, + handleSaveSelectedImageWorkbenchOutput, + handleSeedImageWorkbenchFollowUp, + handleSelectImageWorkbenchOutput, + handleStopImageWorkbenchGeneration, + imageWorkbenchPrimaryActionLabel, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchEventRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchEventRuntime.ts new file mode 100644 index 000000000..7ecacc2ed --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchEventRuntime.ts @@ -0,0 +1,236 @@ +import { useEffect, type Dispatch, type SetStateAction } from "react"; +import { toast } from "sonner"; +import { + COVER_IMAGE_REPLACED_EVENT, + COVER_IMAGE_WORKBENCH_REQUEST_EVENT, + type CoverImageReplacedDetail, + type CoverImageWorkbenchRequestDetail, +} from "@/components/content-creator/canvas/document/platforms/CoverImagePlaceholder"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { LayoutMode } from "@/components/content-creator/types"; +import { + findImageProviderForSelection, + getImageModelIdsForProvider, + pickImageModelBySelection, + type ImageModelPreset, +} from "@/lib/imageGeneration"; +import { + onImageWorkbenchFocus, + onImageWorkbenchRequest, + type ImageWorkbenchExternalRequestDetail, + type ImageWorkbenchFocusDetail, +} from "@/lib/imageWorkbenchEvents"; +import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; +import type { MessageImage } from "../types"; +import { + buildImageWorkbenchCommandText, + resolveCoverAspectRatio, + resolveScopedImageWorkbenchApplyTarget, + type ImageWorkbenchApplyTarget, + type SessionImageWorkbenchState, +} from "./imageWorkbenchHelpers"; + +interface ImageWorkbenchProviderSummary { + id: string; + type: string; + custom_models?: string[]; + api_host?: string; +} + +interface UseWorkspaceImageWorkbenchEventRuntimeParams { + canvasState: CanvasStateUnion | null; + projectId?: string; + contentId?: string | null; + imageWorkbenchProviders: ImageWorkbenchProviderSummary[]; + setImageWorkbenchSelectedProviderId: Dispatch>; + setImageWorkbenchSelectedModelId: Dispatch>; + setImageWorkbenchSelectedSize: Dispatch>; + setLayoutMode: Dispatch>; + setCanvasState: Dispatch>; + updateCurrentImageWorkbenchState: ( + updater: ( + current: SessionImageWorkbenchState, + ) => SessionImageWorkbenchState, + ) => void; + handleImageWorkbenchCommand: (params: { + rawText: string; + parsedCommand: NonNullable>; + images: MessageImage[]; + applyTarget?: ImageWorkbenchApplyTarget | null; + }) => Promise; +} + +export function useWorkspaceImageWorkbenchEventRuntime({ + canvasState, + projectId, + contentId, + imageWorkbenchProviders, + setImageWorkbenchSelectedProviderId, + setImageWorkbenchSelectedModelId, + setImageWorkbenchSelectedSize, + setLayoutMode, + setCanvasState, + updateCurrentImageWorkbenchState, + handleImageWorkbenchCommand, +}: UseWorkspaceImageWorkbenchEventRuntimeParams) { + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent) + .detail; + if (!detail?.prompt?.trim()) { + return; + } + + const rawText = buildImageWorkbenchCommandText(detail.prompt, { + aspectRatio: + canvasState?.type === "document" + ? resolveCoverAspectRatio(canvasState.platform) + : resolveCoverAspectRatio(), + }); + const parsedCommand = parseImageWorkbenchCommand(rawText); + if (!parsedCommand) { + toast.error("封面任务初始化失败"); + return; + } + + void handleImageWorkbenchCommand({ + rawText, + parsedCommand, + images: [], + applyTarget: { + kind: "document-cover", + placeholder: detail.placeholder, + actionLabel: "设为封面", + successLabel: "已设为封面", + }, + }); + }; + + window.addEventListener(COVER_IMAGE_WORKBENCH_REQUEST_EVENT, handler); + return () => + window.removeEventListener(COVER_IMAGE_WORKBENCH_REQUEST_EVENT, handler); + }, [canvasState, handleImageWorkbenchCommand]); + + useEffect(() => { + return onImageWorkbenchRequest( + (detail: ImageWorkbenchExternalRequestDetail) => { + if (detail.projectId && detail.projectId !== (projectId ?? null)) { + return; + } + if (detail.contentId && detail.contentId !== (contentId ?? null)) { + return; + } + if (!detail.prompt.trim()) { + return; + } + + if (detail.modelPreset) { + const preferredProvider = findImageProviderForSelection( + imageWorkbenchProviders, + detail.modelPreset as ImageModelPreset, + ); + if (preferredProvider) { + setImageWorkbenchSelectedProviderId(preferredProvider.id); + const nextModel = pickImageModelBySelection( + getImageModelIdsForProvider( + preferredProvider.id, + preferredProvider.type, + preferredProvider.custom_models, + preferredProvider.api_host, + ), + detail.modelPreset as ImageModelPreset, + ); + if (nextModel) { + setImageWorkbenchSelectedModelId(nextModel); + } + } + } + + const rawText = buildImageWorkbenchCommandText(detail.prompt, { + aspectRatio: detail.aspectRatio, + count: detail.count, + }); + const parsedCommand = parseImageWorkbenchCommand(rawText); + if (!parsedCommand) { + toast.error("图片任务初始化失败"); + return; + } + if (parsedCommand.size) { + setImageWorkbenchSelectedSize(parsedCommand.size); + } + + void handleImageWorkbenchCommand({ + rawText, + parsedCommand, + images: [], + applyTarget: resolveScopedImageWorkbenchApplyTarget({ + canvasState, + projectId: projectId ?? null, + contentId: contentId ?? null, + requestedTarget: detail.target, + }), + }); + }, + ); + }, [ + canvasState, + contentId, + handleImageWorkbenchCommand, + imageWorkbenchProviders, + projectId, + setImageWorkbenchSelectedModelId, + setImageWorkbenchSelectedProviderId, + setImageWorkbenchSelectedSize, + ]); + + useEffect(() => { + return onImageWorkbenchFocus((detail: ImageWorkbenchFocusDetail) => { + if (detail.projectId && detail.projectId !== (projectId ?? null)) { + return; + } + if (detail.contentId && detail.contentId !== (contentId ?? null)) { + return; + } + + updateCurrentImageWorkbenchState((current) => { + if (current.tasks.length === 0 && current.outputs.length === 0) { + return current; + } + + return { + ...current, + active: true, + }; + }); + setLayoutMode("chat-canvas"); + }); + }, [contentId, projectId, setLayoutMode, updateCurrentImageWorkbenchState]); + + useEffect(() => { + const handler = (event: Event) => { + const { placeholder, imageUrl } = ( + event as CustomEvent + ).detail; + if (!placeholder || !imageUrl) { + return; + } + + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + return previous; + } + + const updatedContent = previous.content.split(placeholder).join(imageUrl); + if (updatedContent === previous.content) { + return previous; + } + + return { ...previous, content: updatedContent }; + }); + }; + + window.addEventListener(COVER_IMAGE_REPLACED_EVENT, handler); + return () => + window.removeEventListener(COVER_IMAGE_REPLACED_EVENT, handler); + }, [setCanvasState]); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarPresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarPresentation.tsx new file mode 100644 index 000000000..fc72ccfec --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarPresentation.tsx @@ -0,0 +1,170 @@ +import { useMemo, type ComponentProps, type ReactNode } from "react"; +import { ThemeWorkbenchEntryPromptAccessory } from "../components/ThemeWorkbenchEntryPromptAccessory"; +import { GeneralWorkbenchDialogSection } from "./WorkspaceHarnessDialogs"; +import { WorkspaceInputbar } from "./WorkspaceInputbar"; +import { + buildFloatingTeamWorkspaceDockProps, + buildWorkspaceInputbarProps, +} from "./inputbarPresentation"; +import type { TeamWorkbenchSurfaceProps } from "./teamWorkbenchPresentation"; + +type WorkspaceInputbarBuilderParams = Parameters< + typeof buildWorkspaceInputbarProps +>[0]; +type FloatingTeamWorkspaceDockParams = Omit< + Parameters[0], + "surfaceProps" +>; + +interface WorkspaceTeamWorkbenchSurfaceParams { + shellVisible: TeamWorkbenchSurfaceProps["shellVisible"]; + currentSessionId: TeamWorkbenchSurfaceProps["currentSessionId"]; + currentSessionName: TeamWorkbenchSurfaceProps["currentSessionName"]; + currentSessionRuntimeStatus: TeamWorkbenchSurfaceProps["currentSessionRuntimeStatus"]; + currentSessionLatestTurnStatus: TeamWorkbenchSurfaceProps["currentSessionLatestTurnStatus"]; + currentSessionQueuedTurnCount: TeamWorkbenchSurfaceProps["currentSessionQueuedTurnCount"]; + childSubagentSessions: TeamWorkbenchSurfaceProps["childSubagentSessions"]; + subagentParentContext: TeamWorkbenchSurfaceProps["subagentParentContext"]; + liveRuntimeBySessionId: TeamWorkbenchSurfaceProps["liveRuntimeBySessionId"]; + liveActivityBySessionId: TeamWorkbenchSurfaceProps["liveActivityBySessionId"]; + activityRefreshVersionBySessionId: TeamWorkbenchSurfaceProps["activityRefreshVersionBySessionId"]; + onSendSubagentInput: TeamWorkbenchSurfaceProps["onSendSubagentInput"]; + onWaitSubagentSession: TeamWorkbenchSurfaceProps["onWaitSubagentSession"]; + onWaitActiveTeamSessions: TeamWorkbenchSurfaceProps["onWaitActiveTeamSessions"]; + onCloseCompletedTeamSessions: TeamWorkbenchSurfaceProps["onCloseCompletedTeamSessions"]; + onCloseSubagentSession: TeamWorkbenchSurfaceProps["onCloseSubagentSession"]; + onResumeSubagentSession: TeamWorkbenchSurfaceProps["onResumeSubagentSession"]; + onOpenSubagentSession: TeamWorkbenchSurfaceProps["onOpenSubagentSession"]; + onReturnToParentSession: TeamWorkbenchSurfaceProps["onReturnToParentSession"]; + teamWaitSummary: TeamWorkbenchSurfaceProps["teamWaitSummary"]; + teamControlSummary: TeamWorkbenchSurfaceProps["teamControlSummary"]; + selectedTeamLabel: TeamWorkbenchSurfaceProps["selectedTeamLabel"]; + selectedTeamSummary: TeamWorkbenchSurfaceProps["selectedTeamSummary"]; + selectedTeamRoles: TeamWorkbenchSurfaceProps["selectedTeamRoles"]; + runtimeTeamState: TeamWorkbenchSurfaceProps["runtimeTeamState"]; +} + +interface UseWorkspaceInputbarPresentationParams { + teamWorkbench: WorkspaceTeamWorkbenchSurfaceParams; + inputbar: WorkspaceInputbarBuilderParams; + floatingTeamWorkspaceDock: FloatingTeamWorkspaceDockParams; + themeWorkbenchEntryPrompt: + | ComponentProps["prompt"] + | null; + onRestartThemeWorkbenchEntryPrompt: () => void; + onContinueThemeWorkbenchEntryPrompt: () => Promise | void; + generalWorkbenchDialog: ComponentProps; +} + +interface WorkspaceInputbarPresentationResult { + teamWorkbenchSurfaceProps: TeamWorkbenchSurfaceProps; + inputbarNode: ReactNode; + generalWorkbenchDialog: ReactNode; +} + +export function useWorkspaceInputbarPresentation({ + teamWorkbench, + inputbar, + floatingTeamWorkspaceDock, + themeWorkbenchEntryPrompt, + onRestartThemeWorkbenchEntryPrompt, + onContinueThemeWorkbenchEntryPrompt, + generalWorkbenchDialog, +}: UseWorkspaceInputbarPresentationParams): WorkspaceInputbarPresentationResult { + const teamWorkbenchSurfaceProps = useMemo( + () => ({ + shellVisible: teamWorkbench.shellVisible, + currentSessionId: teamWorkbench.currentSessionId, + currentSessionName: teamWorkbench.currentSessionName, + currentSessionRuntimeStatus: teamWorkbench.currentSessionRuntimeStatus, + currentSessionLatestTurnStatus: + teamWorkbench.currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: + teamWorkbench.currentSessionQueuedTurnCount, + childSubagentSessions: teamWorkbench.childSubagentSessions, + subagentParentContext: teamWorkbench.subagentParentContext, + liveRuntimeBySessionId: teamWorkbench.liveRuntimeBySessionId, + liveActivityBySessionId: teamWorkbench.liveActivityBySessionId, + activityRefreshVersionBySessionId: + teamWorkbench.activityRefreshVersionBySessionId, + onSendSubagentInput: teamWorkbench.onSendSubagentInput, + onWaitSubagentSession: teamWorkbench.onWaitSubagentSession, + onWaitActiveTeamSessions: teamWorkbench.onWaitActiveTeamSessions, + onCloseCompletedTeamSessions: teamWorkbench.onCloseCompletedTeamSessions, + onCloseSubagentSession: teamWorkbench.onCloseSubagentSession, + onResumeSubagentSession: teamWorkbench.onResumeSubagentSession, + onOpenSubagentSession: teamWorkbench.onOpenSubagentSession, + onReturnToParentSession: teamWorkbench.onReturnToParentSession, + teamWaitSummary: teamWorkbench.teamWaitSummary, + teamControlSummary: teamWorkbench.teamControlSummary, + selectedTeamLabel: teamWorkbench.selectedTeamLabel, + selectedTeamSummary: teamWorkbench.selectedTeamSummary, + selectedTeamRoles: teamWorkbench.selectedTeamRoles, + runtimeTeamState: teamWorkbench.runtimeTeamState, + }), + [ + teamWorkbench.activityRefreshVersionBySessionId, + teamWorkbench.childSubagentSessions, + teamWorkbench.currentSessionId, + teamWorkbench.currentSessionLatestTurnStatus, + teamWorkbench.currentSessionName, + teamWorkbench.currentSessionQueuedTurnCount, + teamWorkbench.currentSessionRuntimeStatus, + teamWorkbench.liveActivityBySessionId, + teamWorkbench.liveRuntimeBySessionId, + teamWorkbench.onCloseCompletedTeamSessions, + teamWorkbench.onCloseSubagentSession, + teamWorkbench.onOpenSubagentSession, + teamWorkbench.onResumeSubagentSession, + teamWorkbench.onReturnToParentSession, + teamWorkbench.onSendSubagentInput, + teamWorkbench.onWaitActiveTeamSessions, + teamWorkbench.onWaitSubagentSession, + teamWorkbench.runtimeTeamState, + teamWorkbench.selectedTeamLabel, + teamWorkbench.selectedTeamRoles, + teamWorkbench.selectedTeamSummary, + teamWorkbench.shellVisible, + teamWorkbench.subagentParentContext, + teamWorkbench.teamControlSummary, + teamWorkbench.teamWaitSummary, + ], + ); + + const themeWorkbenchEntryPromptAccessory = useMemo( + () => + themeWorkbenchEntryPrompt ? ( + + ) : null, + [ + onContinueThemeWorkbenchEntryPrompt, + onRestartThemeWorkbenchEntryPrompt, + themeWorkbenchEntryPrompt, + ], + ); + + const workspaceInputbarProps = buildWorkspaceInputbarProps(inputbar); + const floatingTeamWorkspaceDockProps = + buildFloatingTeamWorkspaceDockProps({ + ...floatingTeamWorkspaceDock, + surfaceProps: teamWorkbenchSurfaceProps, + }); + + return { + teamWorkbenchSurfaceProps, + inputbarNode: ( + + ), + generalWorkbenchDialog: ( + + ), + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarScenePresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarScenePresentation.tsx new file mode 100644 index 000000000..6a8014554 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarScenePresentation.tsx @@ -0,0 +1,136 @@ +import { + useCallback, + useMemo, + type Dispatch, + type SetStateAction, +} from "react"; +import type { Character } from "@/lib/api/memory"; +import type { TaskFile } from "../components/TaskFiles"; +import { resolveCanvasTaskFileTarget } from "../utils/taskFileCanvasSync"; +import { isRenderableTaskFile } from "./themeWorkbenchHelpers"; +import { useWorkspaceInputbarPresentation } from "./useWorkspaceInputbarPresentation"; + +type WorkspaceInputbarPresentationParams = Parameters< + typeof useWorkspaceInputbarPresentation +>[0]; + +interface UseWorkspaceInputbarScenePresentationParams { + setMentionedCharacters: Dispatch>; + taskFiles: TaskFile[]; + taskFilesExpanded: boolean; + setTaskFilesExpanded: Dispatch>; + selectedFileId?: string; + isThemeWorkbench: boolean; + inputbarPresentation: { + teamWorkbench: WorkspaceInputbarPresentationParams["teamWorkbench"]; + inputbar: Omit< + WorkspaceInputbarPresentationParams["inputbar"], + | "taskFiles" + | "selectedFileId" + | "taskFilesExpanded" + | "onToggleTaskFiles" + | "onSelectCharacter" + >; + floatingTeamWorkspaceDock: WorkspaceInputbarPresentationParams["floatingTeamWorkspaceDock"]; + themeWorkbenchEntryPrompt: WorkspaceInputbarPresentationParams["themeWorkbenchEntryPrompt"]; + onRestartThemeWorkbenchEntryPrompt: WorkspaceInputbarPresentationParams["onRestartThemeWorkbenchEntryPrompt"]; + onContinueThemeWorkbenchEntryPrompt: WorkspaceInputbarPresentationParams["onContinueThemeWorkbenchEntryPrompt"]; + generalWorkbenchDialog: WorkspaceInputbarPresentationParams["generalWorkbenchDialog"]; + }; +} + +interface WorkspaceInputbarScenePresentationResult { + visibleTaskFiles: TaskFile[]; + visibleSelectedFileId?: string; + activeCanvasTaskFile: TaskFile | null; + teamWorkbenchSurfaceProps: ReturnType< + typeof useWorkspaceInputbarPresentation + >["teamWorkbenchSurfaceProps"]; + inputbarNode: ReturnType< + typeof useWorkspaceInputbarPresentation + >["inputbarNode"]; + generalWorkbenchDialog: ReturnType< + typeof useWorkspaceInputbarPresentation + >["generalWorkbenchDialog"]; +} + +export function useWorkspaceInputbarScenePresentation({ + setMentionedCharacters, + taskFiles, + taskFilesExpanded, + setTaskFilesExpanded, + selectedFileId, + isThemeWorkbench, + inputbarPresentation, +}: UseWorkspaceInputbarScenePresentationParams): WorkspaceInputbarScenePresentationResult { + const handleSelectCharacter = useCallback( + (character: Character) => { + setMentionedCharacters((previous) => { + if (previous.find((item) => item.id === character.id)) { + return previous; + } + return [...previous, character]; + }); + }, + [setMentionedCharacters], + ); + + const handleToggleTaskFiles = useCallback(() => { + setTaskFilesExpanded((previous) => !previous); + }, [setTaskFilesExpanded]); + + const visibleTaskFiles = useMemo( + () => + taskFiles.filter((file) => isRenderableTaskFile(file, isThemeWorkbench)), + [isThemeWorkbench, taskFiles], + ); + + const visibleSelectedFileId = useMemo(() => { + if (!selectedFileId) { + return undefined; + } + return visibleTaskFiles.some((file) => file.id === selectedFileId) + ? selectedFileId + : undefined; + }, [selectedFileId, visibleTaskFiles]); + + const activeCanvasTaskFile = useMemo( + () => + resolveCanvasTaskFileTarget(visibleTaskFiles, visibleSelectedFileId) + .targetFile, + [visibleSelectedFileId, visibleTaskFiles], + ); + + const { + teamWorkbenchSurfaceProps, + inputbarNode, + generalWorkbenchDialog, + } = useWorkspaceInputbarPresentation({ + teamWorkbench: inputbarPresentation.teamWorkbench, + inputbar: { + ...inputbarPresentation.inputbar, + taskFiles: visibleTaskFiles, + selectedFileId: visibleSelectedFileId, + taskFilesExpanded, + onToggleTaskFiles: handleToggleTaskFiles, + onSelectCharacter: handleSelectCharacter, + }, + floatingTeamWorkspaceDock: + inputbarPresentation.floatingTeamWorkspaceDock, + themeWorkbenchEntryPrompt: inputbarPresentation.themeWorkbenchEntryPrompt, + onRestartThemeWorkbenchEntryPrompt: + inputbarPresentation.onRestartThemeWorkbenchEntryPrompt, + onContinueThemeWorkbenchEntryPrompt: + inputbarPresentation.onContinueThemeWorkbenchEntryPrompt, + generalWorkbenchDialog: inputbarPresentation.generalWorkbenchDialog, + }); + + return { + visibleTaskFiles, + visibleSelectedFileId, + activeCanvasTaskFile, + teamWorkbenchSurfaceProps, + inputbarNode, + generalWorkbenchDialog, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx new file mode 100644 index 000000000..422b410bf --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx @@ -0,0 +1,333 @@ +import { useWorkspaceContextHarnessRuntime } from "./useWorkspaceContextHarnessRuntime"; +import { useWorkspaceHarnessInventoryRuntime } from "./useWorkspaceHarnessInventoryRuntime"; +import { useWorkspaceInputbarScenePresentation } from "./useWorkspaceInputbarScenePresentation"; +import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions"; +import { useWorkspaceShellChromeRuntime } from "./useWorkspaceShellChromeRuntime"; +import { useWorkspaceTeamSessionControlRuntime } from "./useWorkspaceTeamSessionControlRuntime"; +import { useWorkspaceTeamSessionRuntime } from "./useWorkspaceTeamSessionRuntime"; +import { useWorkspaceThemeWorkbenchSidebarRuntime } from "./useWorkspaceThemeWorkbenchSidebarRuntime"; + +type InputbarScenePresentationParams = Parameters< + typeof useWorkspaceInputbarScenePresentation +>[0]; +type InputbarPresentationParams = + InputbarScenePresentationParams["inputbarPresentation"]; +type TeamWorkbenchParams = InputbarPresentationParams["teamWorkbench"]; +type InputbarParams = InputbarPresentationParams["inputbar"]; +type FloatingTeamWorkspaceDockParams = + InputbarPresentationParams["floatingTeamWorkspaceDock"]; +type GeneralWorkbenchDialogParams = + InputbarPresentationParams["generalWorkbenchDialog"]; +type NavigationActions = ReturnType; +type ContextHarnessRuntime = ReturnType; +type HarnessInventoryRuntime = ReturnType< + typeof useWorkspaceHarnessInventoryRuntime +>; +type ShellChromeRuntime = ReturnType; +type TeamSessionRuntime = ReturnType; +type TeamSessionControlRuntime = ReturnType< + typeof useWorkspaceTeamSessionControlRuntime +>; +type ThemeWorkbenchSidebarRuntime = ReturnType< + typeof useWorkspaceThemeWorkbenchSidebarRuntime +>; + +interface UseWorkspaceInputbarSceneRuntimeParams { + setMentionedCharacters: InputbarScenePresentationParams["setMentionedCharacters"]; + taskFiles: InputbarScenePresentationParams["taskFiles"]; + taskFilesExpanded: InputbarScenePresentationParams["taskFilesExpanded"]; + setTaskFilesExpanded: InputbarScenePresentationParams["setTaskFilesExpanded"]; + selectedFileId: InputbarScenePresentationParams["selectedFileId"]; + isThemeWorkbench: InputbarScenePresentationParams["isThemeWorkbench"]; + sessionId: TeamWorkbenchParams["currentSessionId"]; + childSubagentSessions: TeamWorkbenchParams["childSubagentSessions"]; + subagentParentContext: TeamWorkbenchParams["subagentParentContext"]; + selectedTeamLabel: TeamWorkbenchParams["selectedTeamLabel"]; + selectedTeamSummary: TeamWorkbenchParams["selectedTeamSummary"]; + runtimeTeamState: TeamWorkbenchParams["runtimeTeamState"]; + teamSessionRuntime: TeamSessionRuntime; + teamSessionControlRuntime: TeamSessionControlRuntime; + handleOpenSubagentSession: TeamWorkbenchParams["onOpenSubagentSession"]; + handleReturnToParentSession: TeamWorkbenchParams["onReturnToParentSession"]; + input: InputbarParams["input"]; + setInput: InputbarParams["setInput"]; + currentGate: InputbarParams["themeWorkbenchGate"]; + pendingA2UIForm: InputbarParams["pendingA2UIForm"]; + handleInputbarA2UISubmit: InputbarParams["onA2UISubmit"]; + a2uiSubmissionNotice: InputbarParams["a2uiSubmissionNotice"]; + themeWorkbenchSidebarRuntime: ThemeWorkbenchSidebarRuntime; + steps: InputbarParams["workflowSteps"]; + themeWorkbenchRunState: InputbarParams["themeWorkbenchRunState"]; + handleSend: InputbarParams["onSend"]; + isSending: boolean; + providerType: InputbarParams["providerType"]; + setProviderType: InputbarParams["setProviderType"]; + model: InputbarParams["model"]; + setModel: InputbarParams["setModel"]; + projectId: InputbarParams["workspaceId"]; + executionStrategy: InputbarParams["executionStrategy"]; + setExecutionStrategy: InputbarParams["setExecutionStrategy"]; + activeTheme: InputbarParams["activeTheme"]; + navigationActions: Pick; + selectedTeam: InputbarParams["selectedTeam"]; + handleSelectTeam: InputbarParams["onSelectTeam"]; + handleEnableSuggestedTeam: InputbarParams["onEnableSuggestedTeam"]; + handleClearMessages: InputbarParams["onClearMessages"]; + handleToggleCanvas: InputbarParams["onToggleCanvas"]; + layoutMode: FloatingTeamWorkspaceDockParams["layoutMode"]; + handleTaskFileClick: InputbarParams["onTaskFileClick"]; + characters: InputbarParams["characters"]; + skills: InputbarParams["skills"]; + skillsLoading: InputbarParams["isSkillsLoading"]; + setChatToolPreferences: InputbarParams["onToolStatesChange"]; + handleNavigateToSkillSettings: InputbarParams["onNavigateToSettings"]; + handleRefreshSkills: InputbarParams["onRefreshSkills"]; + turns: GeneralWorkbenchDialogParams["turns"]; + threadItems: GeneralWorkbenchDialogParams["threadItems"]; + currentTurnId: GeneralWorkbenchDialogParams["currentTurnId"]; + threadRead: GeneralWorkbenchDialogParams["threadRead"]; + pendingActions: GeneralWorkbenchDialogParams["pendingActions"]; + submittedActionsInFlight: GeneralWorkbenchDialogParams["submittedActionsInFlight"]; + queuedTurns: InputbarParams["queuedTurns"]; + resumeThread: GeneralWorkbenchDialogParams["onResumeThread"]; + replayPendingAction?: ( + requestId: string, + assistantMessageId: string, + ) => boolean | Promise; + promoteQueuedTurn: InputbarParams["onPromoteQueuedTurn"]; + removeQueuedTurn: InputbarParams["onRemoveQueuedTurn"]; + latestAssistantMessageId: string | null; + themeWorkbenchEntryPrompt: InputbarPresentationParams["themeWorkbenchEntryPrompt"]; + handleRestartThemeWorkbenchEntryPrompt: InputbarPresentationParams["onRestartThemeWorkbenchEntryPrompt"]; + handleContinueThemeWorkbenchEntryPrompt: InputbarPresentationParams["onContinueThemeWorkbenchEntryPrompt"]; + generalWorkbenchEnabled: boolean; + contextHarnessRuntime: ContextHarnessRuntime; + harnessState: GeneralWorkbenchDialogParams["harnessState"]; + compatSubagentRuntime: GeneralWorkbenchDialogParams["compatSubagentRuntime"]; + harnessInventoryRuntime: HarnessInventoryRuntime; + mappedTheme: GeneralWorkbenchDialogParams["activeTheme"]; + handleHarnessLoadFilePreview: GeneralWorkbenchDialogParams["onLoadFilePreview"]; + handleFileClick: GeneralWorkbenchDialogParams["onOpenFile"]; + shellChromeRuntime: ShellChromeRuntime; + handleActivateTeamWorkbench: FloatingTeamWorkspaceDockParams["onActivateWorkbench"]; + chatToolPreferences: InputbarParams["toolStates"]; +} + +export function useWorkspaceInputbarSceneRuntime({ + setMentionedCharacters, + taskFiles, + taskFilesExpanded, + setTaskFilesExpanded, + selectedFileId, + isThemeWorkbench, + sessionId, + childSubagentSessions, + subagentParentContext, + selectedTeamLabel, + selectedTeamSummary, + runtimeTeamState, + teamSessionRuntime, + teamSessionControlRuntime, + handleOpenSubagentSession, + handleReturnToParentSession, + input, + setInput, + currentGate, + pendingA2UIForm, + handleInputbarA2UISubmit, + a2uiSubmissionNotice, + themeWorkbenchSidebarRuntime, + steps, + themeWorkbenchRunState, + handleSend, + isSending, + providerType, + setProviderType, + model, + setModel, + projectId, + executionStrategy, + setExecutionStrategy, + activeTheme, + navigationActions, + selectedTeam, + handleSelectTeam, + handleEnableSuggestedTeam, + handleClearMessages, + handleToggleCanvas, + layoutMode, + handleTaskFileClick, + characters, + skills, + skillsLoading, + setChatToolPreferences, + handleNavigateToSkillSettings, + handleRefreshSkills, + turns, + threadItems, + currentTurnId, + threadRead, + pendingActions, + submittedActionsInFlight, + queuedTurns, + resumeThread, + replayPendingAction, + promoteQueuedTurn, + removeQueuedTurn, + latestAssistantMessageId, + themeWorkbenchEntryPrompt, + handleRestartThemeWorkbenchEntryPrompt, + handleContinueThemeWorkbenchEntryPrompt, + generalWorkbenchEnabled, + contextHarnessRuntime, + harnessState, + compatSubagentRuntime, + harnessInventoryRuntime, + mappedTheme, + handleHarnessLoadFilePreview, + handleFileClick, + shellChromeRuntime, + handleActivateTeamWorkbench, + chatToolPreferences, +}: UseWorkspaceInputbarSceneRuntimeParams) { + return useWorkspaceInputbarScenePresentation({ + setMentionedCharacters, + taskFiles, + taskFilesExpanded, + setTaskFilesExpanded, + selectedFileId, + isThemeWorkbench, + inputbarPresentation: { + teamWorkbench: { + shellVisible: chatToolPreferences.subagent, + currentSessionId: sessionId, + currentSessionName: teamSessionRuntime.currentSessionTitle, + currentSessionRuntimeStatus: + teamSessionRuntime.currentSessionRuntimeStatus, + currentSessionLatestTurnStatus: + teamSessionRuntime.currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: queuedTurns.length, + childSubagentSessions, + subagentParentContext, + liveRuntimeBySessionId: teamSessionRuntime.liveRuntimeBySessionId, + liveActivityBySessionId: teamSessionRuntime.liveActivityBySessionId, + activityRefreshVersionBySessionId: + teamSessionRuntime.activityRefreshVersionBySessionId, + onSendSubagentInput: teamSessionControlRuntime.handleSendSubagentInput, + onWaitSubagentSession: + teamSessionControlRuntime.handleWaitSubagentSession, + onWaitActiveTeamSessions: + teamSessionControlRuntime.handleWaitActiveTeamSessions, + onCloseCompletedTeamSessions: + teamSessionControlRuntime.handleCloseCompletedTeamSessions, + onCloseSubagentSession: + teamSessionControlRuntime.handleCloseSubagentSession, + onResumeSubagentSession: + teamSessionControlRuntime.handleResumeSubagentSession, + onOpenSubagentSession: handleOpenSubagentSession, + onReturnToParentSession: handleReturnToParentSession, + teamWaitSummary: teamSessionControlRuntime.teamWaitSummary, + teamControlSummary: teamSessionControlRuntime.teamControlSummary, + selectedTeamLabel, + selectedTeamSummary, + selectedTeamRoles: selectedTeam?.roles, + runtimeTeamState, + }, + inputbar: { + input, + setInput, + variant: isThemeWorkbench ? "theme_workbench" : "default", + themeWorkbenchGate: isThemeWorkbench ? currentGate : null, + pendingA2UIForm: pendingA2UIForm || null, + onA2UISubmit: handleInputbarA2UISubmit, + a2uiSubmissionNotice, + workflowSteps: isThemeWorkbench + ? themeWorkbenchSidebarRuntime.themeWorkbenchWorkflowSteps + : steps, + themeWorkbenchRunState, + onSend: handleSend, + onStop: teamSessionControlRuntime.handleStopSending, + isLoading: isSending || queuedTurns.length > 0, + providerType, + setProviderType, + model, + setModel, + workspaceId: projectId, + executionStrategy, + setExecutionStrategy, + activeTheme, + onManageProviders: navigationActions.handleManageProviders, + selectedTeam, + onSelectTeam: handleSelectTeam, + onEnableSuggestedTeam: handleEnableSuggestedTeam, + disabled: !projectId, + onClearMessages: handleClearMessages, + onToggleCanvas: handleToggleCanvas, + isCanvasOpen: layoutMode !== "chat", + onTaskFileClick: handleTaskFileClick, + characters, + skills, + isSkillsLoading: skillsLoading, + toolStates: chatToolPreferences, + onToolStatesChange: setChatToolPreferences, + onNavigateToSettings: handleNavigateToSkillSettings, + onRefreshSkills: handleRefreshSkills, + queuedTurns, + onPromoteQueuedTurn: promoteQueuedTurn, + onRemoveQueuedTurn: removeQueuedTurn, + }, + floatingTeamWorkspaceDock: { + enabled: teamSessionRuntime.showTeamWorkspaceBoard, + layoutMode, + showFloatingInputOverlay: + shellChromeRuntime.shouldShowThemeWorkbenchFloatingInputOverlay, + onActivateWorkbench: handleActivateTeamWorkbench, + }, + themeWorkbenchEntryPrompt, + onRestartThemeWorkbenchEntryPrompt: + handleRestartThemeWorkbenchEntryPrompt, + onContinueThemeWorkbenchEntryPrompt: + handleContinueThemeWorkbenchEntryPrompt, + generalWorkbenchDialog: { + enabled: generalWorkbenchEnabled && !isThemeWorkbench, + open: contextHarnessRuntime.harnessPanelVisible, + onOpenChange: contextHarnessRuntime.setHarnessPanelVisible, + harnessState, + compatSubagentRuntime, + environment: contextHarnessRuntime.harnessEnvironment, + childSubagentSessions, + selectedTeamLabel, + selectedTeamSummary, + selectedTeamRoles: selectedTeam?.roles, + threadRead, + turns, + threadItems, + currentTurnId, + pendingActions, + submittedActionsInFlight, + queuedTurns, + canInterrupt: isSending, + onInterruptCurrentTurn: teamSessionControlRuntime.handleStopSending, + onResumeThread: resumeThread, + onReplayPendingRequest: + latestAssistantMessageId && replayPendingAction + ? (requestId: string) => + replayPendingAction(requestId, latestAssistantMessageId) + : undefined, + onPromoteQueuedTurn: promoteQueuedTurn, + toolInventory: harnessInventoryRuntime.toolInventory, + toolInventoryLoading: harnessInventoryRuntime.toolInventoryLoading, + toolInventoryError: harnessInventoryRuntime.toolInventoryError, + onRefreshToolInventory: harnessInventoryRuntime.refreshToolInventory, + activeTheme: mappedTheme, + toolPreferences: chatToolPreferences, + isSending, + runtimeStatusTitle: contextHarnessRuntime.activeRuntimeStatusTitle, + selectedTeamRoleCount: selectedTeam?.roles.length || 0, + onOpenSubagentSession: handleOpenSubagentSession, + onLoadFilePreview: handleHarnessLoadFilePreview, + onOpenFile: handleFileClick, + }, + }, + }); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceNavigationActions.ts b/src/components/agent/chat/workspace/useWorkspaceNavigationActions.ts new file mode 100644 index 000000000..79fe87cf9 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceNavigationActions.ts @@ -0,0 +1,121 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; +import { toast } from "sonner"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { updateProject as updateProjectById } from "@/lib/api/project"; +import { notifyProjectRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService"; +import type { Page, PageParams } from "@/types/page"; +import { SettingsTabs } from "@/types/settings"; + +interface UseWorkspaceNavigationActionsParams { + applyProjectSelection: (projectId: string) => void; + compactSession: () => Promise; + dismissWorkspacePathError: () => void; + fixWorkspacePathAndRetry: (newPath: string) => Promise; + onNavigate?: (page: Page, params?: PageParams) => void; + projectId?: string; + setEntryBannerVisible: Dispatch>; + setWorkspaceHealthError: Dispatch>; + workspacePathMissing: boolean; +} + +export function useWorkspaceNavigationActions({ + applyProjectSelection, + compactSession, + dismissWorkspacePathError, + fixWorkspacePathAndRetry, + onNavigate, + projectId, + setEntryBannerVisible, + setWorkspaceHealthError, + workspacePathMissing, +}: UseWorkspaceNavigationActionsParams) { + const handleManageProviders = useCallback(() => { + onNavigate?.("settings", { + tab: SettingsTabs.Providers, + }); + }, [onNavigate]); + + const handleBackToResources = useCallback(() => { + onNavigate?.("resources"); + }, [onNavigate]); + + const handleProjectChange = useCallback( + (newProjectId: string) => { + applyProjectSelection(newProjectId); + }, + [applyProjectSelection], + ); + + const handleSelectWorkspaceDirectory = useCallback(async () => { + const newPath = await openDialog({ directory: true, multiple: false }); + if (!newPath) { + return; + } + + if (workspacePathMissing) { + await fixWorkspacePathAndRetry(newPath); + return; + } + + if (!projectId) { + return; + } + + try { + await updateProjectById(projectId, { rootPath: newPath }); + setWorkspaceHealthError(false); + notifyProjectRuntimeAgentsGuide( + { + id: projectId, + rootPath: newPath, + }, + { + successMessage: "工作区目录已更新", + }, + ); + } catch (error) { + toast.error( + `更新路径失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, [ + fixWorkspacePathAndRetry, + projectId, + setWorkspaceHealthError, + workspacePathMissing, + ]); + + const handleOpenAppearanceSettings = useCallback(() => { + onNavigate?.("settings", { + tab: SettingsTabs.Appearance, + }); + }, [onNavigate]); + + const handleCompactContext = useCallback(() => { + void compactSession(); + }, [compactSession]); + + const handleDismissEntryBanner = useCallback(() => { + setEntryBannerVisible(false); + }, [setEntryBannerVisible]); + + const handleWorkspaceAlertSelectDirectory = useCallback(() => { + void handleSelectWorkspaceDirectory(); + }, [handleSelectWorkspaceDirectory]); + + const handleDismissWorkspaceAlert = useCallback(() => { + setWorkspaceHealthError(false); + dismissWorkspacePathError(); + }, [dismissWorkspacePathError, setWorkspaceHealthError]); + + return { + handleBackToResources, + handleCompactContext, + handleDismissEntryBanner, + handleDismissWorkspaceAlert, + handleManageProviders, + handleOpenAppearanceSettings, + handleProjectChange, + handleWorkspaceAlertSelectDirectory, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts new file mode 100644 index 000000000..c2e05f681 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts @@ -0,0 +1,270 @@ +import { useCallback, useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import { toast } from "sonner"; +import { DEFAULT_CANVAS_STATE } from "@/components/general-chat/bridge"; +import type { CreationMode, LayoutMode, ThemeType } from "@/components/content-creator/types"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { TaskFile } from "../components/TaskFiles"; +import type { BrowserTaskPreflight } from "../hooks/handleSendTypes"; +import { buildHomeAgentParams } from "@/lib/workspace/navigation"; +import type { Project } from "@/lib/api/project"; +import type { Character, ProjectMemory } from "@/lib/api/memory"; +import type { Page, PageParams } from "@/types/page"; + +type SetStringState = Dispatch>; + +interface ClearMessagesOptions { + showToast?: boolean; +} + +interface UseWorkspaceResetRuntimeParams { + clearMessages: (options?: ClearMessagesOptions) => void; + clearRuntimeTeamState: () => void; + clearProjectSelectionRuntime: () => void; + resetRestoredSessionState: () => void; + resetProjectSelection: () => void; + resetGuideState: () => void; + hasHandledNewChatRequest: (requestKey: string) => boolean; + markNewChatRequestHandled: (requestKey: string) => void; + createFreshSession: (sessionName?: string) => Promise; + defaultTopicSidebarVisible: boolean; + normalizedInitialTheme: ThemeType; + initialCreationMode?: CreationMode; + newChatAt?: number; + initialSessionName?: string; + projectId?: string; + externalProjectId?: string | null; + onNavigate?: (page: Page, params?: PageParams) => void; + autoCollapsedTopicSidebarRef: MutableRefObject; + processedMessageIdsRef: MutableRefObject>; + setInput: SetStringState; + setSelectedText: SetStringState; + setLayoutMode: Dispatch>; + setShowSidebar: Dispatch>; + setCanvasState: Dispatch>; + setGeneralCanvasState: Dispatch>; + setTaskFiles: Dispatch>; + setSelectedFileId: Dispatch>; + setBrowserTaskPreflight: Dispatch< + SetStateAction + >; + setMentionedCharacters: Dispatch>; + setProject: Dispatch>; + setProjectMemory: Dispatch>; + setActiveTheme: Dispatch>; + setCreationMode: Dispatch>; +} + +export function useWorkspaceResetRuntime({ + clearMessages, + clearRuntimeTeamState, + clearProjectSelectionRuntime, + resetRestoredSessionState, + resetProjectSelection, + resetGuideState, + hasHandledNewChatRequest, + markNewChatRequestHandled, + createFreshSession, + defaultTopicSidebarVisible, + normalizedInitialTheme, + initialCreationMode, + newChatAt, + initialSessionName, + projectId, + externalProjectId, + onNavigate, + autoCollapsedTopicSidebarRef, + processedMessageIdsRef, + setInput, + setSelectedText, + setLayoutMode, + setShowSidebar, + setCanvasState, + setGeneralCanvasState, + setTaskFiles, + setSelectedFileId, + setBrowserTaskPreflight, + setMentionedCharacters, + setProject, + setProjectMemory, + setActiveTheme, + setCreationMode, +}: UseWorkspaceResetRuntimeParams) { + const resetWorkbenchSurface = useCallback(() => { + setLayoutMode("chat"); + setCanvasState(null); + setGeneralCanvasState(DEFAULT_CANVAS_STATE); + setTaskFiles([]); + setSelectedFileId(undefined); + processedMessageIdsRef.current.clear(); + }, [ + processedMessageIdsRef, + setCanvasState, + setGeneralCanvasState, + setLayoutMode, + setSelectedFileId, + setTaskFiles, + ]); + + const resetTopicLocalState = useCallback(() => { + resetWorkbenchSurface(); + clearRuntimeTeamState(); + setBrowserTaskPreflight(null); + resetRestoredSessionState(); + resetGuideState(); + }, [ + clearRuntimeTeamState, + resetGuideState, + resetRestoredSessionState, + resetWorkbenchSurface, + setBrowserTaskPreflight, + ]); + + const handleClearMessages = useCallback(() => { + clearMessages(); + setInput(""); + setSelectedText(""); + setBrowserTaskPreflight(null); + autoCollapsedTopicSidebarRef.current = false; + setShowSidebar(defaultTopicSidebarVisible); + resetWorkbenchSurface(); + clearProjectSelectionRuntime(); + }, [ + autoCollapsedTopicSidebarRef, + clearMessages, + clearProjectSelectionRuntime, + defaultTopicSidebarVisible, + resetWorkbenchSurface, + setBrowserTaskPreflight, + setInput, + setSelectedText, + setShowSidebar, + ]); + + const handleBackHome = useCallback(() => { + clearMessages({ + showToast: false, + }); + setInput(""); + setSelectedText(""); + setShowSidebar(true); + resetWorkbenchSurface(); + resetProjectSelection(); + setProject(null); + setProjectMemory(null); + setActiveTheme("general"); + setCreationMode("guided"); + onNavigate?.("agent", buildHomeAgentParams()); + }, [ + clearMessages, + onNavigate, + resetProjectSelection, + resetWorkbenchSurface, + setActiveTheme, + setCreationMode, + setInput, + setProject, + setProjectMemory, + setSelectedText, + setShowSidebar, + ]); + + useEffect(() => { + if (!newChatAt) { + return; + } + + const requestKey = String(newChatAt); + if (hasHandledNewChatRequest(requestKey)) { + return; + } + markNewChatRequestHandled(requestKey); + + clearMessages({ + showToast: false, + }); + setInput(""); + setSelectedText(""); + setBrowserTaskPreflight(null); + autoCollapsedTopicSidebarRef.current = false; + setShowSidebar(defaultTopicSidebarVisible); + resetWorkbenchSurface(); + setMentionedCharacters([]); + clearProjectSelectionRuntime(); + resetRestoredSessionState(); + resetGuideState(); + + if (!externalProjectId) { + resetProjectSelection(); + setProject(null); + setProjectMemory(null); + setActiveTheme(normalizedInitialTheme); + setCreationMode(initialCreationMode ?? "guided"); + } + + const canCreateFreshSession = Boolean(projectId?.trim()); + if (!canCreateFreshSession) { + return; + } + + let disposed = false; + const toastId = initialSessionName + ? "openclaw-agent-handoff" + : "agent-new-chat"; + + void (async () => { + const newSessionId = await createFreshSession(initialSessionName); + if (disposed) { + return; + } + + if (newSessionId) { + toast.success( + initialSessionName + ? `已创建新任务:${initialSessionName}` + : "已创建新任务", + { id: toastId }, + ); + return; + } + + toast.error("创建新任务失败,请重试。", { id: toastId }); + })(); + + return () => { + disposed = true; + }; + }, [ + autoCollapsedTopicSidebarRef, + clearMessages, + clearProjectSelectionRuntime, + createFreshSession, + defaultTopicSidebarVisible, + externalProjectId, + hasHandledNewChatRequest, + initialCreationMode, + initialSessionName, + markNewChatRequestHandled, + newChatAt, + normalizedInitialTheme, + projectId, + resetGuideState, + resetProjectSelection, + resetRestoredSessionState, + resetWorkbenchSurface, + setActiveTheme, + setBrowserTaskPreflight, + setCreationMode, + setInput, + setMentionedCharacters, + setProject, + setProjectMemory, + setSelectedText, + setShowSidebar, + ]); + + return { + handleBackHome, + handleClearMessages, + resetTopicLocalState, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime.ts new file mode 100644 index 000000000..19c67b9b9 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import type { TeamWorkspaceRuntimeFormationState } from "../teamWorkspaceRuntime"; +import type { RuntimeTeamDispatchPreviewSnapshot } from "./runtimeTeamPreview"; + +interface UseWorkspaceRuntimeTeamDispatchPreviewRuntimeParams { + messagesLength: number; + runtimeTeamState: TeamWorkspaceRuntimeFormationState | null; + sessionId?: string; +} + +export function useWorkspaceRuntimeTeamDispatchPreviewRuntime({ + messagesLength, + runtimeTeamState, + sessionId, +}: UseWorkspaceRuntimeTeamDispatchPreviewRuntimeParams) { + const [runtimeTeamDispatchPreview, setRuntimeTeamDispatchPreview] = + useState(null); + + useEffect(() => { + setRuntimeTeamDispatchPreview(null); + }, [sessionId]); + + useEffect(() => { + if (!runtimeTeamDispatchPreview) { + return; + } + + if (messagesLength > runtimeTeamDispatchPreview.baseMessageCount) { + setRuntimeTeamDispatchPreview(null); + } + }, [messagesLength, runtimeTeamDispatchPreview]); + + useEffect(() => { + if ( + !runtimeTeamDispatchPreview || + runtimeTeamDispatchPreview.status === "failed" || + runtimeTeamState?.status !== "failed" + ) { + return; + } + + setRuntimeTeamDispatchPreview((current) => + current + ? { + ...current, + status: "failed", + formationState: null, + failureMessage: runtimeTeamState.errorMessage?.trim() || null, + } + : null, + ); + }, [runtimeTeamDispatchPreview, runtimeTeamState]); + + return { + runtimeTeamDispatchPreview, + setRuntimeTeamDispatchPreview, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts new file mode 100644 index 000000000..af7966204 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -0,0 +1,737 @@ +import { useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; +import type { Dispatch, SetStateAction } from "react"; +import { loadConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { loadProviderModels } from "@/hooks/useProviderModels"; +import type { Character } from "@/lib/api/memory"; +import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; +import { preheatBrowserAssistInBackground } from "../utils/browserAssistPreheat"; +import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; +import { resolveProviderModelCompatibility } from "../utils/providerModelCompatibility"; +import { + buildHarnessRequestMetadata, + extractExistingHarnessMetadata, + type HarnessTurnTeamBlueprint, +} from "../utils/harnessRequestMetadata"; +import { isTeamRuntimeRecommendation } from "../utils/contextualRecommendations"; +import { saveChatToolPreferences, type ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { HandleSendOptions } from "../hooks/handleSendTypes"; +import type { ThemeWorkbenchSendBoundaryState } from "../hooks/useThemeWorkbenchSendBoundary"; +import { + shouldPrepareRuntimeTeamBeforeSend, + type UseRuntimeTeamFormationResult, +} from "../hooks/useRuntimeTeamFormation"; +import type { SendMessageFn } from "../hooks/agentChatShared"; +import { + isReasoningModel, + resolveBaseModelOnThinkingOff, + resolveThinkingModel, +} from "@/lib/model/thinkingModelResolver"; +import { resolveVisionModel } from "@/lib/model/visionModelResolver"; +import { + loadRememberedBaseModel, + saveRememberedBaseModel, +} from "@/lib/model/thinkingBaseModelMemory"; +import type { MessageImage } from "../types"; +import type { CreationMode, ThemeType } from "@/components/content-creator/types"; +import type { TeamDefinition } from "../utils/teamDefinitions"; +import { + buildRuntimeTeamAssistantDraft, + type RuntimeTeamDispatchPreviewSnapshot, +} from "./runtimeTeamPreview"; +import type { TeamWorkspaceRuntimeFormationState } from "../teamWorkspaceRuntime"; + +const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; + +type ExecutionStrategy = "react" | "code_orchestrated" | "auto"; +type ChatMode = "agent" | "general" | "creator"; +type SetStringState = Dispatch>; + +interface ContextWorkspaceSummary { + enabled: boolean; + prepareActiveContextPrompt: () => Promise; +} + +interface EnsureBrowserAssistCanvasOptions { + silent?: boolean; + navigationMode?: "none" | "explicit-url" | "best-effort"; +} + +interface UseWorkspaceSendActionsParams { + input: string; + setInput: SetStringState; + mentionedCharacters: Character[]; + setMentionedCharacters: Dispatch>; + chatToolPreferences: ChatToolPreferences; + setChatToolPreferences: Dispatch>; + activeTheme: string; + mappedTheme: ThemeType; + creationMode: CreationMode; + chatMode: ChatMode; + isThemeWorkbench: boolean; + contextWorkspace: ContextWorkspaceSummary; + runtimeStyleMessagePrompt: string; + projectId?: string; + sessionId?: string; + providerType: string; + model: string; + setModel: SetStringState; + executionStrategy: ExecutionStrategy; + preferredTeamPresetId?: string | null; + selectedTeam?: TeamDefinition | null; + selectedTeamLabel: string; + selectedTeamSummary: string; + currentGateKey: string; + themeWorkbenchActiveQueueTitle?: string; + contentId?: string | null; + messagesCount: number; + sendMessage: SendMessageFn; + resolveSendBoundary: (input: { + sourceText: string; + sendOptions?: HandleSendOptions; + }) => ThemeWorkbenchSendBoundaryState; + isBlockedByBrowserPreflight: (sendOptions?: HandleSendOptions) => boolean; + maybeStartBrowserTaskPreflight: (input: { + boundary: ThemeWorkbenchSendBoundaryState; + images?: MessageImage[]; + webSearch?: boolean; + thinking?: boolean; + sendExecutionStrategy?: ExecutionStrategy; + autoContinuePayload?: AutoContinueRequestPayload; + sendOptions?: HandleSendOptions; + }) => boolean; + finalizeAfterSendSuccess: (boundary: ThemeWorkbenchSendBoundaryState) => void; + rollbackAfterSendFailure: (boundary: ThemeWorkbenchSendBoundaryState) => void; + prepareRuntimeTeamBeforeSend: UseRuntimeTeamFormationResult["prepareRuntimeTeamBeforeSend"]; + setRuntimeTeamDispatchPreview: Dispatch< + SetStateAction + >; + ensureBrowserAssistCanvas: ( + target: string, + options?: EnsureBrowserAssistCanvasOptions, + ) => Promise; + handleImageWorkbenchCommand: (input: { + rawText: string; + parsedCommand: NonNullable>; + images: MessageImage[]; + }) => Promise; +} + +function applyActiveContextPrompt(text: string, activeContextPrompt: string): string { + if (!activeContextPrompt.trim()) { + return text; + } + + const slashCommandMatch = text.match(/^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/); + if (slashCommandMatch) { + const [, skillName, skillArgs] = slashCommandMatch; + const mergedArgs = [activeContextPrompt, skillArgs.trim()] + .filter((part) => part.length > 0) + .join("\n\n"); + return `/${skillName} ${mergedArgs}`.trim(); + } + + return `${activeContextPrompt}\n\n${text}`; +} + +function applyMentionedCharacterContext( + text: string, + mentionedCharacters: Character[], +): string { + if (mentionedCharacters.length === 0) { + return text; + } + + const characterContext = mentionedCharacters + .map((char) => { + let context = `角色:${char.name}`; + if (char.description) context += `\n简介:${char.description}`; + if (char.personality) context += `\n性格:${char.personality}`; + if (char.background) context += `\n背景:${char.background}`; + return context; + }) + .join("\n\n"); + + return `[角色上下文]\n${characterContext}\n\n[用户输入]\n${text}`; +} + +function applyRuntimeStyleMessagePrompt( + text: string, + runtimeStyleMessagePrompt: string, + sendOptions?: HandleSendOptions, +): string { + if (sendOptions?.purpose || !runtimeStyleMessagePrompt.trim()) { + return text; + } + + return `[本次任务风格要求]\n${runtimeStyleMessagePrompt}\n\n[用户输入]\n${text}`; +} + +function buildPreparedRuntimeTeamBlueprint( + runtimeTeamState: TeamWorkspaceRuntimeFormationState | null | undefined, +): HarnessTurnTeamBlueprint | undefined { + if (!runtimeTeamState || runtimeTeamState.status !== "formed") { + return undefined; + } + + return { + label: + runtimeTeamState.label?.trim() || + runtimeTeamState.blueprint?.label?.trim() || + undefined, + description: + runtimeTeamState.summary?.trim() || + runtimeTeamState.blueprint?.summary?.trim() || + undefined, + roles: + runtimeTeamState.members.length > 0 + ? runtimeTeamState.members.map((member, index) => ({ + id: member.id?.trim() || `runtime-member-${index + 1}`, + label: member.label?.trim() || `角色 ${index + 1}`, + summary: + member.summary?.trim() || + `${member.label?.trim() || `角色 ${index + 1}`}负责当前子任务。`, + profileId: member.profileId?.trim() || undefined, + roleKey: member.roleKey?.trim() || undefined, + skillIds: + member.skillIds.length > 0 ? [...member.skillIds] : undefined, + })) + : undefined, + }; +} + +export type WorkspaceHandleSend = ( + images?: MessageImage[], + webSearch?: boolean, + thinking?: boolean, + textOverride?: string, + sendExecutionStrategy?: ExecutionStrategy, + autoContinuePayload?: AutoContinueRequestPayload, + sendOptions?: HandleSendOptions, +) => Promise; + +export function useWorkspaceSendActions({ + input, + setInput, + mentionedCharacters, + setMentionedCharacters, + chatToolPreferences, + setChatToolPreferences, + activeTheme, + mappedTheme, + creationMode, + chatMode, + isThemeWorkbench, + contextWorkspace, + runtimeStyleMessagePrompt, + projectId, + sessionId, + providerType, + model, + setModel, + executionStrategy, + preferredTeamPresetId, + selectedTeam, + selectedTeamLabel, + selectedTeamSummary, + currentGateKey, + themeWorkbenchActiveQueueTitle, + contentId, + messagesCount, + sendMessage, + resolveSendBoundary, + isBlockedByBrowserPreflight, + maybeStartBrowserTaskPreflight, + finalizeAfterSendSuccess, + rollbackAfterSendFailure, + prepareRuntimeTeamBeforeSend, + setRuntimeTeamDispatchPreview, + ensureBrowserAssistCanvas, + handleImageWorkbenchCommand, +}: UseWorkspaceSendActionsParams) { + const thinkingVariantWarnedRef = useRef>(new Set()); + + const resolveSendProviderContext = useCallback(async () => { + const configuredProviders = await loadConfiguredProviders(); + const selectedProvider = + configuredProviders.find((provider) => provider.key === providerType) || + null; + const providerModels = await loadProviderModels(selectedProvider); + + return { + selectedProvider, + providerModels, + }; + }, [providerType]); + + const handleSend = useCallback( + async ( + images, + webSearch, + thinking, + textOverride, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + ) => { + let sourceText = textOverride ?? input; + if (!sourceText.trim() && (!images || images.length === 0)) { + return false; + } + + const sendBoundary = resolveSendBoundary({ + sourceText, + sendOptions, + }); + sourceText = sendBoundary.sourceText; + + if (isBlockedByBrowserPreflight(sendOptions)) { + toast.info("请先完成当前浏览器准备后,再继续发送新的任务"); + return false; + } + + const effectiveToolPreferences = + sendOptions?.toolPreferencesOverride ?? chatToolPreferences; + const { browserRequirementMatch } = sendBoundary; + const requestedWebSearch = + webSearch ?? effectiveToolPreferences.webSearch; + const effectiveWebSearch = + browserRequirementMatch && + browserRequirementMatch.requirement !== "optional" + ? false + : requestedWebSearch; + const effectiveThinking = thinking ?? effectiveToolPreferences.thinking; + + if (!projectId) { + sendOptions?.observer?.onError?.("请先选择项目后再开始对话"); + toast.error("请先选择项目后再开始对话"); + return false; + } + + const parsedImageWorkbenchCommand = + !sendOptions?.purpose && sourceText.trim() + ? parseImageWorkbenchCommand(sourceText) + : null; + if (parsedImageWorkbenchCommand) { + return handleImageWorkbenchCommand({ + rawText: sourceText, + parsedCommand: parsedImageWorkbenchCommand, + images: images || [], + }); + } + + if ( + maybeStartBrowserTaskPreflight({ + boundary: sendBoundary, + images, + webSearch, + thinking, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + }) + ) { + return true; + } + + let text = sourceText; + const preparedActiveContextPrompt = contextWorkspace.enabled + ? await contextWorkspace.prepareActiveContextPrompt() + : ""; + if (contextWorkspace.enabled && preparedActiveContextPrompt) { + text = applyActiveContextPrompt(text, preparedActiveContextPrompt); + } + + text = applyMentionedCharacterContext(text, mentionedCharacters); + text = applyRuntimeStyleMessagePrompt( + text, + runtimeStyleMessagePrompt, + sendOptions, + ); + + if (browserRequirementMatch) { + void ensureBrowserAssistCanvas( + browserRequirementMatch.launchUrl || sourceText, + { + silent: true, + navigationMode: + browserRequirementMatch.launchUrl && + browserRequirementMatch.launchUrl !== sourceText + ? "explicit-url" + : "best-effort", + }, + ).catch((error) => { + console.warn( + "[AgentChatPage] 强浏览器任务发送前准备浏览器失败,继续由主流程处理:", + error, + ); + }); + } else { + preheatBrowserAssistInBackground({ + activeTheme, + sourceText, + ensureBrowserAssistCanvas, + onError: (error) => { + console.warn( + "[AgentChatPage] 发送前预热浏览器协助失败,继续发送消息:", + error, + ); + }, + }); + } + + setRuntimeTeamDispatchPreview(null); + + try { + const { selectedProvider, providerModels } = + await resolveSendProviderContext(); + const memoryParams = { + scope: "aster" as const, + workspaceId: projectId, + sessionId, + providerKey: providerType, + }; + const rememberedBaseModel = loadRememberedBaseModel(memoryParams); + let effectiveModel = model; + + if (effectiveThinking) { + if (!isReasoningModel(model, providerModels)) { + saveRememberedBaseModel({ + ...memoryParams, + modelId: model, + }); + } + + const thinkingResult = resolveThinkingModel({ + currentModelId: model, + models: providerModels, + }); + effectiveModel = thinkingResult.targetModelId; + + if (thinkingResult.switched) { + setModel(thinkingResult.targetModelId); + } else if ( + thinkingResult.reason === "no_variant" && + providerModels.length > 0 + ) { + const warnKey = `${providerType}:${model}`; + if (!thinkingVariantWarnedRef.current.has(warnKey)) { + thinkingVariantWarnedRef.current.add(warnKey); + toast.warning( + "当前 Provider 没有可用的 Thinking 模型,已保持原模型", + ); + } + } + } else { + const restoreResult = resolveBaseModelOnThinkingOff({ + currentModelId: model, + models: providerModels, + rememberedBaseModel, + }); + effectiveModel = restoreResult.targetModelId; + + if (restoreResult.switched) { + setModel(restoreResult.targetModelId); + } + } + + const compatibilityResult = resolveProviderModelCompatibility({ + providerType, + configuredProviderType: selectedProvider?.type, + model: effectiveModel, + }); + if (compatibilityResult.changed) { + effectiveModel = compatibilityResult.model; + if (model !== compatibilityResult.model) { + setModel(compatibilityResult.model); + } + if (compatibilityResult.reason) { + toast.warning(compatibilityResult.reason); + } + } + + if ((images?.length || 0) > 0) { + const visionResult = resolveVisionModel({ + currentModelId: effectiveModel, + models: providerModels, + }); + + if (visionResult.reason === "no_vision_model") { + toast.error( + "当前 Provider 没有可用的多模态模型,请切换到支持多模态的 Provider 或模型后再发送图片", + ); + return false; + } + + if (visionResult.reason !== "already_vision") { + const suggestedModel = visionResult.targetModelId.trim(); + toast.error( + suggestedModel + ? `当前模型 ${effectiveModel} 不支持多模态图片理解,请切换到 ${suggestedModel} 或其他支持多模态的模型后再发送图片` + : `当前模型 ${effectiveModel} 不支持多模态图片理解,请切换到支持多模态的模型后再发送图片`, + ); + return false; + } + } + + const shouldPrepareRuntimeTeam = shouldPrepareRuntimeTeamBeforeSend({ + subagentEnabled: effectiveToolPreferences.subagent, + projectId, + input: sourceText, + purpose: sendOptions?.purpose, + }); + const runtimeTeamDispatchPreviewKey = shouldPrepareRuntimeTeam + ? crypto.randomUUID() + : null; + if (shouldPrepareRuntimeTeam) { + setRuntimeTeamDispatchPreview({ + key: runtimeTeamDispatchPreviewKey as string, + prompt: sourceText, + images: images || [], + baseMessageCount: messagesCount, + status: "forming", + formationState: null, + }); + } + const preparedRuntimeTeamState = await prepareRuntimeTeamBeforeSend({ + input: sourceText, + providerType, + model: effectiveModel, + executionStrategy: sendExecutionStrategy ?? executionStrategy, + purpose: sendOptions?.purpose, + subagentEnabled: effectiveToolPreferences.subagent, + }); + if (runtimeTeamDispatchPreviewKey) { + if (preparedRuntimeTeamState?.status === "formed") { + setRuntimeTeamDispatchPreview((current) => + current?.key === runtimeTeamDispatchPreviewKey + ? { + ...current, + status: "formed", + formationState: preparedRuntimeTeamState, + failureMessage: null, + } + : current, + ); + } else if (preparedRuntimeTeamState?.status === "failed") { + setRuntimeTeamDispatchPreview((current) => + current?.key === runtimeTeamDispatchPreviewKey + ? { + ...current, + status: "failed", + formationState: null, + failureMessage: + preparedRuntimeTeamState.errorMessage?.trim() || null, + } + : current, + ); + } + } + + const preparedRuntimeTeamBlueprint = + buildPreparedRuntimeTeamBlueprint(preparedRuntimeTeamState); + const turnTeamDecision = + preparedRuntimeTeamState?.status === "formed" + ? "team_prepared" + : "single_agent"; + const turnTeamReason = + preparedRuntimeTeamState?.status === "formed" + ? "runtime_team_prepared" + : shouldPrepareRuntimeTeam + ? "runtime_team_generation_failed" + : !effectiveToolPreferences.subagent + ? "subagent_disabled" + : sendOptions?.purpose + ? "turn_purpose_override" + : "single_agent_direct"; + const assistantDraft = + preparedRuntimeTeamState?.status === "formed" + ? buildRuntimeTeamAssistantDraft(preparedRuntimeTeamState) + : undefined; + + setInput(""); + setMentionedCharacters([]); + + const existingHarnessMetadata = extractExistingHarnessMetadata( + sendOptions?.requestMetadata, + ); + const nextSendOptions: HandleSendOptions = { + ...(sendOptions || {}), + requestMetadata: { + ...(sendOptions?.requestMetadata || {}), + harness: buildHarnessRequestMetadata({ + base: existingHarnessMetadata, + theme: mappedTheme, + creationMode, + chatMode, + webSearchEnabled: effectiveWebSearch, + thinkingEnabled: effectiveThinking, + taskModeEnabled: effectiveToolPreferences.task, + subagentModeEnabled: effectiveToolPreferences.subagent, + sessionMode: isThemeWorkbench ? "theme_workbench" : "default", + gateKey: isThemeWorkbench ? currentGateKey : undefined, + runTitle: themeWorkbenchActiveQueueTitle?.trim() || undefined, + contentId: contentId || undefined, + browserRequirement: browserRequirementMatch?.requirement, + browserRequirementReason: browserRequirementMatch?.reason, + browserLaunchUrl: browserRequirementMatch?.launchUrl, + browserAssistProfileKey: + mappedTheme === "general" + ? GENERAL_BROWSER_ASSIST_PROFILE_KEY + : undefined, + preferredTeamPresetId, + selectedTeamId: selectedTeam?.id, + selectedTeamSource: selectedTeam?.source, + selectedTeamLabel, + selectedTeamSummary, + selectedTeamRoles: selectedTeam?.roles, + turnTeamDecision, + turnTeamReason, + turnTeamBlueprint: preparedRuntimeTeamBlueprint, + }), + }, + }; + const runtimeSendOptions = assistantDraft + ? { + ...nextSendOptions, + assistantDraft, + } + : nextSendOptions; + + await sendMessage( + text, + images || [], + effectiveWebSearch, + effectiveThinking, + false, + sendExecutionStrategy, + effectiveModel, + autoContinuePayload, + runtimeSendOptions, + ); + + finalizeAfterSendSuccess(sendBoundary); + return true; + } catch (error) { + rollbackAfterSendFailure(sendBoundary); + const errorMessage = + error instanceof Error ? error.message : String(error); + setRuntimeTeamDispatchPreview((current) => + current + ? { + ...current, + status: "failed", + failureMessage: errorMessage, + } + : null, + ); + sendOptions?.observer?.onError?.(errorMessage); + console.error("[AgentChat] 发送消息失败:", error); + toast.error(`发送失败: ${errorMessage}`); + setInput(sourceText); + return false; + } + }, + [ + activeTheme, + chatMode, + chatToolPreferences, + contentId, + contextWorkspace, + creationMode, + currentGateKey, + ensureBrowserAssistCanvas, + executionStrategy, + finalizeAfterSendSuccess, + handleImageWorkbenchCommand, + input, + isBlockedByBrowserPreflight, + isThemeWorkbench, + mappedTheme, + maybeStartBrowserTaskPreflight, + mentionedCharacters, + messagesCount, + model, + preferredTeamPresetId, + prepareRuntimeTeamBeforeSend, + projectId, + providerType, + resolveSendBoundary, + resolveSendProviderContext, + rollbackAfterSendFailure, + runtimeStyleMessagePrompt, + selectedTeam, + selectedTeamLabel, + selectedTeamSummary, + sendMessage, + sessionId, + setMentionedCharacters, + setInput, + setModel, + setRuntimeTeamDispatchPreview, + themeWorkbenchActiveQueueTitle, + ], + ); + + const handleRecommendationClick = useCallback( + (shortLabel: string, fullPrompt: string) => { + setInput(fullPrompt); + + if ( + activeTheme !== "general" || + !isTeamRuntimeRecommendation(shortLabel, fullPrompt) + ) { + return; + } + + const nextToolPreferences = chatToolPreferences.subagent + ? chatToolPreferences + : { + ...chatToolPreferences, + subagent: true, + }; + + if (!chatToolPreferences.subagent) { + setChatToolPreferences(nextToolPreferences); + } + saveChatToolPreferences(nextToolPreferences, activeTheme); + void handleSend( + [], + nextToolPreferences.webSearch, + nextToolPreferences.thinking, + fullPrompt, + executionStrategy, + undefined, + { + toolPreferencesOverride: nextToolPreferences, + }, + ); + }, + [ + activeTheme, + chatToolPreferences, + executionStrategy, + handleSend, + setChatToolPreferences, + setInput, + ], + ); + + const handleSendRef = useRef(handleSend); + const webSearchPreferenceRef = useRef(chatToolPreferences.webSearch); + + useEffect(() => { + handleSendRef.current = handleSend; + }, [handleSend]); + + useEffect(() => { + webSearchPreferenceRef.current = chatToolPreferences.webSearch; + }, [chatToolPreferences.webSearch]); + + return { + handleSend, + handleRecommendationClick, + handleSendRef, + webSearchPreferenceRef, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceSessionRestore.ts b/src/components/agent/chat/workspace/useWorkspaceSessionRestore.ts new file mode 100644 index 000000000..baac6ec2e --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceSessionRestore.ts @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { TaskFile } from "../components/TaskFiles"; +import type { CreationMode } from "@/components/content-creator/types"; +import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt"; +import { normalizeSessionTaskFileType } from "./themeWorkbenchHelpers"; + +interface SessionFileSummary { + name: string; + fileType?: string | null; + createdAt?: number; + updatedAt?: number; +} + +interface SessionMetaSummary { + sessionId: string; + theme?: string | null; + creationMode?: string | null; +} + +interface UseWorkspaceSessionRestoreParams { + sessionId?: string; + sessionMeta?: SessionMetaSummary | null; + lockTheme: boolean; + initialTheme?: string; + sessionFiles: SessionFileSummary[]; + readSessionFile: (name: string) => Promise; + taskFilesLength: number; + setActiveTheme: (theme: string) => void; + setCreationMode: (mode: CreationMode) => void; + setTaskFiles: (files: TaskFile[]) => void; +} + +export function useWorkspaceSessionRestore({ + sessionId, + sessionMeta, + lockTheme, + initialTheme, + sessionFiles, + readSessionFile, + taskFilesLength, + setActiveTheme, + setCreationMode, + setTaskFiles, +}: UseWorkspaceSessionRestoreParams) { + const restoredMetaSessionIdRef = useRef(null); + const restoredFilesSessionIdRef = useRef(null); + + const resetRestoredSessionState = useCallback(() => { + restoredMetaSessionIdRef.current = null; + restoredFilesSessionIdRef.current = null; + }, []); + + useEffect(() => { + if (!sessionId || !sessionMeta) { + return; + } + + if (sessionMeta.sessionId !== sessionId) { + return; + } + + if (restoredMetaSessionIdRef.current === sessionId) { + return; + } + + console.log("[AgentChatPage] 恢复会话元数据:", sessionId, sessionMeta); + + if (sessionMeta.theme && (!lockTheme || !initialTheme)) { + const entryIsGeneral = !initialTheme || initialTheme === "general"; + const restoredIsCreation = isContentCreationTheme(sessionMeta.theme); + if (entryIsGeneral && restoredIsCreation) { + console.log( + "[AgentChatPage] 通用对话入口,跳过恢复内容创作主题:", + sessionMeta.theme, + ); + } else { + console.log("[AgentChatPage] 恢复主题:", sessionMeta.theme); + setActiveTheme(sessionMeta.theme); + } + } + + if (sessionMeta.creationMode) { + console.log("[AgentChatPage] 恢复创建模式:", sessionMeta.creationMode); + setCreationMode(sessionMeta.creationMode as CreationMode); + } + + restoredMetaSessionIdRef.current = sessionId; + }, [ + initialTheme, + lockTheme, + sessionId, + sessionMeta, + setActiveTheme, + setCreationMode, + ]); + + useEffect(() => { + if (!sessionId || sessionFiles.length === 0) { + return; + } + + if (restoredFilesSessionIdRef.current === sessionId) { + return; + } + + if (taskFilesLength > 0) { + restoredFilesSessionIdRef.current = sessionId; + return; + } + + console.log( + "[AgentChatPage] 开始恢复文件:", + sessionId, + sessionFiles.length, + "个文件", + ); + + const restoreFiles = async () => { + const restoredFiles: TaskFile[] = []; + + for (const file of sessionFiles) { + try { + const content = await readSessionFile(file.name); + if (content) { + restoredFiles.push({ + id: crypto.randomUUID(), + name: file.name, + type: normalizeSessionTaskFileType( + file.fileType, + file.name, + content, + ), + content, + version: 1, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }); + } + } catch (err) { + console.error("[AgentChatPage] 恢复文件失败:", file.name, err); + } + } + + if (restoredFiles.length > 0) { + console.log( + "[AgentChatPage] 从持久化存储恢复", + restoredFiles.length, + "个文件", + ); + setTaskFiles(restoredFiles); + } + restoredFilesSessionIdRef.current = sessionId; + }; + + void restoreFiles(); + }, [ + readSessionFile, + sessionFiles, + sessionId, + setTaskFiles, + taskFilesLength, + ]); + + return { + resetRestoredSessionState, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceShellChromeRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceShellChromeRuntime.ts new file mode 100644 index 000000000..f1d71f317 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceShellChromeRuntime.ts @@ -0,0 +1,114 @@ +import { useMemo } from "react"; +import type { BrowserTaskPreflight } from "../hooks/handleSendTypes"; +import { resolveThemeWorkbenchLayoutBottomSpacing } from "../utils/themeWorkbenchLayout"; +import { + TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH, + TEAM_PRIMARY_CHAT_PANEL_WIDTH, +} from "./WorkspaceStyles"; + +interface UseWorkspaceShellChromeRuntimeParams { + agentEntry?: "new-task" | "claw"; + browserTaskPreflight: BrowserTaskPreflight | null; + contextWorkspaceEnabled: boolean; + hasDisplayMessages: boolean; + hideTopBar: boolean; + isBootstrapDispatchPending: boolean; + isContentCreationMode: boolean; + isSending: boolean; + isThemeWorkbench: boolean; + layoutMode: string; + queuedTurnCount: number; + shouldUseCompactThemeWorkbench: boolean; + showTeamWorkspaceBoard: boolean; + topBarChrome: "full" | "workspace-compact"; + themeWorkbenchRunState: "idle" | "auto_running" | "await_user_decision"; + currentGateStatus: "running" | "waiting" | "idle"; + hasRealTeamGraph: boolean; + runtimeTeamState: unknown; +} + +export function useWorkspaceShellChromeRuntime({ + agentEntry, + browserTaskPreflight, + contextWorkspaceEnabled, + hasDisplayMessages, + hideTopBar, + isBootstrapDispatchPending, + isContentCreationMode, + isSending, + isThemeWorkbench, + layoutMode, + queuedTurnCount, + shouldUseCompactThemeWorkbench, + showTeamWorkspaceBoard, + topBarChrome, + themeWorkbenchRunState, + currentGateStatus, + hasRealTeamGraph, + runtimeTeamState, +}: UseWorkspaceShellChromeRuntimeParams) { + const hasUnconsumedInitialDispatch = + !shouldUseCompactThemeWorkbench && isBootstrapDispatchPending; + + const showChatLayout = + agentEntry === "claw" || + hasDisplayMessages || + isThemeWorkbench || + hasUnconsumedInitialDispatch || + isSending || + queuedTurnCount > 0 || + Boolean(browserTaskPreflight); + + const shouldHideThemeWorkbenchInputForTheme = shouldUseCompactThemeWorkbench; + const shouldShowThemeWorkbenchFloatingInputOverlay = + isThemeWorkbench && + showChatLayout && + !shouldHideThemeWorkbenchInputForTheme; + + const isWorkspaceCompactChrome = topBarChrome === "workspace-compact"; + const shouldRenderBrandedEmptyState = !showChatLayout; + const shouldRenderTopBar = !hideTopBar && !shouldRenderBrandedEmptyState; + const shouldRenderInlineA2UI = isContentCreationMode; + + const shouldUseTeamPrimaryChatPanelWidth = + layoutMode === "chat-canvas" && + showTeamWorkspaceBoard && + (hasRealTeamGraph || Boolean(runtimeTeamState)); + const layoutTransitionChatPanelWidth = shouldUseTeamPrimaryChatPanelWidth + ? TEAM_PRIMARY_CHAT_PANEL_WIDTH + : undefined; + const layoutTransitionChatPanelMinWidth = shouldUseTeamPrimaryChatPanelWidth + ? TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH + : undefined; + + const themeWorkbenchLayoutBottomSpacing = useMemo( + () => + resolveThemeWorkbenchLayoutBottomSpacing({ + contextWorkspaceEnabled, + showFloatingInputOverlay: shouldShowThemeWorkbenchFloatingInputOverlay, + hasCanvasContent: layoutMode !== "chat", + themeWorkbenchRunState, + gateStatus: currentGateStatus, + }), + [ + contextWorkspaceEnabled, + currentGateStatus, + layoutMode, + shouldShowThemeWorkbenchFloatingInputOverlay, + themeWorkbenchRunState, + ], + ); + + return { + hasUnconsumedInitialDispatch, + isWorkspaceCompactChrome, + layoutTransitionChatPanelMinWidth, + layoutTransitionChatPanelWidth, + shouldHideThemeWorkbenchInputForTheme, + shouldRenderInlineA2UI, + shouldRenderTopBar, + shouldShowThemeWorkbenchFloatingInputOverlay, + showChatLayout, + themeWorkbenchLayoutBottomSpacing, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx new file mode 100644 index 000000000..ae24cd3c2 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx @@ -0,0 +1,93 @@ +import { WorkspaceShellScene } from "./WorkspaceShellScene"; +import { useWorkspaceConversationSceneRuntime } from "./useWorkspaceConversationSceneRuntime"; +import { useWorkspaceThemeWorkbenchShellRuntime } from "./useWorkspaceThemeWorkbenchShellRuntime"; + +type ThemeWorkbenchShellRuntime = ReturnType< + typeof useWorkspaceThemeWorkbenchShellRuntime +>; +type ConversationSceneRuntime = ReturnType< + typeof useWorkspaceConversationSceneRuntime +>; + +interface UseWorkspaceShellSceneRuntimeParams { + compactChrome: boolean; + isThemeWorkbench: boolean; + showChatPanel: boolean; + showSidebar: boolean; + themeWorkbenchShellRuntime: ThemeWorkbenchShellRuntime; + conversationSceneRuntime: ConversationSceneRuntime; + sessionId?: string; + topics: Array<{ id: string; title: string }>; + handleBackHome: () => void; + switchTopic: (topicId: string) => Promise | void; + handleResumeSidebarTask: (taskId: string) => void; + deleteTopic: (topicId: string) => Promise | void; + renameTopic: (topicId: string, title: string) => Promise | void; + displayMessages: Array; + isSending: boolean; + pendingActionCount: number; + queuedTurnCount: number; + childSubagentSessions: Array; + subagentParentContext: unknown; + handleOpenSubagentSession: (sessionId: string) => void; + handleReturnToParentSession: () => void; +} + +export function useWorkspaceShellSceneRuntime({ + compactChrome, + isThemeWorkbench, + showChatPanel, + showSidebar, + themeWorkbenchShellRuntime, + conversationSceneRuntime, + sessionId, + topics, + handleBackHome, + switchTopic, + handleResumeSidebarTask, + deleteTopic, + renameTopic, + displayMessages, + isSending, + pendingActionCount, + queuedTurnCount, + childSubagentSessions, + subagentParentContext, + handleOpenSubagentSession, + handleReturnToParentSession, +}: UseWorkspaceShellSceneRuntimeParams) { + return { + shellSceneNode: ( + + ), + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceStyleActionPresentation.ts b/src/components/agent/chat/workspace/useWorkspaceStyleActionPresentation.ts new file mode 100644 index 000000000..3d0724b09 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceStyleActionPresentation.ts @@ -0,0 +1,155 @@ +import { useCallback, useMemo } from "react"; +import { toast } from "sonner"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { ThemeType } from "@/components/content-creator/types"; +import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge"; +import { + buildStyleAuditPrompt, + buildStyleRewritePrompt, +} from "@/lib/style-guide"; +import type { TaskFile } from "../components/TaskFiles"; +import { + extractStyleActionContent, + resolveStyleActionFileName, +} from "../utils/styleRuntime"; +import { buildRuntimeStyleControlBarProps } from "./chatSurfaceProps"; + +type RuntimeStyleControlBarParams = Parameters< + typeof buildRuntimeStyleControlBarProps +>[0]; + +interface UseWorkspaceStyleActionPresentationParams { + enabled: RuntimeStyleControlBarParams["enabled"]; + projectId: RuntimeStyleControlBarParams["projectId"]; + activeTheme: ThemeType; + projectStyleGuide: RuntimeStyleControlBarParams["projectStyleGuide"]; + selection: RuntimeStyleControlBarParams["selection"]; + onSelectionChange: RuntimeStyleControlBarParams["onSelectionChange"]; + generalCanvasState: GeneralCanvasState; + resolvedCanvasState: CanvasStateUnion | null; + taskFiles: TaskFile[]; + selectedFileId?: string; + runtimeStylePrompt: string; + onDispatchPrompt: ( + prompt: string, + purpose: "style_rewrite" | "style_audit", + ) => void; +} + +interface WorkspaceStyleActionPresentationResult { + runtimeStyleControlBar: RuntimeStyleControlBarParams; +} + +export function useWorkspaceStyleActionPresentation({ + enabled, + projectId, + activeTheme, + projectStyleGuide, + selection, + onSelectionChange, + generalCanvasState, + resolvedCanvasState, + taskFiles, + selectedFileId, + runtimeStylePrompt, + onDispatchPrompt, +}: UseWorkspaceStyleActionPresentationParams): WorkspaceStyleActionPresentationResult { + const styleActionContent = useMemo( + () => + extractStyleActionContent({ + activeTheme, + generalCanvasState, + resolvedCanvasState, + taskFiles, + selectedFileId, + }), + [ + activeTheme, + generalCanvasState, + resolvedCanvasState, + selectedFileId, + taskFiles, + ], + ); + + const styleActionFileName = useMemo( + () => + resolveStyleActionFileName({ + activeTheme, + generalCanvasState, + resolvedCanvasState, + taskFiles, + selectedFileId, + }), + [ + activeTheme, + generalCanvasState, + resolvedCanvasState, + selectedFileId, + taskFiles, + ], + ); + + const actionsDisabled = + !projectId || !runtimeStylePrompt || !styleActionContent.trim(); + + const handleRewrite = useCallback(() => { + if (!styleActionContent.trim()) { + toast.error("当前画布还没有可重写的正文内容"); + return; + } + + if (!runtimeStylePrompt) { + toast.error("请先选择项目默认风格或任务风格"); + return; + } + + onDispatchPrompt( + buildStyleRewritePrompt({ + content: styleActionContent, + stylePrompt: runtimeStylePrompt, + fileName: styleActionFileName, + }), + "style_rewrite", + ); + }, [ + onDispatchPrompt, + runtimeStylePrompt, + styleActionContent, + styleActionFileName, + ]); + + const handleAudit = useCallback(() => { + if (!styleActionContent.trim()) { + toast.error("当前画布还没有可检查的正文内容"); + return; + } + + if (!runtimeStylePrompt) { + toast.error("请先选择项目默认风格或任务风格"); + return; + } + + onDispatchPrompt( + buildStyleAuditPrompt({ + content: styleActionContent, + stylePrompt: runtimeStylePrompt, + }), + "style_audit", + ); + }, [onDispatchPrompt, runtimeStylePrompt, styleActionContent]); + + return { + runtimeStyleControlBar: { + enabled, + projectId, + activeTheme, + projectStyleGuide, + selection, + onSelectionChange, + onRewrite: handleRewrite, + onAudit: handleAudit, + actionsDisabled, + }, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.ts new file mode 100644 index 000000000..97df3bfb1 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.ts @@ -0,0 +1,417 @@ +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime"; +import { + closeAgentRuntimeSubagent, + resumeAgentRuntimeSubagent, + sendAgentRuntimeSubagentInput, + waitAgentRuntimeSubagents, +} from "@/lib/api/agentRuntime"; +import { + isTeamWorkspaceActiveStatus, + isTeamWorkspaceTerminalStatus, + resolveTeamWorkspaceRuntimeStatusLabel, + type TeamWorkspaceControlSummary, + type TeamWorkspaceLiveRuntimeState, + type TeamWorkspaceWaitSummary, +} from "../teamWorkspaceRuntime"; + +function normalizeUniqueSessionIds(ids: string[]): string[] { + return Array.from( + new Set(ids.map((sessionId) => sessionId.trim()).filter(Boolean)), + ); +} + +function buildTeamControlSummary(params: { + action: TeamWorkspaceControlSummary["action"]; + requestedSessionIds: string[]; + cascadeSessionIds?: string[]; + affectedSessionIds?: string[]; +}): TeamWorkspaceControlSummary { + return { + action: params.action, + requestedSessionIds: normalizeUniqueSessionIds(params.requestedSessionIds), + cascadeSessionIds: normalizeUniqueSessionIds( + params.cascadeSessionIds ?? [], + ), + affectedSessionIds: normalizeUniqueSessionIds( + params.affectedSessionIds ?? [], + ), + updatedAt: Date.now(), + }; +} + +interface UseWorkspaceTeamSessionControlRuntimeParams { + childSubagentSessions: AsterSubagentSessionInfo[]; + liveRuntimeBySessionId: Record; + stopSending: () => Promise; +} + +export function useWorkspaceTeamSessionControlRuntime({ + childSubagentSessions, + liveRuntimeBySessionId, + stopSending, +}: UseWorkspaceTeamSessionControlRuntimeParams) { + const [teamWaitSummary, setTeamWaitSummary] = + useState(null); + const [teamControlSummary, setTeamControlSummary] = + useState(null); + + const handleCloseSubagentSession = useCallback( + async (subagentSessionId: string) => { + try { + const response = await closeAgentRuntimeSubagent({ + id: subagentSessionId, + }); + const summary = buildTeamControlSummary({ + action: "close", + requestedSessionIds: [subagentSessionId], + cascadeSessionIds: response.cascade_session_ids, + affectedSessionIds: response.changed_session_ids, + }); + if (summary.affectedSessionIds.length > 0) { + setTeamControlSummary(summary); + } + + if (summary.affectedSessionIds.length > 1) { + toast.success( + `已级联关闭 ${summary.affectedSessionIds.length} 位协作成员`, + ); + } else if (summary.affectedSessionIds.length === 1) { + toast.success("协作成员已关闭"); + } else { + toast.info( + `当前成员状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.previous_status.kind)},未发生新的关闭变更`, + ); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "关闭协作成员失败"; + toast.error(message); + throw error; + } + }, + [], + ); + + const handleResumeSubagentSession = useCallback( + async (subagentSessionId: string) => { + try { + const response = await resumeAgentRuntimeSubagent({ + id: subagentSessionId, + }); + const summary = buildTeamControlSummary({ + action: "resume", + requestedSessionIds: [subagentSessionId], + cascadeSessionIds: response.cascade_session_ids, + affectedSessionIds: response.changed_session_ids, + }); + if (summary.affectedSessionIds.length > 0) { + setTeamControlSummary(summary); + } + + if (summary.affectedSessionIds.length > 1) { + toast.success( + `已级联恢复 ${summary.affectedSessionIds.length} 位协作成员`, + ); + } else if (summary.affectedSessionIds.length === 1) { + toast.success("协作成员已恢复"); + } else { + toast.info( + `当前成员状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.status.kind)},未发生新的恢复变更`, + ); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "恢复协作成员失败"; + toast.error(message); + throw error; + } + }, + [], + ); + + const handleWaitSubagentSession = useCallback( + async (subagentSessionId: string, timeoutMs = 30_000) => { + try { + const response = await waitAgentRuntimeSubagents({ + ids: [subagentSessionId], + timeout_ms: timeoutMs, + }); + if (response.timed_out) { + toast.info("等待超时,该成员仍未进入最终状态"); + return; + } + + const status = response.status[subagentSessionId]; + toast.success( + `该成员已进入${resolveTeamWorkspaceRuntimeStatusLabel(status?.kind)}状态`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "等待成员失败"; + toast.error(message); + throw error; + } + }, + [], + ); + + const handleWaitActiveTeamSessions = useCallback( + async (subagentSessionIds: string[], timeoutMs = 30_000) => { + const normalizedSessionIds = + normalizeUniqueSessionIds(subagentSessionIds); + + if (normalizedSessionIds.length === 0) { + const error = new Error("没有可等待的活跃成员"); + toast.error(error.message); + throw error; + } + + try { + const response = await waitAgentRuntimeSubagents({ + ids: normalizedSessionIds, + timeout_ms: timeoutMs, + }); + if (response.timed_out) { + setTeamWaitSummary({ + awaitedSessionIds: normalizedSessionIds, + timedOut: true, + updatedAt: Date.now(), + }); + toast.info("等待超时,团队内活跃成员仍未进入最终状态"); + return; + } + + const resolvedSessionId = + normalizedSessionIds.find((sessionId) => + isTeamWorkspaceTerminalStatus(response.status[sessionId]?.kind), + ) ?? normalizedSessionIds[0]; + const resolvedStatus = resolvedSessionId + ? response.status[resolvedSessionId]?.kind + : undefined; + + setTeamWaitSummary({ + awaitedSessionIds: normalizedSessionIds, + timedOut: false, + resolvedSessionId, + resolvedStatus, + updatedAt: Date.now(), + }); + toast.success( + `团队成员已进入${resolveTeamWorkspaceRuntimeStatusLabel(resolvedStatus)}状态`, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "等待团队成员失败"; + toast.error(message); + throw error; + } + }, + [], + ); + + const handleCloseCompletedTeamSessions = useCallback( + async (subagentSessionIds: string[]) => { + const normalizedSessionIds = + normalizeUniqueSessionIds(subagentSessionIds); + + if (normalizedSessionIds.length === 0) { + const error = new Error("没有可关闭的已完成成员"); + toast.error(error.message); + throw error; + } + + const results = await Promise.allSettled( + normalizedSessionIds.map((sessionId) => + closeAgentRuntimeSubagent({ id: sessionId }), + ), + ); + const successfulResponses = results + .filter( + ( + result, + ): result is PromiseFulfilledResult< + Awaited> + > => result.status === "fulfilled", + ) + .map((result) => result.value); + const succeededCount = results.filter( + (result) => result.status === "fulfilled", + ).length; + const affectedSessionIds = normalizeUniqueSessionIds( + successfulResponses.flatMap((response) => response.changed_session_ids), + ); + const cascadeSessionIds = normalizeUniqueSessionIds( + successfulResponses.flatMap((response) => response.cascade_session_ids), + ); + const failedResults = results.filter( + (result): result is PromiseRejectedResult => + result.status === "rejected", + ); + + if (successfulResponses.length > 0) { + setTeamControlSummary( + buildTeamControlSummary({ + action: "close_completed", + requestedSessionIds: normalizedSessionIds, + cascadeSessionIds, + affectedSessionIds, + }), + ); + } + + if (succeededCount > 0) { + toast.success( + affectedSessionIds.length > 0 + ? `已级联关闭 ${affectedSessionIds.length} 个会话` + : `已关闭 ${succeededCount} 位已完成成员`, + ); + } + + if (failedResults.length > 0) { + const firstFailure = failedResults[0]?.reason; + const message = + firstFailure instanceof Error + ? firstFailure.message + : "部分已完成成员关闭失败"; + toast.error(message); + if (succeededCount === 0) { + throw firstFailure instanceof Error + ? firstFailure + : new Error(message); + } + } + }, + [], + ); + + const handleSendSubagentInput = useCallback( + async ( + subagentSessionId: string, + message: string, + options?: { interrupt?: boolean }, + ) => { + const normalizedMessage = message.trim(); + if (!normalizedMessage) { + const error = new Error("请输入要发送给成员的内容"); + toast.error(error.message); + throw error; + } + + try { + await sendAgentRuntimeSubagentInput({ + id: subagentSessionId, + message: normalizedMessage, + interrupt: options?.interrupt === true, + }); + toast.success( + options?.interrupt === true + ? "已中断当前执行并发送新说明" + : "已向成员发送补充说明", + ); + } catch (error) { + const messageText = + error instanceof Error ? error.message : "发送成员说明失败"; + toast.error(messageText); + throw error; + } + }, + [], + ); + + const handleStopSending = useCallback(async () => { + await stopSending(); + + const activeTeamSessionIds = normalizeUniqueSessionIds( + childSubagentSessions + .filter((session) => { + const liveRuntime = liveRuntimeBySessionId[session.id]; + const effectiveRuntimeStatus = + liveRuntime?.runtimeStatus ?? session.runtime_status; + const effectiveLatestTurnStatus = + liveRuntime?.latestTurnStatus ?? session.latest_turn_status; + return isTeamWorkspaceActiveStatus( + effectiveRuntimeStatus ?? effectiveLatestTurnStatus, + ); + }) + .map((session) => session.id), + ); + + if (activeTeamSessionIds.length === 0) { + return; + } + + const results = await Promise.allSettled( + activeTeamSessionIds.map((subagentSessionId) => + closeAgentRuntimeSubagent({ + id: subagentSessionId, + }), + ), + ); + + const successfulResponses = results + .filter( + ( + result, + ): result is PromiseFulfilledResult< + Awaited> + > => result.status === "fulfilled", + ) + .map((result) => result.value); + const failedResults = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + const affectedSessionIds = normalizeUniqueSessionIds( + successfulResponses.flatMap((response) => response.changed_session_ids), + ); + const cascadeSessionIds = normalizeUniqueSessionIds( + successfulResponses.flatMap((response) => response.cascade_session_ids), + ); + + if (successfulResponses.length > 0) { + const summary = buildTeamControlSummary({ + action: "close", + requestedSessionIds: activeTeamSessionIds, + cascadeSessionIds, + affectedSessionIds, + }); + if (summary.affectedSessionIds.length > 0) { + setTeamControlSummary(summary); + } + + toast.success( + affectedSessionIds.length > 1 + ? `已暂停 ${affectedSessionIds.length} 位协作成员的处理` + : affectedSessionIds.length === 1 + ? "已暂停协作成员处理" + : activeTeamSessionIds.length > 1 + ? `已向 ${activeTeamSessionIds.length} 位协作成员发送暂停请求` + : "已向协作成员发送暂停请求", + ); + } + + if (failedResults.length > 0) { + const firstFailure = failedResults[0]?.reason; + const message = + firstFailure instanceof Error + ? firstFailure.message + : "停止协作成员失败"; + toast.error(message); + if (successfulResponses.length === 0) { + throw firstFailure instanceof Error ? firstFailure : new Error(message); + } + } + }, [childSubagentSessions, liveRuntimeBySessionId, stopSending]); + + return { + teamWaitSummary, + teamControlSummary, + handleCloseSubagentSession, + handleResumeSubagentSession, + handleWaitSubagentSession, + handleWaitActiveTeamSessions, + handleCloseCompletedTeamSessions, + handleSendSubagentInput, + handleStopSending, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.ts new file mode 100644 index 000000000..29bce7d74 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.ts @@ -0,0 +1,81 @@ +import { useMemo } from "react"; +import type { + AsterSubagentParentContext, + AsterSubagentSessionInfo, +} from "@/lib/api/agentRuntime"; +import { useTeamWorkspaceRuntime } from "../hooks"; +import { + deriveCurrentSessionRuntimeStatus, + deriveLatestTurnRuntimeStatus, +} from "../agentChatWorkspaceShared"; + +interface WorkspaceSessionTopicSummary { + id: string; + title: string; +} + +interface UseWorkspaceTeamSessionRuntimeParams { + sessionId?: string; + topics: WorkspaceSessionTopicSummary[]; + turns: Array<{ status: string }>; + queuedTurnCount: number; + isSending: boolean; + subagentEnabled: boolean; + runtimeTeamState: unknown; + childSubagentSessions: AsterSubagentSessionInfo[]; + subagentParentContext: AsterSubagentParentContext | null; +} + +export function useWorkspaceTeamSessionRuntime({ + sessionId, + topics, + turns, + queuedTurnCount, + isSending, + subagentEnabled, + runtimeTeamState, + childSubagentSessions, + subagentParentContext, +}: UseWorkspaceTeamSessionRuntimeParams) { + const currentSessionTitle = useMemo( + () => topics.find((topic) => topic.id === sessionId)?.title ?? null, + [sessionId, topics], + ); + + const hasRealTeamGraph = + childSubagentSessions.length > 0 || Boolean(subagentParentContext); + const showTeamWorkspaceBoard = + subagentEnabled || hasRealTeamGraph || Boolean(runtimeTeamState); + + const currentSessionRuntimeStatus = useMemo( + () => + deriveCurrentSessionRuntimeStatus({ + isSending, + queuedTurnCount, + turns, + }), + [isSending, queuedTurnCount, turns], + ); + const currentSessionLatestTurnStatus = useMemo( + () => deriveLatestTurnRuntimeStatus(turns), + [turns], + ); + + const liveTeamWorkspaceRuntime = useTeamWorkspaceRuntime({ + currentSessionId: sessionId, + currentSessionRuntimeStatus, + currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: queuedTurnCount, + childSubagentSessions, + subagentParentContext, + }); + + return { + currentSessionTitle, + hasRealTeamGraph, + showTeamWorkspaceBoard, + currentSessionRuntimeStatus, + currentSessionLatestTurnStatus, + ...liveTeamWorkspaceRuntime, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime.ts new file mode 100644 index 000000000..670ad6c6a --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime.ts @@ -0,0 +1,112 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import type { LayoutMode } from "@/components/content-creator/types"; + +interface UseWorkspaceTeamWorkbenchAutoOpenRuntimeParams { + hasRealTeamGraph: boolean; + layoutMode: LayoutMode; + runtimeTeamRequestId: string | null; + sessionId?: string; + setLayoutMode: Dispatch>; +} + +export function useWorkspaceTeamWorkbenchAutoOpenRuntime({ + hasRealTeamGraph, + layoutMode, + runtimeTeamRequestId, + sessionId, + setLayoutMode, +}: UseWorkspaceTeamWorkbenchAutoOpenRuntimeParams) { + const [teamWorkbenchAutoFocusToken, setTeamWorkbenchAutoFocusToken] = + useState(0); + const previousTeamWorkbenchSessionIdRef = useRef( + sessionId ?? null, + ); + const previousRealTeamGraphRef = useRef(hasRealTeamGraph); + const previousTeamWorkbenchLayoutModeRef = useRef(layoutMode); + const previousRuntimeTeamRequestIdRef = useRef( + runtimeTeamRequestId, + ); + const suppressedAutoOpenRef = useRef(false); + const suppressedRuntimeTeamRequestIdRef = useRef(null); + + const dismissActiveTeamWorkbenchAutoOpen = useCallback(() => { + suppressedAutoOpenRef.current = true; + if (runtimeTeamRequestId) { + suppressedRuntimeTeamRequestIdRef.current = runtimeTeamRequestId; + } + }, [runtimeTeamRequestId]); + + const handleActivateTeamWorkbench = useCallback(() => { + setTeamWorkbenchAutoFocusToken((current) => current + 1); + setLayoutMode((current) => (current === "chat" ? "chat-canvas" : current)); + }, [setLayoutMode]); + + useEffect(() => { + const previousLayoutMode = previousTeamWorkbenchLayoutModeRef.current; + + if (previousLayoutMode !== "chat" && layoutMode === "chat") { + suppressedAutoOpenRef.current = true; + if (runtimeTeamRequestId) { + suppressedRuntimeTeamRequestIdRef.current = runtimeTeamRequestId; + } + } + + previousTeamWorkbenchLayoutModeRef.current = layoutMode; + }, [layoutMode, runtimeTeamRequestId]); + + useEffect(() => { + const normalizedSessionId = sessionId ?? null; + + if (previousTeamWorkbenchSessionIdRef.current !== normalizedSessionId) { + previousTeamWorkbenchSessionIdRef.current = normalizedSessionId; + previousRealTeamGraphRef.current = hasRealTeamGraph; + previousTeamWorkbenchLayoutModeRef.current = layoutMode; + previousRuntimeTeamRequestIdRef.current = runtimeTeamRequestId; + suppressedAutoOpenRef.current = false; + suppressedRuntimeTeamRequestIdRef.current = null; + return; + } + + if (previousRuntimeTeamRequestIdRef.current !== runtimeTeamRequestId) { + const previousSuppressedRequestId = + suppressedRuntimeTeamRequestIdRef.current; + previousRuntimeTeamRequestIdRef.current = runtimeTeamRequestId; + if ( + !runtimeTeamRequestId || + previousSuppressedRequestId !== runtimeTeamRequestId + ) { + suppressedAutoOpenRef.current = false; + suppressedRuntimeTeamRequestIdRef.current = null; + } + } + + if ( + hasRealTeamGraph && + !previousRealTeamGraphRef.current && + !suppressedAutoOpenRef.current + ) { + handleActivateTeamWorkbench(); + } + + previousRealTeamGraphRef.current = hasRealTeamGraph; + }, [ + handleActivateTeamWorkbench, + hasRealTeamGraph, + layoutMode, + runtimeTeamRequestId, + sessionId, + ]); + + return { + teamWorkbenchAutoFocusToken, + dismissActiveTeamWorkbenchAutoOpen, + handleActivateTeamWorkbench, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime.ts new file mode 100644 index 000000000..fda000a30 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime.ts @@ -0,0 +1,77 @@ +import { useEffect, type MutableRefObject } from "react"; +import { updateContent } from "@/lib/api/project"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard"; +import { + THEME_WORKBENCH_DOCUMENT_META_KEY, + buildPersistedThemeWorkbenchDocument, +} from "./themeWorkbenchHelpers"; + +interface UseWorkspaceThemeWorkbenchDocumentPersistenceRuntimeParams { + isThemeWorkbench: boolean; + contentId?: string | null; + canvasState: CanvasStateUnion | null; + documentVersionStatusMap: Record; + contentMetadataRef: MutableRefObject>; + persistedWorkbenchSnapshotRef: MutableRefObject; +} + +export function useWorkspaceThemeWorkbenchDocumentPersistenceRuntime({ + isThemeWorkbench, + contentId, + canvasState, + documentVersionStatusMap, + contentMetadataRef, + persistedWorkbenchSnapshotRef, +}: UseWorkspaceThemeWorkbenchDocumentPersistenceRuntimeParams) { + useEffect(() => { + if ( + !isThemeWorkbench || + !contentId || + !canvasState || + canvasState.type !== "document" + ) { + return; + } + + const persisted = buildPersistedThemeWorkbenchDocument( + canvasState, + documentVersionStatusMap, + ); + if (!persisted) { + return; + } + + const snapshot = JSON.stringify(persisted); + if (snapshot === persistedWorkbenchSnapshotRef.current) { + return; + } + + const nextMetadata = { + ...(contentMetadataRef.current || {}), + [THEME_WORKBENCH_DOCUMENT_META_KEY]: persisted, + }; + + const timer = setTimeout(() => { + updateContent(contentId, { + metadata: nextMetadata, + }) + .then((updated) => { + contentMetadataRef.current = updated.metadata || nextMetadata; + persistedWorkbenchSnapshotRef.current = snapshot; + }) + .catch((error) => { + console.warn("[AgentChatPage] 保存文稿版本状态失败:", error); + }); + }, 1000); + + return () => clearTimeout(timer); + }, [ + canvasState, + contentId, + contentMetadataRef, + documentVersionStatusMap, + isThemeWorkbench, + persistedWorkbenchSnapshotRef, + ]); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchRuntime.ts new file mode 100644 index 000000000..23a3e59d5 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchRuntime.ts @@ -0,0 +1,309 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, +} from "react"; +import { + executionRunGetThemeWorkbenchState, + type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState, + type ThemeWorkbenchRunTodoItem, +} from "@/lib/api/executionRun"; +import { subscribeDocumentEditorFocus } from "@/lib/documentEditorFocusEvents"; +import type { ThemeWorkbenchGateState } from "../components/Inputbar/hooks/useThemeWorkbenchInputState"; +import type { ActionRequired } from "../types"; +import { + THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS, + buildThemeWorkbenchRunStateSignature, + inferThemeWorkbenchGateFromQueueItem, + resolveThemeWorkbenchGateByKey, +} from "./themeWorkbenchHelpers"; + +type ThemeWorkbenchRuntimeState = + | "idle" + | "auto_running" + | "await_user_decision"; + +interface UseWorkspaceThemeWorkbenchRuntimeParams { + isThemeWorkbench: boolean; + sessionId?: string; + isSending: boolean; + pendingActionRequest: ActionRequired | null; +} + +interface ThemeWorkbenchGateBase { + key: string; + title: string; + description: string; + requiresUserDecision: boolean; +} + +const IDLE_GATE: ThemeWorkbenchGateBase = { + key: "idle", + title: "编排待启动", + description: "输入目标后将自动进入编排执行。", + requiresUserDecision: false, +}; + +function hasFreshRunningQueueItem( + queueItems: ThemeWorkbenchRunTodoItem[] = [], +): boolean { + return queueItems.some((item) => { + if (item.status !== "running") { + return false; + } + + const startedAt = new Date(item.started_at); + if (Number.isNaN(startedAt.getTime())) { + return false; + } + + return ( + Date.now() - startedAt.getTime() <= THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS + ); + }); +} + +function resolveThemeWorkbenchGateBase(params: { + isThemeWorkbench: boolean; + pendingActionRequest: ActionRequired | null; + themeWorkbenchBackendRunState: BackendThemeWorkbenchRunState | null; + themeWorkbenchActiveQueueItem: ThemeWorkbenchRunTodoItem | null; +}): ThemeWorkbenchGateBase { + const { + isThemeWorkbench, + pendingActionRequest, + themeWorkbenchBackendRunState, + themeWorkbenchActiveQueueItem, + } = params; + + if (!isThemeWorkbench) { + return IDLE_GATE; + } + + if (pendingActionRequest) { + return { + key: pendingActionRequest.actionType, + title: "人工闸门", + description: + pendingActionRequest.prompt || + pendingActionRequest.questions?.[0]?.question || + "等待你的决策以继续执行后续节点。", + requiresUserDecision: true, + }; + } + + if (themeWorkbenchBackendRunState?.run_state === "auto_running") { + const backendGateKey = themeWorkbenchBackendRunState.current_gate_key; + if ( + backendGateKey === "topic_select" || + backendGateKey === "write_mode" || + backendGateKey === "publish_confirm" + ) { + const backendGate = resolveThemeWorkbenchGateByKey( + backendGateKey, + themeWorkbenchActiveQueueItem?.title, + ); + return { + key: backendGate.key, + title: backendGate.title, + description: backendGate.description, + requiresUserDecision: false, + }; + } + + const inferredGate = inferThemeWorkbenchGateFromQueueItem( + themeWorkbenchActiveQueueItem, + ); + return { + key: inferredGate.key, + title: inferredGate.title, + description: inferredGate.description, + requiresUserDecision: false, + }; + } + + return IDLE_GATE; +} + +export function useWorkspaceThemeWorkbenchRuntime({ + isThemeWorkbench, + sessionId, + isSending, + pendingActionRequest, +}: UseWorkspaceThemeWorkbenchRuntimeParams): { + currentGate: ThemeWorkbenchGateState; + documentEditorFocusedRef: MutableRefObject; + themeWorkbenchActiveQueueItem: ThemeWorkbenchRunTodoItem | null; + themeWorkbenchBackendRunState: BackendThemeWorkbenchRunState | null; + themeWorkbenchRunState: ThemeWorkbenchRuntimeState; +} { + const documentEditorFocusedRef = useRef(false); + const themeWorkbenchRunStateSignatureRef = useRef(""); + const [themeWorkbenchBackendRunState, setThemeWorkbenchBackendRunState] = + useState(null); + + useEffect(() => { + const unsubscribe = subscribeDocumentEditorFocus((focused) => { + documentEditorFocusedRef.current = focused; + }); + return unsubscribe; + }, []); + + useEffect(() => { + if (!isThemeWorkbench || !sessionId) { + themeWorkbenchRunStateSignatureRef.current = ""; + setThemeWorkbenchBackendRunState(null); + return; + } + + let disposed = false; + let inFlight = false; + let timer: number | null = null; + const activePollIntervalMs = isSending ? 1000 : 3000; + const idlePollIntervalMs = isSending ? 1000 : 10000; + const focusedPollIntervalMs = isSending ? 1000 : 15000; + + const scheduleNext = (delayMs: number) => { + if (disposed) { + return; + } + + timer = window.setTimeout(() => { + void fetchRunState(); + }, delayMs); + }; + + const fetchRunState = async () => { + if (disposed || inFlight) { + return; + } + + inFlight = true; + try { + const state = await executionRunGetThemeWorkbenchState(sessionId, 3); + if (!disposed) { + const nextSignature = buildThemeWorkbenchRunStateSignature(state); + if (themeWorkbenchRunStateSignatureRef.current !== nextSignature) { + themeWorkbenchRunStateSignatureRef.current = nextSignature; + setThemeWorkbenchBackendRunState(state); + } + + const latestTerminalRunning = + state.latest_terminal?.status === "running"; + const hasActiveBackendRun = + state.run_state === "auto_running" || + hasFreshRunningQueueItem(state.queue_items || []) || + latestTerminalRunning; + scheduleNext( + hasActiveBackendRun + ? activePollIntervalMs + : documentEditorFocusedRef.current + ? focusedPollIntervalMs + : idlePollIntervalMs, + ); + } + } catch (error) { + if (!disposed) { + console.warn("[AgentChatPage] 拉取主题工作台运行状态失败:", error); + if (themeWorkbenchRunStateSignatureRef.current !== "null") { + themeWorkbenchRunStateSignatureRef.current = "null"; + setThemeWorkbenchBackendRunState(null); + } + scheduleNext( + documentEditorFocusedRef.current + ? focusedPollIntervalMs + : activePollIntervalMs, + ); + } + } finally { + inFlight = false; + } + }; + + void fetchRunState(); + + return () => { + disposed = true; + if (timer !== null) { + window.clearTimeout(timer); + } + }; + }, [isSending, isThemeWorkbench, sessionId]); + + const themeWorkbenchActiveQueueItem = useMemo(() => { + const queueItems = themeWorkbenchBackendRunState?.queue_items || []; + return ( + queueItems.find((item) => item.status === "running") || + queueItems[0] || + null + ); + }, [themeWorkbenchBackendRunState?.queue_items]); + + const currentGateBase = useMemo( + () => + resolveThemeWorkbenchGateBase({ + isThemeWorkbench, + pendingActionRequest, + themeWorkbenchBackendRunState, + themeWorkbenchActiveQueueItem, + }), + [ + isThemeWorkbench, + pendingActionRequest, + themeWorkbenchActiveQueueItem, + themeWorkbenchBackendRunState, + ], + ); + + const themeWorkbenchRunState = useMemo(() => { + if (!isThemeWorkbench) { + return "idle"; + } + + if (currentGateBase.requiresUserDecision) { + return "await_user_decision"; + } + + if (themeWorkbenchBackendRunState) { + if (themeWorkbenchBackendRunState.run_state !== "auto_running") { + return "idle"; + } + + return hasFreshRunningQueueItem( + themeWorkbenchBackendRunState.queue_items || [], + ) || isSending + ? "auto_running" + : "idle"; + } + + return isSending ? "auto_running" : "idle"; + }, [ + currentGateBase.requiresUserDecision, + isSending, + isThemeWorkbench, + themeWorkbenchBackendRunState, + ]); + + const currentGate = useMemo(() => { + return { + key: currentGateBase.key, + title: currentGateBase.title, + description: currentGateBase.description, + status: currentGateBase.requiresUserDecision + ? "waiting" + : themeWorkbenchRunState === "auto_running" + ? "running" + : "idle", + }; + }, [currentGateBase, themeWorkbenchRunState]); + + return { + currentGate, + documentEditorFocusedRef, + themeWorkbenchActiveQueueItem, + themeWorkbenchBackendRunState, + themeWorkbenchRunState, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchScaffoldRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchScaffoldRuntime.ts new file mode 100644 index 000000000..16bca05bf --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchScaffoldRuntime.ts @@ -0,0 +1,225 @@ +import { + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import { safeListen } from "@/lib/dev-bridge"; +import { + createInitialCanvasState, + type CanvasStateUnion, +} from "@/components/content-creator/canvas/canvasUtils"; +import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; +import type { LayoutMode, ThemeType } from "@/components/content-creator/types"; +import type { ThemeWorkbenchCreationTaskEvent } from "../components/themeWorkbenchWorkflowData"; +import { useTopicBranchBoard } from "../hooks"; +import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard"; + +const THEME_WORKBENCH_CREATION_TASK_EVENT_NAME = + "lime://creation_task_submitted"; +const MAX_THEME_WORKBENCH_CREATION_TASK_EVENTS = 120; + +interface CreationTaskSubmittedPayload { + task_id?: string; + task_type?: string; + path?: string; + absolute_path?: string; +} + +function normalizeThemeWorkbenchCreationTaskEvent( + payload: CreationTaskSubmittedPayload, +): ThemeWorkbenchCreationTaskEvent | null { + const taskId = payload.task_id?.trim(); + const taskType = payload.task_type?.trim(); + const path = payload.path?.trim(); + if (!taskId || !taskType || !path) { + return null; + } + const createdAt = Date.now(); + return { + taskId, + taskType, + path, + absolutePath: payload.absolute_path?.trim() || undefined, + createdAt, + timeLabel: new Date(createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }), + }; +} + +interface UseWorkspaceThemeWorkbenchScaffoldRuntimeParams { + isThemeWorkbench: boolean; + mappedTheme: ThemeType; + sessionId?: string; + projectId?: string; + canvasState: CanvasStateUnion | null; + documentVersionStatusMap: Record; + setDocumentVersionStatusMap: Dispatch< + SetStateAction> + >; + clearThemeSkillsRailState: () => void; + setCanvasState: Dispatch>; + setLayoutMode: Dispatch>; +} + +export function useWorkspaceThemeWorkbenchScaffoldRuntime({ + isThemeWorkbench, + mappedTheme, + sessionId, + projectId, + canvasState, + documentVersionStatusMap, + setDocumentVersionStatusMap, + clearThemeSkillsRailState, + setCanvasState, + setLayoutMode, +}: UseWorkspaceThemeWorkbenchScaffoldRuntimeParams) { + const [themeWorkbenchSidebarCollapsed, setThemeWorkbenchSidebarCollapsed] = + useState(false); + const [ + themeWorkbenchCreationTaskEvents, + setThemeWorkbenchCreationTaskEvents, + ] = useState([]); + + const shouldUseCompactThemeWorkbench = + isThemeWorkbench && (mappedTheme === "video" || mappedTheme === "poster"); + const shouldSkipThemeWorkbenchAutoGuideWithoutPrompt = + isThemeWorkbench && + (shouldUseCompactThemeWorkbench || mappedTheme === "novel"); + const enableThemeWorkbenchPanelCollapse = + isThemeWorkbench && mappedTheme === "social-media"; + + useEffect(() => { + if (!isThemeWorkbench) { + clearThemeSkillsRailState(); + } + }, [isThemeWorkbench, clearThemeSkillsRailState]); + + useEffect(() => { + return () => { + clearThemeSkillsRailState(); + }; + }, [clearThemeSkillsRailState]); + + useEffect(() => { + if (!isThemeWorkbench) { + setThemeWorkbenchCreationTaskEvents([]); + } + }, [isThemeWorkbench]); + + useEffect(() => { + if (!isThemeWorkbench || !sessionId) { + return; + } + + setThemeWorkbenchCreationTaskEvents([]); + + let cancelled = false; + let unlisten: (() => void) | null = null; + + safeListen( + THEME_WORKBENCH_CREATION_TASK_EVENT_NAME, + (event) => { + if (cancelled) { + return; + } + const normalized = normalizeThemeWorkbenchCreationTaskEvent( + event.payload || {}, + ); + if (!normalized) { + return; + } + setThemeWorkbenchCreationTaskEvents((previous) => { + const deduplicated = previous.filter( + (item) => + item.taskId !== normalized.taskId && + item.path !== normalized.path, + ); + return [normalized, ...deduplicated].slice( + 0, + MAX_THEME_WORKBENCH_CREATION_TASK_EVENTS, + ); + }); + }, + ) + .then((dispose) => { + if (cancelled) { + void dispose(); + return; + } + unlisten = dispose; + }) + .catch((error) => { + console.warn("[AgentChatPage] 监听任务提交事件失败:", error); + }); + + return () => { + cancelled = true; + if (unlisten) { + unlisten(); + } + }; + }, [isThemeWorkbench, sessionId]); + + useEffect(() => { + if (!isThemeWorkbench || canvasState) { + return; + } + + const initialThemeWorkbenchCanvas = + createInitialCanvasState(mappedTheme, "") || + createInitialDocumentState(""); + if (!initialThemeWorkbenchCanvas) { + return; + } + + setCanvasState(initialThemeWorkbenchCanvas); + setLayoutMode((previous) => (previous === "chat" ? "canvas" : previous)); + }, [canvasState, isThemeWorkbench, mappedTheme, setCanvasState, setLayoutMode]); + + useEffect(() => { + if (enableThemeWorkbenchPanelCollapse) { + return; + } + setThemeWorkbenchSidebarCollapsed(false); + }, [enableThemeWorkbenchPanelCollapse]); + + const versionTopics = useMemo(() => { + if (!isThemeWorkbench || !canvasState || canvasState.type !== "document") { + return []; + } + return canvasState.versions.map((version, index) => ({ + id: version.id, + title: version.description?.trim() || `版本 ${index + 1}`, + messagesCount: version.content.trim() ? 2 : 0, + })); + }, [canvasState, isThemeWorkbench]); + + const currentVersionId = + isThemeWorkbench && canvasState?.type === "document" + ? canvasState.currentVersionId + : null; + + const { branchItems, setTopicStatus } = useTopicBranchBoard({ + enabled: isThemeWorkbench && canvasState?.type === "document", + projectId, + currentTopicId: currentVersionId, + topics: versionTopics, + externalStatusMap: documentVersionStatusMap, + onStatusMapChange: setDocumentVersionStatusMap, + }); + + return { + shouldUseCompactThemeWorkbench, + shouldSkipThemeWorkbenchAutoGuideWithoutPrompt, + enableThemeWorkbenchPanelCollapse, + themeWorkbenchSidebarCollapsed, + setThemeWorkbenchSidebarCollapsed, + themeWorkbenchCreationTaskEvents, + branchItems, + setTopicStatus, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchShellRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchShellRuntime.tsx new file mode 100644 index 000000000..1e97ba46b --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchShellRuntime.tsx @@ -0,0 +1,137 @@ +import { useWorkspaceContextHarnessRuntime } from "./useWorkspaceContextHarnessRuntime"; +import { useWorkspaceHarnessInventoryRuntime } from "./useWorkspaceHarnessInventoryRuntime"; +import { useWorkspaceThemeWorkbenchScaffoldRuntime } from "./useWorkspaceThemeWorkbenchScaffoldRuntime"; +import { useWorkspaceThemeWorkbenchSidebarRuntime } from "./useWorkspaceThemeWorkbenchSidebarRuntime"; +import { useThemeWorkbenchSidebarPresentation } from "./useThemeWorkbenchSidebarPresentation"; + +type ThemeWorkbenchSidebarPresentationParams = Parameters< + typeof useThemeWorkbenchSidebarPresentation +>[0]; +type ThemeWorkbenchSidebarHistory = NonNullable< + ThemeWorkbenchSidebarPresentationParams["sidebar"]["history"] +>; +type ContextHarnessRuntime = ReturnType; +type HarnessInventoryRuntime = ReturnType< + typeof useWorkspaceHarnessInventoryRuntime +>; +type ThemeWorkbenchScaffoldRuntime = ReturnType< + typeof useWorkspaceThemeWorkbenchScaffoldRuntime +>; +type ThemeWorkbenchSidebarRuntime = ReturnType< + typeof useWorkspaceThemeWorkbenchSidebarRuntime +>; + +interface UseWorkspaceThemeWorkbenchShellRuntimeParams { + showChatPanel: ThemeWorkbenchSidebarPresentationParams["showChatPanel"]; + showSidebar: ThemeWorkbenchSidebarPresentationParams["showSidebar"]; + contextHarnessRuntime: ContextHarnessRuntime; + themeWorkbenchScaffoldRuntime: ThemeWorkbenchScaffoldRuntime; + themeWorkbenchSidebarRuntime: ThemeWorkbenchSidebarRuntime; + harnessInventoryRuntime: HarnessInventoryRuntime; + handleCreateVersionSnapshot: ThemeWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onCreateVersionSnapshot"]; + handleSwitchBranchVersion: ThemeWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onSwitchBranchVersion"]; + handleSetBranchStatus: ThemeWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onSetBranchStatus"]; + handleAddImage: ThemeWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onAddImage"]; + handleImportDocument: ThemeWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onImportDocument"]; + handleViewContextDetail: ThemeWorkbenchSidebarPresentationParams["sidebar"]["onViewContextDetail"]; + messages: ThemeWorkbenchSidebarHistory["messages"]; + harnessState: ThemeWorkbenchSidebarPresentationParams["harnessDialog"]["harnessState"]; + compatSubagentRuntime: ThemeWorkbenchSidebarPresentationParams["harnessDialog"]["compatSubagentRuntime"]; + childSubagentSessions: ThemeWorkbenchSidebarPresentationParams["harnessDialog"]["childSubagentSessions"]; + selectedTeamLabel: ThemeWorkbenchSidebarPresentationParams["harnessDialog"]["selectedTeamLabel"]; + selectedTeamSummary: ThemeWorkbenchSidebarPresentationParams["harnessDialog"]["selectedTeamSummary"]; + selectedTeamRoles: ThemeWorkbenchSidebarPresentationParams["harnessDialog"]["selectedTeamRoles"]; + handleOpenSubagentSession: ThemeWorkbenchSidebarPresentationParams["harnessDialog"]["onOpenSubagentSession"]; + handleHarnessLoadFilePreview: ThemeWorkbenchSidebarPresentationParams["harnessDialog"]["onLoadFilePreview"]; + handleFileClick: ThemeWorkbenchSidebarPresentationParams["harnessDialog"]["onOpenFile"]; +} + +export function useWorkspaceThemeWorkbenchShellRuntime({ + showChatPanel, + showSidebar, + contextHarnessRuntime, + themeWorkbenchScaffoldRuntime, + themeWorkbenchSidebarRuntime, + harnessInventoryRuntime, + handleCreateVersionSnapshot, + handleSwitchBranchVersion, + handleSetBranchStatus, + handleAddImage, + handleImportDocument, + handleViewContextDetail, + messages, + harnessState, + compatSubagentRuntime, + childSubagentSessions, + selectedTeamLabel, + selectedTeamSummary, + selectedTeamRoles, + handleOpenSubagentSession, + handleHarnessLoadFilePreview, + handleFileClick, +}: UseWorkspaceThemeWorkbenchShellRuntimeParams) { + return useThemeWorkbenchSidebarPresentation({ + showChatPanel, + showSidebar, + isThemeWorkbench: contextHarnessRuntime.isThemeWorkbench, + shouldUseCompactThemeWorkbench: + themeWorkbenchScaffoldRuntime.shouldUseCompactThemeWorkbench, + enablePanelCollapse: + themeWorkbenchScaffoldRuntime.enableThemeWorkbenchPanelCollapse, + sidebarCollapsed: themeWorkbenchScaffoldRuntime.themeWorkbenchSidebarCollapsed, + onSidebarCollapsedChange: + themeWorkbenchScaffoldRuntime.setThemeWorkbenchSidebarCollapsed, + sidebar: { + socialMediaHarnessSummary: harnessInventoryRuntime.socialMediaHarnessSummary, + harnessPanelVisible: contextHarnessRuntime.harnessPanelVisible, + onToggleHarnessPanel: contextHarnessRuntime.handleToggleHarnessPanel, + workflow: { + branchItems: themeWorkbenchScaffoldRuntime.branchItems, + onCreateVersionSnapshot: handleCreateVersionSnapshot, + onSwitchBranchVersion: handleSwitchBranchVersion, + onSetBranchStatus: handleSetBranchStatus, + workflowSteps: themeWorkbenchSidebarRuntime.themeWorkbenchWorkflowSteps, + onAddImage: handleAddImage, + onImportDocument: handleImportDocument, + activityLogs: themeWorkbenchSidebarRuntime.themeWorkbenchActivityLogs, + creationTaskEvents: + themeWorkbenchScaffoldRuntime.themeWorkbenchCreationTaskEvents, + onViewRunDetail: + themeWorkbenchSidebarRuntime.handleViewThemeWorkbenchRunDetail, + activeRunDetail: + themeWorkbenchSidebarRuntime.selectedThemeWorkbenchRunDetail, + activeRunDetailLoading: + themeWorkbenchSidebarRuntime.themeWorkbenchRunDetailLoading, + }, + contextWorkspace: contextHarnessRuntime.contextWorkspace, + onViewContextDetail: handleViewContextDetail, + history: { + hasMore: themeWorkbenchSidebarRuntime.themeWorkbenchHistoryHasMore, + loading: themeWorkbenchSidebarRuntime.themeWorkbenchHistoryLoading, + onLoadMore: + themeWorkbenchSidebarRuntime.handleLoadMoreThemeWorkbenchHistory, + skillDetailMap: themeWorkbenchSidebarRuntime.themeWorkbenchSkillDetailMap, + messages, + }, + }, + harnessDialog: { + enabled: contextHarnessRuntime.isThemeWorkbench, + open: contextHarnessRuntime.harnessPanelVisible, + onOpenChange: contextHarnessRuntime.setHarnessPanelVisible, + harnessState, + compatSubagentRuntime, + environment: contextHarnessRuntime.harnessEnvironment, + childSubagentSessions, + selectedTeamLabel, + selectedTeamSummary, + selectedTeamRoles, + toolInventory: harnessInventoryRuntime.toolInventory, + toolInventoryLoading: harnessInventoryRuntime.toolInventoryLoading, + toolInventoryError: harnessInventoryRuntime.toolInventoryError, + onRefreshToolInventory: harnessInventoryRuntime.refreshToolInventory, + onOpenSubagentSession: handleOpenSubagentSession, + onLoadFilePreview: handleHarnessLoadFilePreview, + onOpenFile: handleFileClick, + }, + }); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchSidebarRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchSidebarRuntime.ts new file mode 100644 index 000000000..1d3144e7f --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchSidebarRuntime.ts @@ -0,0 +1,427 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + executionRunGet, + executionRunListThemeWorkbenchHistory, + type AgentRun, + type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState, + type ThemeWorkbenchRunTerminalItem, +} from "@/lib/api/executionRun"; +import { + skillExecutionApi, + type SkillDetailInfo, +} from "@/lib/api/skill-execution"; +import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace"; +import type { Message } from "../types"; +import { parseSkillSlashCommand } from "../hooks/skillCommand"; +import { + buildThemeWorkbenchWorkflowSteps, + formatThemeWorkbenchRunDurationLabel, + formatThemeWorkbenchRunTimeLabel, + inferThemeWorkbenchGateFromQueueItem, + mergeThemeWorkbenchTerminalItems, + resolveExecutionIdCandidatesForActivityLog, + resolveThemeWorkbenchApplyTargetByGateKey, + resolveThemeWorkbenchRecentTerminals, + resolveThemeWorkbenchSkillSourceRef, +} from "./themeWorkbenchHelpers"; + +interface UseWorkspaceThemeWorkbenchSidebarRuntimeParams { + isThemeWorkbench: boolean; + sessionId?: string; + messages: Message[]; + isSending: boolean; + themeWorkbenchBackendRunState: BackendThemeWorkbenchRunState | null; + contextActivityLogs: SidebarActivityLog[]; + historyPageSize: number; +} + +export function useWorkspaceThemeWorkbenchSidebarRuntime({ + isThemeWorkbench, + sessionId, + messages, + isSending, + themeWorkbenchBackendRunState, + contextActivityLogs, + historyPageSize, +}: UseWorkspaceThemeWorkbenchSidebarRuntimeParams) { + const [themeWorkbenchHistoryTerminals, setThemeWorkbenchHistoryTerminals] = + useState([]); + const [themeWorkbenchHistoryHasMore, setThemeWorkbenchHistoryHasMore] = + useState(false); + const [themeWorkbenchHistoryNextOffset, setThemeWorkbenchHistoryNextOffset] = + useState(null); + const [themeWorkbenchHistoryLoading, setThemeWorkbenchHistoryLoading] = + useState(false); + const [themeWorkbenchSkillDetailMap, setThemeWorkbenchSkillDetailMap] = + useState>({}); + const [selectedThemeWorkbenchRunId, setSelectedThemeWorkbenchRunId] = + useState(null); + const [selectedThemeWorkbenchRunDetail, setSelectedThemeWorkbenchRunDetail] = + useState(null); + const [themeWorkbenchRunDetailLoading, setThemeWorkbenchRunDetailLoading] = + useState(false); + const themeWorkbenchHistoryLoadingRef = useRef(false); + + const loadThemeWorkbenchHistory = useCallback( + async (offset: number, replace: boolean) => { + if ( + !isThemeWorkbench || + !sessionId || + themeWorkbenchHistoryLoadingRef.current + ) { + return; + } + + themeWorkbenchHistoryLoadingRef.current = true; + setThemeWorkbenchHistoryLoading(true); + try { + const page = await executionRunListThemeWorkbenchHistory( + sessionId, + historyPageSize, + offset, + ); + setThemeWorkbenchHistoryTerminals((previous) => + replace + ? mergeThemeWorkbenchTerminalItems(page.items || []) + : mergeThemeWorkbenchTerminalItems(previous, page.items || []), + ); + setThemeWorkbenchHistoryHasMore(Boolean(page.has_more)); + setThemeWorkbenchHistoryNextOffset(page.next_offset ?? null); + } catch (error) { + console.warn("[AgentChatPage] 拉取主题工作台历史日志失败:", error); + if (replace) { + setThemeWorkbenchHistoryTerminals([]); + setThemeWorkbenchHistoryHasMore(false); + setThemeWorkbenchHistoryNextOffset(null); + } + } finally { + themeWorkbenchHistoryLoadingRef.current = false; + setThemeWorkbenchHistoryLoading(false); + } + }, + [historyPageSize, isThemeWorkbench, sessionId], + ); + + useEffect(() => { + if (!isThemeWorkbench || !sessionId) { + themeWorkbenchHistoryLoadingRef.current = false; + setThemeWorkbenchHistoryTerminals([]); + setThemeWorkbenchHistoryHasMore(false); + setThemeWorkbenchHistoryNextOffset(null); + setThemeWorkbenchHistoryLoading(false); + return; + } + + void loadThemeWorkbenchHistory(0, true); + }, [isThemeWorkbench, loadThemeWorkbenchHistory, sessionId]); + + const themeWorkbenchRequiredSkillNames = useMemo(() => { + if (!isThemeWorkbench) { + return [] as string[]; + } + + const requiredSkillNames = new Set(); + messages.forEach((message) => { + if (message.role !== "user") { + return; + } + const skillName = parseSkillSlashCommand(message.content)?.skillName; + if (skillName) { + requiredSkillNames.add(skillName); + } + }); + (themeWorkbenchBackendRunState?.queue_items || []).forEach((item) => { + const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); + if (sourceRef) { + requiredSkillNames.add(sourceRef); + } + }); + const terminalSourceRef = resolveThemeWorkbenchSkillSourceRef( + themeWorkbenchBackendRunState?.latest_terminal || {}, + ); + if (terminalSourceRef) { + requiredSkillNames.add(terminalSourceRef); + } + + return [...requiredSkillNames].sort(); + }, [ + isThemeWorkbench, + messages, + themeWorkbenchBackendRunState?.latest_terminal, + themeWorkbenchBackendRunState?.queue_items, + ]); + + useEffect(() => { + if (!isThemeWorkbench) { + setThemeWorkbenchSkillDetailMap((previous) => + Object.keys(previous).length === 0 ? previous : {}, + ); + return; + } + + const missingSkillNames = themeWorkbenchRequiredSkillNames.filter( + (skillName) => !(skillName in themeWorkbenchSkillDetailMap), + ); + if (missingSkillNames.length === 0) { + return; + } + + let disposed = false; + Promise.all( + missingSkillNames.map(async (skillName) => { + try { + const detail = await skillExecutionApi.getSkillDetail(skillName); + return [skillName, detail] as const; + } catch (error) { + console.warn( + "[AgentChatPage] 加载 Skill 详情失败:", + skillName, + error, + ); + return [skillName, null] as const; + } + }), + ).then((entries) => { + if (disposed) { + return; + } + setThemeWorkbenchSkillDetailMap((previous) => { + const next = { ...previous }; + entries.forEach(([skillName, detail]) => { + next[skillName] = detail; + }); + return next; + }); + }); + + return () => { + disposed = true; + }; + }, [ + isThemeWorkbench, + themeWorkbenchRequiredSkillNames, + themeWorkbenchSkillDetailMap, + ]); + + const themeWorkbenchWorkflowSteps = useMemo( + () => + buildThemeWorkbenchWorkflowSteps( + messages, + themeWorkbenchBackendRunState, + isSending, + themeWorkbenchSkillDetailMap, + ), + [ + isSending, + messages, + themeWorkbenchBackendRunState, + themeWorkbenchSkillDetailMap, + ], + ); + + const themeWorkbenchMergedTerminals = useMemo( + () => + mergeThemeWorkbenchTerminalItems( + resolveThemeWorkbenchRecentTerminals(themeWorkbenchBackendRunState), + themeWorkbenchHistoryTerminals, + ), + [themeWorkbenchBackendRunState, themeWorkbenchHistoryTerminals], + ); + + const themeWorkbenchExecutionRunMap = useMemo(() => { + const map = new Map(); + if (!isThemeWorkbench || !themeWorkbenchBackendRunState) { + return map; + } + + const register = (executionId?: string | null, runId?: string | null) => { + const normalizedExecutionId = executionId?.trim(); + const normalizedRunId = runId?.trim(); + if (!normalizedExecutionId || !normalizedRunId) { + return; + } + map.set(normalizedExecutionId, normalizedRunId); + }; + + (themeWorkbenchBackendRunState.queue_items || []).forEach((item) => { + register(item.execution_id, item.run_id); + }); + themeWorkbenchMergedTerminals.forEach((item) => { + register(item.execution_id, item.run_id); + }); + + return map; + }, [ + isThemeWorkbench, + themeWorkbenchBackendRunState, + themeWorkbenchMergedTerminals, + ]); + + const themeWorkbenchBackendActivityLogs = useMemo< + SidebarActivityLog[] + >(() => { + if (!isThemeWorkbench || !themeWorkbenchBackendRunState) { + return []; + } + + const runningLogs = (themeWorkbenchBackendRunState.queue_items || []).map( + (item) => { + const gateKey = + item.gate_key || inferThemeWorkbenchGateFromQueueItem(item).key; + return { + id: `run-queue-${item.run_id}`, + name: item.title || "执行主题工作台编排", + status: "running" as const, + timeLabel: formatThemeWorkbenchRunTimeLabel(item.started_at), + applyTarget: resolveThemeWorkbenchApplyTargetByGateKey(gateKey), + runId: item.run_id, + executionId: item.execution_id || undefined, + sessionId: item.session_id || undefined, + artifactPaths: + Array.isArray(item.artifact_paths) && item.artifact_paths.length > 0 + ? item.artifact_paths + : undefined, + gateKey, + source: item.source, + sourceRef: item.source_ref || undefined, + }; + }, + ); + + const terminalLogs: SidebarActivityLog[] = + themeWorkbenchMergedTerminals.map((terminal) => ({ + id: `run-terminal-${terminal.run_id}`, + name: terminal.title || "执行主题工作台编排", + status: terminal.status === "success" ? "completed" : "failed", + timeLabel: formatThemeWorkbenchRunTimeLabel( + terminal.finished_at || terminal.started_at, + ), + durationLabel: formatThemeWorkbenchRunDurationLabel( + terminal.started_at, + terminal.finished_at, + ), + applyTarget: resolveThemeWorkbenchApplyTargetByGateKey( + terminal.gate_key || "idle", + ), + runId: terminal.run_id, + executionId: terminal.execution_id || undefined, + sessionId: terminal.session_id || undefined, + artifactPaths: + Array.isArray(terminal.artifact_paths) && + terminal.artifact_paths.length > 0 + ? terminal.artifact_paths + : undefined, + gateKey: terminal.gate_key || "idle", + source: terminal.source, + sourceRef: terminal.source_ref || undefined, + })); + + return [...runningLogs, ...terminalLogs]; + }, [ + isThemeWorkbench, + themeWorkbenchBackendRunState, + themeWorkbenchMergedTerminals, + ]); + + const handleLoadMoreThemeWorkbenchHistory = useCallback(() => { + const nextOffset = + themeWorkbenchHistoryNextOffset ?? themeWorkbenchHistoryTerminals.length; + void loadThemeWorkbenchHistory(nextOffset, false); + }, [ + loadThemeWorkbenchHistory, + themeWorkbenchHistoryNextOffset, + themeWorkbenchHistoryTerminals.length, + ]); + + const themeWorkbenchActivityLogs = useMemo(() => { + if (!isThemeWorkbench) { + return contextActivityLogs; + } + + const enrichedContextLogs = contextActivityLogs.map((log) => { + const normalizedRunId = log.runId?.trim(); + if (normalizedRunId) { + return { + ...log, + runId: normalizedRunId, + }; + } + + const candidateExecutionIds = + resolveExecutionIdCandidatesForActivityLog(log); + for (const executionId of candidateExecutionIds) { + const mappedRunId = themeWorkbenchExecutionRunMap.get(executionId); + if (!mappedRunId) { + continue; + } + return { + ...log, + executionId, + runId: mappedRunId, + }; + } + + return log; + }); + + return [...themeWorkbenchBackendActivityLogs, ...enrichedContextLogs]; + }, [ + contextActivityLogs, + isThemeWorkbench, + themeWorkbenchBackendActivityLogs, + themeWorkbenchExecutionRunMap, + ]); + + const handleViewThemeWorkbenchRunDetail = useCallback((runId: string) => { + const normalizedRunId = runId.trim(); + if (!normalizedRunId) { + return; + } + setSelectedThemeWorkbenchRunId(normalizedRunId); + }, []); + + useEffect(() => { + if (!isThemeWorkbench || !selectedThemeWorkbenchRunId) { + setThemeWorkbenchRunDetailLoading(false); + setSelectedThemeWorkbenchRunDetail(null); + return; + } + + let cancelled = false; + setThemeWorkbenchRunDetailLoading(true); + executionRunGet(selectedThemeWorkbenchRunId) + .then((detail) => { + if (!cancelled) { + setSelectedThemeWorkbenchRunDetail(detail); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + setSelectedThemeWorkbenchRunDetail(null); + console.warn("[AgentChatPage] 加载运行详情失败:", error); + }) + .finally(() => { + if (!cancelled) { + setThemeWorkbenchRunDetailLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [isThemeWorkbench, selectedThemeWorkbenchRunId]); + + return { + handleLoadMoreThemeWorkbenchHistory, + handleViewThemeWorkbenchRunDetail, + selectedThemeWorkbenchRunDetail, + themeWorkbenchActivityLogs, + themeWorkbenchHistoryHasMore, + themeWorkbenchHistoryLoading, + themeWorkbenchRunDetailLoading, + themeWorkbenchSkillDetailMap, + themeWorkbenchWorkflowSteps, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchVersionStatusRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchVersionStatusRuntime.ts new file mode 100644 index 000000000..3290a2c03 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchVersionStatusRuntime.ts @@ -0,0 +1,69 @@ +import { useEffect, type Dispatch, type SetStateAction } from "react"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard"; + +interface ThemeWorkbenchLatestTerminalSummary { + run_id: string; + status?: string | null; +} + +interface UseWorkspaceThemeWorkbenchVersionStatusRuntimeParams { + isThemeWorkbench: boolean; + themeWorkbenchRunState: "idle" | "auto_running" | "await_user_decision"; + canvasState: CanvasStateUnion | null; + latestTerminal: ThemeWorkbenchLatestTerminalSummary | null; + setDocumentVersionStatusMap: Dispatch< + SetStateAction> + >; +} + +export function useWorkspaceThemeWorkbenchVersionStatusRuntime({ + isThemeWorkbench, + themeWorkbenchRunState, + canvasState, + latestTerminal, + setDocumentVersionStatusMap, +}: UseWorkspaceThemeWorkbenchVersionStatusRuntimeParams) { + useEffect(() => { + if (!isThemeWorkbench || themeWorkbenchRunState !== "idle") { + return; + } + if (!canvasState || canvasState.type !== "document") { + return; + } + + setDocumentVersionStatusMap((previous) => { + if (latestTerminal) { + const terminalVersionId = latestTerminal.run_id; + const terminalVersionExists = canvasState.versions.some( + (version) => version.id === terminalVersionId, + ); + if (terminalVersionExists) { + const terminalStatus: TopicBranchStatus = + latestTerminal.status === "success" ? "merged" : "candidate"; + if (previous[terminalVersionId] !== terminalStatus) { + return { + ...previous, + [terminalVersionId]: terminalStatus, + }; + } + } + } + + const currentVersionId = canvasState.currentVersionId; + if (!currentVersionId || previous[currentVersionId] !== "in_progress") { + return previous; + } + return { + ...previous, + [currentVersionId]: "pending", + }; + }); + }, [ + canvasState, + isThemeWorkbench, + latestTerminal, + setDocumentVersionStatusMap, + themeWorkbenchRunState, + ]); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts b/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts new file mode 100644 index 000000000..23550f21e --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts @@ -0,0 +1,215 @@ +import { useCallback, useEffect } from "react"; +import { toast } from "sonner"; +import { logAgentDebug } from "@/lib/agentDebug"; +import { + getProject, + getDefaultProject, + getOrCreateDefaultProject, +} from "@/lib/api/project"; +import { normalizeProjectId } from "../utils/topicProjectResolution"; +import { resolveTopicSwitchProject } from "../utils/topicProjectSwitch"; + +interface PendingTopicSwitchState { + topicId: string; + targetProjectId: string; +} + +interface UseWorkspaceTopicSwitchParams { + projectId?: string; + externalProjectId?: string | null; + originalSwitchTopic: (topicId: string) => Promise; + startTopicProjectResolution: () => boolean; + finishTopicProjectResolution: () => void; + deferTopicSwitch: (topicId: string, targetProjectId: string) => void; + consumePendingTopicSwitch: ( + currentProjectId?: string | null, + ) => PendingTopicSwitchState | null; + rememberProjectId: (nextProjectId?: string | null) => void; + getRememberedProjectId: () => string | null; + loadTopicBoundProjectId: (topicId: string) => string | null; + resetTopicLocalState: () => void; +} + +export function useWorkspaceTopicSwitch({ + projectId, + externalProjectId, + originalSwitchTopic, + startTopicProjectResolution, + finishTopicProjectResolution, + deferTopicSwitch, + consumePendingTopicSwitch, + rememberProjectId, + getRememberedProjectId, + loadTopicBoundProjectId, + resetTopicLocalState, +}: UseWorkspaceTopicSwitchParams) { + const runTopicSwitch = useCallback( + async (topicId: string) => { + const startedAt = Date.now(); + logAgentDebug("AgentChatPage", "runTopicSwitch.start", { + currentProjectId: projectId ?? null, + topicId, + }); + resetTopicLocalState(); + try { + await originalSwitchTopic(topicId); + logAgentDebug("AgentChatPage", "runTopicSwitch.success", { + durationMs: Date.now() - startedAt, + topicId, + }); + } catch (error) { + logAgentDebug( + "AgentChatPage", + "runTopicSwitch.error", + { + durationMs: Date.now() - startedAt, + error, + topicId, + }, + { level: "error" }, + ); + throw error; + } + }, + [originalSwitchTopic, projectId, resetTopicLocalState], + ); + + const switchTopic = useCallback( + async (topicId: string) => { + if (!startTopicProjectResolution()) { + logAgentDebug( + "AgentChatPage", + "switchTopic.skipWhileResolving", + { topicId }, + { level: "warn", throttleMs: 1000 }, + ); + return; + } + + try { + logAgentDebug("AgentChatPage", "switchTopic.start", { + currentProjectId: projectId ?? null, + externalProjectId: externalProjectId ?? null, + topicId, + }); + const decision = await resolveTopicSwitchProject({ + lockedProjectId: externalProjectId ?? null, + topicBoundProjectId: loadTopicBoundProjectId(topicId), + lastProjectId: getRememberedProjectId(), + loadProjectById: async (candidateProjectId) => { + const project = await getProject(candidateProjectId); + return project + ? { id: project.id, isArchived: project.isArchived } + : null; + }, + loadDefaultProject: async () => { + const project = await getDefaultProject(); + return project + ? { id: project.id, isArchived: project.isArchived } + : null; + }, + createDefaultProject: async () => { + const project = await getOrCreateDefaultProject(); + return project + ? { id: project.id, isArchived: project.isArchived } + : null; + }, + }); + logAgentDebug("AgentChatPage", "switchTopic.decision", { + createdDefault: + decision.status === "ok" ? decision.createdDefault : false, + decisionStatus: decision.status, + projectId: decision.status === "ok" ? decision.projectId : null, + topicId, + }); + + if (decision.status === "blocked") { + toast.error("该任务绑定了其他项目,请先切换到对应项目"); + return; + } + + if (decision.status === "missing") { + toast.error("未找到可用项目,请先创建项目"); + return; + } + + const targetProjectId = decision.projectId; + if (decision.createdDefault) { + toast.info("未找到可用项目,已自动创建默认项目"); + } + + const currentProjectId = normalizeProjectId(projectId); + if (currentProjectId !== targetProjectId) { + deferTopicSwitch(topicId, targetProjectId); + logAgentDebug("AgentChatPage", "switchTopic.deferUntilProjectReady", { + currentProjectId, + targetProjectId, + topicId, + }); + return; + } + + rememberProjectId(targetProjectId); + await runTopicSwitch(topicId); + } catch (error) { + console.error("[AgentChatPage] 解析任务项目失败:", error); + logAgentDebug( + "AgentChatPage", + "switchTopic.error", + { + error, + projectId: projectId ?? null, + topicId, + }, + { level: "error" }, + ); + toast.error("切换任务失败,请稍后重试"); + } finally { + finishTopicProjectResolution(); + } + }, + [ + deferTopicSwitch, + externalProjectId, + finishTopicProjectResolution, + getRememberedProjectId, + loadTopicBoundProjectId, + projectId, + rememberProjectId, + runTopicSwitch, + startTopicProjectResolution, + ], + ); + + useEffect(() => { + const pending = consumePendingTopicSwitch(projectId); + if (!pending) { + return; + } + + const currentProjectId = normalizeProjectId(projectId); + logAgentDebug("AgentChatPage", "switchTopic.resumePending", { + projectId: currentProjectId, + topicId: pending.topicId, + }); + runTopicSwitch(pending.topicId).catch((error) => { + console.error("[AgentChatPage] 执行待切换任务失败:", error); + logAgentDebug( + "AgentChatPage", + "switchTopic.resumePendingError", + { + error, + projectId: currentProjectId, + topicId: pending.topicId, + }, + { level: "error" }, + ); + toast.error("加载任务失败,请重试"); + }); + }, [consumePendingTopicSwitch, projectId, runTopicSwitch]); + + return { + runTopicSwitch, + switchTopic, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceWorkflowProgressSync.ts b/src/components/agent/chat/workspace/useWorkspaceWorkflowProgressSync.ts new file mode 100644 index 000000000..ef200f7af --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceWorkflowProgressSync.ts @@ -0,0 +1,77 @@ +import { useEffect, useMemo, useRef } from "react"; +import type { StepStatus } from "@/components/content-creator/types"; +import type { WorkflowProgressSnapshot } from "../agentChatWorkspaceContract"; + +interface WorkflowProgressStep { + id: string; + title: string; + status: StepStatus; +} + +interface UseWorkspaceWorkflowProgressSyncParams { + enabled: boolean; + currentStepIndex: number; + steps: WorkflowProgressStep[]; + onWorkflowProgressChange?: ( + snapshot: WorkflowProgressSnapshot | null, + ) => void; +} + +export function useWorkspaceWorkflowProgressSync({ + enabled, + currentStepIndex, + steps, + onWorkflowProgressChange, +}: UseWorkspaceWorkflowProgressSyncParams) { + const workflowProgressSignature = useMemo(() => { + if (!enabled) { + return "hidden"; + } + + const stepSignature = steps + .map((step) => `${step.id}:${step.status}:${step.title}`) + .join("|"); + return `${currentStepIndex}:${stepSignature}`; + }, [currentStepIndex, enabled, steps]); + + const lastWorkflowProgressSignatureRef = useRef(""); + + useEffect(() => { + if (!onWorkflowProgressChange) { + return; + } + + if ( + lastWorkflowProgressSignatureRef.current === workflowProgressSignature + ) { + return; + } + lastWorkflowProgressSignatureRef.current = workflowProgressSignature; + + if (!enabled) { + onWorkflowProgressChange(null); + return; + } + + onWorkflowProgressChange({ + currentIndex: currentStepIndex, + steps: steps.map((step) => ({ + id: step.id, + title: step.title, + status: step.status, + })), + }); + }, [ + currentStepIndex, + enabled, + onWorkflowProgressChange, + steps, + workflowProgressSignature, + ]); + + useEffect(() => { + return () => { + onWorkflowProgressChange?.(null); + }; + }, [onWorkflowProgressChange]); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts b/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts new file mode 100644 index 000000000..b715b75ae --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts @@ -0,0 +1,576 @@ +import { + useCallback, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from "react"; +import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; +import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; +import { activityLogger } from "@/components/content-creator/utils/activityLogger"; +import { resolveSocialMediaArtifactDescriptor } from "@/components/content-creator/utils/socialMediaHarness"; +import { createInitialMusicState } from "@/components/content-creator/canvas/music/types"; +import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser"; +import type { ThemeType, LayoutMode } from "@/components/content-creator/types"; +import type { TaskFile } from "../components/TaskFiles"; +import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard"; +import type { WriteArtifactContext } from "../types"; +import type { Artifact } from "@/lib/artifact/types"; +import { getContent, updateContent } from "@/lib/api/project"; +import { getFileToStepMap } from "../utils/workflowMapping"; +import { + buildArtifactFromWrite, + resolveArtifactFilePath, + resolveDefaultArtifactViewMode, +} from "../utils/messageArtifacts"; +import { + MAX_PERSISTED_DOCUMENT_VERSIONS, + isThemeWorkbenchPrimaryDocumentArtifact, + resolveTaskFileType, +} from "./themeWorkbenchHelpers"; + +interface ThemeWorkbenchActiveQueueSummary { + run_id?: string | null; + title?: string | null; + status?: string | null; +} + +interface UseWorkspaceWriteFileActionParams { + activeTheme: ThemeType; + artifacts: Artifact[]; + contentId?: string | null; + currentGateKey: string; + currentStepIndex: number; + isContentCreationMode: boolean; + isThemeWorkbench: boolean; + mappedTheme: ThemeType; + projectId?: string | null; + sessionId?: string | null; + themeWorkbenchActiveQueueItem: ThemeWorkbenchActiveQueueSummary | null; + taskFilesRef: MutableRefObject; + socialStageLogRef: MutableRefObject>; + setDocumentVersionStatusMap: Dispatch< + SetStateAction> + >; + saveSessionFile: (fileName: string, content: string) => Promise; + syncGeneralArtifactToResource: (input: { + rawFilePath: string; + preferredName?: string; + }) => Promise; + upsertGeneralArtifact: (artifact: Artifact) => void; + setSelectedArtifactId: (artifactId: string | null) => void; + setArtifactViewMode: Dispatch>; + setLayoutMode: Dispatch>; + completeStep: (payload: { + aiOutput: { fileName: string; preview: string }; + }) => void; + setTaskFiles: Dispatch>; + setSelectedFileId: Dispatch>; + setCanvasState: Dispatch>; + upsertNovelCanvasState: ( + previous: CanvasStateUnion | null, + content: string, + ) => CanvasStateUnion; +} + +export function useWorkspaceWriteFileAction({ + activeTheme, + artifacts, + contentId, + currentGateKey, + currentStepIndex, + isContentCreationMode, + isThemeWorkbench, + mappedTheme, + projectId, + sessionId, + themeWorkbenchActiveQueueItem, + taskFilesRef, + socialStageLogRef, + setDocumentVersionStatusMap, + saveSessionFile, + syncGeneralArtifactToResource, + upsertGeneralArtifact, + setSelectedArtifactId, + setArtifactViewMode, + setLayoutMode, + completeStep, + setTaskFiles, + setSelectedFileId, + setCanvasState, + upsertNovelCanvasState, +}: UseWorkspaceWriteFileActionParams) { + return useCallback( + (content: string, fileName: string, context?: WriteArtifactContext) => { + console.log( + "[AgentChatPage] 收到文件写入:", + fileName, + content.length, + "字符", + ); + + if (activeTheme === "general") { + const existingArtifact = artifacts.find((artifact) => { + if (context?.artifactId && artifact.id === context.artifactId) { + return true; + } + + if (context?.artifact?.id && artifact.id === context.artifact.id) { + return true; + } + + return ( + typeof artifact.meta.filePath === "string" && + artifact.meta.filePath === fileName + ); + }); + const nextContent = + content.length > 0 + ? content + : context?.artifact?.content || existingArtifact?.content || ""; + const nextArtifact = context?.artifact + ? { + ...(existingArtifact || {}), + ...context.artifact, + content: nextContent, + status: + context.status || + context.artifact.status || + existingArtifact?.status || + "pending", + meta: { + ...(existingArtifact?.meta || {}), + ...context.artifact.meta, + ...(context.metadata || {}), + }, + updatedAt: Date.now(), + } + : buildArtifactFromWrite({ + filePath: fileName, + content: nextContent, + context: { + ...context, + artifact: existingArtifact, + status: + context?.status || + (nextContent.length > 0 ? "complete" : "pending"), + }, + }); + + const syncResource = () => { + if (nextArtifact.status !== "complete") { + return; + } + + void syncGeneralArtifactToResource({ + rawFilePath: resolveArtifactFilePath(nextArtifact), + preferredName: nextArtifact.title, + }); + }; + + if (nextContent.length > 0) { + void saveSessionFile(fileName, nextContent) + .then(() => { + syncResource(); + }) + .catch((error) => { + console.error("[AgentChatPage] 持久化 artifact 失败:", error); + syncResource(); + }); + } else { + syncResource(); + } + + upsertGeneralArtifact(nextArtifact); + setSelectedArtifactId(nextArtifact.id); + setArtifactViewMode(resolveDefaultArtifactViewMode(nextArtifact)); + setLayoutMode("chat-canvas"); + return; + } + + const now = Date.now(); + const nextFileType = resolveTaskFileType(fileName, content); + const activeQueueItem = themeWorkbenchActiveQueueItem; + const activeRunVersionId = activeQueueItem?.run_id?.trim() || null; + const activeRunDescription = + activeQueueItem?.title?.trim() || `产物更新 - ${fileName}`; + const socialGateKey = + currentGateKey === "idle" || + currentGateKey === "topic_select" || + currentGateKey === "write_mode" || + currentGateKey === "publish_confirm" + ? currentGateKey + : undefined; + const socialArtifact = + mappedTheme === "social-media" + ? resolveSocialMediaArtifactDescriptor({ + fileName, + gateKey: socialGateKey, + runTitle: activeRunDescription, + }) + : null; + const isPrimaryArtifact = + !isThemeWorkbench || isThemeWorkbenchPrimaryDocumentArtifact(fileName); + const shouldApplyToMainDocument = + nextFileType === "document" && + isPrimaryArtifact && + (!isThemeWorkbench || currentGateKey !== "topic_select"); + const effectiveDocumentVersionId = + activeRunVersionId || + ((isThemeWorkbench || mappedTheme === "social-media") && + shouldApplyToMainDocument + ? `artifact:${fileName}` + : null); + const effectiveVersionDescription = + socialArtifact?.versionLabel || activeRunDescription; + const baseVersionMetadata = + socialArtifact && shouldApplyToMainDocument + ? { + artifactId: socialArtifact.artifactId, + artifactType: socialArtifact.artifactType, + stage: socialArtifact.stage, + platform: socialArtifact.platform, + sourceFileName: fileName, + runId: activeRunVersionId || undefined, + correlationId: + effectiveDocumentVersionId || activeRunVersionId || undefined, + } + : undefined; + const existingTaskFile = taskFilesRef.current.find( + (file) => file.name === fileName, + ); + const hasTaskFileChanged = existingTaskFile?.content !== content; + + if (isThemeWorkbench && effectiveDocumentVersionId) { + const nextStatus: TopicBranchStatus = + activeQueueItem?.status === "running" ? "in_progress" : "pending"; + setDocumentVersionStatusMap((previous) => { + if (previous[effectiveDocumentVersionId] === nextStatus) { + return previous; + } + return { + ...previous, + [effectiveDocumentVersionId]: nextStatus, + }; + }); + } + + void saveSessionFile(fileName, content).catch((error) => { + console.error("[AgentChatPage] 持久化文件失败:", error); + }); + + if (contentId && shouldApplyToMainDocument) { + void getContent(contentId) + .then((existingContent) => { + if (!existingContent) { + console.warn( + "[AgentChatPage] contentId 对应的内容不存在,跳过同步:", + contentId, + ); + return; + } + + void updateContent(contentId, { + body: content, + }).catch((error) => { + console.error("[AgentChatPage] 同步内容到项目失败:", error); + }); + }) + .catch((error) => { + console.error("[AgentChatPage] 检查内容存在性失败:", error); + }); + } else if (isThemeWorkbench && !shouldApplyToMainDocument) { + console.log("[AgentChatPage] 主题工作台非成文阶段,跳过主稿写入:", { + gate: currentGateKey, + fileName, + isPrimaryArtifact, + }); + } + + const fileToStepMap = getFileToStepMap(mappedTheme); + const stepIndex = fileToStepMap[fileName]; + if ( + stepIndex !== undefined && + stepIndex === currentStepIndex && + isContentCreationMode + ) { + console.log( + "[AgentChatPage] 推进工作流步骤:", + stepIndex, + "->", + stepIndex + 1, + ); + completeStep({ + aiOutput: { fileName, preview: content.slice(0, 100) }, + }); + } + + if (socialArtifact && hasTaskFileChanged) { + activityLogger.log({ + eventType: existingTaskFile ? "file_update" : "file_create", + status: "success", + title: `${existingTaskFile ? "更新" : "生成"}${socialArtifact.versionLabel}`, + description: fileName, + workspaceId: projectId || undefined, + sessionId: sessionId || undefined, + source: "aster-chat", + correlationId: + effectiveDocumentVersionId || activeRunVersionId || fileName, + metadata: { + ...baseVersionMetadata, + stageLabel: socialArtifact.stageLabel, + isAuxiliary: socialArtifact.isAuxiliary, + }, + }); + + const stageLogKey = `${ + effectiveDocumentVersionId || socialArtifact.artifactId + }:${socialArtifact.stage}`; + if ( + !socialArtifact.isAuxiliary && + socialStageLogRef.current[stageLogKey] !== socialArtifact.stage + ) { + socialStageLogRef.current[stageLogKey] = socialArtifact.stage; + activityLogger.log({ + eventType: "step_complete", + status: "success", + title: socialArtifact.stageLabel, + description: `${socialArtifact.versionLabel}已进入版本链`, + workspaceId: projectId || undefined, + sessionId: sessionId || undefined, + source: "aster-chat", + correlationId: + effectiveDocumentVersionId || activeRunVersionId || fileName, + metadata: { + ...baseVersionMetadata, + stageLabel: socialArtifact.stageLabel, + }, + }); + } + } + + setTaskFiles((previous) => { + const existingIndex = previous.findIndex((file) => file.name === fileName); + + if (existingIndex >= 0) { + const existing = previous[existingIndex]; + + if (existing.content === content) { + console.log("[AgentChatPage] 文件内容相同,跳过:", fileName); + setSelectedFileId(existing.id); + return previous; + } + + console.log("[AgentChatPage] 更新文件:", fileName); + const nextFiles = [...previous]; + nextFiles[existingIndex] = { + ...existing, + type: nextFileType, + content, + updatedAt: now, + metadata: socialArtifact + ? { + ...(existing.metadata || {}), + ...baseVersionMetadata, + stageLabel: socialArtifact.stageLabel, + versionLabel: socialArtifact.versionLabel, + } + : existing.metadata, + }; + setSelectedFileId(existing.id); + return nextFiles; + } + + console.log("[AgentChatPage] 创建新文件:", fileName); + const newFile: TaskFile = { + id: crypto.randomUUID(), + name: fileName, + type: nextFileType, + content, + version: 1, + createdAt: now, + updatedAt: now, + metadata: socialArtifact + ? { + ...baseVersionMetadata, + stageLabel: socialArtifact.stageLabel, + versionLabel: socialArtifact.versionLabel, + } + : undefined, + }; + setSelectedFileId(newFile.id); + return [...previous, newFile]; + }); + + if (!shouldApplyToMainDocument) { + return; + } + + setCanvasState((previous) => { + console.log("[AgentChatPage] 更新画布状态:", { + prevType: previous?.type, + mappedTheme, + contentLength: content.length, + }); + + if (mappedTheme === "poster") { + return previous; + } + + if (mappedTheme === "music") { + const sections = parseLyrics(content); + if (!previous || previous.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + const titleMatch = content.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + console.log("[AgentChatPage] 创建新音乐状态"); + return musicState; + } + return { + ...previous, + sections, + }; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(previous, content); + } + + if (!previous || previous.type !== "document") { + console.log("[AgentChatPage] 创建新文档状态"); + const initialDocumentState = createInitialDocumentState(content); + if (!effectiveDocumentVersionId) { + if (!socialArtifact) { + return initialDocumentState; + } + return { + ...initialDocumentState, + platform: + socialArtifact.platform || initialDocumentState.platform, + versions: initialDocumentState.versions.map((version) => ({ + ...version, + description: effectiveVersionDescription, + metadata: baseVersionMetadata, + })), + }; + } + if (!isThemeWorkbench && mappedTheme !== "social-media") { + return initialDocumentState; + } + return { + ...initialDocumentState, + platform: socialArtifact?.platform || initialDocumentState.platform, + versions: [ + { + id: effectiveDocumentVersionId, + content, + createdAt: now, + description: effectiveVersionDescription, + metadata: baseVersionMetadata, + }, + ], + currentVersionId: effectiveDocumentVersionId, + content, + }; + } + + if (effectiveDocumentVersionId) { + const existingIndex = previous.versions.findIndex( + (version) => version.id === effectiveDocumentVersionId, + ); + + if (existingIndex >= 0) { + const nextVersions = [...previous.versions]; + const currentVersion = nextVersions[existingIndex]; + nextVersions[existingIndex] = { + ...currentVersion, + content, + description: + currentVersion.description || effectiveVersionDescription, + metadata: { + ...(currentVersion.metadata || {}), + ...(baseVersionMetadata || {}), + }, + }; + return { + ...previous, + content, + platform: socialArtifact?.platform || previous.platform, + versions: nextVersions, + currentVersionId: effectiveDocumentVersionId, + }; + } + + const parentVersion = + previous.versions.find( + (version) => version.id === previous.currentVersionId, + ) || previous.versions[previous.versions.length - 1]; + const nextVersions = [ + ...previous.versions, + { + id: effectiveDocumentVersionId, + content, + createdAt: now, + description: effectiveVersionDescription, + metadata: { + ...(baseVersionMetadata || {}), + parentVersionId: + parentVersion && + parentVersion.id !== effectiveDocumentVersionId + ? parentVersion.id + : undefined, + parentArtifactId: parentVersion?.metadata?.artifactId, + }, + }, + ].slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); + + return { + ...previous, + content, + platform: socialArtifact?.platform || previous.platform, + versions: nextVersions, + currentVersionId: effectiveDocumentVersionId, + }; + } + + console.log("[AgentChatPage] 更新现有文档状态"); + return { + ...previous, + content, + platform: socialArtifact?.platform || previous.platform, + }; + }); + + setLayoutMode("chat-canvas"); + }, + [ + activeTheme, + artifacts, + completeStep, + contentId, + currentGateKey, + currentStepIndex, + isContentCreationMode, + isThemeWorkbench, + mappedTheme, + projectId, + saveSessionFile, + sessionId, + setArtifactViewMode, + setCanvasState, + setDocumentVersionStatusMap, + setLayoutMode, + setSelectedArtifactId, + setSelectedFileId, + setTaskFiles, + socialStageLogRef, + syncGeneralArtifactToResource, + taskFilesRef, + themeWorkbenchActiveQueueItem, + upsertGeneralArtifact, + upsertNovelCanvasState, + ], + ); +} diff --git a/src/components/agent/chat/workspace/workbenchPreview.tsx b/src/components/agent/chat/workspace/workbenchPreview.tsx new file mode 100644 index 000000000..5dcee059c --- /dev/null +++ b/src/components/agent/chat/workspace/workbenchPreview.tsx @@ -0,0 +1,231 @@ +import type { ComponentProps, ReactNode } from "react"; +import { + ArtifactCanvasOverlay, + ArtifactRenderer, + ArtifactToolbar, +} from "@/components/artifact"; +import { CanvasFactory } from "@/components/content-creator/canvas/CanvasFactory"; +import type { ThemeType } from "@/components/content-creator/types"; +import { + CanvasPanel as GeneralCanvasPanel, + type CanvasState as GeneralCanvasState, +} from "@/components/general-chat/bridge"; +import type { Artifact } from "@/lib/artifact/types"; +import type { CanvasWorkbenchDefaultPreview } from "../components/CanvasWorkbenchLayout"; +import { ImageWorkbenchCanvas } from "../components/ImageWorkbenchCanvas"; +import { TeamWorkspaceBoard } from "../components/TeamWorkspaceBoard"; +import { wrapPreviewWithWorkbenchTrigger } from "./workbenchPreviewHelpers"; + +interface ArtifactWorkbenchPreviewProps { + artifact: Artifact; + currentCanvasArtifact: Artifact | null; + displayedCanvasArtifact: Artifact | null; + artifactOverlay: ComponentProps["overlay"] | null; + showPreviousVersionBadge: boolean; + artifactViewMode: ComponentProps["viewMode"]; + onArtifactViewModeChange: NonNullable< + ComponentProps["onViewModeChange"] + >; + artifactPreviewSize: ComponentProps["previewSize"]; + onArtifactPreviewSizeChange: NonNullable< + ComponentProps["onPreviewSizeChange"] + >; + onCloseCanvas: () => void; + stackedWorkbenchTrigger?: ReactNode; +} + +export function ArtifactWorkbenchPreview({ + artifact, + currentCanvasArtifact, + displayedCanvasArtifact, + artifactOverlay, + showPreviousVersionBadge, + artifactViewMode, + onArtifactViewModeChange, + artifactPreviewSize, + onArtifactPreviewSizeChange, + onCloseCanvas, + stackedWorkbenchTrigger, +}: ArtifactWorkbenchPreviewProps) { + const isLiveSelectedArtifact = + currentCanvasArtifact?.id === artifact.id && + displayedCanvasArtifact !== null; + const toolbarArtifact = + isLiveSelectedArtifact && currentCanvasArtifact + ? currentCanvasArtifact + : artifact; + const previewArtifact = + isLiveSelectedArtifact && displayedCanvasArtifact + ? displayedCanvasArtifact + : artifact; + const isBrowserAssistArtifact = previewArtifact.type === "browser_assist"; + const isArtifactStreaming = Boolean( + isLiveSelectedArtifact && + currentCanvasArtifact && + displayedCanvasArtifact && + currentCanvasArtifact.id === displayedCanvasArtifact.id && + currentCanvasArtifact.id === previewArtifact.id && + currentCanvasArtifact.status === "streaming", + ); + + if (isBrowserAssistArtifact) { + return wrapPreviewWithWorkbenchTrigger( +
+ + {isLiveSelectedArtifact && artifactOverlay ? ( + + ) : null} +
, + stackedWorkbenchTrigger, + ); + } + + return ( +
+
+ +
+ + {isLiveSelectedArtifact && artifactOverlay ? ( + + ) : null} +
+
+
+ ); +} + +interface WorkspaceLiveCanvasPreviewProps { + currentImageWorkbenchActive: boolean; + imageWorkbenchProps: ComponentProps; + canvasRenderTheme: ThemeType; + liveArtifact: Artifact | null; + hasDisplayedLiveArtifact: boolean; + renderArtifactPreview: ( + artifact: Artifact, + stackedWorkbenchTrigger?: ReactNode, + ) => ReactNode; + generalCanvasPanelProps: Omit< + ComponentProps, + "toolbarActions" + > | null; + shouldShowCanvasLoadingState: boolean; + canvasLoadingLabel: string; + canvasFactoryProps: ComponentProps | null; + stackedWorkbenchTrigger?: ReactNode; +} + +export function WorkspaceLiveCanvasPreview({ + currentImageWorkbenchActive, + imageWorkbenchProps, + canvasRenderTheme, + liveArtifact, + hasDisplayedLiveArtifact, + renderArtifactPreview, + generalCanvasPanelProps, + shouldShowCanvasLoadingState, + canvasLoadingLabel, + canvasFactoryProps, + stackedWorkbenchTrigger, +}: WorkspaceLiveCanvasPreviewProps) { + if (currentImageWorkbenchActive) { + return wrapPreviewWithWorkbenchTrigger( + , + stackedWorkbenchTrigger, + ); + } + + if ( + canvasRenderTheme === "general" && + liveArtifact && + hasDisplayedLiveArtifact + ) { + return renderArtifactPreview(liveArtifact, stackedWorkbenchTrigger); + } + + if (canvasRenderTheme === "general") { + if (generalCanvasPanelProps?.state.isOpen) { + return ( + + ); + } + return null; + } + + if (shouldShowCanvasLoadingState) { + return wrapPreviewWithWorkbenchTrigger( +
+ {canvasLoadingLabel} +
, + stackedWorkbenchTrigger, + ); + } + + if (!canvasFactoryProps) { + return null; + } + + return wrapPreviewWithWorkbenchTrigger( + , + stackedWorkbenchTrigger, + ); +} + +interface TeamWorkbenchPreviewProps { + boardProps: ComponentProps; + stackedWorkbenchTrigger?: ReactNode; +} + +export function TeamWorkbenchPreview({ + boardProps, + stackedWorkbenchTrigger, +}: TeamWorkbenchPreviewProps) { + return wrapPreviewWithWorkbenchTrigger( +
+
+ +
+
, + stackedWorkbenchTrigger, + ); +} + +export type { CanvasWorkbenchDefaultPreview }; +export type { GeneralCanvasState }; diff --git a/src/components/agent/chat/workspace/workbenchPreviewHelpers.tsx b/src/components/agent/chat/workspace/workbenchPreviewHelpers.tsx new file mode 100644 index 000000000..4dad62ba5 --- /dev/null +++ b/src/components/agent/chat/workspace/workbenchPreviewHelpers.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from "react"; +import type { DocumentVersion } from "@/components/content-creator/canvas/document/types"; +import type { Artifact } from "@/lib/artifact/types"; +import type { CanvasWorkbenchPreviewTarget } from "../components/CanvasWorkbenchLayout"; + +export function resolvePreviousDocumentVersionContent( + version: DocumentVersion | null | undefined, + versions: DocumentVersion[], +): string | null { + if (!version) { + return null; + } + + const parentVersionId = version.metadata?.parentVersionId?.trim(); + if (parentVersionId) { + const parentVersion = versions.find((item) => item.id === parentVersionId); + if (parentVersion) { + return parentVersion.content; + } + } + + const currentIndex = versions.findIndex((item) => item.id === version.id); + if (currentIndex > 0) { + return versions[currentIndex - 1]?.content || null; + } + + return null; +} + +export function wrapPreviewWithWorkbenchTrigger( + preview: ReactNode, + stackedWorkbenchTrigger?: ReactNode, +) { + if (!stackedWorkbenchTrigger) { + return preview; + } + + return ( +
+ {preview} +
+
{stackedWorkbenchTrigger}
+
+
+ ); +} + +function renderWorkbenchStatePreview( + kind: "loading" | "unsupported" | "empty", + options: { + text: string; + stackedWorkbenchTrigger?: ReactNode; + }, +) { + return wrapPreviewWithWorkbenchTrigger( +
+ {options.text} +
, + options.stackedWorkbenchTrigger, + ); +} + +export function renderCanvasWorkbenchPreviewTarget(params: { + target: CanvasWorkbenchPreviewTarget; + stackedWorkbenchTrigger?: ReactNode; + renderDefaultCanvasPreview: (stackedWorkbenchTrigger?: ReactNode) => ReactNode; + renderArtifactPreview: ( + artifact: Artifact, + stackedWorkbenchTrigger?: ReactNode, + ) => ReactNode; + renderTeamWorkbenchPreview: ( + stackedWorkbenchTrigger?: ReactNode, + ) => ReactNode; +}) { + const { target, stackedWorkbenchTrigger } = params; + + switch (target.kind) { + case "default-canvas": + return params.renderDefaultCanvasPreview(stackedWorkbenchTrigger); + case "artifact": + case "synthetic-artifact": + return params.renderArtifactPreview(target.artifact, stackedWorkbenchTrigger); + case "loading": + return renderWorkbenchStatePreview("loading", { + text: "正在准备预览...", + stackedWorkbenchTrigger, + }); + case "unsupported": + return renderWorkbenchStatePreview("unsupported", { + text: target.reason, + stackedWorkbenchTrigger, + }); + case "empty": + return renderWorkbenchStatePreview("empty", { + text: "暂无可预览内容", + stackedWorkbenchTrigger, + }); + case "team-workbench": + return params.renderTeamWorkbenchPreview(stackedWorkbenchTrigger); + default: + return null; + } +} diff --git a/src/components/agent/chat/workspace/workspacePath.ts b/src/components/agent/chat/workspace/workspacePath.ts new file mode 100644 index 000000000..8841113ee --- /dev/null +++ b/src/components/agent/chat/workspace/workspacePath.ts @@ -0,0 +1,47 @@ +function normalizeWorkspacePath(value: string): string { + return value.replace(/\\/g, "/"); +} + +function isAbsoluteLikePath(value: string): boolean { + return ( + value.startsWith("/") || + value.startsWith("~/") || + /^[A-Za-z]:[\\/]/.test(value) || + value.startsWith("\\\\") + ); +} + +function joinWorkspacePath(rootPath: string, filePath: string): string { + return `${rootPath.replace(/[\\/]+$/, "")}/${filePath.replace(/^[\\/]+/, "")}`; +} + +export function extractFileNameFromPath(path: string | null | undefined): string { + const normalized = normalizeWorkspacePath(path?.trim() || ""); + if (!normalized) { + return "未命名文件"; + } + + const segments = normalized.split("/"); + return segments[segments.length - 1] || normalized; +} + +export function resolveAbsoluteWorkspacePath( + workspaceRoot: string | null | undefined, + filePath: string | null | undefined, +): string | undefined { + const normalizedFilePath = filePath?.trim(); + if (!normalizedFilePath) { + return undefined; + } + + if (isAbsoluteLikePath(normalizedFilePath)) { + return normalizedFilePath; + } + + const normalizedWorkspaceRoot = workspaceRoot?.trim(); + if (!normalizedWorkspaceRoot) { + return normalizedFilePath; + } + + return joinWorkspacePath(normalizedWorkspaceRoot, normalizedFilePath); +} diff --git a/src/lib/api/agent.test.ts b/src/lib/api/agent.test.ts index 5d6fc97ef..73b78ef5a 100644 --- a/src/lib/api/agent.test.ts +++ b/src/lib/api/agent.test.ts @@ -15,10 +15,13 @@ import { getAsterAgentStatus, generateAgentRuntimeSessionTitle, getAgentRuntimeSession, + getAgentRuntimeThreadRead, getAgentRuntimeToolInventory, interruptAgentRuntimeTurn, listAgentRuntimeSessions, promoteAgentRuntimeQueuedTurn, + replayAgentRuntimeRequest, + resumeAgentRuntimeThread, resumeAgentRuntimeSubagent, respondAgentRuntimeAction, sendAgentRuntimeSubagentInput, @@ -235,6 +238,87 @@ describe("Agent API 治理护栏", () => { ); }); + it("resumeAgentRuntimeThread 应走统一 runtime resume 命令", async () => { + mockSafeInvoke.mockResolvedValueOnce(true); + + await expect( + resumeAgentRuntimeThread({ + session_id: "session-runtime-resume", + }), + ).resolves.toBe(true); + + expect(mockSafeInvoke).toHaveBeenCalledWith( + "agent_runtime_resume_thread", + { + request: { + session_id: "session-runtime-resume", + }, + }, + ); + }); + + it("replayAgentRuntimeRequest 应走统一 runtime replay 命令", async () => { + mockSafeInvoke.mockResolvedValueOnce({ + type: "action_required", + request_id: "req-runtime-replay", + action_type: "ask_user", + prompt: "请选择执行模式", + }); + + await expect( + replayAgentRuntimeRequest({ + session_id: "session-runtime-replay", + request_id: "req-runtime-replay", + }), + ).resolves.toMatchObject({ + request_id: "req-runtime-replay", + action_type: "ask_user", + }); + + expect(mockSafeInvoke).toHaveBeenCalledWith( + "agent_runtime_replay_request", + { + request: { + session_id: "session-runtime-replay", + request_id: "req-runtime-replay", + }, + }, + ); + }); + + it("getAgentRuntimeThreadRead 应走独立 thread_read 命令并归一化 queued_turns", async () => { + mockSafeInvoke.mockResolvedValueOnce({ + thread_id: "thread-runtime", + status: "waiting_request", + queued_turns: [ + { + queued_turn_id: "queued-turn-1", + message_preview: "继续执行", + created_at: 1711184400, + position: 1, + }, + ], + }); + + await expect(getAgentRuntimeThreadRead("session-runtime")).resolves.toMatchObject({ + thread_id: "thread-runtime", + status: "waiting_request", + queued_turns: [ + expect.objectContaining({ + queued_turn_id: "queued-turn-1", + position: 1, + }), + ], + }); + + expect(mockSafeInvoke).toHaveBeenCalledWith( + "agent_runtime_get_thread_read", + { + sessionId: "session-runtime", + }, + ); + }); + it("promoteAgentRuntimeQueuedTurn 应走统一 runtime promote 命令", async () => { mockSafeInvoke.mockResolvedValueOnce(true); @@ -377,6 +461,20 @@ describe("Agent API 治理护栏", () => { position: 2, }, ], + thread_read: { + thread_id: "thread-runtime-2", + status: "running", + queued_turns: [ + { + queued_turn_id: "queued-2", + message_text: "线程读模型中的排队任务", + message_preview: "线程读模型中的排队任务", + created_at: 1710001510, + image_count: 0, + position: 1, + }, + ], + }, messages: [ { role: "user", @@ -444,6 +542,20 @@ describe("Agent API 治理护栏", () => { position: 2, }, ], + thread_read: { + thread_id: "thread-runtime-2", + status: "running", + queued_turns: [ + { + queued_turn_id: "queued-2", + message_text: "线程读模型中的排队任务", + message_preview: "线程读模型中的排队任务", + created_at: 1710001510, + image_count: 0, + position: 1, + }, + ], + }, messages: [ { role: "user", diff --git a/src/lib/api/agentRuntime.ts b/src/lib/api/agentRuntime.ts index e300102dc..89eef9809 100644 --- a/src/lib/api/agentRuntime.ts +++ b/src/lib/api/agentRuntime.ts @@ -100,6 +100,57 @@ export interface AsterTodoItem { active_form?: string; } +export interface AgentRuntimeRequestView { + id: string; + thread_id: string; + turn_id?: string; + item_id?: string; + request_type: string; + status: string; + title?: string; + payload?: unknown; + decision?: unknown; + scope?: Record; + created_at?: string | number; + resolved_at?: string | number; +} + +export interface AgentRuntimeOutcomeView { + thread_id: string; + turn_id?: string; + outcome_type: string; + summary?: string; + primary_cause?: string; + retryable?: boolean; + ended_at?: string | number; +} + +export interface AgentRuntimeIncidentView { + id: string; + thread_id: string; + turn_id?: string; + item_id?: string; + incident_type: string; + severity?: string; + status?: string; + title?: string; + details?: unknown; + detected_at?: string | number; + cleared_at?: string | number; +} + +export interface AgentRuntimeThreadReadModel { + thread_id: string; + status?: string; + active_turn_id?: string; + pending_requests?: AgentRuntimeRequestView[]; + last_outcome?: AgentRuntimeOutcomeView | null; + incidents?: AgentRuntimeIncidentView[]; + queued_turns?: QueuedTurnSnapshot[]; + interrupt_state?: string; + updated_at?: string | number; +} + export interface AsterSubagentSessionInfo { id: string; name: string; @@ -225,11 +276,25 @@ export interface AsterSessionDetail { turns?: AgentThreadTurn[]; items?: AgentThreadItem[]; queued_turns?: QueuedTurnSnapshot[]; + thread_read?: AgentRuntimeThreadReadModel | null; todo_items?: AsterTodoItem[]; child_subagent_sessions?: AsterSubagentSessionInfo[]; subagent_parent_context?: AsterSubagentParentContext; } +function normalizeThreadReadModel( + threadRead?: AgentRuntimeThreadReadModel | null, +): AgentRuntimeThreadReadModel | null | undefined { + if (!threadRead) { + return threadRead; + } + + return { + ...threadRead, + queued_turns: normalizeQueuedTurnSnapshots(threadRead.queued_turns), + }; +} + export interface AgentTurnConfigSnapshot { provider_config?: AsterProviderConfig; execution_strategy?: AsterExecutionStrategy; @@ -257,6 +322,20 @@ export interface AgentRuntimeInterruptTurnRequest { turn_id?: string; } +export interface AgentRuntimeCompactSessionRequest { + session_id: string; + event_name: string; +} + +export interface AgentRuntimeResumeThreadRequest { + session_id: string; +} + +export interface AgentRuntimeReplayRequestRequest { + session_id: string; + request_id: string; +} + export interface AgentRuntimeRemoveQueuedTurnRequest { session_id: string; queued_turn_id: string; @@ -283,6 +362,22 @@ export interface AgentRuntimeRespondActionRequest { }; } +export interface AgentRuntimeReplayedActionRequiredView { + type: "action_required"; + request_id: string; + action_type: "tool_confirmation" | "ask_user" | "elicitation"; + tool_name?: string; + arguments?: Record; + prompt?: string; + questions?: unknown; + requested_schema?: Record; + scope?: { + session_id?: string; + thread_id?: string; + turn_id?: string; + }; +} + export interface AgentRuntimeUpdateSessionRequest { session_id: string; name?: string; @@ -562,6 +657,24 @@ export async function interruptAgentRuntimeTurn( return await safeInvoke("agent_runtime_interrupt_turn", { request }); } +export async function compactAgentRuntimeSession( + request: AgentRuntimeCompactSessionRequest, +): Promise { + return await safeInvoke("agent_runtime_compact_session", { request }); +} + +export async function resumeAgentRuntimeThread( + request: AgentRuntimeResumeThreadRequest, +): Promise { + return await safeInvoke("agent_runtime_resume_thread", { request }); +} + +export async function replayAgentRuntimeRequest( + request: AgentRuntimeReplayRequestRequest, +): Promise { + return await safeInvoke("agent_runtime_replay_request", { request }); +} + export async function removeAgentRuntimeQueuedTurn( request: AgentRuntimeRemoveQueuedTurnRequest, ): Promise { @@ -651,14 +764,27 @@ export async function getAgentRuntimeSession( sessionId: string, ): Promise { const detail = await safeInvoke("agent_runtime_get_session", { sessionId }); + const normalizedDetail = detail as AsterSessionDetail | null | undefined; return { ...(detail as AsterSessionDetail), queued_turns: normalizeQueuedTurnSnapshots( - (detail as AsterSessionDetail | null | undefined)?.queued_turns, + normalizedDetail?.queued_turns, ), + thread_read: normalizeThreadReadModel(normalizedDetail?.thread_read), }; } +export async function getAgentRuntimeThreadRead( + sessionId: string, +): Promise { + const threadRead = await safeInvoke("agent_runtime_get_thread_read", { + sessionId, + }); + return normalizeThreadReadModel( + threadRead as AgentRuntimeThreadReadModel | null | undefined, + ) as AgentRuntimeThreadReadModel; +} + export async function getAgentRuntimeToolInventory( request: AgentRuntimeToolInventoryRequest = {}, ): Promise { diff --git a/src/lib/api/agentStream.test.ts b/src/lib/api/agentStream.test.ts index a56bdfc99..2f243e514 100644 --- a/src/lib/api/agentStream.test.ts +++ b/src/lib/api/agentStream.test.ts @@ -157,6 +157,42 @@ describe("agentStream.parseStreamEvent", () => { }); }); + it("应保留 context_compaction item 类型", () => { + expect( + parseStreamEvent({ + type: "item_started", + item: { + id: "context-compaction-1", + thread_id: "thread-1", + turn_id: "turn-1", + sequence: 3, + status: "in_progress", + started_at: "2026-03-23T00:00:00Z", + updated_at: "2026-03-23T00:00:00Z", + type: "context_compaction", + stage: "started", + trigger: "manual", + detail: "Compacting session history", + }, + }), + ).toEqual({ + type: "item_started", + item: { + id: "context-compaction-1", + thread_id: "thread-1", + turn_id: "turn-1", + sequence: 3, + status: "in_progress", + started_at: "2026-03-23T00:00:00Z", + updated_at: "2026-03-23T00:00:00Z", + type: "context_compaction", + stage: "started", + trigger: "manual", + detail: "Compacting session history", + }, + }); + }); + it("应兼容 camelCase 的队列快照字段", () => { expect( parseStreamEvent({ diff --git a/src/lib/api/agentStream.ts b/src/lib/api/agentStream.ts index b443a3913..4400d1d47 100644 --- a/src/lib/api/agentStream.ts +++ b/src/lib/api/agentStream.ts @@ -190,6 +190,13 @@ export interface AgentThreadWarningItem extends AgentThreadItemBase { code?: string; } +export interface AgentThreadContextCompactionItem extends AgentThreadItemBase { + type: "context_compaction"; + stage: "started" | "completed" | string; + trigger?: string; + detail?: string; +} + export interface AgentThreadErrorItem extends AgentThreadItemBase { type: "error"; message: string; @@ -213,6 +220,7 @@ export type AgentThreadItem = | AgentThreadFileArtifactItem | AgentThreadSubagentActivityItem | AgentThreadWarningItem + | AgentThreadContextCompactionItem | AgentThreadErrorItem | AgentThreadTurnSummaryItem;