From 723fafe621ad27a22447bc925400fcda7038ffca Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 16 Mar 2026 15:28:21 +0800 Subject: [PATCH] feat: release v0.88.0 with automation and browser runtime upgrades --- .github/workflows/release.yml | 29 +- README.md | 184 +- RELEASE_NOTES.md | 54 +- docs/aiprompts/overview.md | 176 +- .../content/01.introduction/2.installation.md | 13 +- .../05.troubleshooting/1.common-issues.md | 5 +- .../4.windows-startup-issues.md | 12 +- docs/content/06.development/3.building.md | 9 +- docs/content/06.development/4.operations.md | 4 +- .../research/nextbrowser-features-analysis.md | 471 ++++ extensions/proxycast-chrome/README.md | 2 +- extensions/proxycast-chrome/background.js | 40 + package.json | 2 +- scripts/report-legacy-surfaces.mjs | 111 + src-tauri/Cargo.lock | 36 +- src-tauri/Cargo.toml | 10 +- .../crates/agent/src/tools/heartbeat_tool.rs | 763 ------- src-tauri/crates/agent/src/tools/mod.rs | 5 - .../crates/browser-runtime/src/manager.rs | 32 +- src-tauri/crates/browser-runtime/src/types.rs | 2 + src-tauri/crates/core/src/config/mod.rs | 25 +- src-tauri/crates/core/src/config/types.rs | 370 ++- src-tauri/crates/core/src/config/yaml.rs | 14 +- .../crates/core/src/database/dao/agent_run.rs | 92 +- .../core/src/database/dao/automation_job.rs | 413 ++++ .../dao/browser_environment_preset.rs | 416 ++++ .../core/src/database/dao/browser_profile.rs | 434 ++++ .../crates/core/src/database/dao/heartbeat.rs | 137 -- src-tauri/crates/core/src/database/dao/mod.rs | 4 +- src-tauri/crates/core/src/database/schema.rs | 207 +- src-tauri/crates/core/src/plugin/types.rs | 3 + src-tauri/crates/scheduler/src/batch.rs | 392 ---- src-tauri/crates/scheduler/src/batch_dao.rs | 526 ----- src-tauri/crates/scheduler/src/executor.rs | 30 +- src-tauri/crates/scheduler/src/lib.rs | 10 - src-tauri/crates/scheduler/src/template.rs | 134 -- src-tauri/crates/server/src/chrome_bridge.rs | 87 + .../crates/server/src/handlers/batch_api.rs | 519 ----- .../server/src/handlers/batch_executor.rs | 420 ---- src-tauri/crates/server/src/handlers/mod.rs | 3 - src-tauri/crates/server/src/lib.rs | 29 - .../services/src/api_key_provider_service.rs | 51 +- .../websocket/src/handlers/rpc_handler.rs | 8 +- .../src/agent/heartbeat_service_adapter.rs | 204 -- src-tauri/src/agent/mod.rs | 2 - src-tauri/src/app/bootstrap.rs | 20 +- src-tauri/src/app/runner.rs | 97 +- .../src/commands/api_key_provider_cmd.rs | 2 +- src-tauri/src/commands/aster_agent_cmd.rs | 484 +++- src-tauri/src/commands/automation_cmd.rs | 238 ++ .../src/commands/browser_environment_cmd.rs | 120 + src-tauri/src/commands/browser_profile_cmd.rs | 132 ++ src-tauri/src/commands/browser_runtime_cmd.rs | 604 ++++- src-tauri/src/commands/config_cmd.rs | 18 +- src-tauri/src/commands/execution_run_cmd.rs | 4 +- src-tauri/src/commands/heartbeat_cmd.rs | 451 ---- src-tauri/src/commands/mod.rs | 4 +- src-tauri/src/commands/openclaw_cmd.rs | 20 +- src-tauri/src/commands/webview_cmd.rs | 445 +++- src-tauri/src/commands/windows_startup_cmd.rs | 10 +- src-tauri/src/config/tests.rs | 147 +- src-tauri/src/dev_bridge.rs | 30 +- src-tauri/src/dev_bridge/dispatcher.rs | 2010 +---------------- .../dev_bridge/dispatcher/agent_sessions.rs | 136 ++ .../src/dev_bridge/dispatcher/app_runtime.rs | 74 + .../src/dev_bridge/dispatcher/browser.rs | 45 + .../dev_bridge/dispatcher/browser/bridge.rs | 49 + .../src/dev_bridge/dispatcher/browser/cdp.rs | 34 + .../dev_bridge/dispatcher/browser/runtime.rs | 89 + .../dev_bridge/dispatcher/browser/sessions.rs | 70 + .../src/dev_bridge/dispatcher/content.rs | 145 ++ src-tauri/src/dev_bridge/dispatcher/logs.rs | 76 + src-tauri/src/dev_bridge/dispatcher/memory.rs | 242 ++ .../dev_bridge/dispatcher/memory_runtime.rs | 70 + src-tauri/src/dev_bridge/dispatcher/models.rs | 89 + .../src/dev_bridge/dispatcher/openclaw.rs | 38 + .../dispatcher/openclaw/operations.rs | 119 + .../dev_bridge/dispatcher/openclaw/queries.rs | 76 + .../dispatcher/project_resources.rs | 76 + .../src/dev_bridge/dispatcher/providers.rs | 170 ++ .../dev_bridge/dispatcher/runtime_queries.rs | 137 ++ src-tauri/src/dev_bridge/dispatcher/skills.rs | 130 ++ .../src/dev_bridge/dispatcher/workspace.rs | 153 ++ .../dispatcher/workspace/management.rs | 84 + .../dispatcher/workspace/queries.rs | 58 + .../dispatcher/workspace/readiness.rs | 42 + .../browser_runtime_sync.rs | 363 +++ .../services/automation_service/delivery.rs | 1682 ++++++++++++++ .../services/automation_service/executor.rs | 211 ++ .../src/services/automation_service/health.rs | 429 ++++ .../src/services/automation_service/mod.rs | 1358 +++++++++++ .../services/automation_service/schedule.rs | 199 ++ .../services/browser_environment_service.rs | 444 ++++ .../src/services/browser_profile_service.rs | 309 +++ .../src/services/execution_tracker_service.rs | 63 +- .../services/heartbeat_service/delivery.rs | 289 --- .../src/services/heartbeat_service/engine.rs | 389 ---- .../src/services/heartbeat_service/mod.rs | 1125 --------- .../services/heartbeat_service/schedule.rs | 274 --- .../services/heartbeat_service/templates.rs | 205 -- src-tauri/src/services/mod.rs | 4 +- src-tauri/src/services/openclaw_service.rs | 457 +++- src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- src-tauri/tauri.windows.online.conf.json | 11 + src/App.tsx | 7 +- .../capability-agent-teams-placeholder.svg | 39 + .../capability-automations-placeholder.svg | 36 + .../capability-browser-assist-placeholder.svg | 37 + .../capability-skills-placeholder.svg | 42 + src/components/AppSidebar.tsx | 231 +- src/components/SplashScreen.tsx | 364 ++- .../ActionRequestA2UIPreviewCard.tsx | 100 + .../components/AgentThreadTimeline.test.tsx | 500 ++++ .../chat/components/AgentThreadTimeline.tsx | 1174 ++++++++-- .../agent/chat/components/ChatNavbar.test.tsx | 71 +- .../agent/chat/components/ChatNavbar.tsx | 435 ++-- .../chat/components/ChatSidebar.test.tsx | 288 +++ .../agent/chat/components/ChatSidebar.tsx | 495 +++- .../chat/components/DecisionPanel.test.tsx | 62 + .../agent/chat/components/DecisionPanel.tsx | 132 ++ .../agent/chat/components/EmptyState.tsx | 1463 +++++------- .../components/EmptyStateComposerPanel.tsx | 868 +++++++ .../agent/chat/components/EmptyStateHero.tsx | 190 ++ .../components/EmptyStateQuickActions.tsx | 118 + .../Inputbar/components/SkillSelector.tsx | 73 +- .../chat/components/MessageList.test.tsx | 211 +- .../agent/chat/components/MessageList.tsx | 475 ++-- .../components/StreamingRenderer.test.tsx | 82 + .../chat/components/StreamingRenderer.tsx | 60 +- .../agent/chat/components/ToolCallDisplay.tsx | 8 +- .../agent/chat/hooks/agentChatHistory.ts | 11 +- .../agent/chat/hooks/agentChatShared.test.ts | 162 ++ .../agent/chat/hooks/agentChatShared.ts | 162 +- .../agent/chat/hooks/agentChatToolResult.ts | 55 +- .../agent/chat/hooks/agentRuntimeAdapter.ts | 2 + .../chat/hooks/agentStreamEventProcessor.ts | 27 +- .../agent/chat/hooks/skillCommand.test.ts | 121 +- .../agent/chat/hooks/skillCommand.ts | 17 +- .../agent/chat/hooks/useAgentChat.test.tsx | 152 ++ .../agent/chat/hooks/useAgentChat.ts | 41 +- .../agent/chat/hooks/useAgentSession.ts | 8 +- .../agent/chat/hooks/useAgentTools.ts | 89 +- .../chat/hooks/useAsterAgentChat.test.tsx | 220 +- .../agent/chat/hooks/useAsterAgentChat.ts | 9 + src/components/agent/chat/index.test.tsx | 645 +++++- src/components/agent/chat/index.tsx | 1279 ++++++++--- src/components/agent/chat/styles/index.ts | 22 +- src/components/agent/chat/types.ts | 29 +- .../chat/utils/actionRequestA2UI.test.ts | 206 ++ .../agent/chat/utils/actionRequestA2UI.ts | 726 ++++++ .../chat/utils/agentThreadGrouping.test.ts | 175 ++ .../agent/chat/utils/agentThreadGrouping.ts | 669 ++++++ .../chat/utils/browserAssistPreheat.test.ts | 78 + .../agent/chat/utils/browserAssistPreheat.ts | 51 + .../chat/utils/browserTaskRequirement.test.ts | 29 + .../chat/utils/browserTaskRequirement.ts | 105 + .../utils/legacyQuestionnaireA2UI.test.ts | 138 ++ .../chat/utils/legacyQuestionnaireA2UI.ts | 478 ++++ .../chat/utils/messageTurnGrouping.test.ts | 57 + .../agent/chat/utils/messageTurnGrouping.ts | 49 + .../chat/utils/streamDiagnostics.test.ts | 22 + .../agent/chat/utils/streamDiagnostics.ts | 4 +- src/components/automation/AutomationPage.tsx | 22 + src/components/automation/index.ts | 1 + src/components/batch/BatchPage.tsx | 311 --- src/components/batch/BatchTaskDetail.tsx | 306 --- src/components/batch/CreateBatchDialog.tsx | 308 --- src/components/batch/TemplateManager.tsx | 324 --- src/components/batch/index.ts | 1 - .../content-creator/a2ui/taskCardPresets.ts | 7 + .../canvas/video/PromptInput.tsx | 293 ++- .../canvas/video/VideoCanvas.tsx | 144 +- .../canvas/video/VideoSidebar.tsx | 805 ++++--- .../canvas/video/VideoWorkspace.tsx | 1046 +++++++-- .../content-creator/material/ImageGallery.tsx | 422 ++-- src/components/image-gen/ImageGenPage.tsx | 276 ++- .../image-gen/tabs/AiImageGenTab.tsx | 1033 ++++++--- .../image-gen/tabs/ImageSearchTab.tsx | 396 ++-- .../image-gen/tabs/LocalImageTab.tsx | 313 ++- .../image-gen/tabs/MyGalleryTab.tsx | 291 ++- src/components/input-kit/ModelSelector.tsx | 129 +- .../openclaw/OpenClawConfigurePage.tsx | 490 ++-- .../openclaw/OpenClawDashboardPage.tsx | 156 +- .../openclaw/OpenClawInstallPage.tsx | 415 ++-- src/components/openclaw/OpenClawPage.test.tsx | 16 + src/components/openclaw/OpenClawPage.tsx | 728 +++++- .../openclaw/OpenClawProgressPage.tsx | 189 +- .../openclaw/OpenClawRuntimePage.tsx | 425 +++- src/components/openclaw/OpenClawSceneNav.tsx | 49 +- src/components/openclaw/openclawStyles.ts | 17 + src/components/openclaw/types.ts | 3 +- src/components/plugins/PluginManager.test.tsx | 155 ++ src/components/plugins/PluginManager.tsx | 335 ++- src/components/plugins/PluginsPage.tsx | 2 +- src/components/projects/ProjectSelector.tsx | 334 ++- .../api-key/AddCustomProviderModal.tsx | 6 +- .../api-key/ProviderConfigForm.tsx | 62 +- .../api-key/ProviderConfigForm.ui.test.tsx | 128 ++ .../api-key/ProviderConfigForm.utils.ts | 7 + .../provider-pool/api-key/ProviderSetting.tsx | 7 +- src/components/resources/ResourcesPage.tsx | 1157 +++++++--- .../settings-v2/_layout/SettingsSidebar.tsx | 15 + src/components/settings-v2/_layout/index.tsx | 122 +- .../settings-v2/agent/assistant/index.tsx | 443 ---- .../settings-v2/agent/image-gen/index.tsx | 78 +- .../agent/media-services/index.test.tsx | 106 + .../agent/media-services/index.tsx | 309 +++ .../agent/shared/MediaPreferenceSection.tsx | 39 +- .../settings-v2/agent/video-gen/index.tsx | 14 +- .../settings-v2/agent/voice/index.tsx | 54 +- .../general/appearance/index.test.tsx | 98 +- .../settings-v2/general/appearance/index.tsx | 235 +- src/components/settings-v2/home/index.tsx | 8 +- .../settings-v2/hooks/useSettingsCategory.ts | 28 +- .../settings-v2/system/about/index.tsx | 692 ++++-- .../automation/AutomationHealthPanel.test.tsx | 89 + .../automation/AutomationHealthPanel.tsx | 174 ++ .../automation/AutomationJobDialog.test.tsx | 298 +++ .../system/automation/AutomationJobDialog.tsx | 1200 ++++++++++ .../system/automation/index.test.tsx | 499 ++++ .../settings-v2/system/automation/index.tsx | 1717 ++++++++++++++ .../system/chrome-relay/index.test.tsx | 44 +- .../settings-v2/system/chrome-relay/index.tsx | 1354 ++++++----- .../system/execution-tracker/index.test.tsx | 155 ++ .../system/execution-tracker/index.tsx | 889 ++++++-- .../settings-v2/system/experimental/index.tsx | 962 +++++--- .../settings-v2/system/heartbeat/index.tsx | 1270 ----------- src/components/ui/select.tsx | 14 +- src/components/ui/tabs.tsx | 5 +- .../BrowserEnvironmentPresetManager.test.tsx | 126 ++ .../BrowserEnvironmentPresetManager.tsx | 627 +++++ .../BrowserProfileManager.test.tsx | 650 ++++++ .../browser-runtime/BrowserProfileManager.tsx | 794 +++++++ .../BrowserRuntimeDebugPanel.test.tsx | 612 ++++- .../BrowserRuntimeDebugPanel.tsx | 1323 +++++++---- .../BrowserRuntimeWorkspace.test.tsx | 124 +- .../BrowserRuntimeWorkspace.tsx | 116 +- src/features/browser-runtime/api.ts | 46 + .../existingSessionAttachPresentation.test.ts | 50 + .../existingSessionAttachPresentation.ts | 91 + .../existingSessionBridge.test.ts | 72 + .../browser-runtime/existingSessionBridge.ts | 121 + .../existingSessionBridgeClient.test.ts | 259 +++ .../existingSessionBridgeClient.ts | 169 ++ .../existingSessionPageInfo.test.ts | 94 + .../existingSessionPageInfo.ts | 66 + .../useBrowserRuntimeDebug.test.tsx | 6 +- .../browser-runtime/useBrowserRuntimeDebug.ts | 2 +- .../useExistingSessionAttachPanel.test.tsx | 443 ++++ .../useExistingSessionAttachPanel.ts | 322 +++ .../useExistingSessionProfileManager.test.tsx | 361 +++ .../useExistingSessionProfileManager.ts | 309 +++ src/lib/api/agentRuntime.ts | 3 + src/lib/api/apiKeyProvider.ts | 2 +- src/lib/api/appConfig.ts | 1 - src/lib/api/appConfigTypes.ts | 18 +- src/lib/api/automation.ts | 273 +++ src/lib/api/batch.ts | 178 -- src/lib/api/executionRun.ts | 2 +- src/lib/api/heartbeat.ts | 259 --- src/lib/api/openclaw.ts | 20 + src/lib/contentCreator/themeDefaults.ts | 9 + src/lib/dev-bridge/mockPriorityCommands.ts | 11 + src/lib/navigation/sidebarNav.ts | 172 ++ src/lib/tauri-mock/core.ts | 1195 ++++++++-- src/lib/webview-api.ts | 265 ++- src/stores/agentStore.test.ts | 246 ++ src/stores/agentStore.ts | 43 + src/types/page.ts | 3 +- src/types/settings.ts | 14 +- 271 files changed, 47044 insertions(+), 19216 deletions(-) delete mode 100644 src-tauri/crates/agent/src/tools/heartbeat_tool.rs create mode 100644 src-tauri/crates/core/src/database/dao/automation_job.rs create mode 100644 src-tauri/crates/core/src/database/dao/browser_environment_preset.rs create mode 100644 src-tauri/crates/core/src/database/dao/browser_profile.rs delete mode 100644 src-tauri/crates/core/src/database/dao/heartbeat.rs delete mode 100644 src-tauri/crates/scheduler/src/batch.rs delete mode 100644 src-tauri/crates/scheduler/src/batch_dao.rs delete mode 100644 src-tauri/crates/scheduler/src/template.rs delete mode 100644 src-tauri/crates/server/src/handlers/batch_api.rs delete mode 100644 src-tauri/crates/server/src/handlers/batch_executor.rs delete mode 100644 src-tauri/src/agent/heartbeat_service_adapter.rs create mode 100644 src-tauri/src/commands/automation_cmd.rs create mode 100644 src-tauri/src/commands/browser_environment_cmd.rs create mode 100644 src-tauri/src/commands/browser_profile_cmd.rs delete mode 100644 src-tauri/src/commands/heartbeat_cmd.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/app_runtime.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/browser.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/browser/bridge.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/browser/cdp.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/browser/runtime.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/browser/sessions.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/content.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/logs.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/memory.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/models.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/openclaw.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/openclaw/operations.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/project_resources.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/providers.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/skills.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/workspace.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/workspace/management.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/workspace/queries.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/workspace/readiness.rs create mode 100644 src-tauri/src/services/automation_service/browser_runtime_sync.rs create mode 100644 src-tauri/src/services/automation_service/delivery.rs create mode 100644 src-tauri/src/services/automation_service/executor.rs create mode 100644 src-tauri/src/services/automation_service/health.rs create mode 100644 src-tauri/src/services/automation_service/mod.rs create mode 100644 src-tauri/src/services/automation_service/schedule.rs create mode 100644 src-tauri/src/services/browser_environment_service.rs create mode 100644 src-tauri/src/services/browser_profile_service.rs delete mode 100644 src-tauri/src/services/heartbeat_service/delivery.rs delete mode 100644 src-tauri/src/services/heartbeat_service/engine.rs delete mode 100644 src-tauri/src/services/heartbeat_service/mod.rs delete mode 100644 src-tauri/src/services/heartbeat_service/schedule.rs delete mode 100644 src-tauri/src/services/heartbeat_service/templates.rs create mode 100644 src-tauri/tauri.windows.online.conf.json create mode 100644 src/assets/claw-home/capability-agent-teams-placeholder.svg create mode 100644 src/assets/claw-home/capability-automations-placeholder.svg create mode 100644 src/assets/claw-home/capability-browser-assist-placeholder.svg create mode 100644 src/assets/claw-home/capability-skills-placeholder.svg create mode 100644 src/components/agent/chat/components/ActionRequestA2UIPreviewCard.tsx create mode 100644 src/components/agent/chat/components/AgentThreadTimeline.test.tsx create mode 100644 src/components/agent/chat/components/EmptyStateComposerPanel.tsx create mode 100644 src/components/agent/chat/components/EmptyStateHero.tsx create mode 100644 src/components/agent/chat/components/EmptyStateQuickActions.tsx create mode 100644 src/components/agent/chat/hooks/agentChatShared.test.ts create mode 100644 src/components/agent/chat/utils/actionRequestA2UI.test.ts create mode 100644 src/components/agent/chat/utils/actionRequestA2UI.ts create mode 100644 src/components/agent/chat/utils/agentThreadGrouping.test.ts create mode 100644 src/components/agent/chat/utils/agentThreadGrouping.ts create mode 100644 src/components/agent/chat/utils/browserAssistPreheat.test.ts create mode 100644 src/components/agent/chat/utils/browserAssistPreheat.ts create mode 100644 src/components/agent/chat/utils/browserTaskRequirement.test.ts create mode 100644 src/components/agent/chat/utils/browserTaskRequirement.ts create mode 100644 src/components/agent/chat/utils/legacyQuestionnaireA2UI.test.ts create mode 100644 src/components/agent/chat/utils/legacyQuestionnaireA2UI.ts create mode 100644 src/components/agent/chat/utils/messageTurnGrouping.test.ts create mode 100644 src/components/agent/chat/utils/messageTurnGrouping.ts create mode 100644 src/components/automation/AutomationPage.tsx create mode 100644 src/components/automation/index.ts delete mode 100644 src/components/batch/BatchPage.tsx delete mode 100644 src/components/batch/BatchTaskDetail.tsx delete mode 100644 src/components/batch/CreateBatchDialog.tsx delete mode 100644 src/components/batch/TemplateManager.tsx delete mode 100644 src/components/batch/index.ts create mode 100644 src/components/openclaw/openclawStyles.ts create mode 100644 src/components/provider-pool/api-key/ProviderConfigForm.ui.test.tsx delete mode 100644 src/components/settings-v2/agent/assistant/index.tsx create mode 100644 src/components/settings-v2/agent/media-services/index.test.tsx create mode 100644 src/components/settings-v2/agent/media-services/index.tsx create mode 100644 src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx create mode 100644 src/components/settings-v2/system/automation/AutomationHealthPanel.tsx create mode 100644 src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx create mode 100644 src/components/settings-v2/system/automation/AutomationJobDialog.tsx create mode 100644 src/components/settings-v2/system/automation/index.test.tsx create mode 100644 src/components/settings-v2/system/automation/index.tsx create mode 100644 src/components/settings-v2/system/execution-tracker/index.test.tsx delete mode 100644 src/components/settings-v2/system/heartbeat/index.tsx create mode 100644 src/features/browser-runtime/BrowserEnvironmentPresetManager.test.tsx create mode 100644 src/features/browser-runtime/BrowserEnvironmentPresetManager.tsx create mode 100644 src/features/browser-runtime/BrowserProfileManager.test.tsx create mode 100644 src/features/browser-runtime/BrowserProfileManager.tsx create mode 100644 src/features/browser-runtime/existingSessionAttachPresentation.test.ts create mode 100644 src/features/browser-runtime/existingSessionAttachPresentation.ts create mode 100644 src/features/browser-runtime/existingSessionBridge.test.ts create mode 100644 src/features/browser-runtime/existingSessionBridge.ts create mode 100644 src/features/browser-runtime/existingSessionBridgeClient.test.ts create mode 100644 src/features/browser-runtime/existingSessionBridgeClient.ts create mode 100644 src/features/browser-runtime/existingSessionPageInfo.test.ts create mode 100644 src/features/browser-runtime/existingSessionPageInfo.ts create mode 100644 src/features/browser-runtime/useExistingSessionAttachPanel.test.tsx create mode 100644 src/features/browser-runtime/useExistingSessionAttachPanel.ts create mode 100644 src/features/browser-runtime/useExistingSessionProfileManager.test.tsx create mode 100644 src/features/browser-runtime/useExistingSessionProfileManager.ts create mode 100644 src/lib/api/automation.ts delete mode 100644 src/lib/api/batch.ts delete mode 100644 src/lib/api/heartbeat.ts create mode 100644 src/lib/contentCreator/themeDefaults.ts create mode 100644 src/lib/navigation/sidebarNav.ts create mode 100644 src/stores/agentStore.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de4ac15c2..f8679078b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -184,7 +184,7 @@ jobs: # 默认不启用 voice feature(包含 whisper-rs,编译很慢) args: --target ${{ matrix.target }} - - name: Build Tauri app (Windows) + - name: Build Tauri app (Windows online) if: matrix.platform == 'windows-2022' uses: tauri-apps/tauri-action@v0 env: @@ -204,6 +204,33 @@ jobs: releaseBody: ${{ env.RELEASE_BODY }} releaseDraft: false prerelease: false + # 默认推荐在线小包,安装时按需拉取 WebView2 + releaseAssetNamePattern: "[name]_[version]_[arch]-online[setup][ext]" + # 默认不启用 voice feature(包含 whisper-rs,编译很慢) + args: --target ${{ matrix.target }} --config tauri.windows.online.conf.json + + - name: Build Tauri app (Windows offline) + if: matrix.platform == 'windows-2022' + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # 禁用 LTO 加速编译(正式发布可改为 thin) + CARGO_PROFILE_RELEASE_LTO: "off" + # 增加并行编译单元 + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32 + CARGO_INCREMENTAL: 0 + SCCACHE_GHA_ENABLED: "true" + RUSTC_WRAPPER: sccache + with: + tauriScript: npx tauri + projectPath: src-tauri + tagName: ${{ github.event.inputs.version || github.ref_name }} + releaseName: "ProxyCast ${{ github.event.inputs.version || github.ref_name }}" + releaseBody: ${{ env.RELEASE_BODY }} + releaseDraft: false + prerelease: false + # 离线大包内置 WebView2 离线安装器,适用于无网环境 + releaseAssetNamePattern: "[name]_[version]_[arch]-offline[setup][ext]" # 默认不启用 voice feature(包含 whisper-rs,编译很慢) args: --target ${{ matrix.target }} --config tauri.windows.conf.json diff --git a/README.md b/README.md index 0f08e1ca7..fd5788c24 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,117 @@
-# ProxyCast 🚀 +# ProxyCast -**创作类 AI Agent 平台** +**以创作为中心的本地优先 AI Agent 交互工作台** -一句话:把灵感、写作、出图、改稿、沉淀放进同一个工作台,让创作从“想到”直接走到“可发布”。 +一句话:用 Skills 组织经验与流程,用 MCP 接入标准能力,用 Claw 渠道把 Agent 带到飞书、Telegram 等入口,让创作、研究、执行与交付在同一个工作环境里闭合。
--- -## 👋 这是什么 +## 这是什么 -ProxyCast 是面向普通创作者的 AI Agent 平台。 -你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成: +ProxyCast 是一个基于 Tauri 的桌面应用,面向创作者、内容团队与轻知识工作者。它把 Workspace、Agent、Skills、MCP、Claw 渠道和 Artifact 交付整合到同一个桌面环境里,让工作从输入需求直接走向可沉淀、可复用、可继续执行的结果。 -- 和 Agent 对话定方向 -- 生成内容与素材 -- 继续迭代修改 -- 把结果沉淀成可复用资产 +你可以在一个地方完成: + +- 在项目里与 Agent 协作 +- 生成和编辑文档、脚本、图文方案等产物 +- 使用浏览器、终端、MCP 和插件扩展执行空间 +- 让结果沉淀为可复用的记忆、风格和版本资产 --- -## 🧩 支持的创作主题 +## 产品核心 -你可以按创作目标选择主题,也可以跨主题组合使用。 +### 1. Workspace 是环境 -1. **通用对话**:灵感发散、问题梳理、快速头脑风暴 -2. **社媒内容**:选题、标题、正文、多平台改写 -3. **图文海报**:主视觉文案、配图方向、海报内容生成 -4. **歌词曲谱**:歌词起稿、段落续写、风格改编 -5. **知识探索**:知识点拆解、结构化总结、学习卡片 -6. **计划规划**:目标分解、执行节奏、阶段复盘 -7. **办公文档**:报告、方案、邮件、会议纪要整理 -8. **短视频**:脚本结构、分镜思路、口播文案生成 -9. **小说创作**:设定、章节推进、人物对白与续写 +- 项目、文件、会话、记忆、风格和人设围绕同一个工作区持续累积 +- 支持主题化工作台,覆盖通用对话、社媒内容、办公文档、视频、小说等场景 + +### 2. Skills 是经验交互与流程编排 + +- Skills 封装 prompt、references、scripts、assets 和调用规则 +- Skills 既定义如何交互,也定义任务如何推进 +- 它们是 ProxyCast 中经验复用、流程复用和领域方法沉淀的核心单元 + +### 3. MCP 是标准能力层 + +- 基于 MCP 接入 tools、resources、prompts 和工作边界 +- 让浏览器、文件、终端和外部服务成为 Agent 的标准化能力网络 +- 让 ProxyCast 的能力扩展建立在开放标准之上 + +### 4. Claw 渠道是异步协作入口 + +- 支持通过飞书、Telegram、Discord 等渠道与 Agent 持续协作 +- 支持远程触发、异步回流与渠道化交互 +- 让 Agent 不只存在于桌面窗口中 + +### 5. Artifact 是交付物 + +- 持续产出文档、脚本、草稿、海报方案与版本结果 +- 通过画布与工作流把过程沉淀到项目内,形成可复用的项目资产 + +### 6. Agent Runtime 负责执行与编排 + +- 基于 Aster Agent Runtime +- 支持会话、流式执行、技能调用、子任务接力与长期运行 +- 底层保留多 Provider 接入、凭证池、路由与协议兼容能力 --- -## 📖 创作场景(不止一种) +## 适合谁 -### 场景 1:社媒日更 - -- 场景:每天都要稳定发内容,但选题和表达容易重复。 -- 动作:先让 Agent 给出 3 个方向,再选一个生成多版文案与配图思路。 -- 结果:当天可直接发布,同时保留素材供后续复用。 - -### 场景 2:短视频起号 - -- 场景:有想法但脚本总是“有点散”。 -- 动作:用主题工作流先拆结构,再生成口播稿和镜头节奏。 -- 结果:从模糊创意变成可拍摄脚本,沟通成本显著降低。 - -### 场景 3:小说连载 - -- 场景:长期连载容易设定冲突、节奏断档。 -- 动作:在同一项目里持续积累世界观、人物设定和章节草稿。 -- 结果:剧情连贯性更强,更新更稳定。 - -### 场景 4:活动海报与图文 - -- 场景:活动上线前要快速产出多套视觉方向。 -- 动作:先生成文案方向,再出图并按参考图持续迭代。 -- 结果:方案选择更快,历史版本可追溯、可复用。 - -### 场景 5:歌词创作 - -- 场景:有旋律或主题,但歌词总卡在中段。 -- 动作:让 Agent 先给主副歌框架,再逐段续写与改写。 -- 结果:成稿速度更快,风格更统一。 - -### 场景 6:知识内容输出 - -- 场景:学了很多但难以整理成可分享内容。 -- 动作:把资料整理成结构化要点,再输出为卡片或长文。 -- 结果:输入和输出形成闭环,知识更容易长期积累。 - -### 场景 7:计划执行 - -- 场景:目标很大,但每天不知道先做什么。 -- 动作:把目标拆成周计划与日任务,并按进度复盘调整。 -- 结果:执行路径清晰,可持续推进。 - -### 场景 8:办公写作 - -- 场景:报告、邮件、方案反复改,耗时高。 -- 动作:先生成初稿,再按受众快速改成不同版本。 -- 结果:沟通更顺,交付更快。 +- 内容创作者 +- 品牌与运营团队 +- 研究与知识整理型工作者 +- 需要本地优先、结果可追溯的小团队 --- -## 🎨 3 步开始创作 +## 典型场景 -1. **选主题**:按目标进入对应创作主题 -2. **给输入**:一句需求、一个方向或一份素材都可以 -3. **持续迭代**:边聊边改边沉淀,最终得到可发布结果 +### 场景 1:内容创作闭环 + +- 从选题、研究、写作、改稿到最终成稿 +- Skills 驱动任务推进,对话、版本和风格资产持续沉淀在项目中 + +### 场景 2:研究整理 + +- 从网页资料、笔记、素材到提纲、报告和长文输出 +- 通过 Skills 与 MCP 把零散信息整理成结构化交付物 + +### 场景 3:渠道化协作 + +- 通过飞书、Telegram、Discord 等 Claw 渠道接收结果、触发任务、继续协作 +- 让 Agent 进入真实使用的消息入口,而不只停留在桌面端 + +### 场景 4:浏览器与标准能力执行 + +- 在同一个 Agent 会话里接入浏览器、文件、终端和外部服务 +- 让 MCP 成为标准能力层,而不是零散工具调用 + +### 场景 5:批量与长期运行 + +- 针对多条素材、模板或重复流程批量执行 +- 结合心跳、调度与渠道回流形成长期可运行的 Agent 流程 --- -## ❤️ 为什么好用 +## 如何工作 -- **一个地方完成全流程**:从想法到成品不用来回切工具 -- **结果自动沉淀**:历史对话、素材、版本都可回看 -- **越用越顺手**:每个项目都有自己的上下文记忆 +ProxyCast 的核心工作方式是: + +1. 在 Workspace 内组织项目、素材、记忆和风格 +2. 用 Skills 定义经验交互与流程推进 +3. 通过 MCP 接入标准化外部能力 +4. 通过 Claw 渠道把协作延伸到飞书、Telegram、Discord 等入口 +5. 让结果沉淀为文档、脚本、版本和后续任务输入 --- -## 🚀 快速开始 +## 快速开始 ### 安装 @@ -121,27 +126,18 @@ brew install --cask proxycast 从 [Releases](https://github.com/aiclientproxy/proxycast/releases) 下载对应平台安装包。 -- Windows 用户优先下载 `ProxyCast_*_x64-setup.exe`(NSIS 安装器) -- 该安装器已内置 WebView2 Offline Installer,弱网/离线环境成功率更高 +- Windows 用户默认优先下载 `ProxyCast_*_x64-online-setup.exe`(NSIS 在线安装器,体积更小) +- 如果处于离线、内网或受限网络环境,请改用 `ProxyCast_*_x64-offline-setup.exe` - 如被 SmartScreen 拦截,属于未签名或签名信誉不足的 Windows 常见提示,不代表安装包必然损坏 --- -## 🧭 适合谁 +## 文档与开发 -- 自媒体创作者 -- 短视频团队 -- 小说与剧情创作者 -- 运营与品牌内容团队 -- 需要长期沉淀创作资产的个人与小团队 - ---- - -## 📚 文档与开发(可选) - -如果你是开发者,可查看: +如果你是开发者,可查看以下文档: - 项目文档:`docs/aiprompts/` +- 官网定位 PRD:`docs/PRD/website-positioning-prd.md` - Agent 指南:`AGENTS.md` 开发命令: @@ -157,11 +153,11 @@ npm run tauri build --- -## 📄 开源协议 +## 开源协议 [GNU General Public License v3 (GPLv3)](https://www.gnu.org/licenses/gpl-3.0) -## ⚠️ 免责声明 +## 免责声明 本项目仅供学习研究使用,用户需自行承担使用风险。 本项目不直接提供 AI 模型服务,模型能力由第三方提供商提供。 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 704269f10..e121f503a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,46 +1,52 @@ -## ProxyCast v0.87.0 +## ProxyCast v0.88.0 ### ✨ 新功能 -- **浏览器协助运行时**:新增 `browser-runtime` crate、Tauri 命令与调试页,支持在 Agent 会话内拉起浏览器协助能力 -- **Browser Assist 产物渲染**:新增 Browser Assist Artifact 渲染器,可在 General 会话中直接承接浏览器协助状态与操作 -- **画布工作台布局**:新增 `CanvasWorkbenchLayout`,支持预览 / 文件 / 差异 / 工作台联动,以及分栏与堆叠两种布局模式 -- **技能显式选择器**:新增 `SkillSelector` 与相关 harness skill 聚合逻辑,提升技能发现、点选与触发体验 -- **新任务入口模式**:新增 `new-task` 入口、空状态页与会话恢复控制,梳理 Claw / 新任务两类进入路径 +- **自动化任务中心**:新增 `automation` 命令、服务、DAO 与设置页,支持定时任务、计划预览、健康检查、运行历史,以及 `Agent 对话任务 / 浏览器会话任务` 两种执行形态 +- **浏览器资料与环境预设**:新增 Browser Profile / Browser Environment Preset 管理,支持托管浏览器与附着当前 Chrome 两种资料模式,并可直接复用于浏览器运行时 +- **浏览器现有会话接管**:补齐 existing session attach、页签切换、桥接状态同步与 Browser Runtime 工作区协同链路 +- **Agent Chat 决策与空状态升级**:新增 `DecisionPanel`、空状态 Hero / Quick Actions、A2UI 预览卡、线程分组与消息轮次分组,强化新任务入口与浏览器协助预热 ### 🔧 优化与重构 -- **General Chat 主流程重构**:重写 Agent Chat 页的大量 General 场景逻辑,统一画布、Artifact、Workbench 与浏览器协助状态 -- **Webview / Browser Runtime 协调增强**:扩展 `webview_cmd`、窗口服务与前端 API,补齐浏览器运行时会话、窗口与控制链路 -- **Settings v2 大规模重排**:重构外观、快捷键、频道、Chrome Relay、环境、安全性能、账户统计等设置页 -- **项目与记忆工作区升级**:重构内容列表、内容编辑器、项目选择器及多类 Memory 面板,统一信息架构与交互密度 -- **导航与侧栏收敛**:调整应用侧栏、任务侧栏、资源与工作区导航,移除部分旧页面与冗余入口 +- **执行系统收敛**:移除旧 `batch` 页面/API 与 `heartbeat` 命令/设置入口,统一收口到 `automation` 与 `execution tracker` +- **OpenClaw 工作台重做**:重排安装、配置、运行、Dashboard 与进度页,补强 Windows 环境检测、修复引导与诊断复制 +- **Settings v2 持续整理**:合并媒体服务设置,重构 Chrome Relay、Execution Tracker、About、Appearance 等页面,统一导航与信息架构 +- **资源与创作工作台更新**:重构资源页、图像生成、视频画布、项目选择器与多处内容创作界面,提升工作区一致性 ### 🐛 修复 -- **前端 Hooks 依赖修复**:补齐 `useCallback` 依赖,消除 `npm run lint` 中的 `react-hooks/exhaustive-deps` 警告 -- **Rust Clippy 清理**:移除浏览器运行时客户端中的冗余类型转换,保持 `cargo clippy` 输出干净 -- **日志测试并发污染修复**:将日志诊断测试切到独立临时目录,避免并行测试互相删除 `raw_response` 工件 -- **Workspace 错误态兜底**:补强任务侧栏与相关页面的 workspace 异常显示与恢复路径 +- **插件兼容与更新提示补强**:插件管理页新增 Windows 主程序升级入口和最低版本提示,降低插件与主程序版本错配的排障成本 +- **Browser Runtime / DevBridge 稳定性提升**:补齐浏览器会话启动参数、流诊断、桥接 mock 与状态同步,减少运行时偏差 +- **Provider 配置读写修复**:完善 API Key Provider 的 `api_version`、`project` 等配置项读写与表单校验 +- **Windows 启动与安装诊断改进**:补充安装说明、常见问题与构建文档,降低受限网络与依赖缺失场景下的排障摩擦 + +### ⚠️ 兼容性调整 + +- 旧 `Batch` / `Heartbeat` 相关前端入口与后端命令已下线;既有流程请迁移到新的 `Automation` 设置页与统一执行记录 ### 🧪 测试 -- 新增 Browser Assist、Canvas Workbench、Skill Selector、General Resource Sync、Harness Skills 等前端测试 -- 补充项目页、设置页、统计页、内存面板等多处 UI / 交互测试 +- 新增 Automation、Browser Profile / Environment Preset、Existing Session Attach、Agent Chat 分组与插件版本提示等测试覆盖 +- 补充 Browser Runtime、OpenClaw、Execution Tracker、Chrome Relay 与多处设置页交互测试 - 发布前已执行:`cargo fmt --all`、`cargo test`、`cargo clippy`、`npm run lint` ### 📝 文档 -- 补充 Agent Chat / Components 文档说明 -- 新增 `docs/research/` 研究资料目录 -- 更新发布流程相关说明,覆盖 headless Tauri 配置版本同步 +- 更新 README、架构概览、安装、故障排查、构建与运维文档 +- 补充 NextBrowser 相关研究资料与浏览器自动化设计记录 ### 🛠️ 开发体验 -- **版本一致性检查增强**:`check-app-version-consistency.mjs` 现覆盖 `tauri.conf.headless.json` -- **Release Workflow 补强**:GitHub Release 工作流现同步标准与 headless 两份 Tauri 配置版本 -- **跨端发布一致性提升**:统一 `package.json`、Cargo workspace、Tauri 配置与 release notes 的版本入口 +- **Windows 发布流程增强**:Release Workflow 现同时产出在线 / 离线两个 Windows 安装包 +- **版本源保持一致**:统一 `package.json`、Cargo workspace、两份 Tauri 配置与 `RELEASE_NOTES.md` 的发布版本入口 + +### 📦 Windows 下载说明 + +- `ProxyCast_*_x64-online-setup.exe`:默认推荐,体积更小,安装时按需下载 WebView2 +- `ProxyCast_*_x64-offline-setup.exe`:适用于离线、内网或受限网络环境 +- 如果在线安装失败,请改用离线安装包 --- -**完整变更**: v0.86.0...v0.87.0 +**完整变更**: v0.87.0...v0.88.0 diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index b6a355a01..115decca7 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -2,7 +2,25 @@ ## 概述 -ProxyCast 是一个 Tauri 桌面应用,作为 LLM API 代理网关,支持多 Provider 凭证池管理、协议转换、流量监控等功能。 +ProxyCast 是一个以创作为中心的本地优先 AI Agent 交互工作台,基于 Tauri 桌面应用构建,面向创作者、内容团队与轻知识工作者。系统由 Workspace、Skills 编排层、MCP 标准能力层、Claw 渠道层、Artifact 交付层与多模型接入能力共同组成。 + +可以把它理解为三层结构: + +1. **产品层**:Workspace、主题工作台、Agent 对话、Skills、Artifact/Canvas、记忆与风格 +2. **能力层**:MCP、浏览器运行时、终端、插件、批量/心跳、Claw 渠道 +3. **基础设施层**:Aster Agent、Provider 凭证池、协议兼容、路由、服务器、数据库与监控 + +其中,Provider 接入、协议兼容与运行时服务共同构成底层能力底座。 + +同时,术语上应与当前 Agent 生态保持一致: + +- **Sessions**:长期会话与协作上下文 +- **Handoffs**:任务接力与多阶段编排 +- **Guardrails**:权限边界、审批与调用限制 +- **Tracing**:时间线、步骤与调用轨迹可观测 +- **MCP**:tools / resources / prompts / roots 的标准能力接入 + +在 ProxyCast 中,Skills 处于比 MCP 更贴近产品的一层:它不是底层原语,而是将领域经验、交互方式和执行流程打包后的编排单元。 ## 项目结构 @@ -26,51 +44,106 @@ proxycast/ └── docs/ # 文档 ``` -## 核心模块 +## 架构分层 -### 后端 (src-tauri/src/) +### 产品层 | 模块 | 说明 | |------|------| +| `workspace/` | 工作区与项目边界,承载文件、会话与配置上下文 | +| `components/agent/` | Agent 对话主入口,负责会话、流式事件与交互 | +| `components/content-creator/` | 主题化创作工作台与画布联动 | +| `skills/` | 技能加载、标准校验与经验编排能力 | +| `lib/artifact/` | Artifact 解析、状态与轻量渲染器 | +| `memory / style / personas` | 项目记忆、风格策略与人设沉淀 | + +### 能力层 + +| 模块 | 说明 | +|------|------| +| `src/features/browser-runtime/` | 浏览器协助运行时与调试工作区 | +| `src-tauri/src/terminal/` | 内置终端与 PTY 会话 | +| `src-tauri/src/services/heartbeat_service/` | 异步调度、周期任务与投递 | +| `src-tauri/src/plugin/` | 插件系统 | +| `src-tauri/src/services/mcp_service.rs` | MCP 服务器与工具管理 | +| `src-tauri/src/commands/gateway_channel_cmd.rs` | Telegram / Feishu / Discord Claw 渠道运行时 | +| `src-tauri/src/commands/telegram_remote_cmd.rs` | Telegram 远程触发入口 | + +### 基础设施层 + +| 模块 | 说明 | +|------|------| +| `src-tauri/src/agent/` | Aster Agent 集成、会话、工具注册与流式桥接 | | `providers/` | LLM Provider 认证和 API 实现 | | `services/` | 业务服务层 | -| `converter/` | 协议转换 (OpenAI ↔ CW/Claude) | +| `converter/` | 协议转换与兼容层 | | `server/` | HTTP API 服务器 | | `credential/` | 凭证池管理 | | `flow_monitor/` | 流量监控 | -| `terminal/` | 内置终端 | +| `database/` | 数据持久化与 DAO | -### 前端 (src/) +## 核心模块视图 + +### 后端(`src-tauri/src/`) | 模块 | 说明 | |------|------| -| `components/` | React 组件 | +| `agent/` | Aster Agent 运行时桥接与会话管理 | +| `skills/` | Skills 标准集成、动态加载与执行回调 | +| `providers/` | 多 Provider 认证与请求发送 | +| `services/` | 心跳、OpenClaw、浏览器窗口、MCP 等业务服务 | +| `converter/` | 协议兼容与转换 | +| `server/` | HTTP Server 与 REST 能力 | +| `terminal/` | 终端与 PTY | +| `plugin/` | 插件加载与运行时 | +| `voice/` | 语音输入输出与 ASR 流程 | + +### 前端(`src/`) + +| 模块 | 说明 | +|------|------| +| `components/` | 主 UI 组件与主题工作台 | +| `features/` | 浏览器运行时等较独立特性域 | | `hooks/` | 业务逻辑 Hooks | -| `lib/` | 工具函数和 API 封装 | -| `pages/` | 页面组件 | +| `lib/api/` | Tauri API 与运行时封装 | +| `lib/artifact/` | Artifact 状态与解析 | +| `pages/` | 独立窗口与页面入口 | ## 数据流 ``` ┌─────────────────────────────────────────────────────────────────┐ -│ 客户端请求 (Cursor/Continue) │ +│ 用户请求(工作台 / 对话 / Skills / 飞书 / Telegram) │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ HTTP Server │ +│ Workspace / Project / Memory Layer │ +│ 项目路径、工作区配置、主题、记忆、人设、风格、产物上下文 │ +└─────────┬───────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Skills / Orchestration Layer │ +│ 经验规则、references、scripts、流程推进、任务接力与阶段切换 │ +└─────────┬───────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Agent Runtime │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ -│ │ OpenAI API │ │ Claude API │ │ Flow Monitor │ │ -│ │ 兼容端点 │ │ 兼容端点 │ │ (请求拦截) │ │ +│ │ Aster Agent │ │ Session │ │ Stream / Action │ │ +│ │ 执行 │ │ 状态 │ │ Request │ │ │ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │ └─────────┼────────────────┼─────────────────────┼────────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ Router / Processor │ +│ Execution Surface │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ -│ │ 模型路由 │ │ 协议转换 │ │ 弹性策略 │ │ -│ │ (规则匹配) │ │ (Converter) │ │ (重试/超时) │ │ +│ │ MCP Tools / │ │ Browser / │ │ Claw Channels / │ │ +│ │ Resources / │ │ Terminal / │ │ Heartbeat / Plugins │ │ +│ │ Prompts │ │ Files │ │ │ │ │ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │ └─────────┼────────────────┼─────────────────────┼────────────────┘ │ │ │ @@ -89,50 +162,73 @@ proxycast/ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Kiro │ │ Gemini │ │ Claude │ │ OpenAI │ ... │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Artifact / Canvas Layer │ +│ 文档、脚本、海报、版本链、画布状态、导出结果与任务沉淀 │ └─────────────────────────────────────────────────────────────────┘ ``` ## 关键特性 -### 1. 多 Provider 支持 -- OAuth: Kiro, Gemini, Qwen, Claude, Antigravity -- API Key: OpenAI, Claude, 自定义 +### 1. Workspace 驱动 +- Workspace 既是文件边界,也是 context 边界和配置边界 +- 项目、会话、记忆、风格和 Artifact 围绕同一工作区组织 -### 2. 凭证池管理 -- 多凭证轮询负载均衡 -- 自动健康检查和剔除 -- Token 自动刷新 +### 2. Skills 驱动 +- Skills 是经验交互、流程编排与领域方法沉淀的核心单元 +- Skills 可封装 prompt、references、scripts、assets 与调用规则 +- Agent 运行时可动态加载、自动发现与调用 Skills -### 3. 协议转换 -- OpenAI ↔ CodeWhisperer -- OpenAI ↔ Claude -- OpenAI ↔ Antigravity +### 3. MCP 标准能力层 +- 基于 MCP 管理 tools、resources、prompts 与读取边界 +- 为 Agent 提供标准化能力发现、调用与上下文共享方式 -### 4. 流量监控 -- 请求/响应拦截 -- Token 统计 -- 历史查询 +### 4. Claw 渠道协作 +- 支持 Telegram / Feishu / Discord 等渠道运行时 +- 支持远程触发、异步协作、消息回流与外部入口接入 + +### 5. Agent Runtime +- 基于 Aster Agent,支持会话、流式事件、工具调用与多模型配置 +- 支持任务接力、会话持续化、步骤可观测与长期运行 + +### 6. Artifact First +- 输出不止是聊天文本,还包括文档、草稿、脚本、版本链与画布产物 +- `write_file`、画布联动与主题工作流负责把过程沉淀成交付物 + +### 7. 多 Provider 与兼容层 +- OAuth 与 API Key Provider 并存 +- 凭证池、模型路由、协议兼容与 HTTP Server 作为底层支撑 + +### 8. 本地优先与可扩展 +- 桌面应用、本地工作区、插件与外部工具扩展 +- 允许在不改变产品主形态的前提下向更多执行环境延展 ## 文档索引 -### 核心系统 +### 产品与工作台 +- [workspace.md](workspace.md) - Workspace 边界与工作区设计 +- [content-creator.md](content-creator.md) - 主题化创作工作台 +- [../../src-tauri/src/skills/README.md](../../src-tauri/src/skills/README.md) - Skills 标准与集成 +- [terminal.md](terminal.md) - 终端能力 +- [mcp.md](mcp.md) - MCP 服务器 +- [plugins.md](plugins.md) - 插件系统 +- [aster-integration.md](aster-integration.md) - Agent Runtime 集成 + +### 基础设施 - [providers.md](providers.md) - Provider 系统 - [credential-pool.md](credential-pool.md) - 凭证池管理 - [converter.md](converter.md) - 协议转换 - [server.md](server.md) - HTTP 服务器 -### 前端模块 +### 前端与公共模块 - [components.md](components.md) - 组件系统 - [hooks.md](hooks.md) - React Hooks - [lib.md](lib.md) - 工具库 -### 功能模块 -- [flow-monitor.md](flow-monitor.md) - 流量监控 -- [terminal.md](terminal.md) - 内置终端 -- [mcp.md](mcp.md) - MCP 服务器 -- [plugins.md](plugins.md) - 插件系统 - -### 配置与服务 +### 配置、服务与数据 - [commands.md](commands.md) - Tauri 命令 - [services.md](services.md) - 业务服务 - [database.md](database.md) - 数据库层 diff --git a/docs/content/01.introduction/2.installation.md b/docs/content/01.introduction/2.installation.md index 3e0356cd0..3397a9f96 100644 --- a/docs/content/01.introduction/2.installation.md +++ b/docs/content/01.introduction/2.installation.md @@ -16,16 +16,17 @@ navigation: ## 下载 -从 GitHub Releases/Tags 下载最新版本安装包: +从 GitHub Releases 下载最新版本安装包: -[下载 ProxyCast](https://github.com/aiclientproxy/proxycast/tags) +[下载 ProxyCast](https://github.com/aiclientproxy/proxycast/releases) ### 安装包 | 平台 | 文件名 | 说明 | |------|--------|------| | macOS | `ProxyCast_x.x.x_aarch64.dmg` | Apple Silicon Mac | -| Windows | `ProxyCast_x.x.x_x64-setup.exe` | Windows 64位 | +| Windows | `ProxyCast_x.x.x_x64-online-setup.exe` | 默认推荐,体积更小,安装时按需下载 WebView2 | +| Windows | `ProxyCast_x.x.x_x64-offline-setup.exe` | 离线、内网或受限网络环境使用 | ## macOS 安装 @@ -40,11 +41,15 @@ navigation: ## Windows 安装 -1. 下载 `.exe` 安装程序 +1. 优先下载 `ProxyCast_x.x.x_x64-online-setup.exe` 2. 双击运行安装程序 3. 按照安装向导完成安装 4. 从开始菜单启动 ProxyCast +::alert{type="info"} +如果设备处于离线、内网或受限网络环境,请改用 `ProxyCast_x.x.x_x64-offline-setup.exe`。 +:: + ## 验证安装 启动 ProxyCast 后,你应该看到: diff --git a/docs/content/05.troubleshooting/1.common-issues.md b/docs/content/05.troubleshooting/1.common-issues.md index f7cfa805d..410c441a2 100644 --- a/docs/content/05.troubleshooting/1.common-issues.md +++ b/docs/content/05.troubleshooting/1.common-issues.md @@ -107,10 +107,11 @@ navigation: ### 处理建议 -1. 优先重新下载安装 `ProxyCast_*_x64-setup.exe` +1. 优先重新下载安装 `ProxyCast_*_x64-online-setup.exe` 2. 确认 `%APPDATA%\proxycast\` 与 `%USERPROFILE%\.proxycast\` 可写 3. 如被 SmartScreen 拦截,确认来源可信后再继续 -4. 如有条件,运行一键收集脚本后再反馈 +4. 如果处于离线、内网或受限网络环境,改用 `ProxyCast_*_x64-offline-setup.exe` +5. 如有条件,运行一键收集脚本后再反馈 详见 [Windows 启动与安装问题](/troubleshooting/windows-startup-issues)。 diff --git a/docs/content/05.troubleshooting/4.windows-startup-issues.md b/docs/content/05.troubleshooting/4.windows-startup-issues.md index 0ac14a942..af6cbff0a 100644 --- a/docs/content/05.troubleshooting/4.windows-startup-issues.md +++ b/docs/content/05.troubleshooting/4.windows-startup-issues.md @@ -20,7 +20,7 @@ navigation: 推荐优先使用: -- `ProxyCast_*_x64-setup.exe` +- `ProxyCast_*_x64-online-setup.exe` 不建议优先分发: @@ -29,8 +29,8 @@ navigation: 原因: -- `setup.exe` 会一并处理 WebView2 Offline Installer -- 在弱网、离线或新系统环境下成功率更高 +- 在线安装包体积更小,适合大多数 Windows 10/11 用户 +- 如果处于离线、内网或受限网络环境,请改用 `ProxyCast_*_x64-offline-setup.exe` ## 常见症状与处理 @@ -40,14 +40,14 @@ navigation: 1. 确认下载来源可信 2. 如果被 SmartScreen 拦截,点击“更多信息”后再确认是否继续 -3. 重新运行 `setup.exe` 覆盖安装 +3. 重新运行 Windows setup 安装包覆盖安装 4. 安装后从开始菜单再次启动 ### 启动后白屏 处理建议: -1. 优先重装 `setup.exe`,补齐 WebView2 Runtime +1. 优先重装 Windows setup 安装包,补齐 WebView2 Runtime 2. 检查系统是否禁用了 Edge WebView2 Runtime 3. 再确认本地目录是否可写 @@ -56,7 +56,7 @@ navigation: 处理建议: 1. 不要先手动找旧版运行时 -2. 先重新运行 `setup.exe` +2. 先重新运行 Windows setup 安装包 3. 如仍失败,再单独检查 WebView2 Runtime 是否安装完整 ## 目录权限检查 diff --git a/docs/content/06.development/3.building.md b/docs/content/06.development/3.building.md index fa4992755..4734d7e73 100644 --- a/docs/content/06.development/3.building.md +++ b/docs/content/06.development/3.building.md @@ -50,7 +50,7 @@ xcode-select --install **Windows:** - 安装 Visual Studio Build Tools -- 安装 WebView2(开发模式必需;对外分发时建议使用带离线 WebView2 的 NSIS 安装器) +- 安装 WebView2(开发模式必需;对外分发时默认推荐在线小包,离线或受限网络环境再提供离线大包) **Linux:** @@ -141,11 +141,14 @@ pnpm tauri build --target universal-apple-darwin #### Windows 构建 ```bash -# 构建 64 位 Windows 安装包(NSIS setup.exe,内置离线 WebView2 安装器) +# 构建 64 位 Windows 在线安装包(推荐,体积更小,安装时按需下载 WebView2) +pnpm tauri build --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.online.conf.json + +# 构建 64 位 Windows 离线安装包(体积更大,内置离线 WebView2 安装器) pnpm tauri build --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json ``` -> 建议对外分发 `-setup.exe`,不要把默认 `.msi` 作为首选下载项。 +> 建议默认对外分发在线小包;只有内网、离线或受限网络环境,再提供离线大包。 #### Linux 构建 diff --git a/docs/content/06.development/4.operations.md b/docs/content/06.development/4.operations.md index 01df37d30..5f0e3b2df 100644 --- a/docs/content/06.development/4.operations.md +++ b/docs/content/06.development/4.operations.md @@ -42,8 +42,8 @@ navigation: ## Windows 启动失败排查 -- 优先确认用户安装的是 `ProxyCast_*_x64-setup.exe`,不要默认分发便携包或旧的 `.msi` -- 首次启动若提示缺少运行时,优先重新运行 `setup.exe`,它会一并安装 WebView2 Offline Installer +- 优先确认用户安装的是 `ProxyCast_*_x64-online-setup.exe`;离线、内网或受限网络环境再提供 `ProxyCast_*_x64-offline-setup.exe` +- 首次启动若提示缺少运行时,优先重新运行 Windows setup 安装包;如果在线安装失败,再切换到 offline 安装包 - 检查 `%APPDATA%\proxycast\` 与 `%USERPROFILE%\.proxycast\` 是否可写;数据库、日志与部分运行时状态依赖这两个目录 - 收集 `%USERPROFILE%\.proxycast\logs\` 与 `%USERPROFILE%\.proxycast\request_logs\` 作为一线排障材料 - 若前端出现 Windows 启动自检提示,按提示项优先检查目录权限、数据库可访问性、WebView2 与 Shell 可用性 diff --git a/docs/research/nextbrowser-features-analysis.md b/docs/research/nextbrowser-features-analysis.md index 788419984..b07a0c5c3 100644 --- a/docs/research/nextbrowser-features-analysis.md +++ b/docs/research/nextbrowser-features-analysis.md @@ -1608,3 +1608,474 @@ Proxycast 已经具备了 CDP 基础设施,采用**渐进式增强**策略: - ✅ Screencast 优先,Screenshot fallback - ✅ 单 page target 先行,多 tab 后续 - ✅ 高层 action API,原始 CDP 仅调试用 + +--- + +## 九、基于当前代码库的差距复盘(截至 2026-03-15) + +> 本节用于校正文档前文的“规划态”描述,按当前仓库真实实现判断 Proxycast 已做到什么、还缺什么,以及后续应如何按基础设施优先推进。 + +### 9.1 当前已经具备的能力底座 + +#### A. 实时画面与 CDP 会话底座:已具备,可继续加固 +- 已有 `BrowserRuntimeManager`、`CdpSessionState`、事件缓冲区、人工接管状态机: + - `src-tauri/crates/browser-runtime/src/manager.rs` + - `src-tauri/crates/browser-runtime/src/types.rs` +- 已支持 `Page.startScreencast`,失败时自动回退到 `Page.captureScreenshot` 轮询: + - `src-tauri/crates/browser-runtime/src/manager.rs` +- 已暴露 Tauri 命令与前端调试页: + - `src-tauri/src/commands/browser_runtime_cmd.rs` + - `src-tauri/src/commands/webview_cmd.rs` + - `src/features/browser-runtime/BrowserRuntimeWorkspace.tsx` + - `src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx` + +#### B. 浏览器 Profile 隔离:已具备基础,但还不是产品级“个人资料” +- 已支持按 `profile_key` 启动独立 Chrome 用户目录,天然保留 cookies / localStorage / 登录态: + - `src-tauri/src/commands/webview_cmd.rs` +- 已支持列出和关闭运行中的 Profile 会话: + - `get_chrome_profile_sessions` + - `close_chrome_profile_session` +- 现状问题: + - 只有“运行中的 Chrome profile 目录”概念,没有“可管理的 Profile 实体”概念 + - 没有名称、标签、站点、最后使用时间、描述、导入/导出、锁定策略、加密策略 + - 没有“保存当前登录为资料”的明确工作流 + +#### C. 调度引擎:已具备通用能力,但不是浏览器任务编排 +- 已有调度器、轮询执行器、Cron/At/Every 调度计算与健康治理: + - `src-tauri/crates/scheduler/src/*` + - `src-tauri/src/app/scheduler_service.rs` + - `src-tauri/src/services/heartbeat_service/*` + - `src-tauri/src/commands/heartbeat_cmd.rs` +- 现状问题: + - 当前主要服务于 Heartbeat/通用任务,不是浏览器自动化任务模板 + - 缺少“任务绑定哪个 browser profile / 环境预设 / 输出 schema / 人工检查点”的模型 + +#### D. 浏览器动作与输出:已具备最小可用能力 +- 已支持 `navigate / click / type / scroll / read_page / read_console_messages / read_network_requests` +- 已有统一的 `browser_execute_action` 多后端编排: + - `src-tauri/src/commands/webview_cmd.rs` + - `src-tauri/crates/browser-runtime/src/action.rs` +- 现状问题: + - 输出仍偏底层:`markdown / page_info / console / network event` + - 没有任务级结构化输出合同,例如 `json schema / table / csv / fields mapping` + +### 9.2 与截图功能的差距矩阵 + +| 功能 | 当前状态 | 结论 | +|------|----------|------| +| 实时画面 | 已有 CDP 帧流、回退截图、调试页、人工接管 | 已做基础版,需稳定化和产品化 | +| 个人资料(已保存的登录信息) | 已有独立 Chrome profile 目录和会话复用 | 部分完成,缺产品级资料管理 | +| 计划任务 | 已有通用调度器、Heartbeat、Cron 校验 | 部分完成,缺浏览器任务模型与 UI | +| 输入和输出 | 已有页面信息、控制台、网络事件、动作执行结果 | 部分完成,缺结构化 I/O 层 | +| 位置定制 | 未见浏览器级代理、地理位置、时区、语言、UA、指纹预设 | 未实现 | +| 证书 | 未见浏览器级客户端证书/站点证书选择与存储模型 | 未实现 | +| 自动验证码求解器 | 仅支持人工接管,没有 solver 抽象与供应商接入 | 未实现 | +| 连接 | 现有 `connection_cmd` 是终端/SSH/WSL 连接,不是外部业务连接器 | 未实现截图语义下的连接器 | + +### 9.3 架构判断:先不要直接堆功能页 + +如果现在直接开始补“位置定制 / 计划任务 / 连接 / 输入和输出”这些页面,仓库会出现新的平行概念: +- 一套运行中 session 概念 +- 一套 Chrome profile 目录概念 +- 一套 Heartbeat 任务概念 +- 一套未来的浏览器任务概念 + +这会导致三类问题: +- 状态源分裂:Profile、Session、Task、Connector 各自一套 id 和生命周期 +- 配置不可复用:位置定制、登录资料、任务调度之间无法组合 +- 上层功能失去稳定锚点:定时任务、验证码、人机接管都需要先有稳定的会话装配模型 + +因此正确顺序不是“按截图逐个做页面”,而是先补一层浏览器控制面(control plane)。 + +### 9.4 建议新增的统一域模型 + +#### 1. Browser Profile +表示一个“可复用的登录资料容器”,而不是当前仅存在的目录。 + +建议字段: +- `id` +- `key` +- `name` +- `description` +- `site_scope` +- `storage_mode`:`persistent | ephemeral` +- `profile_dir` +- `last_used_at` +- `created_at` +- `updated_at` +- `archived_at` + +#### 2. Browser Environment Preset +承载“位置定制”能力,后续任务和 Profile 都引用它。 + +建议字段: +- `id` +- `name` +- `proxy_type` +- `proxy_server` +- `proxy_auth_ref` +- `country` +- `region` +- `city` +- `timezone_id` +- `locale` +- `accept_language` +- `geolocation_lat` +- `geolocation_lng` +- `geolocation_accuracy_m` +- `user_agent` +- `viewport_width` +- `viewport_height` +- `device_scale_factor` +- `platform` + +#### 3. Browser Task Template +承载“计划任务”的可执行定义,复用现有 scheduler,而不是另起炉灶。 + +建议字段: +- `id` +- `name` +- `entry_url` +- `profile_id` +- `environment_preset_id` +- `schedule_kind` +- `schedule_payload` +- `steps` +- `requires_human_checkpoint` +- `output_schema` +- `output_destination` +- `enabled` + +#### 4. Browser Connector +承载“连接”能力,目标是把结果投递到外部系统,而不是终端连接。 + +建议字段: +- `id` +- `type`:`google_sheets | gmail | webhook | drive | notion | ...` +- `name` +- `auth_kind` +- `secret_ref` +- `config_json` +- `status` +- `last_checked_at` + +#### 5. Browser Certificate Asset +承载浏览器证书与站点绑定。 + +建议字段: +- `id` +- `name` +- `cert_kind`:`client_tls | custom_ca` +- `file_ref` +- `passphrase_ref` +- `host_patterns` +- `created_at` + +### 9.5 推荐实施优先级 + +#### P0. 收口现有浏览器控制面(最高优先级) +目标:把“运行时会话”变成后续一切能力的稳定底座。 + +本阶段做什么: +- 把当前 `profile_key` 升级为数据库中的 `Browser Profile` 实体 +- 给运行时 session 增加 `profile_id / environment_preset_id / task_id` 关联位 +- 把 `open_chrome_profile_window` 的启动参数抽象成 `LaunchBrowserSessionRequest` +- 保持现有 `cdp_direct / extension_bridge / aster_compat` 编排不变,只收口输入模型 +- 给浏览器会话增加稳定审计日志:谁启动、带什么环境、来自哪个任务 + +本阶段不做什么: +- 不先做 CAPTCHA +- 不先做连接器 UI +- 不先做证书上传页 + +原因: +- 没有统一控制面,上层功能都会变成一次性参数拼装,后续很难维护 + +#### P1. 个人资料产品化(高优先级) +目标:让“保存的登录”从目录能力升级为可管理资产。 + +本阶段做什么: +- 新增 Profile 列表、创建、重命名、归档、删除、最近使用 +- 支持“从当前运行会话保存为资料” +- 支持“打开资料并进入人工登录” +- 支持资料与站点作用域绑定 +- 支持资料锁定策略和敏感信息隔离说明 + +验收标准: +- 用户可以明确看到哪些登录资料存在 +- 用户可以复用而不是记 `profile_key` +- Agent 可以按 `profile_id` 复用资料 + +#### P2. 位置定制(高优先级) +目标:让 Profile 可以在不同地区/设备语境中稳定复用。 + +本阶段做什么: +- 浏览器启动参数支持 `--proxy-server` +- CDP 注入 `Emulation.setGeolocationOverride` +- CDP 注入 `Emulation.setTimezoneOverride` +- CDP 注入 `Emulation.setUserAgentOverride` +- 前端提供 Environment Preset 编辑页 +- Profile 与 Preset 解耦,可自由组合 + +关键原则: +- 位置定制必须是独立 Preset,不能直接塞进 Profile +- 否则同一个登录资料无法复用到多个国家/城市场景 + +### 9.6 当前已落地的基础层(截至 2026-03-15) + +#### 已完成 +- `P1 Browser Profile` 已完成第一版资产化: + - 已有 `browser_profiles` 表、DAO、Service、Tauri 命令、前端资料管理 UI + - 运行时会话仍以 `profile_key` 驱动,但新需求已经收口到 `Browser Profile` 实体 +- `P2 Browser Environment Preset` 已完成第一版基础落地: + - 已有 `browser_environment_presets` 表、DAO、Service、Tauri 命令、前端预设管理 UI + - 浏览器工作台支持“资料 + 环境预设”组合启动 + - 启动链已支持: + - Chrome 启动参数 `--proxy-server` + - CDP 注入 `Emulation.setGeolocationOverride` + - CDP 注入 `Emulation.setTimezoneOverride` + - CDP 注入 `Emulation.setUserAgentOverride` + - CDP 注入 `Emulation.setLocaleOverride` + - CDP 注入 `Emulation.setDeviceMetricsOverride` + - 运行时 `session` 已增加 `environment_preset_id / environment_preset_name` 关联位 +- 浏览器运行时统一审计已接入基础层: + - 启动链与动作链统一写入同一浏览器运行时审计缓冲区 + - `launch` 审计已覆盖 `profile/profile_id`、环境预设、`session_id/target_id`、URL、复用状态、窗口打开方式、流模式、浏览器来源、CDP 端口 + - 调试面板高级区可以直接查看最近启动与动作审计 +- 浏览器启动请求已完成第一轮收口: + - 新增统一 `LaunchBrowserSessionRequest` + - `profile_id` 与 `profile_key` 启动都收口到同一 session 启动边界 + - `BrowserProfileManager`、浏览器工作台恢复链、Chrome Relay、Agent Chat 浏览器协助都已切到统一启动请求 + +#### 当前限制 +- 代理属于浏览器启动参数;若资料对应的 Chrome 进程已在运行,切换代理前必须先关闭该资料会话 +- Locale override 依赖目标 Chrome 版本;若方法不存在,当前实现按 best-effort 处理并保留日志告警 +- 当前 Environment Preset 只覆盖运行时真正可落地的字段: + - `proxy_server` + - `timezone_id` + - `locale` + - `accept_language` + - `geolocation_*` + - `user_agent` + - `platform` + - `viewport_*` + - `device_scale_factor` +- 尚未实现: + - 地区标签字段的产品化筛选与统计 + - 证书资产 + - CAPTCHA solver + - 任务模板与 connector 组合编排 + +#### 当前事实源分类 +- `current` + - `browser_profiles` + - `browser_environment_presets` + - `launch_browser_session + LaunchBrowserSessionRequest` + - `automation_job.payload.browser_session + Automation executor` + - `browser_profile_cmd` + - `browser_environment_cmd` + - `BrowserRuntimeAuditRecord` 统一浏览器运行时审计模型 + - `BrowserProfileManager` + - `BrowserEnvironmentPresetManager` + - `BrowserRuntimeDebugPanel` 中的最近启动/动作审计面板 +- `compat` + - 旧的裸 `profile_key` / Chrome 目录启动链仍保留,但只允许委托到新控制面,不再承载新功能 + - `launch_browser_runtime_assist` + - `launch_browser_profile_runtime_assist_cmd` + - `get_browser_action_audit_logs` 命名暂保留,但返回值已升级为统一运行时审计记录 + +#### P3. 浏览器计划任务(中高优先级) +目标:复用现有 scheduler/heartbeat 底座,做真正的浏览器自动化任务。 + +当前进展(第一刀已落地): +- 不新增平行调度系统,先把浏览器任务收口为 `automation_jobs.payload.browser_session` +- 调度执行时直接复用 `launch_browser_session` +- profile / environment preset 在保存任务时就做存在性校验 +- 执行历史继续写现有 `ExecutionTracker` +- 自动化详情页已直接嵌入现有 `BrowserRuntimeDebugPanel`,复用 `waiting_for_human / human_controlling / live` 状态机处理人工接管 +- 浏览器任务不再在启动成功后立即记为 `success`;现在会保持 `agent_runs=running`,并通过 `session_id -> automation_jobs / agent_runs` 回写 `waiting_for_human / human_controlling / agent_resuming` +- 人工点击“恢复给 Agent”后,会在原链路内把自动化任务收口为成功并恢复下一次调度,不新增 `browser_task_runs` 一类旁路表 +- 自动化详情页、运行历史和风险任务面板开始直接消费 `agent_runs.metadata.human_reason`,等待人工/人工接管/恢复中的原因不再只藏在实时面板里 +- 自动化主列表开始直显 `当前阻塞 / 最近异常` 摘要,值守时无需进入详情页也能判断浏览器任务卡在什么环节 +- `delivery_json` 已扩成最小输出投递配置,支持 `output_format=text|json` +- `delivery_json` 已继续扩展为最小输出契约,新增 `output_schema` +- 当前 `output_schema` 第一版支持: + - `text` + - `json` + - `table` + - `csv` + - `links` +- 第一批输出目标先落 `webhook / local_file`;`webhook` 会携带结构化 `output_data`,`local_file` 用于最小闭环落盘,`telegram` 继续只作为兼容通知通道 +- `telegram` 现在明确固定为文本提醒,不承诺结构化 output schema;结构化下游集成只允许继续收敛到 `webhook / local_file` +- `automation_jobs` 已补最小 `last_delivery_json`,最近一次投递结果继续收敛在任务主记录里,不新增投递历史旁路表 +- 自动化详情页开始直接展示: + - 输出契约 + - 最近一次投递结果 +- `best_effort=false` 的语义已收口为真实失败: + - 输出投递失败会把本次 job 最终状态记为 `error` + - 最近一次运行 metadata 会携带 `delivery` 摘要,运行历史与详情页不再各写一套投递状态 + +本阶段做什么: +- 第一阶段:继续基于 `automation_jobs` 承载浏览器任务模板 +- 调度执行时自动装配:`automation job -> profile -> environment preset -> browser session` +- 支持一次性、周期性、cron +- 支持“需要人工介入”的挂起态,与当前 `waiting_for_human / human_controlling` 状态机打通 +- 执行历史统一写入现有执行追踪体系 + +原因: +- 没有 P1/P2,任务就不可复现 +- 定时任务是对稳定会话装配能力的消费方,不应先于底座实现 + +#### P4. 输入和输出 + 连接器(中优先级) +目标:让浏览器任务结果可被下游系统稳定消费。 + +本阶段做什么: +- 定义 `output_schema` +- 支持输出类型:`text / json / table / csv / links` +- 支持输出目标:`download / local_file / webhook / connector` +- 引入 Browser Connector 抽象 +- 第一批只做 `webhook` 和 `google_sheets` + +建议顺序: +1. 先做结构化输出 schema +2. 再做 connector 适配器 + +当前进展(第三刀已落地): +- `delivery_json` 已同时承载: + - `output_schema` + - `output_format` +- `output_schema` 负责表达语义契约,`output_format` 只负责投递编码 +- `webhook` 当前会稳定输出: + - `output_schema` + - `output_format` + - `output_data` +- `local_file` 当前支持: + - text 模式按 schema 渲染 + - json 模式落结构化 payload +- `automation_jobs.last_delivery_json` 已承载最近一次投递结果,历史开关关闭时仍可直接在任务详情中观察 +- `agent_runs.metadata.delivery` 已补投递摘要,运行历史和详情页共用同一份运行态事实 + +当前进展(第四刀已落地): +- 不新增 `browser_connectors` 表,也不引入独立 connector runtime +- 第一个真正 connector 已继续收敛到现有 `delivery` 边界: + - `channel=google_sheets` + - 继续使用 `automation_jobs.delivery_json` + - 继续把最近一次投递结果写回 `automation_jobs.last_delivery_json` +- `google_sheets` 当前采用最小 service account 直连模式: + - 目标串使用 `spreadsheet_id=...;sheet=...;credentials_file=...` + - 可选 `include_header=true` + - 可选 `value_input_option=RAW|USER_ENTERED` +- 输出语义继续复用现有 `output_schema`: + - `table/csv` 直接按行追加 + - `links` 追加为链接记录 + - `text/json` 追加为单行摘要/JSON 记录 +- `telegram` 仍维持 `compat` 文本通知;结构化下游集成只允许继续收敛到 `webhook / local_file / google_sheets` + +当前进展(第五刀已落地): +- delivery 幂等与重试语义继续收敛在同一条事实源: + - `automation_service::delivery` + - `automation_jobs.last_delivery_json` + - `agent_runs.metadata.delivery` +- 新增稳定 `delivery_attempt_id`: + - 有 `run_id` 时直接复用 `dlv-{run_id}` + - 无 history/run_id 时按 `job_id + started_at + execution_retry_count` 生成稳定哈希键 +- `webhook` 当前会输出并透传: + - payload 字段 `delivery_attempt_id` + - 请求头 `Idempotency-Key` + - 请求头 `X-Proxycast-Delivery-Attempt-Id` +- `google_sheets` 当前会在每一行前置: + - `delivery_attempt_id` + - `run_id` + - `job_id` + - `execution_retry_count` +- 网络型输出目标当前采用最小内建重试: + - `webhook` + - `google_sheets` + - 默认最多 3 次,保留同一个 `delivery_attempt_id` +- `last_delivery_json` 与运行历史 metadata 当前会继续记录: + - `delivery_attempt_id` + - `run_id` + - `execution_retry_count` + - `delivery_attempts` + +下一刀不应继续堆通知通道,应该优先补: +- 输出目标的能力边界说明 +- 连接器失败重试与幂等策略 + +原因: +- 没有统一输出 schema,连接器会各自解析页面结果,后续无法维护 + +#### P5. 证书(中低优先级) +目标:支持企业站点、银行类或需要 mTLS 的场景。 + +本阶段做什么: +- 先只支持 `client_tls` 证书资产管理 +- 支持证书与 host pattern 绑定 +- 启动浏览器时注入证书选择策略或使用平台能力完成匹配 + +为什么不是更早: +- 这是企业纵深能力,不是大多数浏览器任务的基础阻塞项 + +#### P6. 自动验证码求解器(低优先级) +目标:减少人工介入,但不破坏当前可用的人机协同链路。 + +本阶段做什么: +- 先定义 `CaptchaSolver` 抽象 +- 再接第三方供应商 +- 最后支持策略:自动求解失败后回退人工接管 + +为什么最后做: +- 当前已有人工接管 + 实时画面,可满足可用性底线 +- CAPTCHA 成本高、供应商不稳定、风控强,不应先于 Profile/Preset/Task/I-O + +### 9.6 建议的数据库与模块落点 + +建议新增表: +- `browser_profiles` +- `browser_environment_presets` +- `browser_profile_bindings` +- `browser_task_templates` +- `browser_task_runs` +- `browser_connectors` +- `browser_certificate_assets` + +建议新增模块: +- `src-tauri/src/browser_control/` + - `profile_service.rs` + - `environment_preset_service.rs` + - `task_template_service.rs` + - `connector_service.rs` + - `certificate_service.rs` + +建议保持不动的模块: +- `src-tauri/crates/browser-runtime/` + - 继续只做运行时与 CDP 交互 +- `src-tauri/src/commands/webview_cmd.rs` + - 继续做命令入口,但逐步改为调用新 service +- `src-tauri/src/app/scheduler_service.rs` + - 继续复用,不重新发明调度器 + +### 9.7 结论 + +从代码现状看,Proxycast 并不是“还没有浏览器底座”,而是已经跨过了最难的第一步: +- 已有实时画面 +- 已有 CDP 会话 +- 已有人工接管 +- 已有独立 Chrome profile +- 已有通用调度器 + +真正缺的是中间那层“浏览器控制面产品模型”: +- Profile 还是目录,不是资产 +- 调度器还是通用任务,不是浏览器任务 +- 输出还是原始事件,不是结构化结果 +- 连接还是终端连接,不是业务连接器 + +所以后续路线必须是: +1. 先收口控制面 +2. 再做个人资料 +3. 再做位置定制 +4. 再做浏览器定时任务 +5. 再做输入和输出与连接器 +6. 最后补证书与自动验证码 + +这条路线最符合当前仓库状态,也最符合 KISS / YAGNI / DRY:先把已有底座变成稳定平台,再让上层功能自然长出来。 diff --git a/extensions/proxycast-chrome/README.md b/extensions/proxycast-chrome/README.md index 85e521068..279671fd1 100644 --- a/extensions/proxycast-chrome/README.md +++ b/extensions/proxycast-chrome/README.md @@ -6,7 +6,7 @@ - Observer 通道自动连接:`/proxycast-chrome-observer/Proxycast_Key=...` - 页面信息上报:标题、URL、Markdown -- 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `go_back` 等 +- 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `list_tabs` / `go_back` 等 - 弹窗配置:`serverUrl`、`bridgeKey`、`profileKey`、监控开关、手动抓取 ## 安装 diff --git a/extensions/proxycast-chrome/background.js b/extensions/proxycast-chrome/background.js index 12e37461f..cbf117cfb 100644 --- a/extensions/proxycast-chrome/background.js +++ b/extensions/proxycast-chrome/background.js @@ -235,6 +235,11 @@ async function executeRemoteCommand(commandData) { return; } + if (command === "list_tabs") { + await handleListTabs(commandData); + return; + } + const tabId = await resolveTargetTabId(); if (!tabId) { sendCommandResult({ @@ -389,6 +394,41 @@ async function handleSwitchTab(commandData, waitForPageInfo) { } } +async function handleListTabs(commandData) { + const requestId = commandData.requestId; + const sourceClientId = commandData.sourceClientId; + + try { + const tabs = await chrome.tabs.query({ currentWindow: true }); + const normalizedTabs = tabs + .filter((tab) => Number.isInteger(tab.id) && Number.isInteger(tab.index)) + .map((tab) => ({ + id: tab.id, + index: tab.index, + active: tab.active === true, + title: tab.title || "", + url: tab.url || "", + })); + + sendCommandResult({ + requestId, + sourceClientId, + status: "success", + message: `已读取 ${normalizedTabs.length} 个标签页`, + data: { + tabs: normalizedTabs, + }, + }); + } catch (error) { + sendCommandResult({ + requestId, + sourceClientId, + status: "error", + error: error?.message || String(error), + }); + } +} + function sendCommandResult(data) { sendObserverMessage({ type: "command_result", diff --git a/package.json b/package.json index 064ce98fa..12b0b469b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.87.0", + "version": "0.88.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/scripts/report-legacy-surfaces.mjs b/scripts/report-legacy-surfaces.mjs index 26448cd71..e70a7a909 100644 --- a/scripts/report-legacy-surfaces.mjs +++ b/scripts/report-legacy-surfaces.mjs @@ -70,6 +70,27 @@ const importSurfaceMonitors = [ targets: ["src/lib/api/agentCompat.ts"], allowedPaths: [], }, + { + id: "heartbeat-api-gateway", + classification: "deprecated", + description: "旧 heartbeat 前端 API 入口", + targets: ["src/lib/api/heartbeat.ts"], + allowedPaths: [], + }, + { + id: "heartbeat-settings-page-entry", + classification: "deprecated", + description: "旧 heartbeat 设置页入口", + targets: ["src/components/settings-v2/system/heartbeat/index.tsx"], + allowedPaths: [], + }, + { + id: "assistant-settings-page-entry", + classification: "deprecated", + description: "旧助理服务设置页入口", + targets: ["src/components/settings-v2/agent/assistant/index.tsx"], + allowedPaths: [], + }, ]; const commandSurfaceMonitors = [ @@ -117,6 +138,50 @@ const commandSurfaceMonitors = [ ], allowedPaths: [], }, + { + id: "heartbeat-legacy-commands", + classification: "deprecated", + description: "旧 heartbeat 命令前端边界", + commands: [ + "get_heartbeat_config", + "update_heartbeat_config", + "get_heartbeat_status", + "get_heartbeat_tasks", + "add_heartbeat_task", + "delete_heartbeat_task", + "update_heartbeat_task", + "get_heartbeat_history", + "get_heartbeat_execution_detail", + "get_heartbeat_task_health", + "deliver_heartbeat_task_health_alerts", + "trigger_heartbeat_now", + "get_task_templates", + "apply_task_template", + "generate_content_creator_tasks", + "preview_heartbeat_schedule", + "validate_heartbeat_schedule", + ], + allowedPaths: [], + }, +]; + +const frontendTextSurfaceMonitors = [ + { + id: "frontend-assistant-settings-surfaces", + classification: "deprecated", + description: "前端助理服务设置页与配置面回流", + patterns: [ + "SettingsTabs.Assistant", + "settings.tab.assistant", + "AssistantSettings", + "AssistantConfig", + "default_assistant_id", + "custom_assistants", + "show_suggestions", + "auto_select", + ], + allowedPaths: [], + }, ]; const rustTextSurfaceMonitors = [ @@ -237,6 +302,38 @@ const rustTextSurfaceMonitors = [ ], allowedPaths: [], }, + { + id: "rust-heartbeat-business-surfaces", + classification: "deprecated", + description: "Rust 业务层 heartbeat 旧实现回流", + patterns: [ + "crate::services::heartbeat_service::", + "services::heartbeat_service::", + "HeartbeatServiceState", + "heartbeat_service_adapter", + "proxycast_core::database::dao::heartbeat", + "heartbeat_tool", + "RunSource::Heartbeat", + "source = 'heartbeat'", + "source: \"heartbeat\".to_string()", + ], + allowedPaths: [], + }, + { + id: "rust-assistant-config-surfaces", + classification: "deprecated", + description: "Rust 助理服务配置面回流", + patterns: [ + "AssistantConfig", + "AssistantProfile", + "default_assistant_id", + "custom_assistants", + "show_suggestions", + "auto_select", + ], + includePathPrefixes: ["src-tauri/crates/core/src/config", "src-tauri/src/config"], + allowedPaths: [], + }, { id: "rust-migration-setting-key-leak", classification: "deprecated", @@ -935,6 +1032,8 @@ 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) => @@ -943,6 +1042,9 @@ const importResults = importSurfaceMonitors.map((monitor) => 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), ); @@ -963,6 +1065,9 @@ const violations = [ ...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}`), ), @@ -984,6 +1089,12 @@ 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) { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fba0905cf..967c5a2d1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6982,7 +6982,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.87.0" +version = "0.88.0" dependencies = [ "anyhow", "arboard", @@ -7008,13 +7008,13 @@ dependencies = [ "hex", "if-addrs", "indexmap 2.13.0", + "jsonwebtoken", "md5", "mouse_position", "notify 6.1.1", "objc", "once_cell", "open", - "openssl", "parking_lot", "portable-pty", "proptest", @@ -7085,7 +7085,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.87.0" +version = "0.88.0" dependencies = [ "aster-core", "async-trait", @@ -7110,7 +7110,7 @@ dependencies = [ [[package]] name = "proxycast-browser-runtime" -version = "0.87.0" +version = "0.88.0" dependencies = [ "chrono", "futures", @@ -7127,7 +7127,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.87.0" +version = "0.88.0" dependencies = [ "async-trait", "parking_lot", @@ -7143,7 +7143,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.87.0" +version = "0.88.0" dependencies = [ "aster-models", "async-trait", @@ -7183,7 +7183,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.87.0" +version = "0.88.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -7218,7 +7218,7 @@ dependencies = [ [[package]] name = "proxycast-gateway" -version = "0.87.0" +version = "0.88.0" dependencies = [ "axum 0.7.9", "chrono", @@ -7239,7 +7239,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.87.0" +version = "0.88.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7259,7 +7259,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.87.0" +version = "0.88.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7291,7 +7291,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.87.0" +version = "0.88.0" dependencies = [ "async-trait", "parking_lot", @@ -7310,7 +7310,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.87.0" +version = "0.88.0" dependencies = [ "anyhow", "async-stream", @@ -7364,7 +7364,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.87.0" +version = "0.88.0" dependencies = [ "aster-core", "async-stream", @@ -7409,7 +7409,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.87.0" +version = "0.88.0" dependencies = [ "axum 0.7.9", "futures", @@ -7424,7 +7424,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.87.0" +version = "0.88.0" dependencies = [ "anyhow", "aster-core", @@ -7466,7 +7466,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.87.0" +version = "0.88.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7484,7 +7484,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.87.0" +version = "0.88.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7511,7 +7511,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.87.0" +version = "0.88.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8f22e6c33..c91cdbd3f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.87.0" +version = "0.88.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -78,6 +78,7 @@ cron = "0.15" dirs = "5" regex = "1" md5 = "0.7" +jsonwebtoken = "9.3.1" urlencoding = "2" subtle = "2.5" flate2 = "1" @@ -113,8 +114,7 @@ rustls-pemfile = "2" portable-pty = "0.8" # SSH -ssh2 = "0.9" -openssl = { version = "0.10", features = ["vendored"] } +ssh2 = { version = "0.9", features = ["vendored-openssl"] } # 系统交互 mouse_position = "0.1.4" @@ -191,7 +191,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.87.0" +version = "0.88.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -283,6 +283,7 @@ cron.workspace = true dirs.workspace = true regex.workspace = true md5.workspace = true +jsonwebtoken.workspace = true urlencoding.workspace = true subtle.workspace = true flate2.workspace = true @@ -314,7 +315,6 @@ portable-pty.workspace = true # SSH ssh2.workspace = true -openssl.workspace = true # 系统交互 mouse_position.workspace = true diff --git a/src-tauri/crates/agent/src/tools/heartbeat_tool.rs b/src-tauri/crates/agent/src/tools/heartbeat_tool.rs deleted file mode 100644 index a97f84649..000000000 --- a/src-tauri/crates/agent/src/tools/heartbeat_tool.rs +++ /dev/null @@ -1,763 +0,0 @@ -//! Heartbeat Tool -//! -//! 为 Aster Agent 提供心跳任务管理能力,允许 AI 通过工具调用管理心跳系统。 - -use aster::tools::{Tool, ToolContext, ToolError, ToolResult}; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::path::PathBuf; -use std::sync::Arc; -use thiserror::Error; - -/// Heartbeat 工具错误类型 -#[derive(Debug, Error)] -pub enum HeartbeatToolError { - #[error("服务未初始化")] - ServiceNotInitialized, - - #[error("参数错误: {0}")] - InvalidParams(String), - - #[error("执行失败: {0}")] - ExecutionFailed(String), - - #[error("IO 错误: {0}")] - IoError(String), -} - -/// 心跳任务预览 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HeartbeatTaskPreview { - pub description: String, - pub priority: Option, - pub timeout_secs: Option, - pub once: bool, - pub model: Option, -} - -/// 心跳执行记录 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HeartbeatExecutionRecord { - pub id: i64, - pub task_description: String, - pub status: String, - pub started_at: String, - pub completed_at: Option, - pub duration_ms: Option, - pub output: Option, - pub retry_count: u32, -} - -/// 心跳状态 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HeartbeatStatus { - pub running: bool, - pub last_run: Option, - pub next_run: Option, - pub last_task_count: usize, - pub total_executions: u64, - pub schedule_description: Option, -} - -/// 心跳周期结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HeartbeatCycleResult { - pub task_count: usize, - pub success_count: usize, - pub failed_count: usize, - pub timeout_count: usize, -} - -/// Heartbeat 服务抽象 trait -/// -/// 这样可以在测试时 mock,也可以避免直接依赖 Tauri 类型 -#[async_trait] -pub trait HeartbeatService: Send + Sync { - /// 获取心跳状态 - fn get_status(&self) -> Result; - - /// 获取应用数据目录 - fn get_app_data_dir(&self) -> Result; - - /// 预览任务列表 - fn preview_tasks(&self) -> Result, HeartbeatToolError>; - - /// 添加任务 - fn add_task( - &self, - description: String, - priority: Option, - timeout_secs: Option, - once: Option, - model: Option, - ) -> Result<(), HeartbeatToolError>; - - /// 删除任务 - fn delete_task(&self, index: usize) -> Result<(), HeartbeatToolError>; - - /// 更新任务 - fn update_task( - &self, - index: usize, - description: String, - priority: Option, - timeout_secs: Option, - once: Option, - model: Option, - ) -> Result<(), HeartbeatToolError>; - - /// 获取执行历史 - fn get_history( - &self, - limit: usize, - ) -> Result, HeartbeatToolError>; - - /// 获取执行详情 - fn get_execution_detail( - &self, - id: i64, - ) -> Result, HeartbeatToolError>; - - /// 手动触发执行 - async fn trigger_now(&self) -> Result; -} - -/// Heartbeat Tool 实现 -pub struct HeartbeatTool { - service: Arc, -} - -impl HeartbeatTool { - /// 创建新的 HeartbeatTool - pub fn new(service: Arc) -> Self { - Self { service } - } - - /// 格式化任务列表为可读文本 - fn format_tasks(tasks: &[HeartbeatTaskPreview]) -> String { - if tasks.is_empty() { - return "当前没有心跳任务".to_string(); - } - - let mut lines = vec!["心跳任务列表:".to_string()]; - for (i, task) in tasks.iter().enumerate() { - lines.push(format!(" [{}] {}", i, task.description)); - if let Some(priority) = task.priority { - lines.push(format!(" 优先级: {}", priority)); - } - if let Some(timeout) = task.timeout_secs { - lines.push(format!(" 超时: {}秒", timeout)); - } - if task.once { - lines.push(" 类型: 一次性任务".to_string()); - } - if let Some(ref model) = task.model { - lines.push(format!(" 模型: {}", model)); - } - } - lines.join("\n") - } - - /// 格式化执行历史为可读文本 - fn format_history(records: &[HeartbeatExecutionRecord]) -> String { - if records.is_empty() { - return "暂无执行历史".to_string(); - } - - let mut lines = vec![ - format!("执行历史 (共 {} 条):", records.len()), - String::new(), - ]; - - for record in records { - lines.push(format!("[{}] {}", record.id, record.task_description)); - lines.push(format!(" 状态: {}", record.status)); - lines.push(format!(" 开始: {}", record.started_at)); - - if let Some(ref completed) = record.completed_at { - lines.push(format!(" 完成: {}", completed)); - } - - if let Some(duration) = record.duration_ms { - lines.push(format!(" 耗时: {}ms", duration)); - } - - if let Some(ref output) = record.output { - let output_preview = if output.len() > 100 { - format!("{}...", &output[..100]) - } else { - output.clone() - }; - lines.push(format!(" 输出: {}", output_preview)); - } - - if record.retry_count > 0 { - lines.push(format!(" 重试: {} 次", record.retry_count)); - } - - lines.push(String::new()); - } - - lines.join("\n") - } - - /// 格式化心跳状态为可读文本 - fn format_status(status: &HeartbeatStatus) -> String { - let mut lines = vec!["心跳引擎状态:".to_string()]; - - lines.push(format!( - " 运行中: {}", - if status.running { "是" } else { "否" } - )); - - if let Some(ref last_run) = status.last_run { - lines.push(format!(" 上次运行: {}", last_run)); - } - - if let Some(ref next_run) = status.next_run { - lines.push(format!(" 下次运行: {}", next_run)); - } - - if let Some(ref desc) = status.schedule_description { - lines.push(format!(" 调度: {}", desc)); - } - - lines.push(format!(" 总执行次数: {}", status.total_executions)); - lines.push(format!(" 上次任务数: {}", status.last_task_count)); - - lines.join("\n") - } - - /// 格式化周期结果为可读文本 - fn format_cycle_result(result: &HeartbeatCycleResult) -> String { - format!( - "心跳周期完成: 共 {} 个任务, 成功 {}, 失败 {}, 超时 {}", - result.task_count, result.success_count, result.failed_count, result.timeout_count - ) - } -} - -#[async_trait] -impl Tool for HeartbeatTool { - fn name(&self) -> &str { - "heartbeat" - } - - fn description(&self) -> &str { - "管理心跳任务系统。支持查看/添加/更新/删除任务、查看执行历史、手动触发执行等操作。" - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "description": "心跳任务管理工具", - "properties": { - "action": { - "type": "string", - "description": "要执行的操作", - "enum": [ - "list_tasks", - "add_task", - "update_task", - "delete_task", - "get_history", - "get_detail", - "get_status", - "trigger" - ], - "default": "list_tasks" - }, - "index": { - "type": "number", - "description": "任务索引 (用于 update_task, delete_task)" - }, - "description": { - "type": "string", - "description": "任务描述 (用于 add_task, update_task)" - }, - "priority": { - "type": "number", - "description": "任务优先级 1-10 (可选,用于 add_task, update_task)" - }, - "timeout_secs": { - "type": "number", - "description": "超时时间秒数 (可选,用于 add_task, update_task)" - }, - "once": { - "type": "boolean", - "description": "是否为一次性任务 (可选,用于 add_task, update_task)" - }, - "model": { - "type": "string", - "description": "指定模型 (可选,用于 add_task, update_task)" - }, - "execution_id": { - "type": "number", - "description": "执行记录 ID (用于 get_detail)" - }, - "limit": { - "type": "number", - "description": "历史记录数量限制 (可选,用于 get_history,默认 50)" - } - }, - "required": ["action"] - }) - } - - async fn execute( - &self, - params: Value, - _context: &ToolContext, - ) -> Result { - let action = params - .get("action") - .and_then(|v| v.as_str()) - .unwrap_or("list_tasks"); - - match action { - "list_tasks" => { - let tasks = self - .service - .preview_tasks() - .map_err(|e| ToolError::execution_failed(format!("获取任务列表失败: {}", e)))?; - - let output = Self::format_tasks(&tasks); - - Ok(ToolResult::success(output) - .with_metadata("task_count", json!(tasks.len())) - .with_metadata( - "tasks", - json!(tasks.iter().map(|t| &t.description).collect::>()), - )) - } - - "add_task" => { - let description = params - .get("description") - .and_then(|v| v.as_str()) - .ok_or_else(|| ToolError::invalid_params("缺少 description 参数"))?; - - let priority = params - .get("priority") - .and_then(|v| v.as_u64()) - .map(|v| v as u8); - let timeout_secs = params.get("timeout_secs").and_then(|v| v.as_u64()); - let once = params.get("once").and_then(|v| v.as_bool()); - let model = params - .get("model") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - self.service - .add_task(description.to_string(), priority, timeout_secs, once, model) - .map_err(|e| ToolError::execution_failed(format!("添加任务失败: {}", e)))?; - - Ok(ToolResult::success(format!("已添加任务: {}", description))) - } - - "update_task" => { - let index = params - .get("index") - .and_then(|v| v.as_u64()) - .ok_or_else(|| ToolError::invalid_params("缺少 index 参数"))? - as usize; - - let description = params - .get("description") - .and_then(|v| v.as_str()) - .ok_or_else(|| ToolError::invalid_params("缺少 description 参数"))?; - - let priority = params - .get("priority") - .and_then(|v| v.as_u64()) - .map(|v| v as u8); - let timeout_secs = params.get("timeout_secs").and_then(|v| v.as_u64()); - let once = params.get("once").and_then(|v| v.as_bool()); - let model = params - .get("model") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - self.service - .update_task( - index, - description.to_string(), - priority, - timeout_secs, - once, - model, - ) - .map_err(|e| ToolError::execution_failed(format!("更新任务失败: {}", e)))?; - - Ok(ToolResult::success(format!( - "已更新任务 [{}]: {}", - index, description - ))) - } - - "delete_task" => { - let index = params - .get("index") - .and_then(|v| v.as_u64()) - .ok_or_else(|| ToolError::invalid_params("缺少 index 参数"))? - as usize; - - // 先获取要删除的任务描述用于确认 - let tasks = self - .service - .preview_tasks() - .map_err(|e| ToolError::execution_failed(format!("获取任务列表失败: {}", e)))?; - - let deleted_desc = tasks - .get(index) - .map(|t| t.description.clone()) - .unwrap_or_else(|| "未知任务".to_string()); - - self.service - .delete_task(index) - .map_err(|e| ToolError::execution_failed(format!("删除任务失败: {}", e)))?; - - Ok(ToolResult::success(format!( - "已删除任务 [{}]: {}", - index, deleted_desc - ))) - } - - "get_history" => { - let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; - - let records = self - .service - .get_history(limit) - .map_err(|e| ToolError::execution_failed(format!("获取历史失败: {}", e)))?; - - let output = Self::format_history(&records); - - Ok(ToolResult::success(output).with_metadata("record_count", json!(records.len()))) - } - - "get_detail" => { - let execution_id = params - .get("execution_id") - .and_then(|v| v.as_i64()) - .ok_or_else(|| ToolError::invalid_params("缺少 execution_id 参数"))?; - - let record = self - .service - .get_execution_detail(execution_id) - .map_err(|e| ToolError::execution_failed(format!("获取详情失败: {}", e)))?; - - match record { - Some(record) => { - let output = format!( - "执行记录详情:\n\ - \n ID: {}\n 任务: {}\n 状态: {}\n 开始: {}\n 完成: {}\n 耗时: {}ms\n \ - 重试: {}\n 输出: {}", - record.id, - record.task_description, - record.status, - record.started_at, - record.completed_at.as_deref().unwrap_or("未完成"), - record.duration_ms.unwrap_or(0), - record.retry_count, - record.output.as_deref().unwrap_or("无") - ); - Ok(ToolResult::success(output)) - } - None => Ok(ToolResult::success(format!( - "未找到执行记录 ID: {}", - execution_id - ))), - } - } - - "get_status" => { - let status = self - .service - .get_status() - .map_err(|e| ToolError::execution_failed(format!("获取状态失败: {}", e)))?; - - let output = Self::format_status(&status); - - Ok(ToolResult::success(output)) - } - - "trigger" => { - let result = self - .service - .trigger_now() - .await - .map_err(|e| ToolError::execution_failed(format!("触发执行失败: {}", e)))?; - - let output = Self::format_cycle_result(&result); - - Ok(ToolResult::success(output) - .with_metadata("task_count", json!(result.task_count)) - .with_metadata("success_count", json!(result.success_count)) - .with_metadata("failed_count", json!(result.failed_count))) - } - - _ => Ok(ToolResult::error(format!("未知操作: {}", action))), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Mock service for testing - struct MockHeartbeatService; - - #[async_trait] - impl HeartbeatService for MockHeartbeatService { - fn get_status(&self) -> Result { - Ok(HeartbeatStatus { - running: true, - last_run: Some("2024-01-01T12:00:00Z".to_string()), - next_run: Some("2024-01-01T13:00:00Z".to_string()), - last_task_count: 5, - total_executions: 100, - schedule_description: Some("每 300 秒".to_string()), - }) - } - - fn get_app_data_dir(&self) -> Result { - Ok(PathBuf::from("/tmp")) - } - - fn preview_tasks(&self) -> Result, HeartbeatToolError> { - Ok(vec![ - HeartbeatTaskPreview { - description: "检查系统状态".to_string(), - priority: Some(8), - timeout_secs: Some(60), - once: false, - model: None, - }, - HeartbeatTaskPreview { - description: "备份数据".to_string(), - priority: Some(5), - timeout_secs: Some(300), - once: false, - model: Some("claude-3-haiku".to_string()), - }, - ]) - } - - fn add_task( - &self, - _description: String, - _priority: Option, - _timeout_secs: Option, - _once: Option, - _model: Option, - ) -> Result<(), HeartbeatToolError> { - Ok(()) - } - - fn delete_task(&self, _index: usize) -> Result<(), HeartbeatToolError> { - Ok(()) - } - - fn update_task( - &self, - _index: usize, - _description: String, - _priority: Option, - _timeout_secs: Option, - _once: Option, - _model: Option, - ) -> Result<(), HeartbeatToolError> { - Ok(()) - } - - fn get_history( - &self, - _limit: usize, - ) -> Result, HeartbeatToolError> { - Ok(vec![HeartbeatExecutionRecord { - id: 1, - task_description: "检查系统状态".to_string(), - status: "success".to_string(), - started_at: "2024-01-01T12:00:00Z".to_string(), - completed_at: Some("2024-01-01T12:00:05Z".to_string()), - duration_ms: Some(5000), - output: Some("系统正常".to_string()), - retry_count: 0, - }]) - } - - fn get_execution_detail( - &self, - _id: i64, - ) -> Result, HeartbeatToolError> { - Ok(Some(HeartbeatExecutionRecord { - id: 1, - task_description: "检查系统状态".to_string(), - status: "success".to_string(), - started_at: "2024-01-01T12:00:00Z".to_string(), - completed_at: Some("2024-01-01T12:00:05Z".to_string()), - duration_ms: Some(5000), - output: Some("系统正常".to_string()), - retry_count: 0, - })) - } - - async fn trigger_now(&self) -> Result { - Ok(HeartbeatCycleResult { - task_count: 2, - success_count: 2, - failed_count: 0, - timeout_count: 0, - }) - } - } - - #[test] - fn test_tool_name() { - let tool = HeartbeatTool::new(Arc::new(MockHeartbeatService)); - assert_eq!(tool.name(), "heartbeat"); - } - - #[test] - fn test_tool_description() { - let tool = HeartbeatTool::new(Arc::new(MockHeartbeatService)); - assert!(!tool.description().is_empty()); - } - - #[test] - fn test_input_schema() { - let tool = HeartbeatTool::new(Arc::new(MockHeartbeatService)); - let schema = tool.input_schema(); - assert!(schema.is_object()); - assert!(schema["properties"].is_object()); - assert!(schema["properties"]["action"].is_object()); - } - - #[test] - fn test_format_tasks() { - let tasks = vec![ - HeartbeatTaskPreview { - description: "任务1".to_string(), - priority: Some(5), - timeout_secs: Some(60), - once: false, - model: None, - }, - HeartbeatTaskPreview { - description: "一次性任务".to_string(), - priority: None, - timeout_secs: None, - once: true, - model: Some("claude-3-haiku".to_string()), - }, - ]; - - let output = HeartbeatTool::format_tasks(&tasks); - assert!(output.contains("任务1")); - assert!(output.contains("一次性任务")); - assert!(output.contains("优先级: 5")); - assert!(output.contains("一次性任务")); - } - - #[test] - fn test_format_empty_tasks() { - let tasks: Vec = vec![]; - let output = HeartbeatTool::format_tasks(&tasks); - assert_eq!(output, "当前没有心跳任务"); - } - - #[test] - fn test_format_status() { - let status = HeartbeatStatus { - running: true, - last_run: Some("2024-01-01T12:00:00Z".to_string()), - next_run: Some("2024-01-01T13:00:00Z".to_string()), - last_task_count: 5, - total_executions: 100, - schedule_description: Some("每 300 秒".to_string()), - }; - - let output = HeartbeatTool::format_status(&status); - assert!(output.contains("运行中: 是")); - assert!(output.contains("总执行次数: 100")); - assert!(output.contains("上次任务数: 5")); - } - - #[test] - fn test_format_history() { - let records = vec![HeartbeatExecutionRecord { - id: 1, - task_description: "测试任务".to_string(), - status: "success".to_string(), - started_at: "2024-01-01T12:00:00Z".to_string(), - completed_at: Some("2024-01-01T12:00:05Z".to_string()), - duration_ms: Some(5000), - output: Some("测试输出".to_string()), - retry_count: 0, - }]; - - let output = HeartbeatTool::format_history(&records); - assert!(output.contains("测试任务")); - assert!(output.contains("success")); - assert!(output.contains("测试输出")); - } - - #[tokio::test] - async fn test_execute_list_tasks() { - let tool = HeartbeatTool::new(Arc::new(MockHeartbeatService)); - let params = json!({ "action": "list_tasks" }); - let result = tool.execute(params, &ToolContext::default()).await.unwrap(); - assert!(result.is_success()); - let content = result.content(); - assert!(content.contains("检查系统状态")); - assert!(content.contains("备份数据")); - } - - #[tokio::test] - async fn test_execute_get_status() { - let tool = HeartbeatTool::new(Arc::new(MockHeartbeatService)); - let params = json!({ "action": "get_status" }); - let result = tool.execute(params, &ToolContext::default()).await.unwrap(); - assert!(result.is_success()); - let content = result.content(); - assert!(content.contains("运行中: 是")); - } - - #[tokio::test] - async fn test_execute_trigger() { - let tool = HeartbeatTool::new(Arc::new(MockHeartbeatService)); - let params = json!({ "action": "trigger" }); - let result = tool.execute(params, &ToolContext::default()).await.unwrap(); - assert!(result.is_success()); - let content = result.content(); - assert!(content.contains("共 2 个任务")); - assert!(content.contains("成功 2")); - } - - #[tokio::test] - async fn test_execute_add_task() { - let tool = HeartbeatTool::new(Arc::new(MockHeartbeatService)); - let params = json!({ - "action": "add_task", - "description": "新任务", - "priority": 7 - }); - let result = tool.execute(params, &ToolContext::default()).await.unwrap(); - assert!(result.is_success()); - let content = result.content(); - assert!(content.contains("已添加任务")); - } - - #[tokio::test] - async fn test_execute_invalid_action() { - let tool = HeartbeatTool::new(Arc::new(MockHeartbeatService)); - let params = json!({ "action": "invalid_action" }); - let result = tool.execute(params, &ToolContext::default()).await.unwrap(); - assert!(result.is_error()); - let content = result.content(); - assert!(content.contains("未知操作")); - } -} diff --git a/src-tauri/crates/agent/src/tools/mod.rs b/src-tauri/crates/agent/src/tools/mod.rs index 9b3e81394..878c884e1 100644 --- a/src-tauri/crates/agent/src/tools/mod.rs +++ b/src-tauri/crates/agent/src/tools/mod.rs @@ -3,14 +3,9 @@ //! 提供各种工具的包装器和辅助函数 pub mod browser_tool; -pub mod heartbeat_tool; pub mod skill_tool_gate; pub use browser_tool::{BrowserAction, BrowserTool, BrowserToolError, BrowserToolResult}; -pub use heartbeat_tool::{ - HeartbeatCycleResult, HeartbeatExecutionRecord, HeartbeatService, HeartbeatStatus, - HeartbeatTaskPreview, HeartbeatTool, HeartbeatToolError, -}; pub use skill_tool_gate::{ clear_skill_tool_session_access, set_skill_tool_session_access, ProxycastSkillTool, }; diff --git a/src-tauri/crates/browser-runtime/src/manager.rs b/src-tauri/crates/browser-runtime/src/manager.rs index 534e29006..685c0c6a6 100644 --- a/src-tauri/crates/browser-runtime/src/manager.rs +++ b/src-tauri/crates/browser-runtime/src/manager.rs @@ -36,6 +36,8 @@ pub struct OpenSessionRequest { pub profile_key: String, pub remote_debugging_port: u16, pub target_id: Option, + pub environment_preset_id: Option, + pub environment_preset_name: Option, } pub struct BrowserRuntimeManager { @@ -82,7 +84,13 @@ impl BrowserRuntimeManager { request: OpenSessionRequest, ) -> Result { if let Some(existing) = self.find_session_by_profile_key(&request.profile_key).await { - return Ok(existing); + if existing.environment_preset_id == request.environment_preset_id { + return Ok(existing); + } + return Err(format!( + "浏览器资料 {} 已存在运行会话,当前环境预设与现有会话不同,请先关闭会话后再切换环境", + request.profile_key + )); } let target = @@ -100,6 +108,8 @@ impl BrowserRuntimeManager { let state = CdpSessionState { session_id: session_id.clone(), profile_key: request.profile_key.clone(), + environment_preset_id: request.environment_preset_id.clone(), + environment_preset_name: request.environment_preset_name.clone(), target_id: target.id.clone(), target_title: target.title.clone(), target_url: target.url.clone(), @@ -221,6 +231,26 @@ impl BrowserRuntimeManager { .await) } + pub async fn send_command( + &self, + session_id: &str, + method: &str, + params: Value, + timeout_ms: u64, + ) -> Result { + self.get_session(session_id) + .await? + .send_command(method, params, timeout_ms) + .await + } + + pub async fn refresh_page_info(&self, session_id: &str) -> Result { + let session = self.get_session(session_id).await?; + let page_info = session.capture_page_info().await?; + session.update_page_info(page_info).await; + Ok(session.state().await) + } + pub async fn subscribe( &self, session_id: &str, diff --git a/src-tauri/crates/browser-runtime/src/types.rs b/src-tauri/crates/browser-runtime/src/types.rs index d8b35591c..5dd530636 100644 --- a/src-tauri/crates/browser-runtime/src/types.rs +++ b/src-tauri/crates/browser-runtime/src/types.rs @@ -163,6 +163,8 @@ pub struct CdpTargetInfo { pub struct CdpSessionState { pub session_id: String, pub profile_key: String, + pub environment_preset_id: Option, + pub environment_preset_name: Option, pub target_id: String, pub target_title: String, pub target_url: String, diff --git a/src-tauri/crates/core/src/config/mod.rs b/src-tauri/crates/core/src/config/mod.rs index 4318a19c5..f3c982244 100644 --- a/src-tauri/crates/core/src/config/mod.rs +++ b/src-tauri/crates/core/src/config/mod.rs @@ -21,7 +21,7 @@ pub use import::{ImportOptions, ImportService, ValidationResult}; pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, AsrCredentialEntry, - AsrProviderType, AssistantConfig, AssistantProfile, BaiduConfig, ChannelsConfig, + AsrProviderType, AutomationExecutionMode, AutomationSettings, BaiduConfig, ChannelsConfig, ChatAppearanceConfig, CloudflareTunnelConfig, Config, ContentCreatorConfig, ConversationSettings, CrashReportingConfig, CredentialEntry, CredentialPoolConfig, CustomProviderConfig, DeliveryConfig, DiscordAccountConfig, DiscordActionsConfig, @@ -31,18 +31,17 @@ pub use types::{ DiscordVoiceAutoJoinConfig, DiscordVoiceConfig, EndpointProvidersConfig, EnvironmentConfig, EnvironmentVariableOverride, ExperimentalFeatures, FeishuAccountConfig, FeishuBotConfig, FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig, GeminiApiKeyEntry, - HeartbeatExecutionMode, HeartbeatSecurityConfig, HeartbeatSettings, HintRouteSettingsEntry, - HintRouterSettings, ImageGenConfig, InjectionRuleConfig, InjectionSettings, LoggingConfig, - MemoryAutoConfig, MemoryConfig, MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, - ModelInfo, ModelsConfig, MultiSearchConfig, MultiSearchEngineEntryConfig, NativeAgentConfig, - NavigationConfig, OpenAIAsrConfig, PairingSettings, ProviderConfig, ProviderModelsConfig, - ProvidersConfig, QuotaExceededConfig, RateLimitSettings, RemoteManagementConfig, - ResponseCacheSettings, RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine, - ServerConfig, ShellEnvironmentImportConfig, TaskSchedule, TelegramAccountConfig, - TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig, ToolCallingConfig, - UpdateCheckConfig, UserProfile, VertexApiKeyEntry, VertexModelAlias, VoiceConfig, - VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, - WebSearchConfig, WebSearchProvider, WhisperLocalConfig, WhisperModelSize, + HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig, InjectionRuleConfig, + InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig, MemoryProfileConfig, + MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig, MultiSearchConfig, + MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, + PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, + RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig, + ScreenshotChatConfig, SearchEngine, ServerConfig, ShellEnvironmentImportConfig, TaskSchedule, + TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig, + ToolCallingConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry, VertexModelAlias, + VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, + VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WhisperLocalConfig, WhisperModelSize, WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY, }; pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService}; diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 43064f7d1..b329f226a 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -416,9 +416,6 @@ pub struct Config { /// 图像生成服务配置 #[serde(default)] pub image_gen: ImageGenConfig, - /// 助理配置 - #[serde(default)] - pub assistant: AssistantConfig, /// 用户资料 #[serde(default)] pub user_profile: UserProfile, @@ -437,9 +434,9 @@ pub struct Config { /// 配对认证配置 #[serde(default)] pub pairing: PairingSettings, - /// 心跳引擎配置 + /// 自动化调度配置 #[serde(default)] - pub heartbeat: HeartbeatSettings, + pub automation: AutomationSettings, /// Gateway 全局配置(隧道/公网接入) #[serde(default)] pub gateway: GatewayConfig, @@ -560,6 +557,9 @@ impl Default for NativeAgentConfig { /// 配置内容创作模式中显示的主题标签 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ContentCreatorConfig { + /// 工作区主题默认值版本 + #[serde(default)] + pub schema_version: u8, /// 启用的主题列表 #[serde(default = "default_enabled_themes")] pub enabled_themes: Vec, @@ -568,20 +568,18 @@ pub struct ContentCreatorConfig { pub media_defaults: MediaGenerationDefaultsConfig, } +fn current_workspace_preferences_schema_version() -> u8 { + 1 +} + fn default_enabled_themes() -> Vec { - vec![ - "general".to_string(), - "social-media".to_string(), - "poster".to_string(), - "music".to_string(), - "video".to_string(), - "novel".to_string(), - ] + vec!["social-media".to_string(), "poster".to_string()] } impl Default for ContentCreatorConfig { fn default() -> Self { Self { + schema_version: current_workspace_preferences_schema_version(), enabled_themes: default_enabled_themes(), media_defaults: MediaGenerationDefaultsConfig::default(), } @@ -631,6 +629,9 @@ pub struct MediaGenerationDefaultsConfig { /// 配置左侧导航栏中显示的功能模块 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct NavigationConfig { + /// 工作区导航默认值版本 + #[serde(default)] + pub schema_version: u8, /// 启用的导航模块列表 #[serde(default = "default_enabled_nav_items")] pub enabled_items: Vec, @@ -638,22 +639,98 @@ pub struct NavigationConfig { fn default_enabled_nav_items() -> Vec { vec![ - "agent".to_string(), - "projects".to_string(), + "home-general".to_string(), + "claw".to_string(), + "video".to_string(), "image-gen".to_string(), - "api-server".to_string(), - "provider-pool".to_string(), + "automation".to_string(), + "openclaw".to_string(), + "resources".to_string(), + "style-library".to_string(), + "memory".to_string(), ] } impl Default for NavigationConfig { fn default() -> Self { Self { + schema_version: current_workspace_preferences_schema_version(), enabled_items: default_enabled_nav_items(), } } } +const LEGACY_DEFAULT_THEME_IDS: &[&str] = &[ + "general", + "social-media", + "poster", + "music", + "video", + "novel", +]; + +const CURRENT_MAIN_NAV_ITEM_IDS: &[&str] = &[ + "home-general", + "claw", + "video", + "image-gen", + "automation", + "terminal", + "plugins", +]; + +const CURRENT_FOOTER_NAV_ITEM_IDS: &[&str] = &[ + "openclaw", + "settings", + "resources", + "tools", + "style-library", + "memory", +]; + +const LEGACY_ONLY_NAV_ITEM_IDS: &[&str] = &["agent", "projects", "api-server", "provider-pool"]; + +const LEGACY_DEFAULT_NAV_ITEM_SETS: &[&[&str]] = &[ + &["home-general", "claw", "video", "image-gen"], + &["home-general", "claw", "video", "image-gen", "automation"], + &["home-general", "video", "image-gen", "plugins"], + &["home-general", "video", "image-gen", "terminal", "plugins"], +]; + +fn has_same_members(items: &[String], expected: &[&str]) -> bool { + if items.len() != expected.len() { + return false; + } + + let item_set: std::collections::HashSet<&str> = items.iter().map(String::as_str).collect(); + expected.iter().all(|item| item_set.contains(item)) +} + +fn should_upgrade_legacy_navigation_defaults(items: &[String]) -> bool { + if items.is_empty() { + return true; + } + + if LEGACY_DEFAULT_NAV_ITEM_SETS + .iter() + .any(|legacy_items| has_same_members(items, legacy_items)) + { + return true; + } + + let contains_footer_items = items + .iter() + .any(|item| CURRENT_FOOTER_NAV_ITEM_IDS.contains(&item.as_str())); + let contains_legacy_only_items = items + .iter() + .any(|item| LEGACY_ONLY_NAV_ITEM_IDS.contains(&item.as_str())); + let main_only_legacy_items = items + .iter() + .all(|item| CURRENT_MAIN_NAV_ITEM_IDS.contains(&item.as_str())); + + contains_legacy_only_items || (main_only_legacy_items && !contains_footer_items) +} + // ============ 实验室功能配置类型 ============ /// 截图对话功能配置 @@ -1998,20 +2075,51 @@ impl Default for Config { memory: MemoryConfig::default(), voice: VoiceConfig::default(), image_gen: ImageGenConfig::default(), - assistant: AssistantConfig::default(), user_profile: UserProfile::default(), rate_limit: RateLimitSettings::default(), crash_reporting: CrashReportingConfig::default(), conversation: ConversationSettings::default(), hint_router: HintRouterSettings::default(), pairing: PairingSettings::default(), - heartbeat: HeartbeatSettings::default(), + automation: AutomationSettings::default(), gateway: GatewayConfig::default(), channels: ChannelsConfig::default(), } } } +impl Config { + pub fn normalize_workspace_preferences(&mut self) -> bool { + let mut changed = false; + let current_version = current_workspace_preferences_schema_version(); + + if self.content_creator.schema_version < current_version { + if self.content_creator.enabled_themes.is_empty() + || has_same_members( + &self.content_creator.enabled_themes, + LEGACY_DEFAULT_THEME_IDS, + ) + { + self.content_creator.enabled_themes = default_enabled_themes(); + } + + self.content_creator.schema_version = current_version; + changed = true; + } + + if self.navigation.schema_version < current_version { + if should_upgrade_legacy_navigation_defaults(&self.navigation.enabled_items) { + self.navigation.enabled_items = default_enabled_nav_items(); + } + + self.navigation.schema_version = current_version; + changed = true; + } + + changed + } +} + // ============ 设置页面配置类型 ============ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -2474,47 +2582,6 @@ pub struct ImageGenConfig { pub image_search_pixabay_api_key: Option, } -/// 助理配置 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] -pub struct AssistantConfig { - /// 默认助理 ID - #[serde(default)] - pub default_assistant_id: Option, - /// 自定义助理列表 - #[serde(default)] - pub custom_assistants: Option>, - /// 启用助理自动选择 - #[serde(default)] - pub auto_select: Option, - /// 显示助理建议 - #[serde(default)] - pub show_suggestions: Option, -} - -/// 助理档案 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct AssistantProfile { - /// ID - pub id: String, - /// 名称 - pub name: String, - /// 描述 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - /// 模型 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - /// 系统提示词 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub system_prompt: Option, - /// 温度参数 - #[serde(default)] - pub temperature: Option, - /// 最大 token 数 - #[serde(default)] - pub max_tokens: Option, -} - /// 用户资料 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct UserProfile { @@ -2567,6 +2634,105 @@ mod unit_tests { assert_eq!(config.crash_reporting.environment, "production"); assert_eq!(config.crash_reporting.sample_rate, 1.0); assert!(!config.crash_reporting.send_pii); + assert_eq!(config.content_creator.schema_version, 1); + assert_eq!( + config.content_creator.enabled_themes, + vec!["social-media".to_string(), "poster".to_string()] + ); + assert_eq!(config.navigation.schema_version, 1); + assert_eq!( + config.navigation.enabled_items, + vec![ + "home-general".to_string(), + "claw".to_string(), + "video".to_string(), + "image-gen".to_string(), + "automation".to_string(), + "openclaw".to_string(), + "resources".to_string(), + "style-library".to_string(), + "memory".to_string(), + ] + ); + } + + #[test] + fn test_normalize_workspace_preferences_upgrades_legacy_defaults() { + let mut config = Config::default(); + config.content_creator.schema_version = 0; + config.content_creator.enabled_themes = vec![ + "general".to_string(), + "social-media".to_string(), + "poster".to_string(), + "music".to_string(), + "video".to_string(), + "novel".to_string(), + ]; + config.navigation.schema_version = 0; + config.navigation.enabled_items = vec![ + "home-general".to_string(), + "claw".to_string(), + "video".to_string(), + "image-gen".to_string(), + ]; + + let changed = config.normalize_workspace_preferences(); + + assert!(changed); + assert_eq!(config.content_creator.schema_version, 1); + assert_eq!( + config.content_creator.enabled_themes, + vec!["social-media".to_string(), "poster".to_string()] + ); + assert_eq!(config.navigation.schema_version, 1); + assert_eq!( + config.navigation.enabled_items, + vec![ + "home-general".to_string(), + "claw".to_string(), + "video".to_string(), + "image-gen".to_string(), + "automation".to_string(), + "openclaw".to_string(), + "resources".to_string(), + "style-library".to_string(), + "memory".to_string(), + ] + ); + } + + #[test] + fn test_normalize_workspace_preferences_preserves_current_custom_values() { + let mut config = Config::default(); + config.content_creator.schema_version = 0; + config.content_creator.enabled_themes = + vec!["social-media".to_string(), "video".to_string()]; + config.navigation.schema_version = 0; + config.navigation.enabled_items = vec![ + "home-general".to_string(), + "claw".to_string(), + "resources".to_string(), + "tools".to_string(), + ]; + + let changed = config.normalize_workspace_preferences(); + + assert!(changed); + assert_eq!(config.content_creator.schema_version, 1); + assert_eq!( + config.content_creator.enabled_themes, + vec!["social-media".to_string(), "video".to_string()] + ); + assert_eq!(config.navigation.schema_version, 1); + assert_eq!( + config.navigation.enabled_items, + vec![ + "home-general".to_string(), + "claw".to_string(), + "resources".to_string(), + "tools".to_string(), + ] + ); } #[test] @@ -3030,7 +3196,7 @@ impl Default for TaskSchedule { } } -/// 通知投递配置 +/// 自动化输出/通知投递配置 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct DeliveryConfig { /// 投递模式: "none" | "announce" @@ -3045,12 +3211,22 @@ pub struct DeliveryConfig { /// 投递失败是否算任务失败 #[serde(default)] pub best_effort: bool, + /// 输出契约,留空时按旧任务结果自动推断 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + /// 输出格式: "text" | "json" + #[serde(default = "default_delivery_output_format")] + pub output_format: String, } fn default_delivery_mode() -> String { "none".to_string() } +fn default_delivery_output_format() -> String { + "text".to_string() +} + impl Default for DeliveryConfig { fn default() -> Self { Self { @@ -3058,14 +3234,16 @@ impl Default for DeliveryConfig { channel: None, target: None, best_effort: true, + output_schema: None, + output_format: default_delivery_output_format(), } } } -/// 心跳执行模式 +/// 自动化执行模式 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] -pub enum HeartbeatExecutionMode { +pub enum AutomationExecutionMode { /// 智能模式:通过 AI Agent 执行任务 #[default] Intelligent, @@ -3075,77 +3253,33 @@ pub enum HeartbeatExecutionMode { LogOnly, } -/// 心跳引擎配置 +/// 自动化调度配置 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct HeartbeatSettings { - /// 是否启用心跳引擎 +pub struct AutomationSettings { + /// 是否启用自动化调度 #[serde(default)] pub enabled: bool, - /// 心跳间隔(秒),最小 300(5分钟)- 向后兼容 - #[serde(default = "default_heartbeat_interval")] - pub interval_secs: u64, - /// 灵活调度配置(优先于 interval_secs) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub schedule: Option, - /// 任务文件名(相对于应用数据目录) - #[serde(default = "default_heartbeat_task_file")] - pub task_file: String, - /// 执行模式 - #[serde(default)] - pub execution_mode: HeartbeatExecutionMode, - /// 是否启用任务历史记录 + /// 后台轮询间隔(秒) + #[serde(default = "default_automation_poll_interval")] + pub poll_interval_secs: u64, + /// 是否启用运行历史 #[serde(default = "default_enable_history")] pub enable_history: bool, - /// 失败重试次数 - #[serde(default = "default_heartbeat_max_retries")] - pub max_retries: u32, - /// 通知投递配置 - #[serde(default)] - pub delivery: DeliveryConfig, - /// 安全策略配置 - #[serde(default)] - pub security: HeartbeatSecurityConfig, } -/// 心跳安全策略配置 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] -pub struct HeartbeatSecurityConfig { - /// 允许的命令白名单(仅适用于 shell 类任务) - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_commands: Vec, - /// 允许的路径前缀(安全起见) - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_paths: Vec, - /// 是否启用安全检查 - #[serde(default)] - pub enabled: bool, -} - -fn default_heartbeat_interval() -> u64 { - 300 -} -fn default_heartbeat_task_file() -> String { - "HEARTBEAT.md".to_string() +fn default_automation_poll_interval() -> u64 { + 30 } fn default_enable_history() -> bool { true } -fn default_heartbeat_max_retries() -> u32 { - 3 -} -impl Default for HeartbeatSettings { +impl Default for AutomationSettings { fn default() -> Self { Self { enabled: false, - interval_secs: 300, - schedule: None, - task_file: "HEARTBEAT.md".to_string(), - execution_mode: HeartbeatExecutionMode::default(), + poll_interval_secs: default_automation_poll_interval(), enable_history: true, - max_retries: default_heartbeat_max_retries(), - delivery: DeliveryConfig::default(), - security: HeartbeatSecurityConfig::default(), } } } diff --git a/src-tauri/crates/core/src/config/yaml.rs b/src-tauri/crates/core/src/config/yaml.rs index 61c61efbd..7864d1c7f 100644 --- a/src-tauri/crates/core/src/config/yaml.rs +++ b/src-tauri/crates/core/src/config/yaml.rs @@ -664,13 +664,16 @@ pub fn load_config() -> Result> { if yaml_path.exists() { let content = std::fs::read_to_string(&yaml_path)?; let mut config: Config = serde_yaml::from_str(&content)?; + let mut should_save = config.normalize_workspace_preferences(); // 如果配置中使用默认 API Key,生成强随机 Key 并保存 if is_default_api_key(&config.server.api_key) { let new_key = generate_secure_api_key(); tracing::warn!("[CONFIG] 检测到默认 API Key,已自动生成强随机 Key"); config.server.api_key = new_key; - // 保存更新后的配置 - if let Err(e) = save_config_yaml(&config) { + should_save = true; + } + if should_save { + if let Err(e) = save_config(&config) { tracing::error!("[CONFIG] 保存配置失败: {}", e); } } @@ -681,13 +684,16 @@ pub fn load_config() -> Result> { if json_path.exists() { let content = std::fs::read_to_string(&json_path)?; let mut config: Config = serde_json::from_str(&content)?; + let mut should_save = config.normalize_workspace_preferences(); // 如果配置中使用默认 API Key,生成强随机 Key 并保存 if is_default_api_key(&config.server.api_key) { let new_key = generate_secure_api_key(); tracing::warn!("[CONFIG] 检测到默认 API Key,已自动生成强随机 Key"); config.server.api_key = new_key; - // 保存更新后的配置(迁移到 YAML) - if let Err(e) = save_config_yaml(&config) { + should_save = true; + } + if should_save { + if let Err(e) = save_config(&config) { tracing::error!("[CONFIG] 保存配置失败: {}", e); } } diff --git a/src-tauri/crates/core/src/database/dao/agent_run.rs b/src-tauri/crates/core/src/database/dao/agent_run.rs index c4f2c217e..9c01d72db 100644 --- a/src-tauri/crates/core/src/database/dao/agent_run.rs +++ b/src-tauri/crates/core/src/database/dao/agent_run.rs @@ -1,6 +1,6 @@ //! 统一执行追踪(agent_runs)数据访问对象 //! -//! 提供跨 chat / skill / heartbeat 的执行摘要记录能力。 +//! 提供跨 chat / skill / automation 的执行摘要记录能力。 use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; @@ -137,6 +137,26 @@ impl AgentRunDao { Ok(changed > 0) } + pub fn refresh_running_run( + conn: &Connection, + id: &str, + updated_at: &str, + session_id: Option<&str>, + metadata: Option<&str>, + ) -> Result { + let changed = conn.execute( + "UPDATE agent_runs + SET session_id = COALESCE(?1, session_id), + metadata = COALESCE(?2, metadata), + updated_at = ?3 + WHERE id = ?4 + AND finished_at IS NULL + AND status IN ('queued', 'running')", + params![session_id, metadata, updated_at, id], + )?; + Ok(changed > 0) + } + pub fn get_run(conn: &Connection, id: &str) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT id, source, source_ref, session_id, status, started_at, finished_at, duration_ms, @@ -246,6 +266,46 @@ impl AgentRunDao { iter.collect() } + pub fn list_runs_by_source_ref( + conn: &Connection, + source: &str, + source_ref: &str, + limit: usize, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, source, source_ref, session_id, status, started_at, finished_at, duration_ms, + error_code, error_message, metadata, created_at, updated_at + FROM agent_runs + WHERE source = ?1 + AND source_ref = ?2 + ORDER BY started_at DESC + LIMIT ?3", + )?; + + let iter = stmt.query_map(params![source, source_ref, limit as i64], |row| { + let status_raw: String = row.get(4)?; + let status = + AgentRunStatus::try_from(status_raw.as_str()).unwrap_or(AgentRunStatus::Error); + Ok(AgentRun { + id: row.get(0)?, + source: row.get(1)?, + source_ref: row.get(2)?, + session_id: row.get(3)?, + status, + started_at: row.get(5)?, + finished_at: row.get(6)?, + duration_ms: row.get(7)?, + error_code: row.get(8)?, + error_message: row.get(9)?, + metadata: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + }) + })?; + + iter.collect() + } + pub fn list_terminal_runs_by_session( conn: &Connection, session_id: &str, @@ -447,4 +507,34 @@ mod tests { assert_eq!(second_page.len(), 1); assert_eq!(second_page[0].id, "run-success"); } + + #[test] + fn refresh_running_run_should_update_session_and_metadata() { + let conn = setup_conn(); + let mut run = sample_run("run-refresh", AgentRunStatus::Running); + run.session_id = None; + AgentRunDao::create_run(&conn, &run).expect("写入 run 失败"); + + let updated_at = Utc::now().to_rfc3339(); + let changed = AgentRunDao::refresh_running_run( + &conn, + "run-refresh", + &updated_at, + Some("session-refresh"), + Some("{\"browser_lifecycle_state\":\"waiting_for_human\"}"), + ) + .expect("刷新运行中 run 失败"); + + assert!(changed); + + let fetched = AgentRunDao::get_run(&conn, "run-refresh") + .expect("查询 run 失败") + .expect("run 不存在"); + assert_eq!(fetched.session_id.as_deref(), Some("session-refresh")); + assert_eq!( + fetched.metadata.as_deref(), + Some("{\"browser_lifecycle_state\":\"waiting_for_human\"}") + ); + assert_eq!(fetched.updated_at, updated_at); + } } diff --git a/src-tauri/crates/core/src/database/dao/automation_job.rs b/src-tauri/crates/core/src/database/dao/automation_job.rs new file mode 100644 index 000000000..98aee2f7a --- /dev/null +++ b/src-tauri/crates/core/src/database/dao/automation_job.rs @@ -0,0 +1,413 @@ +//! 自动化任务数据访问对象 +//! +//! 负责结构化自动化任务的持久化与查询。 + +use crate::config::{AutomationExecutionMode, DeliveryConfig, TaskSchedule}; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationJobLastDelivery { + pub success: bool, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + pub output_kind: String, + pub output_schema: String, + pub output_format: String, + pub output_preview: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delivery_attempt_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_id: Option, + #[serde(default)] + pub execution_retry_count: u32, + #[serde(default)] + pub delivery_attempts: u32, + pub attempted_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationJob { + pub id: String, + pub name: String, + pub description: Option, + pub enabled: bool, + pub workspace_id: String, + pub execution_mode: AutomationExecutionMode, + pub schedule: TaskSchedule, + pub payload: Value, + pub delivery: DeliveryConfig, + pub timeout_secs: Option, + pub max_retries: u32, + pub next_run_at: Option, + pub last_status: Option, + pub last_error: Option, + pub last_run_at: Option, + pub last_finished_at: Option, + pub running_started_at: Option, + pub consecutive_failures: u32, + pub last_retry_count: u32, + pub auto_disabled_until: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_delivery: Option, + pub created_at: String, + pub updated_at: String, +} + +pub struct AutomationJobDao; + +impl AutomationJobDao { + pub fn create(conn: &Connection, job: &AutomationJob) -> Result<(), rusqlite::Error> { + let schedule_json = serde_json::to_string(&job.schedule) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let payload_json = serde_json::to_string(&job.payload) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let delivery_json = serde_json::to_string(&job.delivery) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let last_delivery_json = job + .last_delivery + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + + conn.execute( + "INSERT INTO automation_jobs ( + id, name, description, enabled, workspace_id, execution_mode, + schedule_json, payload_json, delivery_json, timeout_secs, max_retries, + next_run_at, last_status, last_error, last_run_at, last_finished_at, + running_started_at, consecutive_failures, last_retry_count, + auto_disabled_until, created_at, updated_at, last_delivery_json + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, + ?7, ?8, ?9, ?10, ?11, + ?12, ?13, ?14, ?15, ?16, + ?17, ?18, ?19, ?20, ?21, ?22, ?23 + )", + params![ + job.id, + job.name, + job.description, + if job.enabled { 1 } else { 0 }, + job.workspace_id, + serde_json::to_string(&job.execution_mode) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + schedule_json, + payload_json, + delivery_json, + job.timeout_secs.map(|v| v as i64), + job.max_retries as i64, + job.next_run_at, + job.last_status, + job.last_error, + job.last_run_at, + job.last_finished_at, + job.running_started_at, + job.consecutive_failures as i64, + job.last_retry_count as i64, + job.auto_disabled_until, + job.created_at, + job.updated_at, + last_delivery_json, + ], + )?; + + Ok(()) + } + + pub fn get(conn: &Connection, id: &str) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT + id, name, description, enabled, workspace_id, execution_mode, + schedule_json, payload_json, delivery_json, timeout_secs, max_retries, + next_run_at, last_status, last_error, last_run_at, last_finished_at, + running_started_at, consecutive_failures, last_retry_count, + auto_disabled_until, created_at, updated_at, last_delivery_json + FROM automation_jobs + WHERE id = ?1", + )?; + + let mut rows = stmt.query(params![id])?; + if let Some(row) = rows.next()? { + Ok(Some(Self::row_to_job(row)?)) + } else { + Ok(None) + } + } + + pub fn list(conn: &Connection) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT + id, name, description, enabled, workspace_id, execution_mode, + schedule_json, payload_json, delivery_json, timeout_secs, max_retries, + next_run_at, last_status, last_error, last_run_at, last_finished_at, + running_started_at, consecutive_failures, last_retry_count, + auto_disabled_until, created_at, updated_at, last_delivery_json + FROM automation_jobs + ORDER BY updated_at DESC, created_at DESC", + )?; + + let rows = stmt.query_map([], Self::row_to_job)?; + rows.collect() + } + + pub fn list_due( + conn: &Connection, + now_rfc3339: &str, + limit: usize, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT + id, name, description, enabled, workspace_id, execution_mode, + schedule_json, payload_json, delivery_json, timeout_secs, max_retries, + next_run_at, last_status, last_error, last_run_at, last_finished_at, + running_started_at, consecutive_failures, last_retry_count, + auto_disabled_until, created_at, updated_at, last_delivery_json + FROM automation_jobs + WHERE enabled = 1 + AND next_run_at IS NOT NULL + AND datetime(next_run_at) <= datetime(?1) + AND running_started_at IS NULL + AND ( + auto_disabled_until IS NULL + OR datetime(auto_disabled_until) <= datetime(?1) + ) + ORDER BY datetime(next_run_at) ASC + LIMIT ?2", + )?; + + let rows = stmt.query_map(params![now_rfc3339, limit as i64], Self::row_to_job)?; + rows.collect() + } + + pub fn update(conn: &Connection, job: &AutomationJob) -> Result<(), rusqlite::Error> { + let schedule_json = serde_json::to_string(&job.schedule) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let payload_json = serde_json::to_string(&job.payload) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let delivery_json = serde_json::to_string(&job.delivery) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let last_delivery_json = job + .last_delivery + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + + conn.execute( + "UPDATE automation_jobs + SET name = ?1, + description = ?2, + enabled = ?3, + workspace_id = ?4, + execution_mode = ?5, + schedule_json = ?6, + payload_json = ?7, + delivery_json = ?8, + timeout_secs = ?9, + max_retries = ?10, + next_run_at = ?11, + last_status = ?12, + last_error = ?13, + last_run_at = ?14, + last_finished_at = ?15, + running_started_at = ?16, + consecutive_failures = ?17, + last_retry_count = ?18, + auto_disabled_until = ?19, + updated_at = ?20, + last_delivery_json = ?21 + WHERE id = ?22", + params![ + job.name, + job.description, + if job.enabled { 1 } else { 0 }, + job.workspace_id, + serde_json::to_string(&job.execution_mode) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + schedule_json, + payload_json, + delivery_json, + job.timeout_secs.map(|v| v as i64), + job.max_retries as i64, + job.next_run_at, + job.last_status, + job.last_error, + job.last_run_at, + job.last_finished_at, + job.running_started_at, + job.consecutive_failures as i64, + job.last_retry_count as i64, + job.auto_disabled_until, + job.updated_at, + last_delivery_json, + job.id, + ], + )?; + + Ok(()) + } + + pub fn delete(conn: &Connection, id: &str) -> Result { + let rows = conn.execute("DELETE FROM automation_jobs WHERE id = ?1", params![id])?; + Ok(rows > 0) + } + + fn row_to_job(row: &rusqlite::Row<'_>) -> Result { + let execution_mode_raw: String = row.get(5)?; + let schedule_json: String = row.get(6)?; + let payload_json: String = row.get(7)?; + let delivery_json: String = row.get(8)?; + let last_delivery_json: Option = row.get(22)?; + + let execution_mode = serde_json::from_str(&execution_mode_raw).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(e)) + })?; + let schedule = serde_json::from_str(&schedule_json).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e)) + })?; + let payload = serde_json::from_str(&payload_json).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e)) + })?; + let delivery = serde_json::from_str(&delivery_json).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(e)) + })?; + let last_delivery = last_delivery_json + .map(|value| { + serde_json::from_str::(&value).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 22, + rusqlite::types::Type::Text, + Box::new(e), + ) + }) + }) + .transpose()?; + + Ok(AutomationJob { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + enabled: row.get::<_, i64>(3)? != 0, + workspace_id: row.get(4)?, + execution_mode, + schedule, + payload, + delivery, + timeout_secs: row.get::<_, Option>(9)?.map(|v| v as u64), + max_retries: row.get::<_, i64>(10)? as u32, + next_run_at: row.get(11)?, + last_status: row.get(12)?, + last_error: row.get(13)?, + last_run_at: row.get(14)?, + last_finished_at: row.get(15)?, + running_started_at: row.get(16)?, + consecutive_failures: row.get::<_, i64>(17)? as u32, + last_retry_count: row.get::<_, i64>(18)? as u32, + auto_disabled_until: row.get(19)?, + last_delivery, + created_at: row.get(20)?, + updated_at: row.get(21)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use rusqlite::Connection; + use serde_json::json; + + #[test] + fn create_and_get_should_preserve_last_delivery() { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("创建数据表失败"); + + let job = AutomationJob { + id: "job-1".to_string(), + name: "巡检任务".to_string(), + description: Some("测试最近一次投递结果".to_string()), + enabled: true, + workspace_id: "workspace-1".to_string(), + execution_mode: AutomationExecutionMode::Intelligent, + schedule: TaskSchedule::Every { every_secs: 300 }, + payload: json!({ + "kind": "agent_turn", + "prompt": "汇总今日异常", + "web_search": false + }), + delivery: DeliveryConfig::default(), + timeout_secs: Some(120), + max_retries: 2, + next_run_at: Some("2026-03-16T00:10:00Z".to_string()), + last_status: Some("error".to_string()), + last_error: Some("写入本地文件失败".to_string()), + last_run_at: Some("2026-03-16T00:00:00Z".to_string()), + last_finished_at: Some("2026-03-16T00:00:05Z".to_string()), + running_started_at: None, + consecutive_failures: 1, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: Some(AutomationJobLastDelivery { + success: false, + message: "写入本地文件失败: permission denied".to_string(), + channel: Some("local_file".to_string()), + target: Some("/tmp/proxycast/output.json".to_string()), + output_kind: "json".to_string(), + output_schema: "json".to_string(), + output_format: "json".to_string(), + output_preview: "{\"status\":\"error\"}".to_string(), + delivery_attempt_id: Some("dlv-run-1".to_string()), + run_id: Some("run-1".to_string()), + execution_retry_count: 1, + delivery_attempts: 2, + attempted_at: "2026-03-16T00:00:04Z".to_string(), + }), + created_at: "2026-03-16T00:00:00Z".to_string(), + updated_at: "2026-03-16T00:00:05Z".to_string(), + }; + + AutomationJobDao::create(&conn, &job).expect("创建自动化任务失败"); + let loaded = AutomationJobDao::get(&conn, "job-1") + .expect("读取自动化任务失败") + .expect("自动化任务不存在"); + + assert_eq!( + loaded + .last_delivery + .as_ref() + .and_then(|value| value.channel.as_deref()), + Some("local_file") + ); + assert_eq!( + loaded.last_delivery.as_ref().map(|value| value.success), + Some(false) + ); + assert_eq!( + loaded + .last_delivery + .as_ref() + .map(|value| value.output_preview.as_str()), + Some("{\"status\":\"error\"}") + ); + assert_eq!( + loaded + .last_delivery + .as_ref() + .and_then(|value| value.delivery_attempt_id.as_deref()), + Some("dlv-run-1") + ); + assert_eq!( + loaded + .last_delivery + .as_ref() + .map(|value| value.delivery_attempts), + Some(2) + ); + } +} diff --git a/src-tauri/crates/core/src/database/dao/browser_environment_preset.rs b/src-tauri/crates/core/src/database/dao/browser_environment_preset.rs new file mode 100644 index 000000000..6a13a7aed --- /dev/null +++ b/src-tauri/crates/core/src/database/dao/browser_environment_preset.rs @@ -0,0 +1,416 @@ +use chrono::Utc; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BrowserEnvironmentPresetRecord { + pub id: String, + pub name: String, + pub description: Option, + pub proxy_server: Option, + pub timezone_id: Option, + pub locale: Option, + pub accept_language: Option, + pub geolocation_lat: Option, + pub geolocation_lng: Option, + pub geolocation_accuracy_m: Option, + pub user_agent: Option, + pub platform: Option, + pub viewport_width: Option, + pub viewport_height: Option, + pub device_scale_factor: Option, + pub created_at: String, + pub updated_at: String, + pub last_used_at: Option, + pub archived_at: Option, +} + +#[derive(Debug, Clone)] +pub struct UpsertBrowserEnvironmentPresetInput { + pub id: Option, + pub name: String, + pub description: Option, + pub proxy_server: Option, + pub timezone_id: Option, + pub locale: Option, + pub accept_language: Option, + pub geolocation_lat: Option, + pub geolocation_lng: Option, + pub geolocation_accuracy_m: Option, + pub user_agent: Option, + pub platform: Option, + pub viewport_width: Option, + pub viewport_height: Option, + pub device_scale_factor: Option, +} + +pub struct BrowserEnvironmentPresetDao; + +impl BrowserEnvironmentPresetDao { + pub fn get_by_id( + conn: &Connection, + id: &str, + ) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT + id, + name, + description, + proxy_server, + timezone_id, + locale, + accept_language, + geolocation_lat, + geolocation_lng, + geolocation_accuracy_m, + user_agent, + platform, + viewport_width, + viewport_height, + device_scale_factor, + created_at, + updated_at, + last_used_at, + archived_at + FROM browser_environment_presets + WHERE id = ?1", + [id], + map_browser_environment_preset_row, + ) + .optional() + } + + pub fn list( + conn: &Connection, + include_archived: bool, + ) -> Result, rusqlite::Error> { + let sql = if include_archived { + "SELECT + id, + name, + description, + proxy_server, + timezone_id, + locale, + accept_language, + geolocation_lat, + geolocation_lng, + geolocation_accuracy_m, + user_agent, + platform, + viewport_width, + viewport_height, + device_scale_factor, + created_at, + updated_at, + last_used_at, + archived_at + FROM browser_environment_presets + ORDER BY + CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END, + COALESCE(last_used_at, updated_at) DESC, + name COLLATE NOCASE ASC" + } else { + "SELECT + id, + name, + description, + proxy_server, + timezone_id, + locale, + accept_language, + geolocation_lat, + geolocation_lng, + geolocation_accuracy_m, + user_agent, + platform, + viewport_width, + viewport_height, + device_scale_factor, + created_at, + updated_at, + last_used_at, + archived_at + FROM browser_environment_presets + WHERE archived_at IS NULL + ORDER BY + COALESCE(last_used_at, updated_at) DESC, + name COLLATE NOCASE ASC" + }; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], map_browser_environment_preset_row)?; + rows.collect() + } + + pub fn upsert( + conn: &Connection, + input: &UpsertBrowserEnvironmentPresetInput, + ) -> Result { + let now = Utc::now().to_rfc3339(); + let id = input + .id + .clone() + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let created_at = Self::get_by_id(conn, &id)? + .map(|existing| existing.created_at) + .unwrap_or_else(|| now.clone()); + + conn.execute( + "INSERT INTO browser_environment_presets ( + id, + name, + description, + proxy_server, + timezone_id, + locale, + accept_language, + geolocation_lat, + geolocation_lng, + geolocation_accuracy_m, + user_agent, + platform, + viewport_width, + viewport_height, + device_scale_factor, + created_at, + updated_at, + last_used_at, + archived_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, NULL, NULL) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + proxy_server = excluded.proxy_server, + timezone_id = excluded.timezone_id, + locale = excluded.locale, + accept_language = excluded.accept_language, + geolocation_lat = excluded.geolocation_lat, + geolocation_lng = excluded.geolocation_lng, + geolocation_accuracy_m = excluded.geolocation_accuracy_m, + user_agent = excluded.user_agent, + platform = excluded.platform, + viewport_width = excluded.viewport_width, + viewport_height = excluded.viewport_height, + device_scale_factor = excluded.device_scale_factor, + updated_at = excluded.updated_at", + params![ + id, + input.name, + input.description, + input.proxy_server, + input.timezone_id, + input.locale, + input.accept_language, + input.geolocation_lat, + input.geolocation_lng, + input.geolocation_accuracy_m, + input.user_agent, + input.platform, + input.viewport_width, + input.viewport_height, + input.device_scale_factor, + created_at, + now, + ], + )?; + + Self::get_by_id(conn, &id)?.ok_or(rusqlite::Error::QueryReturnedNoRows) + } + + pub fn archive(conn: &Connection, id: &str) -> Result { + let affected = conn.execute( + "UPDATE browser_environment_presets + SET archived_at = ?2, updated_at = ?2 + WHERE id = ?1 AND archived_at IS NULL", + params![id, Utc::now().to_rfc3339()], + )?; + Ok(affected > 0) + } + + pub fn restore(conn: &Connection, id: &str) -> Result { + let affected = conn.execute( + "UPDATE browser_environment_presets + SET archived_at = NULL, updated_at = ?2 + WHERE id = ?1 AND archived_at IS NOT NULL", + params![id, Utc::now().to_rfc3339()], + )?; + Ok(affected > 0) + } + + pub fn touch_last_used(conn: &Connection, id: &str) -> Result { + let now = Utc::now().to_rfc3339(); + let affected = conn.execute( + "UPDATE browser_environment_presets + SET last_used_at = ?2, updated_at = ?2 + WHERE id = ?1", + params![id, now], + )?; + Ok(affected > 0) + } +} + +fn map_browser_environment_preset_row( + row: &rusqlite::Row<'_>, +) -> Result { + Ok(BrowserEnvironmentPresetRecord { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + proxy_server: row.get(3)?, + timezone_id: row.get(4)?, + locale: row.get(5)?, + accept_language: row.get(6)?, + geolocation_lat: row.get(7)?, + geolocation_lng: row.get(8)?, + geolocation_accuracy_m: row.get(9)?, + user_agent: row.get(10)?, + platform: row.get(11)?, + viewport_width: row.get(12)?, + viewport_height: row.get(13)?, + device_scale_factor: row.get(14)?, + created_at: row.get(15)?, + updated_at: row.get(16)?, + last_used_at: row.get(17)?, + archived_at: row.get(18)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE browser_environment_presets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + proxy_server TEXT, + timezone_id TEXT, + locale TEXT, + accept_language TEXT, + geolocation_lat REAL, + geolocation_lng REAL, + geolocation_accuracy_m REAL, + user_agent TEXT, + platform TEXT, + viewport_width INTEGER, + viewport_height INTEGER, + device_scale_factor REAL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + )", + [], + ) + .unwrap(); + conn + } + + #[test] + fn should_insert_and_list_active_presets() { + let conn = setup_db(); + let inserted = BrowserEnvironmentPresetDao::upsert( + &conn, + &UpsertBrowserEnvironmentPresetInput { + id: None, + name: "美区桌面".to_string(), + description: Some("美国住宅代理".to_string()), + proxy_server: Some("http://127.0.0.1:7890".to_string()), + timezone_id: Some("America/Los_Angeles".to_string()), + locale: Some("en-US".to_string()), + accept_language: Some("en-US,en;q=0.9".to_string()), + geolocation_lat: Some(37.7749), + geolocation_lng: Some(-122.4194), + geolocation_accuracy_m: Some(100.0), + user_agent: Some("Mozilla/5.0".to_string()), + platform: Some("MacIntel".to_string()), + viewport_width: Some(1440), + viewport_height: Some(900), + device_scale_factor: Some(2.0), + }, + ) + .unwrap(); + + let presets = BrowserEnvironmentPresetDao::list(&conn, false).unwrap(); + assert_eq!(presets.len(), 1); + assert_eq!(presets[0], inserted); + } + + #[test] + fn should_archive_and_restore_preset() { + let conn = setup_db(); + let inserted = BrowserEnvironmentPresetDao::upsert( + &conn, + &UpsertBrowserEnvironmentPresetInput { + id: None, + name: "美区桌面".to_string(), + description: None, + proxy_server: None, + timezone_id: None, + locale: None, + accept_language: None, + geolocation_lat: None, + geolocation_lng: None, + geolocation_accuracy_m: None, + user_agent: None, + platform: None, + viewport_width: None, + viewport_height: None, + device_scale_factor: None, + }, + ) + .unwrap(); + + assert!(BrowserEnvironmentPresetDao::archive(&conn, &inserted.id).unwrap()); + assert!(BrowserEnvironmentPresetDao::list(&conn, false) + .unwrap() + .is_empty()); + + let archived = BrowserEnvironmentPresetDao::get_by_id(&conn, &inserted.id) + .unwrap() + .unwrap(); + assert!(archived.archived_at.is_some()); + + assert!(BrowserEnvironmentPresetDao::restore(&conn, &inserted.id).unwrap()); + let restored = BrowserEnvironmentPresetDao::get_by_id(&conn, &inserted.id) + .unwrap() + .unwrap(); + assert!(restored.archived_at.is_none()); + } + + #[test] + fn should_touch_last_used_timestamp() { + let conn = setup_db(); + let inserted = BrowserEnvironmentPresetDao::upsert( + &conn, + &UpsertBrowserEnvironmentPresetInput { + id: None, + name: "美区桌面".to_string(), + description: None, + proxy_server: None, + timezone_id: None, + locale: None, + accept_language: None, + geolocation_lat: None, + geolocation_lng: None, + geolocation_accuracy_m: None, + user_agent: None, + platform: None, + viewport_width: None, + viewport_height: None, + device_scale_factor: None, + }, + ) + .unwrap(); + + assert!(BrowserEnvironmentPresetDao::touch_last_used(&conn, &inserted.id).unwrap()); + let touched = BrowserEnvironmentPresetDao::get_by_id(&conn, &inserted.id) + .unwrap() + .unwrap(); + assert!(touched.last_used_at.is_some()); + } +} diff --git a/src-tauri/crates/core/src/database/dao/browser_profile.rs b/src-tauri/crates/core/src/database/dao/browser_profile.rs new file mode 100644 index 000000000..c8d5df775 --- /dev/null +++ b/src-tauri/crates/core/src/database/dao/browser_profile.rs @@ -0,0 +1,434 @@ +use chrono::Utc; +use rusqlite::{params, types::Type, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::io; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum BrowserProfileTransportKind { + #[default] + ManagedCdp, + ExistingSession, +} + +impl BrowserProfileTransportKind { + fn as_str(self) -> &'static str { + match self { + Self::ManagedCdp => "managed_cdp", + Self::ExistingSession => "existing_session", + } + } + + fn parse(value: String) -> Result { + match value.as_str() { + "managed_cdp" => Ok(Self::ManagedCdp), + "existing_session" => Ok(Self::ExistingSession), + _ => Err(rusqlite::Error::FromSqlConversionFailure( + 0, + Type::Text, + Box::new(io::Error::new( + io::ErrorKind::InvalidData, + format!("未知的浏览器资料传输模式: {value}"), + )), + )), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BrowserProfileRecord { + pub id: String, + pub profile_key: String, + pub name: String, + pub description: Option, + pub site_scope: Option, + pub launch_url: Option, + pub transport_kind: BrowserProfileTransportKind, + pub profile_dir: String, + pub managed_profile_dir: Option, + pub created_at: String, + pub updated_at: String, + pub last_used_at: Option, + pub archived_at: Option, +} + +#[derive(Debug, Clone)] +pub struct UpsertBrowserProfileInput { + pub id: Option, + pub profile_key: String, + pub name: String, + pub description: Option, + pub site_scope: Option, + pub launch_url: Option, + pub transport_kind: BrowserProfileTransportKind, + pub profile_dir: String, + pub managed_profile_dir: Option, +} + +pub struct BrowserProfileDao; + +impl BrowserProfileDao { + pub fn get_by_id( + conn: &Connection, + id: &str, + ) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT + id, + profile_key, + name, + description, + site_scope, + launch_url, + transport_kind, + profile_dir, + managed_profile_dir, + created_at, + updated_at, + last_used_at, + archived_at + FROM browser_profiles + WHERE id = ?1", + [id], + map_browser_profile_row, + ) + .optional() + } + + pub fn get_by_profile_key( + conn: &Connection, + profile_key: &str, + ) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT + id, + profile_key, + name, + description, + site_scope, + launch_url, + transport_kind, + profile_dir, + managed_profile_dir, + created_at, + updated_at, + last_used_at, + archived_at + FROM browser_profiles + WHERE profile_key = ?1", + [profile_key], + map_browser_profile_row, + ) + .optional() + } + + pub fn list( + conn: &Connection, + include_archived: bool, + ) -> Result, rusqlite::Error> { + let sql = if include_archived { + "SELECT + id, + profile_key, + name, + description, + site_scope, + launch_url, + transport_kind, + profile_dir, + managed_profile_dir, + created_at, + updated_at, + last_used_at, + archived_at + FROM browser_profiles + ORDER BY + CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END, + COALESCE(last_used_at, updated_at) DESC, + name COLLATE NOCASE ASC" + } else { + "SELECT + id, + profile_key, + name, + description, + site_scope, + launch_url, + transport_kind, + profile_dir, + managed_profile_dir, + created_at, + updated_at, + last_used_at, + archived_at + FROM browser_profiles + WHERE archived_at IS NULL + ORDER BY + COALESCE(last_used_at, updated_at) DESC, + name COLLATE NOCASE ASC" + }; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], map_browser_profile_row)?; + rows.collect() + } + + pub fn upsert( + conn: &Connection, + input: &UpsertBrowserProfileInput, + ) -> Result { + let now = Utc::now().to_rfc3339(); + let id = input + .id + .clone() + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let created_at = Self::get_by_id(conn, &id)? + .map(|existing| existing.created_at) + .unwrap_or_else(|| now.clone()); + + conn.execute( + "INSERT INTO browser_profiles ( + id, + profile_key, + name, + description, + site_scope, + launch_url, + transport_kind, + profile_dir, + managed_profile_dir, + created_at, + updated_at, + last_used_at, + archived_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, NULL) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + site_scope = excluded.site_scope, + launch_url = excluded.launch_url, + transport_kind = excluded.transport_kind, + profile_dir = excluded.profile_dir, + managed_profile_dir = excluded.managed_profile_dir, + updated_at = excluded.updated_at", + params![ + id, + input.profile_key, + input.name, + input.description, + input.site_scope, + input.launch_url, + input.transport_kind.as_str(), + input.profile_dir, + input.managed_profile_dir, + created_at, + now, + ], + )?; + + Self::get_by_id(conn, &id)?.ok_or(rusqlite::Error::QueryReturnedNoRows) + } + + pub fn archive(conn: &Connection, id: &str) -> Result { + let affected = conn.execute( + "UPDATE browser_profiles + SET archived_at = ?2, updated_at = ?2 + WHERE id = ?1 AND archived_at IS NULL", + params![id, Utc::now().to_rfc3339()], + )?; + Ok(affected > 0) + } + + pub fn restore(conn: &Connection, id: &str) -> Result { + let affected = conn.execute( + "UPDATE browser_profiles + SET archived_at = NULL, updated_at = ?2 + WHERE id = ?1 AND archived_at IS NOT NULL", + params![id, Utc::now().to_rfc3339()], + )?; + Ok(affected > 0) + } + + pub fn touch_last_used(conn: &Connection, id: &str) -> Result { + let now = Utc::now().to_rfc3339(); + let affected = conn.execute( + "UPDATE browser_profiles + SET last_used_at = ?2, updated_at = ?2 + WHERE id = ?1", + params![id, now], + )?; + Ok(affected > 0) + } +} + +fn map_browser_profile_row( + row: &rusqlite::Row<'_>, +) -> Result { + let transport_kind = BrowserProfileTransportKind::parse(row.get(6)?)?; + Ok(BrowserProfileRecord { + id: row.get(0)?, + profile_key: row.get(1)?, + name: row.get(2)?, + description: row.get(3)?, + site_scope: row.get(4)?, + launch_url: row.get(5)?, + transport_kind, + profile_dir: row.get(7)?, + managed_profile_dir: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + last_used_at: row.get(11)?, + archived_at: row.get(12)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE browser_profiles ( + id TEXT PRIMARY KEY, + profile_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + site_scope TEXT, + launch_url TEXT, + transport_kind TEXT NOT NULL DEFAULT 'managed_cdp', + profile_dir TEXT NOT NULL, + managed_profile_dir TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + )", + [], + ) + .unwrap(); + conn + } + + #[test] + fn should_insert_and_list_active_profiles() { + let conn = setup_db(); + let inserted = BrowserProfileDao::upsert( + &conn, + &UpsertBrowserProfileInput { + id: None, + profile_key: "shop_us".to_string(), + name: "美区店铺".to_string(), + description: Some("主账号".to_string()), + site_scope: Some("shop.example.com".to_string()), + launch_url: Some("https://shop.example.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + profile_dir: "/tmp/browser/shop_us".to_string(), + managed_profile_dir: Some("/tmp/browser/shop_us".to_string()), + }, + ) + .unwrap(); + + let profiles = BrowserProfileDao::list(&conn, false).unwrap(); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0], inserted); + assert_eq!( + profiles[0].transport_kind, + BrowserProfileTransportKind::ManagedCdp + ); + assert_eq!( + profiles[0].managed_profile_dir.as_deref(), + Some("/tmp/browser/shop_us") + ); + } + + #[test] + fn should_archive_and_restore_profile() { + let conn = setup_db(); + let inserted = BrowserProfileDao::upsert( + &conn, + &UpsertBrowserProfileInput { + id: None, + profile_key: "shop_us".to_string(), + name: "美区店铺".to_string(), + description: None, + site_scope: None, + launch_url: None, + transport_kind: BrowserProfileTransportKind::ManagedCdp, + profile_dir: "/tmp/browser/shop_us".to_string(), + managed_profile_dir: Some("/tmp/browser/shop_us".to_string()), + }, + ) + .unwrap(); + + assert!(BrowserProfileDao::archive(&conn, &inserted.id).unwrap()); + assert!(BrowserProfileDao::list(&conn, false).unwrap().is_empty()); + + let archived = BrowserProfileDao::get_by_id(&conn, &inserted.id) + .unwrap() + .unwrap(); + assert!(archived.archived_at.is_some()); + + assert!(BrowserProfileDao::restore(&conn, &inserted.id).unwrap()); + let restored = BrowserProfileDao::get_by_id(&conn, &inserted.id) + .unwrap() + .unwrap(); + assert!(restored.archived_at.is_none()); + } + + #[test] + fn should_touch_last_used_timestamp() { + let conn = setup_db(); + let inserted = BrowserProfileDao::upsert( + &conn, + &UpsertBrowserProfileInput { + id: None, + profile_key: "shop_us".to_string(), + name: "美区店铺".to_string(), + description: None, + site_scope: None, + launch_url: None, + transport_kind: BrowserProfileTransportKind::ManagedCdp, + profile_dir: "/tmp/browser/shop_us".to_string(), + managed_profile_dir: Some("/tmp/browser/shop_us".to_string()), + }, + ) + .unwrap(); + + assert!(BrowserProfileDao::touch_last_used(&conn, &inserted.id).unwrap()); + let touched = BrowserProfileDao::get_by_id(&conn, &inserted.id) + .unwrap() + .unwrap(); + assert!(touched.last_used_at.is_some()); + } + + #[test] + fn should_round_trip_existing_session_transport() { + let conn = setup_db(); + let inserted = BrowserProfileDao::upsert( + &conn, + &UpsertBrowserProfileInput { + id: None, + profile_key: "weibo_attach".to_string(), + name: "微博附着".to_string(), + description: Some("附着当前 Chrome".to_string()), + site_scope: Some("weibo.com".to_string()), + launch_url: Some("https://weibo.com".to_string()), + transport_kind: BrowserProfileTransportKind::ExistingSession, + profile_dir: String::new(), + managed_profile_dir: None, + }, + ) + .unwrap(); + + let fetched = BrowserProfileDao::get_by_id(&conn, &inserted.id) + .unwrap() + .unwrap(); + assert_eq!( + fetched.transport_kind, + BrowserProfileTransportKind::ExistingSession + ); + assert_eq!(fetched.profile_dir, ""); + assert_eq!(fetched.managed_profile_dir, None); + } +} diff --git a/src-tauri/crates/core/src/database/dao/heartbeat.rs b/src-tauri/crates/core/src/database/dao/heartbeat.rs deleted file mode 100644 index 6b9cd8f2a..000000000 --- a/src-tauri/crates/core/src/database/dao/heartbeat.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! 心跳任务执行记录数据访问对象 - -use rusqlite::{params, Connection}; -use serde::{Deserialize, Serialize}; - -/// 心跳任务执行记录 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HeartbeatExecution { - pub id: i64, - pub task_description: String, - pub priority: Option, - pub execution_mode: String, - pub status: String, - pub started_at: String, - pub completed_at: Option, - pub duration_ms: Option, - pub output: Option, - pub retry_count: u32, - pub metadata: Option, -} - -pub struct HeartbeatDao; - -impl HeartbeatDao { - /// 创建执行记录,返回新记录 ID - pub fn create_execution( - conn: &Connection, - exec: &HeartbeatExecution, - ) -> Result { - conn.execute( - "INSERT INTO heartbeat_executions (task_description, priority, execution_mode, status, started_at, completed_at, duration_ms, output, retry_count, metadata) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - exec.task_description, - exec.priority, - exec.execution_mode, - exec.status, - exec.started_at, - exec.completed_at, - exec.duration_ms, - exec.output, - exec.retry_count, - exec.metadata, - ], - )?; - Ok(conn.last_insert_rowid()) - } - - /// 更新执行记录状态和结果 - pub fn update_execution( - conn: &Connection, - id: i64, - status: &str, - output: Option<&str>, - completed_at: &str, - duration_ms: i64, - ) -> Result<(), rusqlite::Error> { - conn.execute( - "UPDATE heartbeat_executions SET status = ?1, output = ?2, completed_at = ?3, duration_ms = ?4 WHERE id = ?5", - params![status, output, completed_at, duration_ms, id], - )?; - Ok(()) - } - - /// 获取最近的执行记录 - pub fn get_recent_executions( - conn: &Connection, - limit: usize, - ) -> Result, rusqlite::Error> { - let mut stmt = conn.prepare( - "SELECT id, task_description, priority, execution_mode, status, started_at, completed_at, duration_ms, output, retry_count, metadata - FROM heartbeat_executions ORDER BY id DESC LIMIT ?1", - )?; - - let rows = stmt.query_map(params![limit as i64], |row| { - Ok(HeartbeatExecution { - id: row.get(0)?, - task_description: row.get(1)?, - priority: row.get::<_, Option>(2)?, - execution_mode: row.get(3)?, - status: row.get(4)?, - started_at: row.get(5)?, - completed_at: row.get(6)?, - duration_ms: row.get(7)?, - output: row.get(8)?, - retry_count: row.get::<_, u32>(9)?, - metadata: row.get(10)?, - }) - })?; - - rows.collect() - } - - /// 根据 ID 获取执行记录 - pub fn get_execution_by_id( - conn: &Connection, - id: i64, - ) -> Result, rusqlite::Error> { - let mut stmt = conn.prepare( - "SELECT id, task_description, priority, execution_mode, status, started_at, completed_at, duration_ms, output, retry_count, metadata - FROM heartbeat_executions WHERE id = ?1", - )?; - - let mut rows = stmt.query_map(params![id], |row| { - Ok(HeartbeatExecution { - id: row.get(0)?, - task_description: row.get(1)?, - priority: row.get::<_, Option>(2)?, - execution_mode: row.get(3)?, - status: row.get(4)?, - started_at: row.get(5)?, - completed_at: row.get(6)?, - duration_ms: row.get(7)?, - output: row.get(8)?, - retry_count: row.get::<_, u32>(9)?, - metadata: row.get(10)?, - }) - })?; - - match rows.next() { - Some(Ok(exec)) => Ok(Some(exec)), - Some(Err(e)) => Err(e), - None => Ok(None), - } - } - - /// 删除指定日期之前的旧记录 - pub fn delete_old_executions( - conn: &Connection, - before: &str, - ) -> Result { - conn.execute( - "DELETE FROM heartbeat_executions WHERE started_at < ?1", - params![before], - ) - } -} diff --git a/src-tauri/crates/core/src/database/dao/mod.rs b/src-tauri/crates/core/src/database/dao/mod.rs index 10081c157..759e2c22d 100644 --- a/src-tauri/crates/core/src/database/dao/mod.rs +++ b/src-tauri/crates/core/src/database/dao/mod.rs @@ -4,9 +4,11 @@ pub mod agent_run; pub mod agent_runtime_queue; pub mod agent_timeline; pub mod api_key_provider; +pub mod automation_job; pub mod brand_persona_dao; +pub mod browser_environment_preset; +pub mod browser_profile; pub mod chat; -pub mod heartbeat; pub mod installed_plugins; pub mod material_dao; pub mod mcp; diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index 2fa76aff9..3a382bcb6 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -649,6 +649,95 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // ============================================================================ + // Browser Profile 相关表 + // ============================================================================ + + conn.execute( + "CREATE TABLE IF NOT EXISTS browser_profiles ( + id TEXT PRIMARY KEY, + profile_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + site_scope TEXT, + launch_url TEXT, + transport_kind TEXT NOT NULL DEFAULT 'managed_cdp', + profile_dir TEXT NOT NULL, + managed_profile_dir TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + )", + [], + )?; + + let _ = conn.execute( + "ALTER TABLE browser_profiles ADD COLUMN transport_kind TEXT NOT NULL DEFAULT 'managed_cdp'", + [], + ); + let _ = conn.execute( + "ALTER TABLE browser_profiles ADD COLUMN managed_profile_dir TEXT", + [], + ); + let _ = conn.execute( + "UPDATE browser_profiles + SET managed_profile_dir = profile_dir + WHERE transport_kind = 'managed_cdp' + AND (managed_profile_dir IS NULL OR managed_profile_dir = '')", + [], + ); + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_browser_profiles_key ON browser_profiles(profile_key)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_browser_profiles_archived ON browser_profiles(archived_at)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_browser_profiles_updated ON browser_profiles(updated_at)", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS browser_environment_presets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + proxy_server TEXT, + timezone_id TEXT, + locale TEXT, + accept_language TEXT, + geolocation_lat REAL, + geolocation_lng REAL, + geolocation_accuracy_m REAL, + user_agent TEXT, + platform TEXT, + viewport_width INTEGER, + viewport_height INTEGER, + device_scale_factor REAL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_browser_environment_presets_archived ON browser_environment_presets(archived_at)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_browser_environment_presets_updated ON browser_environment_presets(updated_at)", + [], + )?; + // Migration: 添加项目管理相关字段到 workspaces 表 let _ = conn.execute("ALTER TABLE workspaces ADD COLUMN icon TEXT", []); let _ = conn.execute("ALTER TABLE workspaces ADD COLUMN color TEXT", []); @@ -1267,29 +1356,49 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; - // 心跳任务执行记录表 + // 自动化任务表 conn.execute( - "CREATE TABLE IF NOT EXISTS heartbeat_executions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_description TEXT NOT NULL, - priority INTEGER, + "CREATE TABLE IF NOT EXISTS automation_jobs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + workspace_id TEXT NOT NULL, execution_mode TEXT NOT NULL, - status TEXT NOT NULL, - started_at TEXT NOT NULL, - completed_at TEXT, - duration_ms INTEGER, - output TEXT, - retry_count INTEGER NOT NULL DEFAULT 0, - metadata TEXT + schedule_json TEXT NOT NULL, + payload_json TEXT NOT NULL, + delivery_json TEXT NOT NULL, + timeout_secs INTEGER, + max_retries INTEGER NOT NULL DEFAULT 3, + next_run_at TEXT, + last_status TEXT, + last_error TEXT, + last_run_at TEXT, + last_finished_at TEXT, + running_started_at TEXT, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_retry_count INTEGER NOT NULL DEFAULT 0, + auto_disabled_until TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_delivery_json TEXT )", [], )?; + let _ = conn.execute( + "ALTER TABLE automation_jobs ADD COLUMN last_delivery_json TEXT", + [], + ); conn.execute( - "CREATE INDEX IF NOT EXISTS idx_heartbeat_executions_started_at ON heartbeat_executions(started_at)", + "CREATE INDEX IF NOT EXISTS idx_automation_jobs_next_run_at ON automation_jobs(next_run_at)", [], )?; conn.execute( - "CREATE INDEX IF NOT EXISTS idx_heartbeat_executions_status ON heartbeat_executions(status)", + "CREATE INDEX IF NOT EXISTS idx_automation_jobs_workspace_id ON automation_jobs(workspace_id)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_automation_jobs_enabled_updated_at ON automation_jobs(enabled, updated_at DESC)", [], )?; @@ -1328,6 +1437,76 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_upgrade_legacy_browser_profile_table_with_transport_columns() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE browser_profiles ( + id TEXT PRIMARY KEY, + profile_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + site_scope TEXT, + launch_url TEXT, + profile_dir TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + )", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO browser_profiles ( + id, profile_key, name, description, site_scope, launch_url, profile_dir, + created_at, updated_at, last_used_at, archived_at + ) VALUES (?1, ?2, ?3, NULL, NULL, ?4, ?5, ?6, ?6, NULL, NULL)", + ( + "profile-1", + "shop_us", + "美区资料", + "https://seller.example.com/", + "/tmp/proxycast/chrome_profiles/shop_us", + "2026-03-15T00:00:00Z", + ), + ) + .unwrap(); + + create_tables(&conn).expect("应成功升级旧版 browser_profiles 表"); + + let mut columns = conn.prepare("PRAGMA table_info(browser_profiles)").unwrap(); + let column_names = columns + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(column_names.iter().any(|name| name == "transport_kind")); + assert!(column_names + .iter() + .any(|name| name == "managed_profile_dir")); + + let upgraded = conn + .query_row( + "SELECT transport_kind, managed_profile_dir + FROM browser_profiles + WHERE id = ?1", + ["profile-1"], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .unwrap(); + assert_eq!(upgraded.0, "managed_cdp"); + assert_eq!( + upgraded.1.as_deref(), + Some("/tmp/proxycast/chrome_profiles/shop_us") + ); + } +} + /// 迁移:添加proxy_url列到provider_pool_credentials表 /// 使用重建表结构的方式确保数据完整性 fn migrate_add_proxy_url_column(conn: &Connection) -> Result<(), rusqlite::Error> { diff --git a/src-tauri/crates/core/src/plugin/types.rs b/src-tauri/crates/core/src/plugin/types.rs index aac33afc3..d2cf32af2 100644 --- a/src-tauri/crates/core/src/plugin/types.rs +++ b/src-tauri/crates/core/src/plugin/types.rs @@ -445,6 +445,8 @@ pub struct PluginInfo { pub path: PathBuf, /// 支持的钩子 pub hooks: Vec, + /// 最低 ProxyCast 版本要求 + pub min_proxycast_version: Option, /// 配置 schema pub config_schema: Option, /// 当前配置 @@ -528,6 +530,7 @@ impl PluginInstance { status: self.state.status, path: self.path.clone(), hooks: manifest.hooks.clone(), + min_proxycast_version: manifest.min_proxycast_version.clone(), config_schema: manifest.config_schema.clone(), config: self.config.clone(), state: self.state.clone(), diff --git a/src-tauri/crates/scheduler/src/batch.rs b/src-tauri/crates/scheduler/src/batch.rs deleted file mode 100644 index 2543ff63d..000000000 --- a/src-tauri/crates/scheduler/src/batch.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! 批量任务定义 -//! -//! 定义批量任务相关的数据结构和状态 - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use uuid::Uuid; - -/// 批量任务选项 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchOptions { - /// 并发数量 (默认为 3) - #[serde(default = "default_concurrency")] - pub concurrency: usize, - - /// 失败后是否继续 (默认为 true) - #[serde(default = "default_continue_on_error")] - pub continue_on_error: bool, - - /// 重试次数 (默认为 0) - #[serde(default)] - pub retry_count: usize, - - /// 任务超时时间(秒) (默认为 120) - #[serde(default = "default_timeout")] - pub timeout_seconds: u64, -} - -fn default_concurrency() -> usize { - 3 -} - -fn default_continue_on_error() -> bool { - true -} - -fn default_timeout() -> u64 { - 120 -} - -impl Default for BatchOptions { - fn default() -> Self { - Self { - concurrency: default_concurrency(), - continue_on_error: default_continue_on_error(), - retry_count: 0, - timeout_seconds: default_timeout(), - } - } -} - -/// 单个任务定义 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskDefinition { - /// 任务 ID (可选,如果不提供则自动生成) - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// 模板变量 - pub variables: HashMap, - - /// 任务元数据 (用于追踪和识别) - #[serde(default)] - pub metadata: HashMap, -} - -/// 单个任务结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskResult { - /// 任务 ID - pub task_id: Uuid, - - /// 任务状态 - pub status: TaskStatus, - - /// 响应内容 (如果成功) - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - - /// 错误信息 (如果失败) - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - - /// 使用 token 数 - #[serde(default)] - pub usage: TokenUsage, - - /// 开始时间 - pub started_at: chrono::DateTime, - - /// 完成时间 - pub completed_at: Option>, -} - -/// 任务状态 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TaskStatus { - /// 等待中 - Pending, - - /// 运行中 - Running, - - /// 已完成 - Completed, - - /// 失败 - Failed, - - /// 已取消 - Cancelled, -} - -/// Token 使用统计 -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] -pub struct TokenUsage { - /// 输入 token 数 - #[serde(default)] - pub prompt_tokens: u32, - - /// 输出 token 数 - #[serde(default)] - pub completion_tokens: u32, - - /// 总 token 数 - #[serde(default)] - pub total_tokens: u32, -} - -impl TokenUsage { - pub fn new(prompt_tokens: u32, completion_tokens: u32) -> Self { - Self { - prompt_tokens, - completion_tokens, - total_tokens: prompt_tokens + completion_tokens, - } - } -} - -/// 批量任务 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchTask { - /// 批量任务 ID - pub id: Uuid, - - /// 批量任务名称 - pub name: String, - - /// 任务模板 ID - pub template_id: Uuid, - - /// 任务列表 - pub tasks: Vec, - - /// 批量任务选项 - #[serde(default)] - pub options: BatchOptions, - - /// 批量任务状态 - pub status: BatchTaskStatus, - - /// 任务结果 - #[serde(default)] - pub results: Vec, - - /// 创建时间 - pub created_at: chrono::DateTime, - - /// 开始时间 - #[serde(skip_serializing_if = "Option::is_none")] - pub started_at: Option>, - - /// 完成时间 - #[serde(skip_serializing_if = "Option::is_none")] - pub completed_at: Option>, -} - -/// 批量任务状态 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum BatchTaskStatus { - /// 等待中 - Pending, - - /// 运行中 - Running, - - /// 已完成 - Completed, - - /// 部分完成 (部分任务失败) - PartiallyCompleted, - - /// 失败 (所有任务失败) - Failed, - - /// 已取消 - Cancelled, -} - -impl BatchTask { - /// 创建新的批量任务 - pub fn new( - name: String, - template_id: Uuid, - tasks: Vec, - options: BatchOptions, - ) -> Self { - let now = chrono::Utc::now(); - Self { - id: Uuid::new_v4(), - name, - template_id, - tasks, - options, - status: BatchTaskStatus::Pending, - results: Vec::new(), - created_at: now, - started_at: None, - completed_at: None, - } - } - - /// 获取进度信息 - pub fn get_progress(&self) -> (usize, usize, usize) { - // (总数, 成功数, 失败数) - let total = self.tasks.len(); - let completed = self - .results - .iter() - .filter(|r| r.status == TaskStatus::Completed) - .count(); - let failed = self - .results - .iter() - .filter(|r| r.status == TaskStatus::Failed) - .count(); - (total, completed, failed) - } - - /// 获取统计信息 - pub fn get_statistics(&self) -> BatchTaskStatistics { - let (total, completed, failed) = self.get_progress(); - let running = self - .results - .iter() - .filter(|r| r.status == TaskStatus::Running) - .count(); - let total_tokens: TokenUsage = - self.results - .iter() - .fold(TokenUsage::default(), |mut acc, r| { - acc.prompt_tokens += r.usage.prompt_tokens; - acc.completion_tokens += r.usage.completion_tokens; - acc.total_tokens += r.usage.total_tokens; - acc - }); - - BatchTaskStatistics { - total_tasks: total, - completed_tasks: completed, - failed_tasks: failed, - running_tasks: running, - pending_tasks: total - completed - failed - running, - total_tokens, - } - } -} - -/// 批量任务统计信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchTaskStatistics { - /// 总任务数 - pub total_tasks: usize, - - /// 已完成任务数 - pub completed_tasks: usize, - - /// 失败任务数 - pub failed_tasks: usize, - - /// 运行中任务数 - pub running_tasks: usize, - - /// 等待中任务数 - pub pending_tasks: usize, - - /// 总 token 使用量 - pub total_tokens: TokenUsage, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_batch_options_default() { - let options = BatchOptions::default(); - assert_eq!(options.concurrency, 3); - assert_eq!(options.continue_on_error, true); - assert_eq!(options.retry_count, 0); - assert_eq!(options.timeout_seconds, 120); - } - - #[test] - fn test_batch_task_creation() { - let tasks = vec![ - TaskDefinition { - id: None, - variables: { - let mut map = HashMap::new(); - map.insert("content".to_string(), "测试1".to_string()); - map - }, - metadata: HashMap::new(), - }, - TaskDefinition { - id: None, - variables: { - let mut map = HashMap::new(); - map.insert("content".to_string(), "测试2".to_string()); - map - }, - metadata: HashMap::new(), - }, - ]; - - let batch_task = BatchTask::new( - "测试批量任务".to_string(), - Uuid::new_v4(), - tasks, - BatchOptions::default(), - ); - - assert_eq!(batch_task.name, "测试批量任务"); - assert_eq!(batch_task.tasks.len(), 2); - assert_eq!(batch_task.status, BatchTaskStatus::Pending); - } - - #[test] - fn test_get_progress() { - let mut batch_task = BatchTask::new( - "测试".to_string(), - Uuid::new_v4(), - vec![ - TaskDefinition { - id: Some(Uuid::new_v4()), - variables: HashMap::new(), - metadata: HashMap::new(), - }, - TaskDefinition { - id: Some(Uuid::new_v4()), - variables: HashMap::new(), - metadata: HashMap::new(), - }, - TaskDefinition { - id: Some(Uuid::new_v4()), - variables: HashMap::new(), - metadata: HashMap::new(), - }, - ], - BatchOptions::default(), - ); - - // 添加一些结果 - batch_task.results.push(TaskResult { - task_id: batch_task.tasks[0].id.unwrap(), - status: TaskStatus::Completed, - content: Some("完成".to_string()), - error: None, - usage: TokenUsage::default(), - started_at: chrono::Utc::now(), - completed_at: Some(chrono::Utc::now()), - }); - - batch_task.results.push(TaskResult { - task_id: batch_task.tasks[1].id.unwrap(), - status: TaskStatus::Failed, - content: None, - error: Some("失败".to_string()), - usage: TokenUsage::default(), - started_at: chrono::Utc::now(), - completed_at: Some(chrono::Utc::now()), - }); - - let (total, completed, failed) = batch_task.get_progress(); - assert_eq!(total, 3); - assert_eq!(completed, 1); - assert_eq!(failed, 1); - } -} diff --git a/src-tauri/crates/scheduler/src/batch_dao.rs b/src-tauri/crates/scheduler/src/batch_dao.rs deleted file mode 100644 index 003dbf035..000000000 --- a/src-tauri/crates/scheduler/src/batch_dao.rs +++ /dev/null @@ -1,526 +0,0 @@ -//! 批量任务数据访问对象 (DAO) -//! -//! 提供批量任务和模板的数据库操作 - -use super::batch::{BatchTask, BatchTaskStatus}; -use super::template::TaskTemplate; -use anyhow::{Context, Result}; -use proxycast_core::database::{lock_db, DbConnection}; -use rusqlite::{params, OptionalExtension}; -use uuid::Uuid; - -/// 批量任务 DAO -pub struct BatchTaskDao; - -impl BatchTaskDao { - /// 初始化数据库表 - pub fn init_tables(db: &DbConnection) -> Result<()> { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - // 创建批量任务表 - conn.execute( - "CREATE TABLE IF NOT EXISTS batch_tasks ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - template_id TEXT NOT NULL, - status TEXT NOT NULL, - options_json TEXT NOT NULL, - tasks_json TEXT NOT NULL, - results_json TEXT, - created_at TEXT NOT NULL, - started_at TEXT, - completed_at TEXT - )", - [], - ) - .context("创建 batch_tasks 表失败")?; - - // 创建模板表 - conn.execute( - "CREATE TABLE IF NOT EXISTS batch_templates ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - model TEXT NOT NULL, - system_prompt TEXT, - user_message_template TEXT NOT NULL, - temperature REAL, - max_tokens INTEGER, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )", - [], - ) - .context("创建 batch_templates 表失败")?; - - // 创建索引 - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_batch_tasks_status ON batch_tasks(status)", - [], - )?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_batch_tasks_created_at ON batch_tasks(created_at DESC)", - [], - )?; - - Ok(()) - } - - /// 保存批量任务 - pub fn save(db: &DbConnection, batch_task: &BatchTask) -> Result<()> { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - let options_json = serde_json::to_string(&batch_task.options)?; - let tasks_json = serde_json::to_string(&batch_task.tasks)?; - let results_json = if batch_task.results.is_empty() { - None - } else { - Some(serde_json::to_string(&batch_task.results)?) - }; - - conn.execute( - "INSERT OR REPLACE INTO batch_tasks - (id, name, template_id, status, options_json, tasks_json, results_json, - created_at, started_at, completed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - batch_task.id.to_string(), - batch_task.name, - batch_task.template_id.to_string(), - serde_json::to_string(&batch_task.status)?, - options_json, - tasks_json, - results_json, - batch_task.created_at.to_rfc3339(), - batch_task.started_at.map(|t| t.to_rfc3339()), - batch_task.completed_at.map(|t| t.to_rfc3339()), - ], - ) - .context("保存批量任务失败")?; - - Ok(()) - } - - /// 根据 ID 查询批量任务 - pub fn get_by_id(db: &DbConnection, id: &Uuid) -> Result> { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - let mut stmt = conn.prepare( - "SELECT id, name, template_id, status, options_json, tasks_json, results_json, - created_at, started_at, completed_at - FROM batch_tasks WHERE id = ?1", - )?; - - let result = stmt - .query_row(params![id.to_string()], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let template_id: String = row.get(2)?; - let status_str: String = row.get(3)?; - let options_json: String = row.get(4)?; - let tasks_json: String = row.get(5)?; - let results_json: Option = row.get(6)?; - let created_at: String = row.get(7)?; - let started_at: Option = row.get(8)?; - let completed_at: Option = row.get(9)?; - - Ok(( - id, - name, - template_id, - status_str, - options_json, - tasks_json, - results_json, - created_at, - started_at, - completed_at, - )) - }) - .optional()?; - - if let Some(( - id, - name, - template_id, - status_str, - options_json, - tasks_json, - results_json, - created_at, - started_at, - completed_at, - )) = result - { - let batch_task = BatchTask { - id: Uuid::parse_str(&id)?, - name, - template_id: Uuid::parse_str(&template_id)?, - status: serde_json::from_str(&status_str)?, - options: serde_json::from_str(&options_json)?, - tasks: serde_json::from_str(&tasks_json)?, - results: results_json - .as_deref() - .map(|json| serde_json::from_str(json)) - .transpose() - .unwrap_or_default() - .unwrap_or_default(), - created_at: chrono::DateTime::parse_from_rfc3339(&created_at)?.into(), - started_at: started_at - .as_deref() - .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.into())) - .transpose()?, - completed_at: completed_at - .as_deref() - .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.into())) - .transpose()?, - }; - - Ok(Some(batch_task)) - } else { - Ok(None) - } - } - - /// 查询所有批量任务 - pub fn list_all(db: &DbConnection, limit: usize) -> Result> { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - let mut stmt = conn.prepare( - "SELECT id, name, template_id, status, options_json, tasks_json, results_json, - created_at, started_at, completed_at - FROM batch_tasks - ORDER BY created_at DESC - LIMIT ?1", - )?; - - let rows = stmt.query_map(params![limit], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let template_id: String = row.get(2)?; - let status_str: String = row.get(3)?; - let options_json: String = row.get(4)?; - let tasks_json: String = row.get(5)?; - let results_json: Option = row.get(6)?; - let created_at: String = row.get(7)?; - let started_at: Option = row.get(8)?; - let completed_at: Option = row.get(9)?; - - Ok(( - id, - name, - template_id, - status_str, - options_json, - tasks_json, - results_json, - created_at, - started_at, - completed_at, - )) - })?; - - let mut batch_tasks = Vec::new(); - for row in rows { - let ( - id, - name, - template_id, - status_str, - options_json, - tasks_json, - results_json, - created_at, - started_at, - completed_at, - ) = row?; - - let batch_task = BatchTask { - id: Uuid::parse_str(&id)?, - name, - template_id: Uuid::parse_str(&template_id)?, - status: serde_json::from_str(&status_str)?, - options: serde_json::from_str(&options_json)?, - tasks: serde_json::from_str(&tasks_json)?, - results: results_json - .as_deref() - .map(|json| serde_json::from_str(json)) - .transpose() - .unwrap_or_default() - .unwrap_or_default(), - created_at: chrono::DateTime::parse_from_rfc3339(&created_at)?.into(), - started_at: started_at - .as_deref() - .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.into())) - .transpose()?, - completed_at: completed_at - .as_deref() - .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.into())) - .transpose()?, - }; - - batch_tasks.push(batch_task); - } - - Ok(batch_tasks) - } - - /// 删除批量任务 - pub fn delete(db: &DbConnection, id: &Uuid) -> Result { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - let affected = conn.execute( - "DELETE FROM batch_tasks WHERE id = ?1", - params![id.to_string()], - )?; - - Ok(affected > 0) - } - - /// 更新批量任务状态 - pub fn update_status(db: &DbConnection, id: &Uuid, status: BatchTaskStatus) -> Result<()> { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - conn.execute( - "UPDATE batch_tasks SET status = ?1 WHERE id = ?2", - params![serde_json::to_string(&status)?, id.to_string()], - )?; - - Ok(()) - } - - /// 更新批量任务结果、状态和时间戳 - /// - /// 用于执行器在每个子任务完成后实时更新数据库 - pub fn update_results( - db: &DbConnection, - id: &Uuid, - status: BatchTaskStatus, - results: &[super::batch::TaskResult], - started_at: Option>, - completed_at: Option>, - ) -> Result<()> { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - let results_json = if results.is_empty() { - None - } else { - Some(serde_json::to_string(results)?) - }; - - conn.execute( - "UPDATE batch_tasks SET status = ?1, results_json = ?2, started_at = ?3, completed_at = ?4 WHERE id = ?5", - params![ - serde_json::to_string(&status)?, - results_json, - started_at.map(|t| t.to_rfc3339()), - completed_at.map(|t| t.to_rfc3339()), - id.to_string(), - ], - )?; - - Ok(()) - } -} - -/// 模板 DAO -pub struct TemplateDao; - -impl TemplateDao { - /// 保存模板 - pub fn save(db: &DbConnection, template: &TaskTemplate) -> Result<()> { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - conn.execute( - "INSERT OR REPLACE INTO batch_templates - (id, name, description, model, system_prompt, user_message_template, - temperature, max_tokens, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - template.id.to_string(), - template.name, - template.description, - template.model, - template.system_prompt, - template.user_message_template, - template.temperature, - template.max_tokens, - template.created_at.to_rfc3339(), - template.updated_at.to_rfc3339(), - ], - ) - .context("保存模板失败")?; - - Ok(()) - } - - /// 根据 ID 查询模板 - pub fn get_by_id(db: &DbConnection, id: &Uuid) -> Result> { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - let mut stmt = conn.prepare( - "SELECT id, name, description, model, system_prompt, user_message_template, - temperature, max_tokens, created_at, updated_at - FROM batch_templates WHERE id = ?1", - )?; - - let result = stmt - .query_row(params![id.to_string()], |row| { - Ok(TaskTemplate { - id: Uuid::parse_str(&row.get::<_, String>(0)?).unwrap(), - name: row.get(1)?, - description: row.get(2)?, - model: row.get(3)?, - system_prompt: row.get(4)?, - user_message_template: row.get(5)?, - temperature: row.get(6)?, - max_tokens: row.get(7)?, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?) - .unwrap() - .into(), - updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(9)?) - .unwrap() - .into(), - }) - }) - .optional()?; - - Ok(result) - } - - /// 查询所有模板 - pub fn list_all(db: &DbConnection) -> Result> { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - let mut stmt = conn.prepare( - "SELECT id, name, description, model, system_prompt, user_message_template, - temperature, max_tokens, created_at, updated_at - FROM batch_templates - ORDER BY created_at DESC", - )?; - - let rows = stmt.query_map([], |row| { - Ok(TaskTemplate { - id: Uuid::parse_str(&row.get::<_, String>(0)?).unwrap(), - name: row.get(1)?, - description: row.get(2)?, - model: row.get(3)?, - system_prompt: row.get(4)?, - user_message_template: row.get(5)?, - temperature: row.get(6)?, - max_tokens: row.get(7)?, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?) - .unwrap() - .into(), - updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(9)?) - .unwrap() - .into(), - }) - })?; - - let mut templates = Vec::new(); - for row in rows { - templates.push(row?); - } - - Ok(templates) - } - - /// 删除模板 - pub fn delete(db: &DbConnection, id: &Uuid) -> Result { - let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; - - let affected = conn.execute( - "DELETE FROM batch_templates WHERE id = ?1", - params![id.to_string()], - )?; - - Ok(affected > 0) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rusqlite::Connection; - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - - fn setup_test_db() -> DbConnection { - let conn = Connection::open_in_memory().unwrap(); - let db = Arc::new(Mutex::new(conn)); - BatchTaskDao::init_tables(&db).unwrap(); - db - } - - #[test] - fn test_init_tables() { - let db = setup_test_db(); - // 如果能成功创建,说明表初始化成功 - assert!(true); - } - - #[test] - fn test_save_and_get_template() { - let db = setup_test_db(); - - let template = TaskTemplate::new( - "测试模板".to_string(), - "gpt-4".to_string(), - "请处理: {{content}}".to_string(), - ); - - // 保存模板 - TemplateDao::save(&db, &template).unwrap(); - - // 查询模板 - let loaded = TemplateDao::get_by_id(&db, &template.id).unwrap(); - assert!(loaded.is_some()); - - let loaded = loaded.unwrap(); - assert_eq!(loaded.name, template.name); - assert_eq!(loaded.model, template.model); - assert_eq!(loaded.user_message_template, template.user_message_template); - } - - #[test] - fn test_save_and_get_batch_task() { - let db = setup_test_db(); - - let template = TaskTemplate::new( - "测试模板".to_string(), - "gpt-4".to_string(), - "请处理: {{content}}".to_string(), - ); - - let tasks = vec![super::super::batch::TaskDefinition { - id: None, - variables: { - let mut map = HashMap::new(); - map.insert("content".to_string(), "测试内容".to_string()); - map - }, - metadata: HashMap::new(), - }]; - - let batch_task = BatchTask::new( - "测试批量任务".to_string(), - template.id, - tasks, - super::super::batch::BatchOptions::default(), - ); - - // 保存批量任务 - BatchTaskDao::save(&db, &batch_task).unwrap(); - - // 查询批量任务 - let loaded = BatchTaskDao::get_by_id(&db, &batch_task.id).unwrap(); - assert!(loaded.is_some()); - - let loaded = loaded.unwrap(); - assert_eq!(loaded.name, batch_task.name); - assert_eq!(loaded.template_id, batch_task.template_id); - assert_eq!(loaded.tasks.len(), 1); - } -} diff --git a/src-tauri/crates/scheduler/src/executor.rs b/src-tauri/crates/scheduler/src/executor.rs index 9c018fac1..4ce90a946 100644 --- a/src-tauri/crates/scheduler/src/executor.rs +++ b/src-tauri/crates/scheduler/src/executor.rs @@ -1,6 +1,6 @@ //! Agent Task Executor //! -//! 负责执行调度的任务 +//! 负责执行调度任务。 use super::types::ScheduledTask; use async_trait::async_trait; @@ -97,10 +97,6 @@ impl TaskExecutor for AgentExecutor { // 执行 Agent 对话任务 self.execute_agent_chat(task, db, &aster_config).await? } - "batch_process" => { - // 执行批量处理任务 - self.execute_batch_process(task, db, &aster_config).await? - } "scheduled_report" => { // 执行定时报告任务 self.execute_scheduled_report(task, db, &aster_config) @@ -274,30 +270,6 @@ impl AgentExecutor { })) } - /// 执行批量处理任务 - async fn execute_batch_process( - &self, - task: &ScheduledTask, - _db: &DbConnection, - _aster_config: &proxycast_agent::credential_bridge::AsterProviderConfig, - ) -> Result { - let items = task - .params - .get("items") - .and_then(|v| v.as_array()) - .ok_or_else(|| "缺少 items 参数".to_string())?; - - tracing::info!("[AgentExecutor] 执行批量处理: {} 项", items.len()); - - // TODO: 实际执行批量处理逻辑 - Ok(serde_json::json!({ - "type": "batch_process", - "total": items.len(), - "processed": items.len(), - "status": "success" - })) - } - /// 执行定时报告任务 async fn execute_scheduled_report( &self, diff --git a/src-tauri/crates/scheduler/src/lib.rs b/src-tauri/crates/scheduler/src/lib.rs index 525da08be..ac104973c 100644 --- a/src-tauri/crates/scheduler/src/lib.rs +++ b/src-tauri/crates/scheduler/src/lib.rs @@ -13,7 +13,6 @@ //! - 定时任务调度 //! - 任务状态跟踪 //! - 失败重试机制 -//! - 批量任务支持 //! //! ## 使用示例 //! @@ -44,23 +43,14 @@ //! # } //! ``` -pub mod batch; -pub mod batch_dao; pub mod dao; pub mod executor; pub mod scheduler; -pub mod template; pub mod types; -pub use batch::{ - BatchOptions, BatchTask, BatchTaskStatistics, BatchTaskStatus, TaskDefinition, TaskResult, - TaskStatus as BatchTaskStatus2, TokenUsage, -}; -pub use batch_dao::{BatchTaskDao, TemplateDao}; pub use dao::SchedulerDao; pub use executor::{AgentExecutor, TaskExecutor}; pub use scheduler::{AgentScheduler, SchedulerGovernanceConfig, SchedulerTrait}; -pub use template::TaskTemplate; pub use types::{ ScheduledTask, TaskFilter, TaskStatus, DEFAULT_TASK_COOLDOWN_SECS, DEFAULT_TASK_FAILURE_THRESHOLD, diff --git a/src-tauri/crates/scheduler/src/template.rs b/src-tauri/crates/scheduler/src/template.rs deleted file mode 100644 index b9eb63f62..000000000 --- a/src-tauri/crates/scheduler/src/template.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! 任务模板定义 -//! -//! 定义可复用的任务模板 - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use uuid::Uuid; - -/// 任务模板 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskTemplate { - /// 模板 ID - pub id: Uuid, - - /// 模板名称 - pub name: String, - - /// 模板描述 - pub description: Option, - - /// 模型名称 - pub model: String, - - /// 系统提示词 - pub system_prompt: Option, - - /// 用户消息模板 (支持变量替换,例如 "{{variable_name}}") - pub user_message_template: String, - - /// 温度参数 - pub temperature: Option, - - /// 最大 tokens - pub max_tokens: Option, - - /// 创建时间 - pub created_at: chrono::DateTime, - - /// 更新时间 - pub updated_at: chrono::DateTime, -} - -impl TaskTemplate { - /// 创建新的任务模板 - pub fn new(name: String, model: String, user_message_template: String) -> Self { - let now = chrono::Utc::now(); - Self { - id: Uuid::new_v4(), - name, - description: None, - model, - system_prompt: None, - user_message_template, - temperature: None, - max_tokens: None, - created_at: now, - updated_at: now, - } - } - - /// 设置描述 - pub fn with_description(mut self, description: String) -> Self { - self.description = Some(description); - self - } - - /// 设置系统提示词 - pub fn with_system_prompt(mut self, system_prompt: String) -> Self { - self.system_prompt = Some(system_prompt); - self - } - - /// 设置温度 - pub fn with_temperature(mut self, temperature: f32) -> Self { - self.temperature = Some(temperature); - self - } - - /// 设置最大 tokens - pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { - self.max_tokens = Some(max_tokens); - self - } - - /// 渲染用户消息 (替换变量) - pub fn render_user_message(&self, variables: &HashMap) -> String { - let mut message = self.user_message_template.clone(); - - for (key, value) in variables { - let placeholder = format!("{{{{{}}}}}", key); - message = message.replace(&placeholder, value); - } - - message - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_template_creation() { - let template = TaskTemplate::new( - "测试模板".to_string(), - "gpt-4".to_string(), - "请处理: {{content}}".to_string(), - ) - .with_description("这是一个测试模板".to_string()) - .with_temperature(0.7) - .with_max_tokens(1000); - - assert_eq!(template.name, "测试模板"); - assert_eq!(template.model, "gpt-4"); - assert_eq!(template.temperature, Some(0.7)); - assert_eq!(template.max_tokens, Some(1000)); - } - - #[test] - fn test_render_user_message() { - let template = TaskTemplate::new( - "测试模板".to_string(), - "gpt-4".to_string(), - "请处理内容: {{content}}, 来自: {{source}}".to_string(), - ); - - let mut variables = HashMap::new(); - variables.insert("content".to_string(), "测试内容".to_string()); - variables.insert("source".to_string(), "测试来源".to_string()); - - let rendered = template.render_user_message(&variables); - assert_eq!(rendered, "请处理内容: 测试内容, 来自: 测试来源"); - } -} diff --git a/src-tauri/crates/server/src/chrome_bridge.rs b/src-tauri/crates/server/src/chrome_bridge.rs index 3470fbf69..4c76d50bd 100644 --- a/src-tauri/crates/server/src/chrome_bridge.rs +++ b/src-tauri/crates/server/src/chrome_bridge.rs @@ -32,6 +32,7 @@ const COMMAND_WHITELIST: &[&str] = &[ "go_back", "go_forward", "switch_tab", + "list_tabs", ]; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -108,6 +109,8 @@ pub struct ChromeBridgeCommandResult { pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub page_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, } #[derive(Debug, Clone, Deserialize)] @@ -120,6 +123,8 @@ pub struct ObserverCommandResultPayload { pub message: Option, #[serde(default)] pub error: Option, + #[serde(default)] + pub data: Option, } #[derive(Debug, Clone, Deserialize)] @@ -336,6 +341,7 @@ impl ChromeBridgeHub { message: None, error: Some("observer 通道发送失败,连接可能已断开。".to_string()), page_info: None, + data: None, }, None, ) @@ -363,6 +369,7 @@ impl ChromeBridgeHub { message: None, error: Some("等待 Chrome 执行结果超时。".to_string()), page_info: None, + data: None, }, None, ) @@ -449,6 +456,7 @@ impl ChromeBridgeHub { message: None, error: Some("observer 通道发送失败,连接可能已断开。".to_string()), page_info: None, + data: None, }, None, ) @@ -555,6 +563,7 @@ impl ChromeBridgeHub { message: payload.message.or(Some("命令执行成功".to_string())), error: None, page_info: None, + data: payload.data, } } else { ChromeBridgeCommandResult { @@ -564,6 +573,7 @@ impl ChromeBridgeHub { message: None, error: payload.error.or(Some("命令执行失败".to_string())), page_info: None, + data: payload.data, } }; @@ -617,6 +627,7 @@ impl ChromeBridgeHub { message: pending_cmd.execution_message.clone(), error: None, page_info: Some(page_info.clone()), + data: None, }; self.dispatch_pending_result(pending_cmd, result, Some(true)) .await; @@ -735,6 +746,7 @@ impl ChromeBridgeHub { message: None, error: Some("命令执行超时。".to_string()), page_info: None, + data: None, }; self.dispatch_pending_result(pending, result, None).await; } @@ -749,6 +761,7 @@ impl ChromeBridgeHub { message: None, error: Some("observer 已断开连接。".to_string()), page_info: None, + data: None, }; self.dispatch_pending_result(pending, result, None).await; } @@ -801,6 +814,7 @@ impl ChromeBridgeHub { "requestId": result.request_id, "status": "success", "message": result.message, + "data": result.data, } }), ) @@ -1018,6 +1032,7 @@ mod tests { fn should_validate_command_whitelist() { assert!(validate_command("open_url", &Some("https://example.com".to_string())).is_ok()); assert!(validate_command("click", &None).is_ok()); + assert!(validate_command("list_tabs", &None).is_ok()); assert!(validate_command("eval_js", &None).is_err()); } @@ -1136,4 +1151,76 @@ mod tests { assert!(result.success); assert!(result.page_info.is_some()); } + + #[tokio::test] + async fn observer_command_result_should_preserve_data_payload() { + let hub = Arc::new(ChromeBridgeHub::new()); + let (observer_tx, _observer_rx) = mpsc::unbounded_channel::(); + hub.register_observer( + "observer-a".to_string(), + Some("default".to_string()), + None, + observer_tx, + ) + .await; + + let (result_tx, result_rx) = oneshot::channel(); + { + let mut inner = hub.inner.lock().await; + inner.pending_commands.insert( + "req-tabs".to_string(), + PendingCommand { + request_id: "req-tabs".to_string(), + source: PendingSource::Api(result_tx), + command: "list_tabs".to_string(), + observer_client_id: "observer-a".to_string(), + wait_for_page_info: false, + command_completed: false, + execution_message: None, + created_at: Utc::now(), + expires_at: Instant::now() + Duration::from_secs(30), + }, + ); + } + + hub.handle_observer_command_result( + "observer-a", + ObserverCommandResultPayload { + request_id: "req-tabs".to_string(), + status: "success".to_string(), + message: Some("ok".to_string()), + error: None, + data: Some(json!({ + "tabs": [ + { + "id": 101, + "index": 0, + "title": "首页", + "url": "https://weibo.com/home", + "active": true, + } + ], + })), + }, + ) + .await; + + let result = result_rx.await.expect("must receive tabs result"); + assert!(result.success); + assert_eq!(result.command, "list_tabs"); + assert_eq!( + result.data, + Some(json!({ + "tabs": [ + { + "id": 101, + "index": 0, + "title": "首页", + "url": "https://weibo.com/home", + "active": true, + } + ], + })), + ); + } } diff --git a/src-tauri/crates/server/src/handlers/batch_api.rs b/src-tauri/crates/server/src/handlers/batch_api.rs deleted file mode 100644 index ffa303703..000000000 --- a/src-tauri/crates/server/src/handlers/batch_api.rs +++ /dev/null @@ -1,519 +0,0 @@ -//! 批量任务 API 端点 -//! -//! 提供批量任务的创建、查询和管理接口 - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use proxycast_scheduler::{ - BatchOptions, BatchTask, BatchTaskDao, BatchTaskStatistics, TaskDefinition, TaskTemplate, - TemplateDao, -}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::AppState; - -/// 创建批量任务请求 -#[derive(Debug, Deserialize)] -pub struct CreateBatchTaskRequest { - /// 批量任务名称 - pub name: String, - - /// 任务模板 ID - pub template_id: Uuid, - - /// 任务列表 - pub tasks: Vec, - - /// 批量任务选项 - #[serde(default)] - pub options: BatchOptions, -} - -/// 创建批量任务响应 -#[derive(Debug, Serialize)] -pub struct CreateBatchTaskResponse { - /// 批量任务 ID - pub id: Uuid, - - /// 批量任务名称 - pub name: String, - - /// 任务数量 - pub task_count: usize, - - /// 创建时间 - pub created_at: chrono::DateTime, -} - -/// 批量任务详情响应 -#[derive(Debug, Serialize)] -pub struct BatchTaskDetailResponse { - /// 批量任务信息 - #[serde(flatten)] - pub batch_task: BatchTask, - - /// 统计信息 - pub statistics: BatchTaskStatistics, -} - -/// POST /api/batch/tasks - 创建批量任务 -pub async fn create_batch_task( - State(state): State, - Json(request): Json, -) -> Response { - // 检查数据库是否可用 - let db = match &state.db { - Some(db) => db, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": "数据库未初始化", - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - // 验证模板是否存在 - let _template = match TemplateDao::get_by_id(db, &request.template_id) { - Ok(Some(t)) => t, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": { - "message": format!("模板不存在: {}", request.template_id), - "type": "not_found" - } - })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": format!("查询模板失败: {}", e), - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - // 创建批量任务 - let batch_task = BatchTask::new( - request.name.clone(), - request.template_id, - request.tasks, - request.options, - ); - - let batch_id = batch_task.id; - let task_count = batch_task.tasks.len(); - let created_at = batch_task.created_at; - - // 保存到数据库 - if let Err(e) = BatchTaskDao::save(db, &batch_task) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": format!("保存批量任务失败: {}", e), - "type": "database_error" - } - })), - ) - .into_response(); - } - - state.logs.write().await.add( - "info", - &format!( - "[BATCH] 创建批量任务: id={}, name={}, task_count={}", - batch_id, request.name, task_count - ), - ); - - // 启动异步执行任务 - if let Some(executor) = state.batch_executor.read().await.as_ref() { - executor.start_batch(batch_id).await; - } - - // 返回响应 - ( - StatusCode::CREATED, - Json(CreateBatchTaskResponse { - id: batch_id, - name: request.name, - task_count, - created_at, - }), - ) - .into_response() -} - -/// GET /api/batch/tasks/:id - 获取批量任务详情 -pub async fn get_batch_task(State(state): State, Path(id): Path) -> Response { - let db = match &state.db { - Some(db) => db, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": "数据库未初始化", - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - match BatchTaskDao::get_by_id(db, &id) { - Ok(Some(batch_task)) => { - let statistics = batch_task.get_statistics(); - ( - StatusCode::OK, - Json(BatchTaskDetailResponse { - batch_task, - statistics, - }), - ) - .into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": { - "message": format!("批量任务不存在: {}", id), - "type": "not_found" - } - })), - ) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": format!("查询批量任务失败: {}", e), - "type": "database_error" - } - })), - ) - .into_response(), - } -} - -/// GET /api/batch/tasks - 获取批量任务列表 -pub async fn list_batch_tasks(State(state): State) -> Response { - let db = match &state.db { - Some(db) => db, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": "数据库未初始化", - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - match BatchTaskDao::list_all(db, 100) { - Ok(tasks) => ( - StatusCode::OK, - Json(serde_json::json!({ - "tasks": tasks - })), - ) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": format!("查询批量任务列表失败: {}", e), - "type": "database_error" - } - })), - ) - .into_response(), - } -} - -/// DELETE /api/batch/tasks/:id - 取消批量任务 -pub async fn cancel_batch_task(State(state): State, Path(id): Path) -> Response { - let db = match &state.db { - Some(db) => db, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": "数据库未初始化", - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - // 检查任务是否存在 - let batch_task = match BatchTaskDao::get_by_id(db, &id) { - Ok(Some(task)) => task, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": { - "message": format!("批量任务不存在: {}", id), - "type": "not_found" - } - })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": format!("查询批量任务失败: {}", e), - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - // 只能取消运行中的任务 - if batch_task.status != proxycast_scheduler::BatchTaskStatus::Running - && batch_task.status != proxycast_scheduler::BatchTaskStatus::Pending - { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": { - "message": format!("任务状态为 {:?},无法取消", batch_task.status), - "type": "invalid_state" - } - })), - ) - .into_response(); - } - - // 通过执行器取消 - let cancelled = if let Some(executor) = state.batch_executor.read().await.as_ref() { - executor.cancel_batch(&id).await - } else { - false - }; - - if !cancelled { - // 如果执行器中没有找到(可能还没开始执行),直接更新 DB 状态 - let _ = - BatchTaskDao::update_status(db, &id, proxycast_scheduler::BatchTaskStatus::Cancelled); - } - - state - .logs - .write() - .await - .add("info", &format!("[BATCH] 取消批量任务: id={}", id)); - - (StatusCode::OK, Json(serde_json::json!({"cancelled": true}))).into_response() -} - -/// POST /api/batch/templates - 创建任务模板 -pub async fn create_template( - State(state): State, - Json(template): Json, -) -> Response { - let db = match &state.db { - Some(db) => db, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": "数据库未初始化", - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - if let Err(e) = TemplateDao::save(db, &template) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": format!("保存模板失败: {}", e), - "type": "database_error" - } - })), - ) - .into_response(); - } - - state.logs.write().await.add( - "info", - &format!( - "[BATCH] 创建任务模板: id={}, name={}", - template.id, template.name - ), - ); - - (StatusCode::CREATED, Json(template)).into_response() -} - -/// GET /api/batch/templates - 获取模板列表 -pub async fn list_templates(State(state): State) -> Response { - let db = match &state.db { - Some(db) => db, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": "数据库未初始化", - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - match TemplateDao::list_all(db) { - Ok(templates) => ( - StatusCode::OK, - Json(serde_json::json!({ - "templates": templates - })), - ) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": format!("查询模板列表失败: {}", e), - "type": "database_error" - } - })), - ) - .into_response(), - } -} - -/// GET /api/batch/templates/:id - 获取模板详情 -pub async fn get_template(State(state): State, Path(id): Path) -> Response { - let db = match &state.db { - Some(db) => db, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": "数据库未初始化", - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - match TemplateDao::get_by_id(db, &id) { - Ok(Some(template)) => (StatusCode::OK, Json(template)).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": { - "message": format!("模板不存在: {}", id), - "type": "not_found" - } - })), - ) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": format!("查询模板失败: {}", e), - "type": "database_error" - } - })), - ) - .into_response(), - } -} - -/// DELETE /api/batch/templates/:id - 删除模板 -pub async fn delete_template(State(state): State, Path(id): Path) -> Response { - let db = match &state.db { - Some(db) => db, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": "数据库未初始化", - "type": "database_error" - } - })), - ) - .into_response(); - } - }; - - match TemplateDao::delete(db, &id) { - Ok(true) => { - state - .logs - .write() - .await - .add("info", &format!("[BATCH] 删除模板: id={}", id)); - (StatusCode::NO_CONTENT, ()).into_response() - } - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": { - "message": format!("模板不存在: {}", id), - "type": "not_found" - } - })), - ) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": format!("删除模板失败: {}", e), - "type": "database_error" - } - })), - ) - .into_response(), - } -} diff --git a/src-tauri/crates/server/src/handlers/batch_executor.rs b/src-tauri/crates/server/src/handlers/batch_executor.rs deleted file mode 100644 index f8ad3102e..000000000 --- a/src-tauri/crates/server/src/handlers/batch_executor.rs +++ /dev/null @@ -1,420 +0,0 @@ -//! 批量任务执行器 -//! -//! 负责异步执行批量任务,支持并发控制、重试、超时和取消 - -use std::collections::HashMap; -use std::sync::Arc; - -use axum::http::StatusCode; -use proxycast_core::models::openai::{ - ChatCompletionRequest, ChatCompletionResponse, ChatMessage, MessageContent, -}; -use proxycast_scheduler::{BatchTaskDao, BatchTaskStatus, TaskResult, TemplateDao, TokenUsage}; -use tokio::sync::RwLock; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -use crate::AppState; - -/// 批量任务执行器 -#[derive(Clone)] -pub struct BatchTaskExecutor { - state: AppState, - cancel_tokens: Arc>>, -} - -impl BatchTaskExecutor { - pub fn new(state: AppState) -> Self { - Self { - state, - cancel_tokens: Arc::new(RwLock::new(HashMap::new())), - } - } - - /// 启动批量任务执行(spawn 后台任务) - pub async fn start_batch(&self, batch_id: Uuid) { - let cancel_token = CancellationToken::new(); - self.cancel_tokens - .write() - .await - .insert(batch_id, cancel_token.clone()); - - let state = self.state.clone(); - let cancel_tokens = self.cancel_tokens.clone(); - - tokio::spawn(async move { - Self::execute_batch(state, batch_id, cancel_token).await; - // 执行完毕后清理 cancel token - cancel_tokens.write().await.remove(&batch_id); - }); - } - - /// 取消运行中的批量任务 - pub async fn cancel_batch(&self, batch_id: &Uuid) -> bool { - if let Some(token) = self.cancel_tokens.read().await.get(batch_id) { - token.cancel(); - true - } else { - false - } - } - - /// 核心执行逻辑 - async fn execute_batch(state: AppState, batch_id: Uuid, cancel_token: CancellationToken) { - let db = match &state.db { - Some(db) => db, - None => { - tracing::error!("[BATCH] 数据库未初始化, batch_id={}", batch_id); - return; - } - }; - - // 1. 从 DB 加载 BatchTask - let mut batch_task = match BatchTaskDao::get_by_id(db, &batch_id) { - Ok(Some(task)) => task, - Ok(None) => { - tracing::error!("[BATCH] 批量任务不存在: {}", batch_id); - return; - } - Err(e) => { - tracing::error!("[BATCH] 加载批量任务失败: {}", e); - return; - } - }; - - // 2. 加载模板 - let template = match TemplateDao::get_by_id(db, &batch_task.template_id) { - Ok(Some(t)) => t, - Ok(None) => { - tracing::error!("[BATCH] 模板不存在: {}", batch_task.template_id); - let _ = BatchTaskDao::update_status(db, &batch_id, BatchTaskStatus::Failed); - return; - } - Err(e) => { - tracing::error!("[BATCH] 加载模板失败: {}", e); - let _ = BatchTaskDao::update_status(db, &batch_id, BatchTaskStatus::Failed); - return; - } - }; - - // 3. 更新状态为 Running - let now = chrono::Utc::now(); - batch_task.status = BatchTaskStatus::Running; - batch_task.started_at = Some(now); - let _ = BatchTaskDao::update_results( - db, - &batch_id, - BatchTaskStatus::Running, - &batch_task.results, - batch_task.started_at, - None, - ); - - tracing::info!( - "[BATCH] 开始执行批量任务: id={}, name={}, task_count={}", - batch_id, - batch_task.name, - batch_task.tasks.len() - ); - - // 4. 用 Semaphore 控制并发 - let concurrency = batch_task.options.concurrency.max(1); - let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency)); - let results = Arc::new(RwLock::new(Vec::::new())); - let mut handles = Vec::new(); - - for task_def in &batch_task.tasks { - let task_id = task_def.id.unwrap_or_else(Uuid::new_v4); - let variables = task_def.variables.clone(); - let sem = semaphore.clone(); - let state = state.clone(); - let cancel = cancel_token.clone(); - let results = results.clone(); - let model = template.model.clone(); - let system_prompt = template.system_prompt.clone(); - let user_message = template.render_user_message(&variables); - let temperature = template.temperature; - let max_tokens = template.max_tokens; - let retry_count = batch_task.options.retry_count; - let timeout_secs = batch_task.options.timeout_seconds; - let db_clone = db.clone(); - let batch_id_clone = batch_id; - - let handle = tokio::spawn(async move { - let _permit = sem.acquire().await.unwrap(); - - // 检查取消 - if cancel.is_cancelled() { - let result = TaskResult { - task_id, - status: proxycast_scheduler::BatchTaskStatus2::Cancelled, - content: None, - error: Some("任务已取消".to_string()), - usage: TokenUsage::default(), - started_at: chrono::Utc::now(), - completed_at: Some(chrono::Utc::now()), - }; - results.write().await.push(result); - return; - } - - let result = Self::execute_single_task( - &state, - task_id, - &model, - system_prompt.as_deref(), - &user_message, - temperature, - max_tokens, - retry_count, - timeout_secs, - &cancel, - ) - .await; - - results.write().await.push(result); - - // 实时更新 DB 进度 - let current_results = results.read().await.clone(); - let _ = BatchTaskDao::update_results( - &db_clone, - &batch_id_clone, - BatchTaskStatus::Running, - ¤t_results, - None, - None, - ); - }); - - handles.push(handle); - } - - // 等待所有任务完成 - for handle in handles { - let _ = handle.await; - } - - // 5. 计算最终状态 - let final_results = results.read().await.clone(); - let total = batch_task.tasks.len(); - let completed = final_results - .iter() - .filter(|r| r.status == proxycast_scheduler::BatchTaskStatus2::Completed) - .count(); - let cancelled = final_results - .iter() - .filter(|r| r.status == proxycast_scheduler::BatchTaskStatus2::Cancelled) - .count(); - - let final_status = if cancel_token.is_cancelled() { - BatchTaskStatus::Cancelled - } else if completed == total { - BatchTaskStatus::Completed - } else if completed == 0 { - BatchTaskStatus::Failed - } else { - BatchTaskStatus::PartiallyCompleted - }; - - let completed_at = chrono::Utc::now(); - let _ = BatchTaskDao::update_results( - db, - &batch_id, - final_status, - &final_results, - batch_task.started_at, - Some(completed_at), - ); - - tracing::info!( - "[BATCH] 批量任务完成: id={}, status={:?}, completed={}/{}, cancelled={}", - batch_id, - final_status, - completed, - total, - cancelled - ); - } - - /// 执行单个子任务(含重试和超时) - async fn execute_single_task( - state: &AppState, - task_id: Uuid, - model: &str, - system_prompt: Option<&str>, - user_message: &str, - temperature: Option, - max_tokens: Option, - retry_count: usize, - timeout_secs: u64, - cancel: &CancellationToken, - ) -> TaskResult { - let started_at = chrono::Utc::now(); - let max_attempts = retry_count + 1; - - for attempt in 0..max_attempts { - if cancel.is_cancelled() { - return TaskResult { - task_id, - status: proxycast_scheduler::BatchTaskStatus2::Cancelled, - content: None, - error: Some("任务已取消".to_string()), - usage: TokenUsage::default(), - started_at, - completed_at: Some(chrono::Utc::now()), - }; - } - - if attempt > 0 { - tracing::info!( - "[BATCH] 重试任务: task_id={}, attempt={}/{}", - task_id, - attempt + 1, - max_attempts - ); - } - - // 构建请求 - let mut messages = Vec::new(); - if let Some(sys) = system_prompt { - messages.push(ChatMessage { - role: "system".to_string(), - content: Some(MessageContent::Text(sys.to_string())), - tool_calls: None, - tool_call_id: None, - reasoning_content: None, - }); - } - messages.push(ChatMessage { - role: "user".to_string(), - content: Some(MessageContent::Text(user_message.to_string())), - tool_calls: None, - tool_call_id: None, - reasoning_content: None, - }); - - let request = ChatCompletionRequest { - model: model.to_string(), - messages, - temperature, - max_tokens, - top_p: None, - stream: false, - tools: None, - tool_choice: None, - reasoning_effort: None, - }; - - // 调用 LLM(带超时) - let result = tokio::time::timeout( - std::time::Duration::from_secs(timeout_secs), - Self::call_llm(state, &request), - ) - .await; - - match result { - Ok(Ok((content, usage))) => { - return TaskResult { - task_id, - status: proxycast_scheduler::BatchTaskStatus2::Completed, - content: Some(content), - error: None, - usage, - started_at, - completed_at: Some(chrono::Utc::now()), - }; - } - Ok(Err(e)) => { - if attempt == max_attempts - 1 { - return TaskResult { - task_id, - status: proxycast_scheduler::BatchTaskStatus2::Failed, - content: None, - error: Some(e), - usage: TokenUsage::default(), - started_at, - completed_at: Some(chrono::Utc::now()), - }; - } - // 重试前等待 - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - Err(_) => { - if attempt == max_attempts - 1 { - return TaskResult { - task_id, - status: proxycast_scheduler::BatchTaskStatus2::Failed, - content: None, - error: Some(format!("任务超时 ({}s)", timeout_secs)), - usage: TokenUsage::default(), - started_at, - completed_at: Some(chrono::Utc::now()), - }; - } - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - } - } - - // 不应到达这里 - TaskResult { - task_id, - status: proxycast_scheduler::BatchTaskStatus2::Failed, - content: None, - error: Some("未知错误".to_string()), - usage: TokenUsage::default(), - started_at, - completed_at: Some(chrono::Utc::now()), - } - } - - /// 调用 LLM:选择凭证 + 调用 provider - async fn call_llm( - state: &AppState, - request: &ChatCompletionRequest, - ) -> Result<(String, TokenUsage), String> { - let db = state.db.as_ref().ok_or("数据库未初始化")?; - - // 选择凭证 - let credential = state - .pool_service - .select_credential_with_fallback( - db, - &state.api_key_service, - "", - Some(&request.model), - None, - None, - ) - .await? - .ok_or_else(|| format!("没有可用的凭证来调用模型: {}", request.model))?; - - // 调用 provider - let response = - super::provider_calls::call_provider_openai(state, &credential, request, None).await; - - // 解析响应 - let status = response.status(); - let body = axum::body::to_bytes(response.into_body(), 10 * 1024 * 1024) - .await - .map_err(|e| format!("读取响应体失败: {}", e))?; - - if status != StatusCode::OK { - let error_text = String::from_utf8_lossy(&body); - return Err(format!("LLM 调用失败 ({}): {}", status, error_text)); - } - - let resp: ChatCompletionResponse = - serde_json::from_slice(&body).map_err(|e| format!("解析响应失败: {}", e))?; - - let content = resp - .choices - .first() - .and_then(|c| c.message.content.clone()) - .unwrap_or_default(); - - let usage = TokenUsage::new(resp.usage.prompt_tokens, resp.usage.completion_tokens); - - Ok((content, usage)) - } -} diff --git a/src-tauri/crates/server/src/handlers/mod.rs b/src-tauri/crates/server/src/handlers/mod.rs index cc742c66a..eb493f3a2 100644 --- a/src-tauri/crates/server/src/handlers/mod.rs +++ b/src-tauri/crates/server/src/handlers/mod.rs @@ -4,8 +4,6 @@ pub mod api; pub mod api_key_provider_utils; -pub mod batch_api; -pub mod batch_executor; pub mod chrome_bridge_ws; pub mod credentials_api; pub mod image_handler; @@ -14,7 +12,6 @@ pub mod provider_calls; pub mod websocket; pub use api::*; -pub use batch_api::*; pub use chrome_bridge_ws::*; pub use credentials_api::*; pub use image_handler::*; diff --git a/src-tauri/crates/server/src/lib.rs b/src-tauri/crates/server/src/lib.rs index d5336e46f..cc9f9dde8 100644 --- a/src-tauri/crates/server/src/lib.rs +++ b/src-tauri/crates/server/src/lib.rs @@ -642,9 +642,6 @@ pub struct AppState { pub kiro_event_service: Arc, /// API Key Provider 服务(用于智能降级) pub api_key_service: Arc, - /// 批量任务执行器 - pub batch_executor: - Arc>>, /// 速率限制器 pub rate_limiter: Option>, /// 幂等性存储 @@ -1082,7 +1079,6 @@ async fn run_server( provider_models, kiro_event_service, api_key_service, - batch_executor: Arc::new(tokio::sync::RwLock::new(None)), rate_limiter: Some(Arc::new( middleware::rate_limit::SlidingWindowRateLimiter::new( middleware::rate_limit::RateLimitConfig::default(), @@ -1095,12 +1091,6 @@ async fn run_server( sanitizer: Arc::new(proxycast_core::sanitizer::CredentialSanitizer::with_defaults()), }; - // 初始化批量任务执行器 - { - let executor = handlers::batch_executor::BatchTaskExecutor::new(state.clone()); - *state.batch_executor.write().await = Some(executor); - } - // ========== 开发模式:通过回调启动桥接服务器 ========== if let Some(callback) = dev_bridge_callback { callback(state.clone()); @@ -1151,23 +1141,6 @@ async fn run_server( get(handlers::credentials_get_token), ); - // 批量任务 API 路由 - let batch_api_routes = Router::new() - .route("/api/batch/tasks", post(handlers::create_batch_task)) - .route("/api/batch/tasks", get(handlers::list_batch_tasks)) - .route("/api/batch/tasks/:id", get(handlers::get_batch_task)) - .route( - "/api/batch/tasks/:id", - axum::routing::delete(handlers::cancel_batch_task), - ) - .route("/api/batch/templates", post(handlers::create_template)) - .route("/api/batch/templates", get(handlers::list_templates)) - .route("/api/batch/templates/:id", get(handlers::get_template)) - .route( - "/api/batch/templates/:id", - axum::routing::delete(handlers::delete_template), - ); - let allowed_origins = vec![ HeaderValue::from_static("http://localhost:1420"), HeaderValue::from_static("http://127.0.0.1:1420"), @@ -1245,8 +1218,6 @@ async fn run_server( .merge(kiro_api_routes) // 凭证 API 路由(用于 aster Agent 集成) .merge(credentials_api_routes) - // 批量任务 API 路由 - .merge(batch_api_routes) .layer(cors_layer) .layer(DefaultBodyLimit::max(body_limit)) .layer(TimeoutLayer::with_status_code( diff --git a/src-tauri/crates/services/src/api_key_provider_service.rs b/src-tauri/crates/services/src/api_key_provider_service.rs index c0c4eda99..18e1aed36 100644 --- a/src-tauri/crates/services/src/api_key_provider_service.rs +++ b/src-tauri/crates/services/src/api_key_provider_service.rs @@ -44,10 +44,19 @@ pub struct ConnectionTestResult { mod tests { use super::ApiKeyProviderService; use proxycast_core::database::dao::api_key_provider::ApiProviderType; - use proxycast_core::database::init_database; + use proxycast_core::database::{migration, schema, DbConnection}; + use rusqlite::Connection; use rusqlite::OptionalExtension; + use std::sync::{Arc, Mutex}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + fn init_test_database() -> DbConnection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + schema::create_tables(&conn).expect("初始化表结构失败"); + migration::migrate_from_json(&conn).expect("执行数据库迁移失败"); + Arc::new(Mutex::new(conn)) + } + fn resolve_real_codex_provider_id( db: &proxycast_core::database::DbConnection, ) -> Result { @@ -146,6 +155,42 @@ data: [DONE]\n"; assert!(none.is_none()); } + #[test] + fn test_system_provider_type_can_be_updated() { + let db = init_test_database(); + let service = ApiKeyProviderService::new(); + + service + .initialize_system_providers(&db) + .expect("初始化系统 Provider 失败"); + + let updated = service + .update_provider( + &db, + "openai", + None, + Some(ApiProviderType::Openai), + None, + None, + None, + None, + None, + None, + None, + None, + ) + .expect("更新系统 Provider 类型失败"); + + assert_eq!(updated.provider_type, ApiProviderType::Openai); + + let persisted = service + .get_provider(&db, "openai") + .expect("读取系统 Provider 失败") + .expect("系统 Provider 应存在"); + + assert_eq!(persisted.provider.provider_type, ApiProviderType::Openai); + } + #[test] fn test_parse_openai_responses_content_prefers_output_text() { let body = serde_json::json!({ @@ -1153,11 +1198,7 @@ impl ApiKeyProviderService { if let Some(n) = name { provider.name = n; } - // 只有自定义 Provider 才能修改类型 if let Some(t) = provider_type { - if provider.is_system { - return Err("系统 Provider 不允许修改类型".to_string()); - } provider.provider_type = t; } if let Some(h) = api_host { diff --git a/src-tauri/crates/websocket/src/handlers/rpc_handler.rs b/src-tauri/crates/websocket/src/handlers/rpc_handler.rs index 0d76d1c97..731cc281f 100644 --- a/src-tauri/crates/websocket/src/handlers/rpc_handler.rs +++ b/src-tauri/crates/websocket/src/handlers/rpc_handler.rs @@ -503,7 +503,7 @@ impl RpcHandler { self.create_run_record( &db, &execution_id, - "heartbeat", + "automation", Some(task.id.clone()), None, Some(json!({ @@ -667,7 +667,7 @@ impl RpcHandler { .prepare( "SELECT status, started_at FROM agent_runs - WHERE source = 'heartbeat' + WHERE source = 'automation' AND status IN ('error', 'timeout') AND datetime(started_at) >= datetime(?1)", ) @@ -1417,7 +1417,7 @@ mod tests { let now = Utc::now().to_rfc3339(); let run_error = AgentRun { id: Uuid::new_v4().to_string(), - source: "heartbeat".to_string(), + source: "automation".to_string(), source_ref: Some("task-1".to_string()), session_id: None, status: AgentRunStatus::Error, @@ -1433,7 +1433,7 @@ mod tests { AgentRunDao::create_run(&conn, &run_error).expect("插入错误执行记录失败"); let run_timeout = AgentRun { id: Uuid::new_v4().to_string(), - source: "heartbeat".to_string(), + source: "automation".to_string(), source_ref: Some("task-2".to_string()), session_id: None, status: AgentRunStatus::Timeout, diff --git a/src-tauri/src/agent/heartbeat_service_adapter.rs b/src-tauri/src/agent/heartbeat_service_adapter.rs deleted file mode 100644 index a5106add9..000000000 --- a/src-tauri/src/agent/heartbeat_service_adapter.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Heartbeat Service Adapter -//! -//! 将 Tauri 的 HeartbeatServiceState 适配为 Aster Agent 工具的 HeartbeatService trait - -use crate::services::heartbeat_service::{ - CycleResult, HeartbeatService as ProxycastHeartbeatService, HeartbeatServiceState, - HeartbeatStatus as ProxycastHeartbeatStatus, - HeartbeatTaskPreview as ProxycastHeartbeatTaskPreview, -}; -use proxycast_agent::tools::heartbeat_tool::{ - HeartbeatCycleResult, HeartbeatExecutionRecord, HeartbeatService, HeartbeatStatus, - HeartbeatTaskPreview, HeartbeatToolError, -}; -use proxycast_core::app_paths; -use proxycast_core::database::dao::heartbeat::HeartbeatExecution; -use std::path::PathBuf; -use std::sync::Arc; -use tauri::AppHandle; - -/// Heartbeat Service 适配器 -/// -/// 将 Tauri 的 HeartbeatServiceState 转换为 Agent 工具可使用的 trait -pub struct HeartbeatServiceAdapter { - hb_service: Arc>, - app_handle: AppHandle, -} - -impl HeartbeatServiceAdapter { - /// 创建新的适配器 - pub fn new(hb_state: HeartbeatServiceState, app_handle: AppHandle) -> Self { - Self { - hb_service: Arc::clone(&hb_state.0), - app_handle, - } - } - - /// 获取应用数据目录 - fn app_data_dir(&self) -> Result { - app_paths::preferred_data_dir() - .map_err(|e| HeartbeatToolError::IoError(format!("获取应用数据目录失败: {}", e))) - } - - /// 转换执行记录 - fn convert_execution(exec: &HeartbeatExecution) -> HeartbeatExecutionRecord { - HeartbeatExecutionRecord { - id: exec.id, - task_description: exec.task_description.clone(), - status: exec.status.clone(), - started_at: exec.started_at.clone(), - completed_at: exec.completed_at.clone(), - duration_ms: exec.duration_ms, - output: exec.output.clone(), - retry_count: exec.retry_count, - } - } - - /// 转换任务预览 - fn convert_task_preview(task: &ProxycastHeartbeatTaskPreview) -> HeartbeatTaskPreview { - HeartbeatTaskPreview { - description: task.description.clone(), - priority: task.priority, - timeout_secs: task.timeout_secs, - once: task.once, - model: task.model.clone(), - } - } - - /// 转换状态 - fn convert_status(status: ProxycastHeartbeatStatus) -> HeartbeatStatus { - HeartbeatStatus { - running: status.running, - last_run: status.last_run, - next_run: status.next_run, - last_task_count: status.last_task_count, - total_executions: status.total_executions, - schedule_description: status.schedule_description, - } - } -} - -#[async_trait::async_trait] -impl HeartbeatService for HeartbeatServiceAdapter { - fn get_status(&self) -> Result { - let service = self.hb_service.blocking_read(); - Ok(Self::convert_status(service.get_status())) - } - - fn get_app_data_dir(&self) -> Result { - self.app_data_dir() - } - - fn preview_tasks(&self) -> Result, HeartbeatToolError> { - let app_data_dir = self.app_data_dir()?; - let service = self.hb_service.blocking_read(); - let tasks = service - .preview_tasks(&app_data_dir) - .map_err(|e| HeartbeatToolError::ExecutionFailed(format!("获取任务列表失败: {}", e)))?; - Ok(tasks.iter().map(Self::convert_task_preview).collect()) - } - - fn add_task( - &self, - description: String, - priority: Option, - timeout_secs: Option, - once: Option, - model: Option, - ) -> Result<(), HeartbeatToolError> { - let app_data_dir = self.app_data_dir()?; - let service = self.hb_service.blocking_read(); - service - .add_task( - &app_data_dir, - description, - priority, - timeout_secs, - once, - model, - ) - .map_err(|e| HeartbeatToolError::ExecutionFailed(format!("添加任务失败: {}", e))) - } - - fn delete_task(&self, index: usize) -> Result<(), HeartbeatToolError> { - let app_data_dir = self.app_data_dir()?; - let service = self.hb_service.blocking_read(); - service - .delete_task(&app_data_dir, index) - .map_err(|e| HeartbeatToolError::ExecutionFailed(format!("删除任务失败: {}", e))) - } - - fn update_task( - &self, - index: usize, - description: String, - priority: Option, - timeout_secs: Option, - once: Option, - model: Option, - ) -> Result<(), HeartbeatToolError> { - let app_data_dir = self.app_data_dir()?; - let service = self.hb_service.blocking_read(); - service - .update_task( - &app_data_dir, - index, - description, - priority, - timeout_secs, - once, - model, - ) - .map_err(|e| HeartbeatToolError::ExecutionFailed(format!("更新任务失败: {}", e))) - } - - fn get_history( - &self, - limit: usize, - ) -> Result, HeartbeatToolError> { - let service = self.hb_service.blocking_read(); - let records = service - .get_execution_history(limit) - .map_err(|e| HeartbeatToolError::ExecutionFailed(format!("获取历史失败: {}", e)))?; - Ok(records.iter().map(Self::convert_execution).collect()) - } - - fn get_execution_detail( - &self, - id: i64, - ) -> Result, HeartbeatToolError> { - let service = self.hb_service.blocking_read(); - let record = service - .get_execution_detail(id) - .map_err(|e| HeartbeatToolError::ExecutionFailed(format!("获取详情失败: {}", e)))?; - Ok(record.map(|r| Self::convert_execution(&r))) - } - - async fn trigger_now(&self) -> Result { - let app_data_dir = self.app_data_dir()?; - let result = { - let service = self.hb_service.read().await; - service - .trigger_now(app_data_dir, Some(self.app_handle.clone())) - .await - }; - - // 更新状态 - { - let mut service = self.hb_service.write().await; - service.update_status_after_cycle(&CycleResult { - task_count: result.task_count, - success_count: result.success_count, - failed_count: result.failed_count, - timeout_count: result.timeout_count, - }); - } - - Ok(HeartbeatCycleResult { - task_count: result.task_count, - success_count: result.success_count, - failed_count: result.failed_count, - timeout_count: result.timeout_count, - }) - } -} diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index 0a49e9426..ed88e5e53 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -6,7 +6,6 @@ pub mod aster_agent; pub mod aster_state; pub mod credential_bridge; -pub mod heartbeat_service_adapter; pub mod subagent_scheduler; // 从 proxycast-agent crate re-export @@ -23,7 +22,6 @@ pub use aster_state::AsterAgentState; pub use credential_bridge::{ create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError, }; -pub use heartbeat_service_adapter::HeartbeatServiceAdapter; pub use proxycast_agent::{ convert_agent_event, convert_to_tauri_message, QueueInsertResult, QueuedTurnSnapshot, QueuedTurnTask, SessionTurnQueueManager, TauriAgentEvent, diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 53374b559..d21eb0958 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -28,7 +28,7 @@ use crate::database::{self, DbConnection}; use crate::logger; use crate::mcp::McpManagerState; use crate::plugin; -use crate::services::heartbeat_service::{HeartbeatService, HeartbeatServiceState}; +use crate::services::automation_service::{AutomationService, AutomationServiceState}; use crate::skills::ensure_default_local_skills; use crate::telemetry; use crate::voice::recording_service::{create_recording_service_state, RecordingServiceState}; @@ -78,7 +78,7 @@ pub struct AppStates { pub tool_hooks_service: ToolHooksServiceState, pub recording_service: RecordingServiceState, pub mcp_manager: McpManagerState, - pub heartbeat_service: HeartbeatServiceState, + pub automation_service: AutomationServiceState, pub workflow_service: Arc>, pub progress_store: Arc>, // 用于 setup hook 的共享实例 @@ -127,11 +127,6 @@ pub fn init_states(config: &Config) -> Result { } } - // 初始化批量任务表 - if let Err(e) = proxycast_scheduler::BatchTaskDao::init_tables(&db) { - tracing::warn!("[Bootstrap] 批量任务表初始化失败: {}", e); - } - // 服务状态 let skill_service = SkillService::new().map_err(|e| format!("SkillService 初始化失败: {e}"))?; let skill_service_state = SkillServiceState(Arc::new(skill_service)); @@ -284,10 +279,11 @@ pub fn init_states(config: &Config) -> Result { let mcp_manager = crate::mcp::McpClientManager::new(None); let mcp_manager_state: McpManagerState = Arc::new(tokio::sync::Mutex::new(mcp_manager)); - // 初始化心跳引擎服务 - let mut heartbeat_service = HeartbeatService::new(config.heartbeat.clone()); - heartbeat_service.set_db(db.clone()); - let heartbeat_service_state = HeartbeatServiceState(Arc::new(RwLock::new(heartbeat_service))); + // 初始化自动化调度服务 + let mut automation_service = AutomationService::new(config.automation.clone()); + automation_service.set_db(db.clone()); + let automation_service_state = + AutomationServiceState(Arc::new(RwLock::new(automation_service))); // 初始化工作流服务 let workflow_service = proxycast_services::content_creator::WorkflowService::new(); @@ -328,7 +324,7 @@ pub fn init_states(config: &Config) -> Result { tool_hooks_service: tool_hooks_service_state, recording_service: recording_service_state, mcp_manager: mcp_manager_state, - heartbeat_service: heartbeat_service_state, + automation_service: automation_service_state, workflow_service: workflow_service_state, progress_store: progress_store_state, shared_stats, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 04381a6a6..f8b037461 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -88,7 +88,7 @@ pub fn run() { tool_hooks_service, recording_service, mcp_manager: mcp_manager_state, - heartbeat_service: heartbeat_service_state, + automation_service: automation_service_state, workflow_service, progress_store, shared_stats, @@ -178,7 +178,7 @@ pub fn run() { .manage(tool_hooks_service) .manage(recording_service) .manage(mcp_manager_state) - .manage(heartbeat_service_state) + .manage(automation_service_state) .manage(workflow_service) .manage(progress_store) .manage(proxycast_gateway::telegram::TelegramGatewayState::default()) @@ -303,8 +303,8 @@ pub fn run() { let log_state = app.try_state::(); let config_manager = app.try_state::(); let mcp_manager = app.try_state::(); - let heartbeat_state = - app.try_state::(); + let automation_state = + app.try_state::(); match ( aster_agent_state, @@ -313,7 +313,7 @@ pub fn run() { log_state, config_manager, mcp_manager, - heartbeat_state, + automation_state, ) { ( Some(aster_agent_state), @@ -322,7 +322,7 @@ pub fn run() { Some(log_state), Some(config_manager), Some(mcp_manager), - Some(heartbeat_state), + Some(automation_state), ) => Some(( app.handle().clone(), aster_agent_state.inner().clone(), @@ -335,7 +335,7 @@ pub fn run() { config_manager.0.clone(), ), mcp_manager.inner().clone(), - heartbeat_state.inner().clone(), + automation_state.inner().clone(), )), _ => None, } @@ -349,7 +349,7 @@ pub fn run() { logs, config_manager, mcp_manager, - heartbeat_state, + automation_state, )) = startup_runtime_resume { match crate::commands::aster_agent_cmd::resume_persisted_runtime_queues_on_startup( @@ -360,7 +360,7 @@ pub fn run() { &logs, &config_manager, &mcp_manager, - &heartbeat_state, + &automation_state, ) { Ok(resumed) if resumed > 0 => { tracing::info!("[启动] 已恢复 {} 个会话的排队执行", resumed); @@ -816,57 +816,48 @@ pub fn run() { } }); - // 初始化心跳引擎(设置 AppHandle 并根据配置自动启动) + // 初始化自动化调度服务(设置 AppHandle 并根据配置自动启动) { let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { - // 组件监督器:心跳引擎启动失败时自动重试 + // 组件监督器:自动化调度服务启动失败时自动重试 let max_retries = 3; let mut retry_count = 0; let mut retry_delay = tokio::time::Duration::from_secs(5); loop { if retry_count >= max_retries { - tracing::error!("[启动] 心跳引擎启动重试次数已达上限,放弃启动"); + tracing::error!("[启动] 自动化调度服务启动重试次数已达上限,放弃启动"); break; } tokio::time::sleep(retry_delay).await; - if let Some(hb_state) = app_handle - .try_state::() + if let Some(automation_state) = app_handle + .try_state::() { - let mut service = hb_state.0.write().await; + let mut service = automation_state.0.write().await; service.set_app_handle(app_handle.clone()); if service.get_config().enabled { - let app_data_dir = match proxycast_core::app_paths::preferred_data_dir() { - Ok(dir) => dir, - Err(e) => { - tracing::error!("[启动] 无法获取应用数据目录: {}", e); - retry_count += 1; - retry_delay = retry_delay.saturating_mul(2); // 指数退避 - continue; - } - }; - let self_ref = hb_state.0.clone(); - match service.start(app_data_dir, self_ref).await { + let self_ref = automation_state.0.clone(); + match service.start(self_ref).await { Ok(()) => { - tracing::info!("[启动] 心跳引擎已自动启动(尝试 {}/{})", retry_count + 1, max_retries); + tracing::info!("[启动] 自动化调度服务已自动启动(尝试 {}/{})", retry_count + 1, max_retries); break; } Err(e) => { - tracing::warn!("[启动] 心跳引擎启动失败(尝试 {}/{}): {}", retry_count + 1, max_retries, e); + tracing::warn!("[启动] 自动化调度服务启动失败(尝试 {}/{}): {}", retry_count + 1, max_retries, e); retry_count += 1; retry_delay = retry_delay.saturating_mul(2); } } } else { - tracing::info!("[启动] 心跳引擎已禁用,跳过启动"); + tracing::info!("[启动] 自动化调度服务已禁用,跳过启动"); break; } } else { - tracing::error!("[启动] 无法获取 HeartbeatServiceState"); + tracing::error!("[启动] 无法获取 AutomationServiceState"); break; } } @@ -1076,7 +1067,9 @@ pub fn run() { commands::openclaw_cmd::openclaw_get_progress_logs, commands::openclaw_cmd::openclaw_install, commands::openclaw_cmd::openclaw_install_dependency, + commands::openclaw_cmd::openclaw_check_update, commands::openclaw_cmd::openclaw_uninstall, + commands::openclaw_cmd::openclaw_perform_update, commands::openclaw_cmd::openclaw_cleanup_temp_artifacts, commands::openclaw_cmd::openclaw_start_gateway, commands::openclaw_cmd::openclaw_stop_gateway, @@ -1218,6 +1211,7 @@ pub fn run() { commands::provider_pool_cmd::cancel_kiro_playwright_login, commands::browser_runtime_cmd::open_browser_runtime_debugger_window, commands::browser_runtime_cmd::close_browser_runtime_debugger_window, + commands::browser_runtime_cmd::launch_browser_session, commands::browser_runtime_cmd::launch_browser_runtime_assist, // API Key Provider commands commands::api_key_provider_cmd::get_system_provider_catalog, @@ -1472,6 +1466,17 @@ pub fn run() { commands::connection_cmd::connection_save_raw_config, commands::connection_cmd::connection_test, commands::connection_cmd::connection_import_ssh_host, + // Browser environment preset commands + commands::browser_environment_cmd::list_browser_environment_presets_cmd, + commands::browser_environment_cmd::save_browser_environment_preset_cmd, + commands::browser_environment_cmd::archive_browser_environment_preset_cmd, + commands::browser_environment_cmd::restore_browser_environment_preset_cmd, + // Browser profile commands + commands::browser_profile_cmd::list_browser_profiles_cmd, + commands::browser_profile_cmd::save_browser_profile_cmd, + commands::browser_profile_cmd::archive_browser_profile_cmd, + commands::browser_profile_cmd::restore_browser_profile_cmd, + commands::browser_profile_cmd::launch_browser_profile_runtime_assist_cmd, // Sysinfo commands crate::services::sysinfo_service::get_sysinfo, crate::services::sysinfo_service::subscribe_sysinfo, @@ -1775,24 +1780,20 @@ pub fn run() { crate::voice::commands::cancel_recording, crate::voice::commands::get_recording_status, crate::voice::commands::list_audio_devices, - // Heartbeat Engine commands - commands::heartbeat_cmd::get_heartbeat_config, - commands::heartbeat_cmd::update_heartbeat_config, - commands::heartbeat_cmd::get_heartbeat_status, - commands::heartbeat_cmd::get_heartbeat_tasks, - commands::heartbeat_cmd::add_heartbeat_task, - commands::heartbeat_cmd::delete_heartbeat_task, - commands::heartbeat_cmd::update_heartbeat_task, - commands::heartbeat_cmd::get_heartbeat_history, - commands::heartbeat_cmd::get_heartbeat_execution_detail, - commands::heartbeat_cmd::get_heartbeat_task_health, - commands::heartbeat_cmd::deliver_heartbeat_task_health_alerts, - commands::heartbeat_cmd::get_task_templates, - commands::heartbeat_cmd::apply_task_template, - commands::heartbeat_cmd::generate_content_creator_tasks, - commands::heartbeat_cmd::trigger_heartbeat_now, - commands::heartbeat_cmd::preview_heartbeat_schedule, - commands::heartbeat_cmd::validate_heartbeat_schedule, + // Automation commands + commands::automation_cmd::get_automation_scheduler_config, + commands::automation_cmd::update_automation_scheduler_config, + commands::automation_cmd::get_automation_status, + commands::automation_cmd::get_automation_jobs, + commands::automation_cmd::get_automation_job, + commands::automation_cmd::create_automation_job, + commands::automation_cmd::update_automation_job, + commands::automation_cmd::delete_automation_job, + commands::automation_cmd::run_automation_job_now, + commands::automation_cmd::get_automation_health, + commands::automation_cmd::get_automation_run_history, + commands::automation_cmd::preview_automation_schedule, + commands::automation_cmd::validate_automation_schedule, // Telegram 远程触发命令 commands::gateway_channel_cmd::gateway_channel_start, commands::gateway_channel_cmd::gateway_channel_stop, diff --git a/src-tauri/src/commands/api_key_provider_cmd.rs b/src-tauri/src/commands/api_key_provider_cmd.rs index 88fedd00c..a8dc0afab 100644 --- a/src-tauri/src/commands/api_key_provider_cmd.rs +++ b/src-tauri/src/commands/api_key_provider_cmd.rs @@ -41,7 +41,7 @@ pub struct AddCustomProviderRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateProviderRequest { pub name: Option, - /// Provider 类型(仅自定义 Provider 可修改) + /// Provider 类型(系统/自定义 Provider 均可修改) #[serde(rename = "type")] pub provider_type: Option, pub api_host: Option, diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index 9793a12a8..a8dd9a546 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -6,9 +6,8 @@ use crate::agent::aster_state::{ProviderConfig, SessionConfigBuilder}; use crate::agent::{ - AsterAgentState, AsterAgentWrapper, HeartbeatServiceAdapter, ProxyCastScheduler, - QueueInsertResult, QueuedTurnSnapshot, QueuedTurnTask, SessionDetail, SessionInfo, - SubAgentRole, TauriAgentEvent, + AsterAgentState, AsterAgentWrapper, ProxyCastScheduler, QueueInsertResult, QueuedTurnSnapshot, + QueuedTurnTask, SessionDetail, SessionInfo, SubAgentRole, TauriAgentEvent, }; use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::commands::webview_cmd::{ @@ -25,8 +24,8 @@ use crate::mcp::{McpManagerState, McpServerConfig}; use crate::services::agent_timeline_service::{ build_action_response_value, complete_action_item, AgentTimelineRecorder, }; +use crate::services::automation_service::AutomationServiceState; use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource}; -use crate::services::heartbeat_service::HeartbeatServiceState; use crate::services::memory_profile_prompt_service::{ merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources, }; @@ -103,6 +102,7 @@ const PROXYCAST_CREATE_IMAGE_TASK_TOOL_NAME: &str = "proxycast_create_image_gene const PROXYCAST_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "proxycast_create_url_parse_task"; const PROXYCAST_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "proxycast_create_typesetting_task"; const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】"; +const ELICITATION_CONTEXT_PROMPT_MARKER: &str = "【已收集的补充信息】"; const PROXYCAST_TOOL_METADATA_BEGIN: &str = "[ProxyCast 工具元数据开始]"; const PROXYCAST_TOOL_METADATA_END: &str = "[ProxyCast 工具元数据结束]"; const BROWSER_ASSIST_ALLOW_PATTERN: &str = "mcp__proxycast-browser__*"; @@ -558,6 +558,8 @@ pub struct AgentRuntimeRespondActionRequest { pub response: Option, #[serde(default, alias = "userData")] pub user_data: Option, + #[serde(default)] + pub metadata: Option, } #[derive(Debug, Deserialize)] @@ -674,6 +676,123 @@ fn merge_system_prompt_with_auto_continue( } } +fn build_elicitation_context_system_prompt( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let metadata = request_metadata?.as_object()?; + let context = metadata.get("elicitation_context")?.as_object()?; + let entries = context.get("entries")?.as_array()?; + + let rendered_entries = entries + .iter() + .filter_map(|entry| { + let entry_object = entry.as_object()?; + let label = entry_object + .get("label") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let summary = entry_object + .get("summary") + .or_else(|| entry_object.get("value")) + .and_then(render_elicitation_context_value)?; + Some(format!("- {label}: {summary}")) + }) + .collect::>(); + + if rendered_entries.is_empty() { + return None; + } + + let source = context + .get("source") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("structured_form"); + let mode = context + .get("mode") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("runtime_metadata"); + + Some(format!( + "{ELICITATION_CONTEXT_PROMPT_MARKER}\n\ +来源:{source}\n\ +模式:{mode}\n\ +执行要求:\n\ +1. 下列信息来自用户刚刚提交的结构化补充信息,视为本轮已确认约束。\n\ +2. 回答与后续执行时优先吸收这些信息,不要重复追问同一字段。\n\ +3. 若仍缺关键信息,只追问尚未填写的最少字段。\n\ +已确认信息:\n\ +{}", + rendered_entries.join("\n") + )) +} + +fn render_elicitation_context_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => { + let normalized = text.trim(); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } + } + serde_json::Value::Number(number) => Some(number.to_string()), + serde_json::Value::Bool(boolean) => Some(if *boolean { + "是".to_string() + } else { + "否".to_string() + }), + serde_json::Value::Array(items) => { + let rendered = items + .iter() + .filter_map(render_elicitation_context_value) + .collect::>(); + if rendered.is_empty() { + None + } else { + Some(rendered.join("、")) + } + } + serde_json::Value::Object(object) => { + let rendered = serde_json::to_string(object).ok()?; + let normalized = rendered.trim(); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } + } + serde_json::Value::Null => None, + } +} + +fn merge_system_prompt_with_elicitation_context( + base_prompt: Option, + request_metadata: Option<&serde_json::Value>, +) -> Option { + let Some(elicitation_prompt) = build_elicitation_context_system_prompt(request_metadata) else { + return base_prompt; + }; + + match base_prompt { + Some(base) => { + if base.contains(ELICITATION_CONTEXT_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(elicitation_prompt) + } else { + Some(format!("{base}\n\n{elicitation_prompt}")) + } + } + None => Some(elicitation_prompt), + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] struct SocialRunArtifactDescriptor { artifact_id: String, @@ -976,6 +1095,42 @@ fn is_browser_assist_enabled(request_metadata: Option<&serde_json::Value>) -> bo extract_browser_assist_runtime_hint(request_metadata).is_some() || !browser_assist.is_empty() } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BrowserTaskRequirement { + Optional, + Required, + RequiredWithUserStep, +} + +fn extract_browser_task_requirement( + request_metadata: Option<&serde_json::Value>, +) -> Option { + match extract_harness_string( + request_metadata, + &["browser_requirement", "browserRequirement"], + ) + .as_deref() + { + Some("optional") => Some(BrowserTaskRequirement::Optional), + Some("required") => Some(BrowserTaskRequirement::Required), + Some("required_with_user_step") => Some(BrowserTaskRequirement::RequiredWithUserStep), + _ => None, + } +} + +fn apply_browser_requirement_to_request_tool_policy( + request_metadata: Option<&serde_json::Value>, + request_web_search: Option, + request_search_mode: Option, +) -> (Option, Option) { + match extract_browser_task_requirement(request_metadata) { + Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep) => { + (Some(false), Some(RequestToolPolicyMode::Disabled)) + } + _ => (request_web_search, request_search_mode), + } +} + fn build_session_scoped_permission_conditions(session_id: &str) -> Vec { let session_id = session_id.trim(); if session_id.is_empty() { @@ -1233,6 +1388,7 @@ fn build_turn_runtime_statuses( let reasoning_supported = model_supports_reasoning(model_name); let news_expansion_needed = request_tool_policy.allows_web_search() && message_suggests_news_expansion(&request.message); + let browser_task_requirement = extract_browser_task_requirement(request.metadata.as_ref()); let initial_checkpoints = vec![ execution_strategy_label(effective_strategy).to_string(), @@ -1245,6 +1401,14 @@ fn build_turn_runtime_statuses( } else { "默认直接回答优先".to_string() }, + if matches!( + browser_task_requirement, + Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep) + ) { + "当前任务要求真实浏览器执行,不允许退化为联网检索".to_string() + } else { + "浏览器能力按需升级".to_string() + }, if thinking_enabled && reasoning_supported { "模型支持深度思考,先进入推理判定".to_string() } else if thinking_enabled { @@ -1397,6 +1561,12 @@ fn extend_map_with_harness_fields( ("runTitle", "run_title"), ("content_id", "content_id"), ("contentId", "content_id"), + ("browser_requirement", "browser_requirement"), + ("browserRequirement", "browser_requirement"), + ("browser_requirement_reason", "browser_requirement_reason"), + ("browserRequirementReason", "browser_requirement_reason"), + ("browser_launch_url", "browser_launch_url"), + ("browserLaunchUrl", "browser_launch_url"), ] { if target.contains_key(target_key) { continue; @@ -2994,6 +3164,47 @@ impl ProxycastBrowserMcpTool { parse_browser_backend_hint(&raw) } + fn supports_cdp_direct_action(action_name: &str) -> bool { + matches!( + action_name.trim().to_ascii_lowercase().as_str(), + "tabs_context_mcp" + | "tabs_create_mcp" + | "navigate" + | "click" + | "type" + | "form_input" + | "scroll" + | "scroll_page" + | "refresh_page" + | "go_back" + | "go_forward" + | "get_page_info" + | "read_page" + | "get_page_text" + | "read_console_messages" + | "read_network_requests" + ) + } + + fn resolve_backend( + action_name: &str, + params: &serde_json::Value, + session_hint: Option<&BrowserAssistRuntimeHint>, + ) -> Option { + if let Some(explicit_backend) = Self::parse_backend(params) { + return Some(explicit_backend); + } + + match session_hint.and_then(|hint| hint.preferred_backend.clone()) { + Some(BrowserBackendType::CdpDirect) + if !Self::supports_cdp_direct_action(action_name) => + { + None + } + other => other, + } + } + fn extract_profile_key(params: &serde_json::Value, context: &ToolContext) -> Option { if let Some(value) = params.get("profile_key").and_then(|v| v.as_str()) { let trimmed = value.trim(); @@ -3054,11 +3265,7 @@ impl Tool for ProxycastBrowserMcpTool { _context: &ToolContext, ) -> Result { let session_hint = get_browser_assist_runtime_hint(&_context.session_id).await; - let backend = Self::parse_backend(¶ms).or_else(|| { - session_hint - .as_ref() - .and_then(|hint| hint.preferred_backend.clone()) - }); + let backend = Self::resolve_backend(&self.action_name, ¶ms, session_hint.as_ref()); let profile_key = Self::extract_profile_key(¶ms, _context) .or_else(|| session_hint.as_ref().map(|hint| hint.profile_key.clone())); if let (Some(hint), Some(profile_key)) = (session_hint.as_ref(), profile_key.as_ref()) { @@ -4560,7 +4767,7 @@ async fn apply_workspace_sandbox_permissions( config_manager: &GlobalConfigManagerState, db: &DbConnection, api_key_provider_service: &ApiKeyProviderServiceState, - heartbeat_state: &HeartbeatServiceState, + _automation_state: &AutomationServiceState, app_handle: &AppHandle, session_id: &str, request_metadata: Option<&serde_json::Value>, @@ -5017,7 +5224,6 @@ async fn apply_workspace_sandbox_permissions( "ask", "tool_search", "three_stage_workflow", - "heartbeat", SOCIAL_IMAGE_TOOL_NAME, PROXYCAST_CREATE_VIDEO_TASK_TOOL_NAME, PROXYCAST_CREATE_BROADCAST_TASK_TOOL_NAME, @@ -5106,12 +5312,6 @@ async fn apply_workspace_sandbox_permissions( registry.register(Box::new(workspace_bash_tool)); } - // 注册心跳工具 - let heartbeat_adapter = - HeartbeatServiceAdapter::new(heartbeat_state.clone(), app_handle.clone()); - let heartbeat_tool = proxycast_agent::tools::HeartbeatTool::new(Arc::new(heartbeat_adapter)); - registry.register(Box::new(heartbeat_tool)); - register_social_image_tool_to_registry(&mut registry, config_manager.0.clone()); register_creation_task_tools_to_registry( &mut registry, @@ -5145,7 +5345,7 @@ async fn execute_aster_chat_request( logs: &LogState, config_manager: &GlobalConfigManagerState, mcp_manager: &McpManagerState, - heartbeat_state: &HeartbeatServiceState, + automation_state: &AutomationServiceState, request: AsterChatRequest, ) -> Result<(), String> { tracing::info!( @@ -5298,21 +5498,29 @@ async fn execute_aster_chat_request( let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode); + let (request_web_search, request_search_mode) = + apply_browser_requirement_to_request_tool_policy( + request.metadata.as_ref(), + request.web_search, + request.search_mode, + ); // 构建请求级工具策略: // - web_search=true 默认只表示“允许搜索” // - 仅显式 search_mode=required 时才强制预搜索 let request_tool_policy = resolve_request_tool_policy_with_mode( - request.web_search, - request.search_mode, + request_web_search, + request_search_mode, mode_default_web_search, ); tracing::info!( - "[AsterAgent][WebSearchGuard] session={}, chat_mode={:?}, request_web_search={:?}, request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}", + "[AsterAgent][WebSearchGuard] session={}, chat_mode={:?}, request_web_search={:?}, request_search_mode={:?}, effective_request_web_search={:?}, effective_request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}", session_id, runtime_chat_mode, request.web_search, request.search_mode, + request_web_search, + request_search_mode, mode_default_web_search, request_tool_policy.effective_web_search, request_tool_policy.search_mode.as_str() @@ -5398,9 +5606,12 @@ async fn execute_aster_chat_request( None, ); let merged_prompt = merge_system_prompt_with_auto_continue( - merge_system_prompt_with_request_tool_policy( - merge_system_prompt_with_web_search(prompt_with_memory, &runtime_config), - &request_tool_policy, + merge_system_prompt_with_elicitation_context( + merge_system_prompt_with_request_tool_policy( + merge_system_prompt_with_web_search(prompt_with_memory, &runtime_config), + &request_tool_policy, + ), + request.metadata.as_ref(), ), auto_continue_config.as_ref(), ); @@ -5482,7 +5693,7 @@ async fn execute_aster_chat_request( config_manager, db, api_key_provider_service, - heartbeat_state, + automation_state, app, session_id, request.metadata.as_ref(), @@ -5837,7 +6048,7 @@ pub async fn aster_agent_chat_stream( logs: State<'_, LogState>, config_manager: State<'_, GlobalConfigManagerState>, mcp_manager: State<'_, McpManagerState>, - heartbeat_state: State<'_, HeartbeatServiceState>, + automation_state: State<'_, AutomationServiceState>, request: AsterChatRequest, ) -> Result<(), String> { execute_aster_chat_request( @@ -5848,7 +6059,7 @@ pub async fn aster_agent_chat_stream( logs.inner(), config_manager.inner(), mcp_manager.inner(), - heartbeat_state.inner(), + automation_state.inner(), request, ) .await @@ -5862,7 +6073,7 @@ struct AgentRuntimeExecutionContext { logs: LogState, config_manager: GlobalConfigManagerState, mcp_manager: McpManagerState, - heartbeat_state: HeartbeatServiceState, + automation_state: AutomationServiceState, } impl AgentRuntimeExecutionContext { @@ -5874,7 +6085,7 @@ impl AgentRuntimeExecutionContext { logs: &LogState, config_manager: &GlobalConfigManagerState, mcp_manager: &McpManagerState, - heartbeat_state: &HeartbeatServiceState, + automation_state: &AutomationServiceState, ) -> Self { Self { app, @@ -5886,7 +6097,7 @@ impl AgentRuntimeExecutionContext { logs: logs.clone(), config_manager: GlobalConfigManagerState(config_manager.0.clone()), mcp_manager: mcp_manager.clone(), - heartbeat_state: heartbeat_state.clone(), + automation_state: automation_state.clone(), } } } @@ -5903,7 +6114,7 @@ impl Clone for AgentRuntimeExecutionContext { logs: self.logs.clone(), config_manager: GlobalConfigManagerState(self.config_manager.0.clone()), mcp_manager: self.mcp_manager.clone(), - heartbeat_state: self.heartbeat_state.clone(), + automation_state: self.automation_state.clone(), } } } @@ -6137,7 +6348,7 @@ async fn execute_runtime_turn_and_continue_queue( &context.logs, &context.config_manager, &context.mcp_manager, - &context.heartbeat_state, + &context.automation_state, request, ) .await; @@ -6210,7 +6421,7 @@ pub fn resume_persisted_runtime_queues_on_startup( logs: &LogState, config_manager: &GlobalConfigManagerState, mcp_manager: &McpManagerState, - heartbeat_state: &HeartbeatServiceState, + automation_state: &AutomationServiceState, ) -> Result { let session_ids = list_persisted_runtime_queue_session_ids(db)?; if session_ids.is_empty() { @@ -6227,7 +6438,7 @@ pub fn resume_persisted_runtime_queues_on_startup( logs, config_manager, mcp_manager, - heartbeat_state, + automation_state, ); if resume_runtime_queue_if_needed(context, session_id.clone())? { resumed += 1; @@ -6261,7 +6472,7 @@ pub async fn agent_runtime_submit_turn( logs: State<'_, LogState>, config_manager: State<'_, GlobalConfigManagerState>, mcp_manager: State<'_, McpManagerState>, - heartbeat_state: State<'_, HeartbeatServiceState>, + automation_state: State<'_, AutomationServiceState>, request: AgentRuntimeSubmitTurnRequest, ) -> Result<(), String> { let runtime_request: AsterChatRequest = request.into(); @@ -6277,7 +6488,7 @@ pub async fn agent_runtime_submit_turn( logs.inner(), config_manager.inner(), mcp_manager.inner(), - heartbeat_state.inner(), + automation_state.inner(), ); let _ = resume_runtime_queue_if_needed(context.clone(), session_id.clone())?; @@ -6444,7 +6655,7 @@ pub async fn agent_runtime_get_session( logs: State<'_, LogState>, config_manager: State<'_, GlobalConfigManagerState>, mcp_manager: State<'_, McpManagerState>, - heartbeat_state: State<'_, HeartbeatServiceState>, + automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { ensure_runtime_queue_loaded(state.inner(), db.inner(), &session_id)?; @@ -6459,7 +6670,7 @@ pub async fn agent_runtime_get_session( logs.inner(), config_manager.inner(), mcp_manager.inner(), - heartbeat_state.inner(), + automation_state.inner(), ); if let Err(error) = resume_runtime_queue_if_needed(context, session_id.clone()) { tracing::warn!( @@ -6622,6 +6833,8 @@ pub async fn aster_agent_confirm( pub struct SubmitElicitationResponseRequest { pub request_id: String, pub user_data: serde_json::Value, + #[serde(default)] + pub metadata: Option, } fn validate_elicitation_submission(session_id: &str, request_id: &str) -> Result { @@ -6688,6 +6901,7 @@ pub async fn agent_runtime_respond_action( SubmitElicitationResponseRequest { request_id: request.request_id.clone(), user_data, + metadata: request.metadata.clone(), }, ) .await @@ -6722,9 +6936,14 @@ pub async fn aster_agent_submit_elicitation_response( request.user_data, )); - let session_config = SessionConfigBuilder::new(&session_id) - .include_context_trace(true) - .build(); + let mut session_config_builder = + SessionConfigBuilder::new(&session_id).include_context_trace(true); + if let Some(prompt) = + merge_system_prompt_with_elicitation_context(None, request.metadata.as_ref()) + { + session_config_builder = session_config_builder.system_prompt(prompt); + } + let session_config = session_config_builder.build(); let agent_arc = state.get_agent_arc(); let guard = agent_arc.read().await; @@ -7021,6 +7240,42 @@ mod tests { )); } + #[test] + fn test_browser_required_task_disables_web_search_policy() { + let metadata = serde_json::json!({ + "harness": { + "browser_requirement": "required_with_user_step" + } + }); + + assert_eq!( + apply_browser_requirement_to_request_tool_policy( + Some(&metadata), + Some(true), + Some(RequestToolPolicyMode::Allowed), + ), + (Some(false), Some(RequestToolPolicyMode::Disabled)) + ); + } + + #[test] + fn test_optional_browser_task_keeps_original_web_search_policy() { + let metadata = serde_json::json!({ + "harness": { + "browser_requirement": "optional" + } + }); + + assert_eq!( + apply_browser_requirement_to_request_tool_policy( + Some(&metadata), + Some(true), + Some(RequestToolPolicyMode::Allowed), + ), + (Some(true), Some(RequestToolPolicyMode::Allowed)) + ); + } + #[test] fn test_should_enable_model_skill_tool_defaults_to_false() { let metadata = serde_json::json!({ @@ -7084,6 +7339,68 @@ mod tests { ); } + #[test] + fn test_resolve_browser_backend_keeps_explicit_backend() { + let params = serde_json::json!({ + "backend": "cdp_direct" + }); + let session_hint = BrowserAssistRuntimeHint { + profile_key: "general_browser_assist".to_string(), + preferred_backend: Some(BrowserBackendType::AsterCompat), + auto_launch: true, + launch_url: None, + }; + + assert_eq!( + ProxycastBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)), + Some(BrowserBackendType::CdpDirect) + ); + } + + #[test] + fn test_resolve_browser_backend_does_not_force_cdp_for_unsupported_action() { + let params = serde_json::json!({}); + let session_hint = BrowserAssistRuntimeHint { + profile_key: "general_browser_assist".to_string(), + preferred_backend: Some(BrowserBackendType::CdpDirect), + auto_launch: true, + launch_url: None, + }; + + assert_eq!( + ProxycastBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)), + None + ); + assert_eq!( + ProxycastBrowserMcpTool::resolve_backend( + "javascript_tool", + ¶ms, + Some(&session_hint) + ), + None + ); + } + + #[test] + fn test_resolve_browser_backend_keeps_cdp_for_supported_action() { + let params = serde_json::json!({}); + let session_hint = BrowserAssistRuntimeHint { + profile_key: "general_browser_assist".to_string(), + preferred_backend: Some(BrowserBackendType::CdpDirect), + auto_launch: true, + launch_url: None, + }; + + assert_eq!( + ProxycastBrowserMcpTool::resolve_backend("navigate", ¶ms, Some(&session_hint)), + Some(BrowserBackendType::CdpDirect) + ); + assert_eq!( + ProxycastBrowserMcpTool::resolve_backend("read_page", ¶ms, Some(&session_hint)), + Some(BrowserBackendType::CdpDirect) + ); + } + #[test] fn test_is_browser_assist_enabled_respects_explicit_flag() { let disabled_metadata = serde_json::json!({ @@ -7211,6 +7528,7 @@ mod tests { confirmed: true, response: Some("{\"answer\":\"A\"}".to_string()), user_data: Some(serde_json::json!({ "answer": "B" })), + metadata: None, }; assert_eq!( @@ -7228,6 +7546,7 @@ mod tests { confirmed: true, response: Some("{\"answer\":\"A\"}".to_string()), user_data: None, + metadata: None, }; assert_eq!( @@ -7451,6 +7770,89 @@ mod tests { assert_eq!(merged, base); } + #[test] + fn test_merge_system_prompt_with_elicitation_context_appends_prompt() { + let metadata = serde_json::json!({ + "elicitation_context": { + "source": "legacy_questionnaire", + "mode": "compatibility_bridge", + "entries": [ + { + "label": "目标受众", + "summary": "客户" + }, + { + "label": "语气偏好", + "summary": "友好专业" + } + ] + } + }); + + let merged = merge_system_prompt_with_elicitation_context( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!(merged.contains(ELICITATION_CONTEXT_PROMPT_MARKER)); + assert!(merged.contains("目标受众")); + assert!(merged.contains("友好专业")); + assert!(merged.contains("legacy_questionnaire")); + } + + #[test] + fn test_merge_system_prompt_with_elicitation_context_skips_duplicate_marker() { + let metadata = serde_json::json!({ + "elicitation_context": { + "entries": [ + { + "label": "目标受众", + "summary": "客户" + } + ] + } + }); + let base = Some(format!("{ELICITATION_CONTEXT_PROMPT_MARKER}\n已有信息")); + let merged = merge_system_prompt_with_elicitation_context(base.clone(), Some(&metadata)); + assert_eq!(merged, base); + } + + #[test] + fn test_merge_system_prompt_with_elicitation_context_formats_non_string_values() { + let metadata = serde_json::json!({ + "elicitation_context": { + "entries": [ + { + "label": "渠道偏好", + "value": ["公众号", "视频号"] + }, + { + "label": "是否需要 CTA", + "value": true + }, + { + "label": "目标轮次", + "value": 3 + } + ] + } + }); + + let merged = merge_system_prompt_with_elicitation_context( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!(merged.contains("渠道偏好")); + assert!(merged.contains("公众号、视频号")); + assert!(merged.contains("是否需要 CTA")); + assert!(merged.contains("是")); + assert!(merged.contains("目标轮次")); + assert!(merged.contains("3")); + } + #[test] fn test_should_fallback_to_react_from_code_orchestrated_when_no_event_emitted() { let error = ReplyAttemptError { diff --git a/src-tauri/src/commands/automation_cmd.rs b/src-tauri/src/commands/automation_cmd.rs new file mode 100644 index 000000000..68c790f93 --- /dev/null +++ b/src-tauri/src/commands/automation_cmd.rs @@ -0,0 +1,238 @@ +//! 自动化任务命令 + +use crate::app::AppState; +use crate::services::automation_service::health::{AutomationHealthQuery, AutomationHealthResult}; +use crate::services::automation_service::schedule::{ + preview_next_run as preview_next_run_for_schedule, validate_schedule as validate_schedule_value, +}; +use crate::services::automation_service::{ + AutomationCycleResult, AutomationJobDraft, AutomationJobRecord, AutomationJobUpdate, + AutomationPayload, AutomationServiceState, AutomationStatus, +}; +use proxycast_core::config::{AutomationExecutionMode, DeliveryConfig, TaskSchedule}; +use proxycast_core::database::dao::agent_run::AgentRun; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationSchedulerConfigResponse { + pub enabled: bool, + pub poll_interval_secs: u64, + pub enable_history: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationJobRequest { + pub name: String, + pub description: Option, + pub enabled: Option, + pub workspace_id: String, + pub execution_mode: Option, + pub schedule: TaskSchedule, + pub payload: AutomationPayload, + pub delivery: Option, + pub timeout_secs: Option, + pub max_retries: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UpdateAutomationJobRequest { + pub name: Option, + pub description: Option, + pub enabled: Option, + pub workspace_id: Option, + pub execution_mode: Option, + pub schedule: Option, + pub payload: Option, + pub delivery: Option, + pub timeout_secs: Option, + pub clear_timeout_secs: Option, + pub max_retries: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduleValidationResult { + pub valid: bool, + pub error: Option, +} + +#[tauri::command] +pub async fn get_automation_scheduler_config( + state: State<'_, AppState>, +) -> Result { + let state = state.read().await; + Ok(AutomationSchedulerConfigResponse { + enabled: state.config.automation.enabled, + poll_interval_secs: state.config.automation.poll_interval_secs, + enable_history: state.config.automation.enable_history, + }) +} + +#[tauri::command] +pub async fn update_automation_scheduler_config( + state: State<'_, AppState>, + automation_state: State<'_, AutomationServiceState>, + config: AutomationSchedulerConfigResponse, + app: AppHandle, +) -> Result<(), String> { + let was_enabled = { + let state = state.read().await; + state.config.automation.enabled + }; + + { + let mut state = state.write().await; + state.config.automation.enabled = config.enabled; + state.config.automation.poll_interval_secs = config.poll_interval_secs.max(5); + state.config.automation.enable_history = config.enable_history; + crate::config::save_config(&state.config).map_err(|e| e.to_string())?; + } + + let new_config = { + let state = state.read().await; + state.config.automation.clone() + }; + let mut service = automation_state.0.write().await; + service.update_config(new_config); + service.set_app_handle(app); + let self_ref = automation_state.0.clone(); + if config.enabled && !was_enabled { + service.start(self_ref).await?; + } else if !config.enabled && was_enabled { + service.stop().await?; + } + Ok(()) +} + +#[tauri::command] +pub async fn get_automation_status( + automation_state: State<'_, AutomationServiceState>, +) -> Result { + let service = automation_state.0.read().await; + Ok(service.get_status()) +} + +#[tauri::command] +pub async fn get_automation_jobs( + automation_state: State<'_, AutomationServiceState>, +) -> Result, String> { + let service = automation_state.0.read().await; + service.list_jobs() +} + +#[tauri::command] +pub async fn get_automation_job( + automation_state: State<'_, AutomationServiceState>, + id: String, +) -> Result, String> { + let service = automation_state.0.read().await; + service.get_job(id.trim()) +} + +#[tauri::command] +pub async fn create_automation_job( + automation_state: State<'_, AutomationServiceState>, + request: AutomationJobRequest, +) -> Result { + let service = automation_state.0.read().await; + service.create_job(AutomationJobDraft { + name: request.name, + description: request.description, + enabled: request.enabled.unwrap_or(true), + workspace_id: request.workspace_id, + execution_mode: request + .execution_mode + .unwrap_or(AutomationExecutionMode::Intelligent), + schedule: request.schedule, + payload: request.payload, + delivery: request.delivery.unwrap_or_default(), + timeout_secs: request.timeout_secs, + max_retries: request.max_retries.unwrap_or(3), + }) +} + +#[tauri::command] +pub async fn update_automation_job( + automation_state: State<'_, AutomationServiceState>, + id: String, + request: UpdateAutomationJobRequest, +) -> Result { + let service = automation_state.0.read().await; + service.update_job( + id.trim(), + AutomationJobUpdate { + name: request.name, + description: request.description, + enabled: request.enabled, + workspace_id: request.workspace_id, + execution_mode: request.execution_mode, + schedule: request.schedule, + payload: request.payload, + delivery: request.delivery, + timeout_secs: if request.clear_timeout_secs.unwrap_or(false) { + Some(None) + } else { + request.timeout_secs.map(Some) + }, + max_retries: request.max_retries, + }, + ) +} + +#[tauri::command] +pub async fn delete_automation_job( + automation_state: State<'_, AutomationServiceState>, + id: String, +) -> Result { + let service = automation_state.0.read().await; + service.delete_job(id.trim()) +} + +#[tauri::command] +pub async fn run_automation_job_now( + automation_state: State<'_, AutomationServiceState>, + id: String, +) -> Result { + let service = automation_state.0.read().await; + service.run_job_now(id.trim()).await +} + +#[tauri::command] +pub async fn get_automation_health( + automation_state: State<'_, AutomationServiceState>, + query: Option, +) -> Result { + let service = automation_state.0.read().await; + service.get_health(query) +} + +#[tauri::command] +pub async fn get_automation_run_history( + automation_state: State<'_, AutomationServiceState>, + id: String, + limit: Option, +) -> Result, String> { + let service = automation_state.0.read().await; + service.get_job_runs(id.trim(), limit.unwrap_or(20)) +} + +#[tauri::command] +pub async fn preview_automation_schedule(schedule: TaskSchedule) -> Result, String> { + preview_next_run_for_schedule(&schedule).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn validate_automation_schedule( + schedule: TaskSchedule, +) -> Result { + match validate_schedule_value(&schedule, chrono::Utc::now()) { + Ok(()) => Ok(ScheduleValidationResult { + valid: true, + error: None, + }), + Err(error) => Ok(ScheduleValidationResult { + valid: false, + error: Some(error.to_string()), + }), + } +} diff --git a/src-tauri/src/commands/browser_environment_cmd.rs b/src-tauri/src/commands/browser_environment_cmd.rs new file mode 100644 index 000000000..541f4d5fb --- /dev/null +++ b/src-tauri/src/commands/browser_environment_cmd.rs @@ -0,0 +1,120 @@ +use crate::database::{lock_db, DbConnection}; +use crate::services::browser_environment_service::{ + archive_browser_environment_preset, get_browser_environment_preset, + list_browser_environment_presets, restore_browser_environment_preset, + save_browser_environment_preset, SaveBrowserEnvironmentPresetInput, +}; +use proxycast_core::database::dao::browser_environment_preset::BrowserEnvironmentPresetRecord; +use serde::Deserialize; +use tauri::State; + +#[derive(Debug, Deserialize)] +pub struct ListBrowserEnvironmentPresetsRequest { + #[serde(default)] + pub include_archived: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SaveBrowserEnvironmentPresetRequest { + #[serde(default)] + pub id: Option, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub proxy_server: Option, + #[serde(default)] + pub timezone_id: Option, + #[serde(default)] + pub locale: Option, + #[serde(default)] + pub accept_language: Option, + #[serde(default)] + pub geolocation_lat: Option, + #[serde(default)] + pub geolocation_lng: Option, + #[serde(default)] + pub geolocation_accuracy_m: Option, + #[serde(default)] + pub user_agent: Option, + #[serde(default)] + pub platform: Option, + #[serde(default)] + pub viewport_width: Option, + #[serde(default)] + pub viewport_height: Option, + #[serde(default)] + pub device_scale_factor: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BrowserEnvironmentPresetRecordRequest { + pub id: String, +} + +#[tauri::command] +pub fn list_browser_environment_presets_cmd( + db: State<'_, DbConnection>, + request: Option, +) -> Result, String> { + let request = request.unwrap_or(ListBrowserEnvironmentPresetsRequest { + include_archived: false, + }); + let conn = lock_db(&db)?; + list_browser_environment_presets(&conn, request.include_archived) +} + +#[tauri::command] +pub fn save_browser_environment_preset_cmd( + db: State<'_, DbConnection>, + request: SaveBrowserEnvironmentPresetRequest, +) -> Result { + let conn = lock_db(&db)?; + save_browser_environment_preset( + &conn, + SaveBrowserEnvironmentPresetInput { + id: request.id, + name: request.name, + description: request.description, + proxy_server: request.proxy_server, + timezone_id: request.timezone_id, + locale: request.locale, + accept_language: request.accept_language, + geolocation_lat: request.geolocation_lat, + geolocation_lng: request.geolocation_lng, + geolocation_accuracy_m: request.geolocation_accuracy_m, + user_agent: request.user_agent, + platform: request.platform, + viewport_width: request.viewport_width, + viewport_height: request.viewport_height, + device_scale_factor: request.device_scale_factor, + }, + ) +} + +#[tauri::command] +pub fn archive_browser_environment_preset_cmd( + db: State<'_, DbConnection>, + request: BrowserEnvironmentPresetRecordRequest, +) -> Result { + let conn = lock_db(&db)?; + archive_browser_environment_preset(&conn, &request.id) +} + +#[tauri::command] +pub fn restore_browser_environment_preset_cmd( + db: State<'_, DbConnection>, + request: BrowserEnvironmentPresetRecordRequest, +) -> Result { + let conn = lock_db(&db)?; + restore_browser_environment_preset(&conn, &request.id) +} + +#[allow(dead_code)] +pub fn get_browser_environment_preset_cmd( + db: State<'_, DbConnection>, + request: BrowserEnvironmentPresetRecordRequest, +) -> Result, String> { + let conn = lock_db(&db)?; + get_browser_environment_preset(&conn, &request.id) +} diff --git a/src-tauri/src/commands/browser_profile_cmd.rs b/src-tauri/src/commands/browser_profile_cmd.rs new file mode 100644 index 000000000..2b1cdabff --- /dev/null +++ b/src-tauri/src/commands/browser_profile_cmd.rs @@ -0,0 +1,132 @@ +use crate::app::AppState; +use crate::commands::browser_runtime_cmd::{ + launch_browser_session_with_db, BrowserRuntimeAssistLaunchResponse, LaunchBrowserSessionRequest, +}; +use crate::database::{lock_db, DbConnection}; +use crate::services::browser_profile_service::{ + archive_browser_profile, list_browser_profiles, restore_browser_profile, save_browser_profile, + SaveBrowserProfileInput, +}; +use proxycast_browser_runtime::BrowserStreamMode; +use proxycast_core::database::dao::browser_profile::{ + BrowserProfileRecord, BrowserProfileTransportKind, +}; +use serde::Deserialize; +use tauri::{AppHandle, State}; + +#[derive(Debug, Deserialize)] +pub struct ListBrowserProfilesRequest { + #[serde(default)] + pub include_archived: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SaveBrowserProfileRequest { + #[serde(default)] + pub id: Option, + pub profile_key: String, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub site_scope: Option, + #[serde(default)] + pub launch_url: Option, + #[serde(default)] + pub transport_kind: BrowserProfileTransportKind, +} + +#[derive(Debug, Deserialize)] +pub struct BrowserProfileRecordRequest { + pub id: String, +} + +#[derive(Debug, Deserialize)] +pub struct LaunchBrowserProfileRuntimeAssistRequest { + pub id: String, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub environment_preset_id: Option, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub open_window: Option, + #[serde(default)] + pub stream_mode: Option, +} + +#[tauri::command] +pub fn list_browser_profiles_cmd( + db: State<'_, DbConnection>, + request: Option, +) -> Result, String> { + let request = request.unwrap_or(ListBrowserProfilesRequest { + include_archived: false, + }); + let conn = lock_db(&db)?; + list_browser_profiles(&conn, request.include_archived) +} + +#[tauri::command] +pub fn save_browser_profile_cmd( + db: State<'_, DbConnection>, + request: SaveBrowserProfileRequest, +) -> Result { + let conn = lock_db(&db)?; + save_browser_profile( + &conn, + SaveBrowserProfileInput { + id: request.id, + profile_key: request.profile_key, + name: request.name, + description: request.description, + site_scope: request.site_scope, + launch_url: request.launch_url, + transport_kind: request.transport_kind, + }, + ) +} + +#[tauri::command] +pub fn archive_browser_profile_cmd( + db: State<'_, DbConnection>, + request: BrowserProfileRecordRequest, +) -> Result { + let conn = lock_db(&db)?; + archive_browser_profile(&conn, &request.id) +} + +#[tauri::command] +pub fn restore_browser_profile_cmd( + db: State<'_, DbConnection>, + request: BrowserProfileRecordRequest, +) -> Result { + let conn = lock_db(&db)?; + restore_browser_profile(&conn, &request.id) +} + +#[tauri::command] +pub async fn launch_browser_profile_runtime_assist_cmd( + app_handle: AppHandle, + app_state: State<'_, AppState>, + db: State<'_, DbConnection>, + request: LaunchBrowserProfileRuntimeAssistRequest, +) -> Result { + launch_browser_session_with_db( + app_handle, + app_state.inner().clone(), + db.inner().clone(), + LaunchBrowserSessionRequest { + profile_id: Some(request.id), + profile_key: None, + url: request.url, + environment_preset_id: request.environment_preset_id, + environment: None, + target_id: request.target_id, + open_window: request.open_window.unwrap_or(false), + stream_mode: request.stream_mode.unwrap_or(BrowserStreamMode::Both), + }, + ) + .await +} diff --git a/src-tauri/src/commands/browser_runtime_cmd.rs b/src-tauri/src/commands/browser_runtime_cmd.rs index 7f58f80d3..6730b5b5b 100644 --- a/src-tauri/src/commands/browser_runtime_cmd.rs +++ b/src-tauri/src/commands/browser_runtime_cmd.rs @@ -2,14 +2,26 @@ use crate::app::AppState; use crate::commands::webview_cmd::{ - open_cdp_session_global, open_chrome_profile_window_global, shared_browser_runtime, - start_browser_stream_global, OpenCdpSessionRequest, OpenChromeProfileRequest, - OpenChromeProfileResponse, StartBrowserStreamRequest, + append_browser_runtime_launch_audit, open_cdp_session_global, + open_chrome_profile_window_global, shared_browser_runtime, start_browser_stream_global, + BrowserRuntimeLaunchAuditInput, ChromeProfileLaunchOptions, OpenCdpSessionRequest, + OpenChromeProfileRequest, OpenChromeProfileResponse, StartBrowserStreamRequest, +}; +use crate::database::{lock_db, DbConnection}; +use crate::services::browser_environment_service::{ + apply_browser_environment_to_session, build_browser_environment_launch_config, + get_browser_environment_preset, touch_browser_environment_preset_last_used, + BrowserEnvironmentLaunchConfig, +}; +use crate::services::browser_profile_service::{ + get_browser_profile, touch_browser_profile_last_used, }; use crate::services::browser_runtime_window; use proxycast_browser_runtime::BrowserStreamMode; use proxycast_browser_runtime::CdpSessionState; +use proxycast_core::database::dao::browser_profile::BrowserProfileTransportKind; use serde::{Deserialize, Serialize}; +use serde_json::json; use std::time::Instant; use tauri::AppHandle; use tokio::time::{sleep, Duration}; @@ -27,9 +39,17 @@ pub struct OpenBrowserRuntimeDebuggerWindowRequest { } #[derive(Debug, Deserialize)] -pub struct LaunchBrowserRuntimeAssistRequest { - pub profile_key: String, - pub url: String, +pub struct LaunchBrowserSessionRequest { + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub profile_key: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub environment_preset_id: Option, + #[serde(default)] + pub environment: Option, #[serde(default)] pub target_id: Option, #[serde(default = "default_open_window")] @@ -38,12 +58,42 @@ pub struct LaunchBrowserRuntimeAssistRequest { pub stream_mode: BrowserStreamMode, } +#[derive(Debug, Deserialize)] +pub struct LaunchBrowserRuntimeAssistRequest { + pub profile_key: String, + pub url: String, + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub target_id: Option, + #[serde(default = "default_open_window")] + pub open_window: bool, + #[serde(default = "default_stream_mode")] + pub stream_mode: BrowserStreamMode, + #[serde(default)] + pub environment: Option, +} + #[derive(Debug, Serialize)] -pub struct BrowserRuntimeAssistLaunchResponse { +pub struct BrowserSessionLaunchResponse { pub profile: OpenChromeProfileResponse, pub session: CdpSessionState, } +pub type BrowserRuntimeAssistLaunchResponse = BrowserSessionLaunchResponse; + +#[derive(Debug, Clone)] +pub struct ResolvedLaunchBrowserSessionRequest { + pub profile_id: Option, + pub profile_key: String, + pub url: String, + pub environment_preset_id: Option, + pub environment: Option, + pub target_id: Option, + pub open_window: bool, + pub stream_mode: BrowserStreamMode, +} + fn default_stream_mode() -> BrowserStreamMode { BrowserStreamMode::Both } @@ -52,6 +102,118 @@ fn default_open_window() -> bool { true } +fn default_launch_url() -> String { + "https://www.google.com/".to_string() +} + +async fn finalize_browser_runtime_launch_audit( + mut audit: BrowserRuntimeLaunchAuditInput, + error: Option, +) { + audit.success = error.is_none(); + audit.error = error; + append_browser_runtime_launch_audit(audit).await; +} + +fn resolve_launch_browser_session_request( + db: &DbConnection, + request: LaunchBrowserSessionRequest, +) -> Result { + if request.environment_preset_id.is_some() && request.environment.is_some() { + return Err( + "启动浏览器会话时不能同时指定 environment_preset_id 与 environment".to_string(), + ); + } + + let mut resolved_profile_id = None; + let mut resolved_profile_key = request + .profile_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let mut resolved_url = request + .url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + if let Some(profile_id) = request.profile_id.as_deref() { + let conn = lock_db(db)?; + let profile = get_browser_profile(&conn, profile_id)? + .filter(|profile| profile.archived_at.is_none()) + .ok_or_else(|| format!("未找到可用的浏览器资料: {profile_id}"))?; + + if let Some(ref profile_key) = resolved_profile_key { + if profile.profile_key != *profile_key { + return Err(format!( + "浏览器资料 {profile_id} 的 profile_key 与请求不一致: {} != {profile_key}", + profile.profile_key + )); + } + } + if profile.transport_kind == BrowserProfileTransportKind::ExistingSession { + return Err( + "当前资料使用“附着当前 Chrome”模式,运行时附着链路尚未接入;请先改用“托管浏览器”模式启动" + .to_string(), + ); + } + + resolved_profile_id = Some(profile.id.clone()); + resolved_profile_key = Some(profile.profile_key.clone()); + if resolved_url.is_none() { + resolved_url = profile.launch_url.clone(); + } + } + + let profile_key = resolved_profile_key + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "启动浏览器会话时必须提供 profile_id 或 profile_key".to_string())?; + + let environment = if let Some(preset_id) = request.environment_preset_id.as_deref() { + let conn = lock_db(db)?; + let preset = get_browser_environment_preset(&conn, preset_id)? + .filter(|preset| preset.archived_at.is_none()) + .ok_or_else(|| format!("未找到可用的浏览器环境预设: {preset_id}"))?; + Some(build_browser_environment_launch_config(&preset)?) + } else { + request.environment + }; + + let environment_preset_id = request.environment_preset_id.or_else(|| { + environment + .as_ref() + .and_then(|value| value.preset_id.clone()) + }); + + Ok(ResolvedLaunchBrowserSessionRequest { + profile_id: resolved_profile_id, + profile_key, + url: resolved_url.unwrap_or_else(default_launch_url), + environment_preset_id, + environment, + target_id: request.target_id, + open_window: request.open_window, + stream_mode: request.stream_mode, + }) +} + +fn touch_launched_browser_session_records( + db: &DbConnection, + profile_id: Option<&str>, + environment_preset_id: Option<&str>, +) { + if let Ok(conn) = lock_db(db) { + if let Some(profile_id) = profile_id { + let _ = touch_browser_profile_last_used(&conn, profile_id); + } + if let Some(environment_preset_id) = environment_preset_id { + let _ = touch_browser_environment_preset_last_used(&conn, environment_preset_id); + } + } +} + async fn wait_for_cdp_ready( remote_debugging_port: u16, requested_target_id: Option<&str>, @@ -115,6 +277,38 @@ pub fn close_browser_runtime_debugger_window(app_handle: AppHandle) -> Result<() .map_err(|e| format!("关闭浏览器运行时调试窗口失败: {e}")) } +#[tauri::command] +pub async fn launch_browser_session( + app_handle: AppHandle, + app_state: tauri::State<'_, AppState>, + db: tauri::State<'_, DbConnection>, + request: LaunchBrowserSessionRequest, +) -> Result { + launch_browser_session_with_db( + app_handle, + app_state.inner().clone(), + db.inner().clone(), + request, + ) + .await +} + +pub async fn launch_browser_session_with_db( + app_handle: AppHandle, + app_state: AppState, + db: DbConnection, + request: LaunchBrowserSessionRequest, +) -> Result { + let request = resolve_launch_browser_session_request(&db, request)?; + let response = launch_browser_session_global(app_handle, app_state, request.clone()).await?; + touch_launched_browser_session_records( + &db, + request.profile_id.as_deref(), + request.environment_preset_id.as_deref(), + ); + Ok(response) +} + #[tauri::command] pub async fn launch_browser_runtime_assist( app_handle: AppHandle, @@ -129,24 +323,92 @@ pub async fn launch_browser_runtime_assist_global( app_state: AppState, request: LaunchBrowserRuntimeAssistRequest, ) -> Result { + launch_browser_session_global( + app_handle, + app_state, + ResolvedLaunchBrowserSessionRequest { + profile_id: request.profile_id, + profile_key: request.profile_key, + url: request.url, + environment_preset_id: request + .environment + .as_ref() + .and_then(|environment| environment.preset_id.clone()), + environment: request.environment, + target_id: request.target_id, + open_window: request.open_window, + stream_mode: request.stream_mode, + }, + ) + .await +} + +pub async fn launch_browser_session_global( + app_handle: AppHandle, + app_state: AppState, + request: ResolvedLaunchBrowserSessionRequest, +) -> Result { + let mut launch_audit = BrowserRuntimeLaunchAuditInput { + profile_key: request.profile_key.clone(), + profile_id: request.profile_id.clone(), + environment_preset_id: request.environment_preset_id.clone(), + environment_preset_name: request + .environment + .as_ref() + .and_then(|environment| environment.preset_name.clone()), + target_id: request.target_id.clone(), + session_id: None, + url: request.url.clone(), + reused: None, + open_window: request.open_window, + stream_mode: request.stream_mode, + browser_source: None, + remote_debugging_port: None, + success: false, + error: None, + }; + let chrome_launch_options = request + .environment + .as_ref() + .map(build_chrome_launch_options) + .unwrap_or_default(); + let launch_url = request.url.clone(); + let bootstrap_url = if request.environment.is_some() { + "about:blank".to_string() + } else { + launch_url.clone() + }; let launch_started_at = Instant::now(); let profile_started_at = Instant::now(); - let profile = open_chrome_profile_window_global( + let profile = match open_chrome_profile_window_global( app_handle.clone(), app_state, OpenChromeProfileRequest { profile_key: request.profile_key.clone(), - url: request.url.clone(), + url: bootstrap_url.clone(), + launch_options: Some(chrome_launch_options), }, ) - .await?; + .await + { + Ok(profile) => profile, + Err(error) => { + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } + }; let profile_elapsed_ms = profile_started_at.elapsed().as_millis(); + launch_audit.reused = Some(profile.reused); + launch_audit.browser_source = profile.browser_source.clone(); + launch_audit.remote_debugging_port = profile.remote_debugging_port; if !profile.success { - return Err(profile + let error = profile .error .clone() - .unwrap_or_else(|| "打开浏览器 profile 失败".to_string())); + .unwrap_or_else(|| "打开浏览器 profile 失败".to_string()); + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); } info!( @@ -155,70 +417,151 @@ pub async fn launch_browser_runtime_assist_global( reused = profile.reused, remote_debugging_port = ?profile.remote_debugging_port, elapsed_ms = profile_elapsed_ms, - "browser runtime assist: profile ready" + "browser session launch: profile ready" ); - let remote_debugging_port = profile - .remote_debugging_port - .ok_or_else(|| "浏览器 profile 缺少 remote_debugging_port,无法连接 CDP".to_string())?; + let remote_debugging_port = match profile.remote_debugging_port { + Some(port) => port, + None => { + let error = "浏览器 profile 缺少 remote_debugging_port,无法连接 CDP".to_string(); + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } + }; + launch_audit.remote_debugging_port = Some(remote_debugging_port); let cdp_ready_started_at = Instant::now(); - wait_for_cdp_ready(remote_debugging_port, request.target_id.as_deref()).await?; + if let Err(error) = + wait_for_cdp_ready(remote_debugging_port, request.target_id.as_deref()).await + { + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } let cdp_ready_elapsed_ms = cdp_ready_started_at.elapsed().as_millis(); info!( profile_key = %request.profile_key, remote_debugging_port, elapsed_ms = cdp_ready_elapsed_ms, - "browser runtime assist: cdp ready" + "browser session launch: cdp ready" ); let open_session_started_at = Instant::now(); - let session = open_cdp_session_global(OpenCdpSessionRequest { + let session = match open_cdp_session_global(OpenCdpSessionRequest { profile_key: request.profile_key.clone(), target_id: request.target_id.clone(), + environment_preset_id: request + .environment + .as_ref() + .and_then(|environment| environment.preset_id.clone()), + environment_preset_name: request + .environment + .as_ref() + .and_then(|environment| environment.preset_name.clone()), }) - .await?; + .await + { + Ok(session) => session, + Err(error) => { + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } + }; let open_session_elapsed_ms = open_session_started_at.elapsed().as_millis(); + launch_audit.target_id = Some(session.target_id.clone()); + launch_audit.session_id = Some(session.session_id.clone()); info!( profile_key = %request.profile_key, session_id = %session.session_id, target_id = ?session.target_id, elapsed_ms = open_session_elapsed_ms, - "browser runtime assist: cdp session opened" + "browser session launch: cdp session opened" ); + if let Some(environment) = request.environment.as_ref() { + let runtime = shared_browser_runtime(); + if let Err(error) = + apply_browser_environment_to_session(runtime.as_ref(), &session.session_id, environment) + .await + { + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } + } + let stream_started_at = Instant::now(); - let stream_mode = request.stream_mode.clone(); - let session = start_browser_stream_global( + let stream_mode = request.stream_mode; + let mut session = match start_browser_stream_global( app_handle.clone(), StartBrowserStreamRequest { session_id: session.session_id.clone(), - mode: stream_mode.clone(), + mode: stream_mode, }, ) - .await?; + .await + { + Ok(session) => session, + Err(error) => { + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } + }; let stream_elapsed_ms = stream_started_at.elapsed().as_millis(); info!( profile_key = %request.profile_key, session_id = %session.session_id, stream_mode = ?stream_mode, elapsed_ms = stream_elapsed_ms, - "browser runtime assist: stream started" + "browser session launch: stream started" ); if request.open_window { let window_started_at = Instant::now(); - browser_runtime_window::open_browser_runtime_window( + if let Err(error) = browser_runtime_window::open_browser_runtime_window( &app_handle, Some(&session.session_id), Some(&request.profile_key), ) - .map_err(|e| format!("打开浏览器运行时调试窗口失败: {e}"))?; + .map_err(|e| format!("打开浏览器运行时调试窗口失败: {e}")) + { + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } info!( profile_key = %request.profile_key, session_id = %session.session_id, elapsed_ms = window_started_at.elapsed().as_millis(), - "browser runtime assist: debugger window opened" + "browser session launch: debugger window opened" + ); + } + + if request.environment.is_some() { + let navigation_started_at = Instant::now(); + let runtime = shared_browser_runtime(); + if let Err(error) = runtime + .execute_action( + &session.session_id, + "navigate", + json!({ + "url": launch_url, + }), + ) + .await + { + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } + session = match runtime.refresh_page_info(&session.session_id).await { + Ok(session) => session, + Err(error) => { + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } + }; + info!( + profile_key = %request.profile_key, + session_id = %session.session_id, + elapsed_ms = navigation_started_at.elapsed().as_millis(), + "browser session launch: environment ready and navigated" ); } @@ -231,8 +574,207 @@ pub async fn launch_browser_runtime_assist_global( open_session_elapsed_ms, stream_elapsed_ms, open_window = request.open_window, - "browser runtime assist: launch completed" + "browser session launch: launch completed" ); - Ok(BrowserRuntimeAssistLaunchResponse { profile, session }) + finalize_browser_runtime_launch_audit(launch_audit, None).await; + Ok(BrowserSessionLaunchResponse { profile, session }) +} + +fn build_chrome_launch_options( + environment: &BrowserEnvironmentLaunchConfig, +) -> ChromeProfileLaunchOptions { + ChromeProfileLaunchOptions { + proxy_server: environment.proxy_server.clone(), + language: environment.browser_launch_language(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + + fn setup_db() -> DbConnection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE browser_profiles ( + id TEXT PRIMARY KEY, + profile_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + site_scope TEXT, + launch_url TEXT, + transport_kind TEXT NOT NULL DEFAULT 'managed_cdp', + profile_dir TEXT NOT NULL, + managed_profile_dir TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + ); + CREATE TABLE browser_environment_presets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + proxy_server TEXT, + timezone_id TEXT, + locale TEXT, + accept_language TEXT, + geolocation_lat REAL, + geolocation_lng REAL, + geolocation_accuracy_m REAL, + user_agent TEXT, + platform TEXT, + viewport_width INTEGER, + viewport_height INTEGER, + device_scale_factor REAL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + );", + ) + .unwrap(); + Arc::new(Mutex::new(conn)) + } + + #[test] + fn resolve_launch_browser_session_request_should_resolve_profile_and_preset() { + let db = setup_db(); + { + let conn = lock_db(&db).unwrap(); + conn.execute( + "INSERT INTO browser_profiles ( + id, profile_key, name, description, site_scope, launch_url, transport_kind, + profile_dir, managed_profile_dir, created_at, updated_at, last_used_at, archived_at + ) VALUES (?1, ?2, ?3, NULL, NULL, ?4, 'managed_cdp', ?5, ?5, ?6, ?6, NULL, NULL)", + ( + "profile-1", + "shop_us", + "美区电商账号", + "https://seller.example.com/", + "/tmp/proxycast/chrome_profiles/shop_us", + "2026-03-15T00:00:00Z", + ), + ) + .unwrap(); + conn.execute( + "INSERT INTO browser_environment_presets ( + id, name, description, proxy_server, timezone_id, locale, accept_language, + geolocation_lat, geolocation_lng, geolocation_accuracy_m, user_agent, platform, + viewport_width, viewport_height, device_scale_factor, created_at, updated_at, + last_used_at, archived_at + ) VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, NULL, NULL, NULL, NULL, NULL, ?7, ?8, ?9, ?10, ?10, NULL, NULL)", + ( + "env-1", + "美区桌面", + "http://127.0.0.1:7890", + "America/Los_Angeles", + "en-US", + "en-US,en;q=0.9", + 1440_i64, + 900_i64, + 2.0_f64, + "2026-03-15T00:00:00Z", + ), + ) + .unwrap(); + } + + let resolved = resolve_launch_browser_session_request( + &db, + LaunchBrowserSessionRequest { + profile_id: Some("profile-1".to_string()), + profile_key: None, + url: None, + environment_preset_id: Some("env-1".to_string()), + environment: None, + target_id: Some("target-1".to_string()), + open_window: false, + stream_mode: BrowserStreamMode::Both, + }, + ) + .expect("request should resolve"); + + assert_eq!(resolved.profile_id.as_deref(), Some("profile-1")); + assert_eq!(resolved.profile_key, "shop_us"); + assert_eq!(resolved.url, "https://seller.example.com/"); + assert_eq!(resolved.environment_preset_id.as_deref(), Some("env-1")); + assert_eq!( + resolved + .environment + .as_ref() + .and_then(|value| value.preset_name.as_deref()), + Some("美区桌面") + ); + assert_eq!( + resolved + .environment + .as_ref() + .and_then(|value| value.proxy_server.as_deref()), + Some("http://127.0.0.1:7890") + ); + } + + #[test] + fn resolve_launch_browser_session_request_should_reject_mixed_environment_inputs() { + let db = setup_db(); + let error = resolve_launch_browser_session_request( + &db, + LaunchBrowserSessionRequest { + profile_id: None, + profile_key: Some("general_browser_assist".to_string()), + url: Some("https://example.com/".to_string()), + environment_preset_id: Some("env-1".to_string()), + environment: Some(BrowserEnvironmentLaunchConfig::default()), + target_id: None, + open_window: false, + stream_mode: BrowserStreamMode::Both, + }, + ) + .unwrap_err(); + + assert!(error.contains("不能同时指定")); + } + + #[test] + fn resolve_launch_browser_session_request_should_reject_existing_session_profile() { + let db = setup_db(); + { + let conn = lock_db(&db).unwrap(); + conn.execute( + "INSERT INTO browser_profiles ( + id, profile_key, name, description, site_scope, launch_url, transport_kind, + profile_dir, managed_profile_dir, created_at, updated_at, last_used_at, archived_at + ) VALUES (?1, ?2, ?3, NULL, NULL, ?4, 'existing_session', '', NULL, ?5, ?5, NULL, NULL)", + ( + "profile-attach", + "weibo_attach", + "微博附着", + "https://weibo.com/", + "2026-03-15T00:00:00Z", + ), + ) + .unwrap(); + } + + let error = resolve_launch_browser_session_request( + &db, + LaunchBrowserSessionRequest { + profile_id: Some("profile-attach".to_string()), + profile_key: None, + url: None, + environment_preset_id: None, + environment: None, + target_id: None, + open_window: false, + stream_mode: BrowserStreamMode::Both, + }, + ) + .unwrap_err(); + + assert!(error.contains("附着当前 Chrome")); + } } diff --git a/src-tauri/src/commands/config_cmd.rs b/src-tauri/src/commands/config_cmd.rs index b1d24547b..0343dbf57 100644 --- a/src-tauri/src/commands/config_cmd.rs +++ b/src-tauri/src/commands/config_cmd.rs @@ -597,7 +597,7 @@ pub struct VersionCheckResult { pub error: Option, } -const FALLBACK_TAGS_URL: &str = "https://github.com/aiclientproxy/proxycast/tags"; +const FALLBACK_RELEASES_URL: &str = "https://github.com/aiclientproxy/proxycast/releases"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct UpdateCheckCache { @@ -765,7 +765,7 @@ fn build_version_check_result( download_url: Option, error: Option, ) -> VersionCheckResult { - let resolved_download_url = download_url.or_else(|| Some(FALLBACK_TAGS_URL.to_string())); + let resolved_download_url = download_url.or_else(|| Some(FALLBACK_RELEASES_URL.to_string())); let has_update = latest .as_deref() .map(|latest_version| version_compare(current, latest_version)) @@ -1064,12 +1064,22 @@ async fn get_platform_download_from_github(version: &str) -> Result<(String, Str fn get_platform_patterns() -> Vec<&'static str> { #[cfg(all(target_os = "windows", target_arch = "x86_64"))] { - vec!["x64-setup.exe", "x64_en-US.msi"] + vec![ + "x64-online-setup.exe", + "x64-setup.exe", + "x64-offline-setup.exe", + "x64_en-US.msi", + ] } #[cfg(all(target_os = "windows", target_arch = "aarch64"))] { - vec!["arm64-setup.exe", "arm64_en-US.msi"] + vec![ + "arm64-online-setup.exe", + "arm64-setup.exe", + "arm64-offline-setup.exe", + "arm64_en-US.msi", + ] } #[cfg(all(target_os = "macos", target_arch = "x86_64"))] diff --git a/src-tauri/src/commands/execution_run_cmd.rs b/src-tauri/src/commands/execution_run_cmd.rs index c19870cf9..f8c452863 100644 --- a/src-tauri/src/commands/execution_run_cmd.rs +++ b/src-tauri/src/commands/execution_run_cmd.rs @@ -1,6 +1,6 @@ //! 统一执行轨迹查询命令 //! -//! 提供对 `agent_runs` 的只读查询能力,供前端查看 chat / skill / heartbeat 执行摘要。 +//! 提供对 `agent_runs` 的只读查询能力,供前端查看 chat / skill / automation 执行摘要。 use crate::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; use crate::database::DbConnection; @@ -239,7 +239,7 @@ fn derive_run_title(run: &AgentRun) -> String { match run.source.as_str() { "skill" => "执行主题工作台技能".to_string(), - "heartbeat" => "执行定时任务".to_string(), + "automation" => "执行自动化任务".to_string(), _ => "执行主题工作台编排".to_string(), } } diff --git a/src-tauri/src/commands/heartbeat_cmd.rs b/src-tauri/src/commands/heartbeat_cmd.rs deleted file mode 100644 index b6b8216ce..000000000 --- a/src-tauri/src/commands/heartbeat_cmd.rs +++ /dev/null @@ -1,451 +0,0 @@ -//! 心跳引擎 Tauri 命令 - -use crate::app::LogState; -use crate::config::save_config; -use crate::database::DbConnection; -use crate::services::heartbeat_service::schedule::{ - preview_next_run, validate_schedule as validate_schedule_fn, -}; -use crate::services::heartbeat_service::templates::{TaskTemplate, TaskTemplateRegistry}; -use crate::services::heartbeat_service::{delivery::deliver_result, delivery::TaskResult}; -use crate::services::heartbeat_service::{ - CycleResult, HeartbeatServiceState, HeartbeatStatus, HeartbeatTaskPreview, -}; -use crate::AppState; -use proxycast_core::app_paths; -use proxycast_core::config::{DeliveryConfig, HeartbeatSecurityConfig, TaskSchedule}; -use proxycast_core::database::dao::heartbeat::HeartbeatExecution; -use proxycast_websocket::handlers::{RpcHandler, RpcHandlerState}; -use proxycast_websocket::protocol::{CronHealthResult, GatewayRpcRequest, RpcMethod}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -// ========== 配置响应类型 ========== - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HeartbeatConfigResponse { - pub enabled: bool, - pub interval_secs: u64, - pub schedule: Option, - pub task_file: String, - pub execution_mode: String, - pub enable_history: bool, - pub max_retries: u32, - pub delivery: DeliveryConfig, - pub security: HeartbeatSecurityConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct HeartbeatTaskHealthQuery { - pub running_timeout_minutes: Option, - pub top_limit: Option, - pub cooldown_alert_threshold: Option, - pub stale_running_alert_threshold: Option, - pub failed_24h_alert_threshold: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HeartbeatTaskHealthAlertDeliveryResult { - pub delivered: bool, - pub alert_count: usize, - pub channel: Option, - pub message: String, -} - -// ========== 配置命令 ========== - -#[tauri::command] -pub async fn get_heartbeat_config( - state: tauri::State<'_, AppState>, -) -> Result { - let s = state.read().await; - let c = &s.config.heartbeat; - Ok(HeartbeatConfigResponse { - enabled: c.enabled, - interval_secs: c.interval_secs, - schedule: c.schedule.clone(), - task_file: c.task_file.clone(), - execution_mode: match c.execution_mode { - proxycast_core::config::HeartbeatExecutionMode::Intelligent => "intelligent".into(), - proxycast_core::config::HeartbeatExecutionMode::Skill => "skill".into(), - proxycast_core::config::HeartbeatExecutionMode::LogOnly => "log_only".into(), - }, - enable_history: c.enable_history, - max_retries: c.max_retries, - delivery: c.delivery.clone(), - security: c.security.clone(), - }) -} - -#[tauri::command] -pub async fn update_heartbeat_config( - state: tauri::State<'_, AppState>, - hb_state: tauri::State<'_, HeartbeatServiceState>, - app: tauri::AppHandle, - config: HeartbeatConfigResponse, -) -> Result<(), String> { - use proxycast_core::config::HeartbeatExecutionMode; - - let execution_mode = match config.execution_mode.as_str() { - "intelligent" => HeartbeatExecutionMode::Intelligent, - "skill" => HeartbeatExecutionMode::Skill, - "log_only" => HeartbeatExecutionMode::LogOnly, - _ => HeartbeatExecutionMode::Intelligent, - }; - - // 验证调度配置 - if let Some(ref schedule) = config.schedule { - if let Err(e) = validate_schedule_fn(schedule, chrono::Utc::now()) { - return Err(format!("调度配置无效: {}", e)); - } - } - - let was_enabled; - // 更新 AppState 中的配置 - { - let mut s = state.write().await; - was_enabled = s.config.heartbeat.enabled; - s.config.heartbeat.enabled = config.enabled; - s.config.heartbeat.interval_secs = config.interval_secs; - s.config.heartbeat.schedule = config.schedule.clone(); - s.config.heartbeat.task_file = config.task_file.clone(); - s.config.heartbeat.execution_mode = execution_mode; - s.config.heartbeat.enable_history = config.enable_history; - s.config.heartbeat.max_retries = config.max_retries; - s.config.heartbeat.delivery = config.delivery.clone(); - s.config.heartbeat.security = config.security.clone(); - save_config(&s.config).map_err(|e| e.to_string())?; - } - - // 同步更新 HeartbeatService 的配置,并处理启停 - { - let mut service = hb_state.0.write().await; - service.update_config(proxycast_core::config::HeartbeatSettings { - enabled: config.enabled, - interval_secs: config.interval_secs, - schedule: config.schedule, - task_file: config.task_file, - execution_mode, - enable_history: config.enable_history, - max_retries: config.max_retries, - delivery: config.delivery, - security: config.security, - }); - - // 处理启停逻辑 - if config.enabled && !was_enabled { - service.set_app_handle(app.clone()); - let app_data_dir = app_paths::preferred_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; - let self_ref = hb_state.0.clone(); - service.start(app_data_dir, self_ref).await?; - } else if !config.enabled && was_enabled { - service.stop().await?; - } - } - - Ok(()) -} - -// ========== 状态和任务命令 ========== - -#[tauri::command] -pub async fn get_heartbeat_status( - hb_state: tauri::State<'_, HeartbeatServiceState>, -) -> Result { - let service = hb_state.0.read().await; - Ok(service.get_status()) -} - -#[tauri::command] -pub async fn get_heartbeat_tasks( - hb_state: tauri::State<'_, HeartbeatServiceState>, - _app: tauri::AppHandle, -) -> Result, String> { - let app_data_dir = - app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; - let service = hb_state.0.read().await; - service.preview_tasks(&app_data_dir) -} - -// ========== 执行历史命令 ========== - -#[tauri::command] -pub async fn get_heartbeat_history( - hb_state: tauri::State<'_, HeartbeatServiceState>, - limit: Option, -) -> Result, String> { - let service = hb_state.0.read().await; - service.get_execution_history(limit.unwrap_or(50)) -} - -#[tauri::command] -pub async fn get_heartbeat_execution_detail( - hb_state: tauri::State<'_, HeartbeatServiceState>, - execution_id: i64, -) -> Result, String> { - let service = hb_state.0.read().await; - service.get_execution_detail(execution_id) -} - -#[tauri::command] -pub async fn get_heartbeat_task_health( - db: tauri::State<'_, DbConnection>, - logs: tauri::State<'_, LogState>, - query: Option, -) -> Result { - query_heartbeat_task_health_via_rpc(db.inner().clone(), logs.inner().clone(), query).await -} - -#[tauri::command] -pub async fn deliver_heartbeat_task_health_alerts( - state: tauri::State<'_, AppState>, - db: tauri::State<'_, DbConnection>, - logs: tauri::State<'_, LogState>, - query: Option, -) -> Result { - let health = - query_heartbeat_task_health_via_rpc(db.inner().clone(), logs.inner().clone(), query) - .await?; - if health.alerts.is_empty() { - return Ok(HeartbeatTaskHealthAlertDeliveryResult { - delivered: false, - alert_count: 0, - channel: None, - message: "当前无告警,未触发投递".to_string(), - }); - } - - let delivery_config = { - let app_state = state.read().await; - app_state.config.heartbeat.delivery.clone() - }; - let channel = delivery_config.channel.clone(); - - let output = health - .alerts - .iter() - .take(5) - .map(|alert| { - format!( - "- [{}] {} ({}/{})", - alert.severity, alert.message, alert.current_value, alert.threshold - ) - }) - .collect::>() - .join("\n"); - let has_critical = health - .alerts - .iter() - .any(|item| item.severity.eq_ignore_ascii_case("critical")); - let result = TaskResult { - task: format!("Heartbeat 治理告警({} 条)", health.alerts.len()), - status: if has_critical { - "failed".to_string() - } else { - "partial".to_string() - }, - output, - duration_ms: 0, - timestamp: chrono::Utc::now().to_rfc3339(), - }; - let delivery_result = deliver_result(&delivery_config, &result).await; - if !delivery_result.success && !delivery_config.best_effort { - return Err(format!("告警投递失败: {}", delivery_result.message)); - } - - Ok(HeartbeatTaskHealthAlertDeliveryResult { - delivered: delivery_result.success, - alert_count: health.alerts.len(), - channel, - message: delivery_result.message, - }) -} - -async fn query_heartbeat_task_health_via_rpc( - db: DbConnection, - logs: LogState, - query: Option, -) -> Result { - let rpc_state = RpcHandlerState::new(Some(db), None, logs); - let rpc_handler = RpcHandler::new(rpc_state); - let params = query - .map(|q| serde_json::to_value(q).map_err(|e| format!("序列化健康查询参数失败: {e}"))) - .transpose()?; - let request = GatewayRpcRequest { - jsonrpc: "2.0".to_string(), - id: Uuid::new_v4().to_string(), - method: RpcMethod::CronHealth, - params, - }; - let response = rpc_handler.handle_request(request).await; - if let Some(error) = response.error { - return Err(format!( - "获取任务健康失败: {} (code={})", - error.message, error.code - )); - } - let result = response - .result - .ok_or_else(|| "获取任务健康失败: RPC 返回缺少 result".to_string())?; - serde_json::from_value(result).map_err(|e| format!("解析任务健康结果失败: {e}")) -} - -// ========== 任务模板命令 ========== - -#[tauri::command] -pub async fn get_task_templates() -> Result, String> { - Ok(TaskTemplateRegistry::get_all_templates()) -} - -#[tauri::command] -pub async fn apply_task_template( - template_id: String, - _app: tauri::AppHandle, -) -> Result<(), String> { - let app_data_dir = - app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; - - let template = TaskTemplateRegistry::get_template_by_id(&template_id) - .ok_or_else(|| format!("模板不存在: {}", template_id))?; - - TaskTemplateRegistry::apply_template(&template, &app_data_dir) -} - -// ========== 任务增删改命令 ========== - -#[tauri::command] -pub async fn add_heartbeat_task( - hb_state: tauri::State<'_, HeartbeatServiceState>, - _app: tauri::AppHandle, - description: String, - priority: Option, - timeout_secs: Option, - once: Option, - model: Option, -) -> Result<(), String> { - let app_data_dir = - app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; - let service = hb_state.0.read().await; - service.add_task( - &app_data_dir, - description, - priority, - timeout_secs, - once, - model, - ) -} - -#[tauri::command] -pub async fn delete_heartbeat_task( - hb_state: tauri::State<'_, HeartbeatServiceState>, - _app: tauri::AppHandle, - index: usize, -) -> Result<(), String> { - let app_data_dir = - app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; - let service = hb_state.0.read().await; - service.delete_task(&app_data_dir, index) -} - -#[tauri::command] -pub async fn update_heartbeat_task( - hb_state: tauri::State<'_, HeartbeatServiceState>, - _app: tauri::AppHandle, - index: usize, - description: String, - priority: Option, - timeout_secs: Option, - once: Option, - model: Option, -) -> Result<(), String> { - let app_data_dir = - app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; - let service = hb_state.0.read().await; - service.update_task( - &app_data_dir, - index, - description, - priority, - timeout_secs, - once, - model, - ) -} - -// ========== 内容创作集成命令 ========== - -#[tauri::command] -pub async fn generate_content_creator_tasks( - state: tauri::State<'_, AppState>, - _app: tauri::AppHandle, -) -> Result { - use crate::services::heartbeat_service::templates::ContentCreatorTaskGenerator; - - let enabled_themes = { - let s = state.read().await; - s.config.content_creator.enabled_themes.clone() - }; - - let tasks = ContentCreatorTaskGenerator::generate_tasks(&enabled_themes); - let count = tasks.len(); - - if count > 0 { - let app_data_dir = - app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; - ContentCreatorTaskGenerator::append_to_heartbeat(tasks, &app_data_dir)?; - } - - Ok(count) -} - -// ========== 手动触发命令 ========== - -#[tauri::command] -pub async fn trigger_heartbeat_now( - hb_state: tauri::State<'_, HeartbeatServiceState>, - app: tauri::AppHandle, -) -> Result { - let app_data_dir = - app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; - - let result = { - let service = hb_state.0.read().await; - service.trigger_now(app_data_dir, Some(app.clone())).await - }; - { - let mut service = hb_state.0.write().await; - service.update_status_after_cycle(&result); - } - Ok(result) -} - -// ========== 调度预览和验证命令 ========== - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScheduleValidationResult { - pub valid: bool, - pub error: Option, -} - -#[tauri::command] -pub async fn preview_heartbeat_schedule(schedule: TaskSchedule) -> Result, String> { - preview_next_run(&schedule).map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn validate_heartbeat_schedule( - schedule: TaskSchedule, -) -> Result { - match validate_schedule_fn(&schedule, chrono::Utc::now()) { - Ok(()) => Ok(ScheduleValidationResult { - valid: true, - error: None, - }), - Err(e) => Ok(ScheduleValidationResult { - valid: false, - error: Some(e.to_string()), - }), - } -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 78471cfe1..04e7c4af2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,9 @@ pub mod api_key_provider_cmd; pub mod asr_cmd; pub mod aster_agent_cmd; pub mod auto_fix_cmd; +pub mod automation_cmd; +pub mod browser_environment_cmd; +pub mod browser_profile_cmd; pub mod browser_runtime_cmd; pub mod channels_cmd; pub mod config_cmd; @@ -19,7 +22,6 @@ pub mod external_tools_cmd; pub mod file_upload_cmd; pub mod gateway_channel_cmd; pub mod gateway_tunnel_cmd; -pub mod heartbeat_cmd; pub mod image_search_cmd; pub mod image_upload_cmd; pub mod injection_cmd; diff --git a/src-tauri/src/commands/openclaw_cmd.rs b/src-tauri/src/commands/openclaw_cmd.rs index ec1d571c3..4ded50934 100644 --- a/src-tauri/src/commands/openclaw_cmd.rs +++ b/src-tauri/src/commands/openclaw_cmd.rs @@ -3,7 +3,7 @@ use crate::database::DbConnection; use crate::services::openclaw_service::{ openclaw_install_event_name, ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus, ChannelInfo, CommandPreview, EnvironmentStatus, GatewayStatusInfo, HealthInfo, - InstallProgressEvent, NodeCheckResult, OpenClawServiceState, SyncModelEntry, + InstallProgressEvent, NodeCheckResult, OpenClawServiceState, SyncModelEntry, UpdateInfo, }; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, State}; @@ -106,6 +106,14 @@ pub async fn openclaw_cleanup_temp_artifacts( service.cleanup_temp_artifacts(Some(&app)).await } +#[tauri::command] +pub async fn openclaw_check_update( + service: State<'_, OpenClawServiceState>, +) -> Result { + let service = service.0.lock().await; + service.check_update().await +} + #[tauri::command] pub async fn openclaw_uninstall( app: AppHandle, @@ -116,6 +124,16 @@ pub async fn openclaw_uninstall( service.uninstall(&app).await } +#[tauri::command] +pub async fn openclaw_perform_update( + app: AppHandle, + service: State<'_, OpenClawServiceState>, +) -> Result { + let mut service = service.0.lock().await; + service.clear_progress_logs(); + service.perform_update(&app).await +} + #[tauri::command] pub async fn openclaw_start_gateway( app: AppHandle, diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index bb7005d9c..482c5d52c 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -9,6 +9,15 @@ //! - 控制窗口位置和大小 use crate::app::AppState; +use crate::database::DbConnection; +use crate::services::automation_service::browser_runtime_sync::{ + complete_browser_session_after_resume, sync_browser_session_runtime_state, +}; +use crate::services::browser_profile_service::{ + normalize_browser_profile_key, + resolve_chrome_profile_data_dir as resolve_managed_chrome_profile_data_dir, + resolve_chrome_profile_data_dir_from_base as resolve_managed_chrome_profile_data_dir_from_base, +}; use aster::chrome_mcp::{ get_chrome_mcp_tools, is_chrome_integration_configured, is_chrome_integration_supported, }; @@ -178,6 +187,15 @@ pub struct CreateWebviewResponse { pub error: Option, } +/// 启动外部 Chrome Profile 的请求参数 +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct ChromeProfileLaunchOptions { + #[serde(default)] + pub proxy_server: Option, + #[serde(default)] + pub language: Option, +} + /// 启动外部 Chrome Profile 的请求参数 #[derive(Debug, Deserialize)] pub struct OpenChromeProfileRequest { @@ -185,6 +203,9 @@ pub struct OpenChromeProfileRequest { pub profile_key: String, /// 要打开的 URL pub url: String, + /// 浏览器启动级选项 + #[serde(default)] + pub launch_options: Option, } /// 启动外部 Chrome Profile 的响应 @@ -350,6 +371,10 @@ pub struct OpenCdpSessionRequest { pub profile_key: String, #[serde(default)] pub target_id: Option, + #[serde(default)] + pub environment_preset_id: Option, + #[serde(default)] + pub environment_preset_name: Option, } #[derive(Debug, Clone, Deserialize)] @@ -408,21 +433,138 @@ static BROWSER_BACKEND_POLICY: Lazy> = const BROWSER_AUDIT_LOG_MAX: usize = 200; +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserRuntimeAuditKind { + Action, + Launch, +} + #[derive(Debug, Clone, Serialize)] -pub struct BrowserActionAuditRecord { +pub struct BrowserRuntimeAuditRecord { pub id: String, pub created_at: String, - pub action: String, + pub kind: BrowserRuntimeAuditKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub action: Option, pub profile_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub requested_backend: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub selected_backend: Option, pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attempts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment_preset_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment_preset_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reused: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub open_window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub browser_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_debugging_port: Option, } -static BROWSER_ACTION_AUDIT_LOGS: Lazy>> = +pub type BrowserActionAuditRecord = BrowserRuntimeAuditRecord; + +#[derive(Debug, Clone)] +pub struct BrowserRuntimeLaunchAuditInput { + pub profile_key: String, + pub profile_id: Option, + pub environment_preset_id: Option, + pub environment_preset_name: Option, + pub target_id: Option, + pub session_id: Option, + pub url: String, + pub reused: Option, + pub open_window: bool, + pub stream_mode: BrowserStreamMode, + pub browser_source: Option, + pub remote_debugging_port: Option, + pub success: bool, + pub error: Option, +} + +impl BrowserRuntimeAuditRecord { + fn action( + id: String, + action: String, + profile_key: Option, + requested_backend: Option, + selected_backend: Option, + success: bool, + error: Option, + attempts: Vec, + ) -> Self { + Self { + id, + created_at: chrono::Utc::now().to_rfc3339(), + kind: BrowserRuntimeAuditKind::Action, + action: Some(action), + profile_key, + profile_id: None, + requested_backend, + selected_backend, + success, + error, + attempts, + environment_preset_id: None, + environment_preset_name: None, + target_id: None, + session_id: None, + url: None, + reused: None, + open_window: None, + stream_mode: None, + browser_source: None, + remote_debugging_port: None, + } + } + + fn launch(input: BrowserRuntimeLaunchAuditInput) -> Self { + Self { + id: format!("browser-launch-{}", uuid::Uuid::new_v4()), + created_at: chrono::Utc::now().to_rfc3339(), + kind: BrowserRuntimeAuditKind::Launch, + action: None, + profile_key: Some(input.profile_key), + profile_id: input.profile_id, + requested_backend: None, + selected_backend: None, + success: input.success, + error: input.error, + attempts: Vec::new(), + environment_preset_id: input.environment_preset_id, + environment_preset_name: input.environment_preset_name, + target_id: input.target_id, + session_id: input.session_id, + url: Some(input.url), + reused: input.reused, + open_window: Some(input.open_window), + stream_mode: Some(input.stream_mode), + browser_source: input.browser_source, + remote_debugging_port: input.remote_debugging_port, + } + } +} + +static BROWSER_RUNTIME_AUDIT_LOGS: Lazy>> = Lazy::new(|| Mutex::new(VecDeque::new())); /// 创建独立的浏览器窗口 @@ -557,6 +699,7 @@ async fn open_chrome_profile_window_with_manager( request: OpenChromeProfileRequest, ) -> Result { let profile_key = normalize_profile_key(&request.profile_key); + let launch_options = request.launch_options.clone().unwrap_or_default(); let parsed_url = match request.url.parse::() { Ok(url) => url, Err(e) => { @@ -602,6 +745,12 @@ async fn open_chrome_profile_window_with_manager( let devtools_http_url = format!("http://127.0.0.1:{remote_port}/json/version"); if wait_for_managed_cdp_ready(remote_port, None).await.is_ok() { + if launch_options.proxy_server.is_some() { + return Err( + "当前资料已有运行中的浏览器进程;代理属于启动参数,切换代理前请先关闭该资料会话" + .to_string(), + ); + } tracing::info!( "[ChromeProfile] 复用未登记的 CDP 会话: profile_key={}, port={}", profile_key, @@ -639,6 +788,12 @@ async fn open_chrome_profile_window_with_manager( if let Some(existing) = guard.sessions.get_mut(&profile_key) { match existing.child.try_wait() { Ok(None) => { + if launch_options.proxy_server.is_some() { + return Err( + "当前资料已有运行中的浏览器进程;代理属于启动参数,切换代理前请先关闭该资料会话" + .to_string(), + ); + } // reuse 场景:不重复加载扩展 spawn_chrome_with_profile( &existing.browser_path, @@ -647,6 +802,7 @@ async fn open_chrome_profile_window_with_manager( &url_text, true, None, + &launch_options, )?; existing.last_url = url_text.clone(); return Ok(OpenChromeProfileResponse { @@ -678,6 +834,7 @@ async fn open_chrome_profile_window_with_manager( &url_text, true, Some(&extension_dir), + &launch_options, )?; let pid = child.id(); @@ -799,6 +956,7 @@ async fn ensure_managed_chrome_profile_with_manager( url: Option, ) -> Result { let normalized_profile_key = normalize_profile_key(&profile_key); + let launch_options = ChromeProfileLaunchOptions::default(); if let Some(existing) = list_alive_profile_sessions(manager.clone()) .await .into_iter() @@ -855,6 +1013,7 @@ async fn ensure_managed_chrome_profile_with_manager( &url_text, true, None, + &launch_options, )?; let pid = child.id(); @@ -1214,6 +1373,8 @@ pub async fn open_cdp_session( profile_key: session.profile_key, remote_debugging_port: session.remote_debugging_port, target_id: request.target_id, + environment_preset_id: request.environment_preset_id, + environment_preset_name: request.environment_preset_name, }) .await } @@ -1231,6 +1392,8 @@ pub async fn open_cdp_session_global( profile_key: session.profile_key, remote_debugging_port: session.remote_debugging_port, target_id: request.target_id, + environment_preset_id: request.environment_preset_id, + environment_preset_name: request.environment_preset_name, }) .await } @@ -1288,62 +1451,94 @@ pub async fn stop_browser_stream_global( #[tauri::command] pub async fn get_browser_session_state( + db: tauri::State<'_, DbConnection>, request: BrowserSessionStateRequest, ) -> Result { - shared_browser_runtime() + let state = shared_browser_runtime() .get_session_state(&request.session_id) - .await + .await?; + sync_automation_browser_state(db.inner(), &state, false); + Ok(state) } pub async fn get_browser_session_state_global( + db: DbConnection, request: BrowserSessionStateRequest, ) -> Result { - get_browser_session_state(request).await + let state = shared_browser_runtime() + .get_session_state(&request.session_id) + .await?; + sync_automation_browser_state(&db, &state, false); + Ok(state) } #[tauri::command] pub async fn take_over_browser_session( + db: tauri::State<'_, DbConnection>, request: UpdateBrowserSessionControlRequest, ) -> Result { - shared_browser_runtime() + let state = shared_browser_runtime() .take_over_session(&request.session_id, request.human_reason) - .await + .await?; + sync_automation_browser_state(db.inner(), &state, false); + Ok(state) } pub async fn take_over_browser_session_global( + db: DbConnection, request: UpdateBrowserSessionControlRequest, ) -> Result { - take_over_browser_session(request).await + let state = shared_browser_runtime() + .take_over_session(&request.session_id, request.human_reason) + .await?; + sync_automation_browser_state(&db, &state, false); + Ok(state) } #[tauri::command] pub async fn release_browser_session( + db: tauri::State<'_, DbConnection>, request: UpdateBrowserSessionControlRequest, ) -> Result { - shared_browser_runtime() + let state = shared_browser_runtime() .release_session(&request.session_id, request.human_reason) - .await + .await?; + sync_automation_browser_state(db.inner(), &state, false); + Ok(state) } pub async fn release_browser_session_global( + db: DbConnection, request: UpdateBrowserSessionControlRequest, ) -> Result { - release_browser_session(request).await + let state = shared_browser_runtime() + .release_session(&request.session_id, request.human_reason) + .await?; + sync_automation_browser_state(&db, &state, false); + Ok(state) } #[tauri::command] pub async fn resume_browser_session( + db: tauri::State<'_, DbConnection>, request: UpdateBrowserSessionControlRequest, ) -> Result { - shared_browser_runtime() + let state = shared_browser_runtime() .resume_session(&request.session_id, request.human_reason) - .await + .await?; + sync_automation_browser_state(db.inner(), &state, true); + Ok(state) } pub async fn resume_browser_session_global( + db: DbConnection, request: UpdateBrowserSessionControlRequest, ) -> Result { - resume_browser_session(request).await + let state = shared_browser_runtime() + .resume_session(&request.session_id, request.human_reason) + .await?; + sync_automation_browser_state(&db, &state, true); + Ok(state) } #[tauri::command] @@ -1362,6 +1557,21 @@ pub async fn get_browser_event_buffer_global( get_browser_event_buffer(request).await } +fn sync_automation_browser_state(db: &DbConnection, state: &CdpSessionState, finalize: bool) { + let result = if finalize { + complete_browser_session_after_resume(db, state) + } else { + sync_browser_session_runtime_state(db, state) + }; + if let Err(error) = result { + tracing::warn!( + "[BrowserRuntime] 同步自动化浏览器运行态失败: session_id={}, error={}", + state.session_id, + error + ); + } +} + /// 通过统一编排层执行浏览器动作 #[tauri::command] pub async fn browser_execute_action( @@ -1380,7 +1590,7 @@ pub async fn get_browser_action_audit_logs( let max_count = limit .unwrap_or(BROWSER_AUDIT_LOG_MAX) .min(BROWSER_AUDIT_LOG_MAX); - let logs = BROWSER_ACTION_AUDIT_LOGS.lock().await; + let logs = BROWSER_RUNTIME_AUDIT_LOGS.lock().await; let mut result = logs.iter().cloned().collect::>(); result.reverse(); result.truncate(max_count); @@ -1442,17 +1652,16 @@ pub async fn browser_execute_action_with_manager( error: None, attempts: attempts.clone(), }; - append_browser_action_audit(BrowserActionAuditRecord { - id: request_id, - created_at: chrono::Utc::now().to_rfc3339(), - action: result.action.clone(), - profile_key: profile_key.clone(), - requested_backend: request.backend.clone(), - selected_backend: result.backend.clone(), - success: true, - error: None, + append_browser_runtime_audit(BrowserRuntimeAuditRecord::action( + request_id, + result.action.clone(), + profile_key.clone(), + request.backend.clone(), + result.backend.clone(), + true, + None, attempts, - }) + )) .await; return Ok(result); } @@ -1474,17 +1683,16 @@ pub async fn browser_execute_action_with_manager( error: Some(error.clone()), attempts: attempts.clone(), }; - append_browser_action_audit(BrowserActionAuditRecord { - id: request_id, - created_at: chrono::Utc::now().to_rfc3339(), - action: result.action.clone(), - profile_key: profile_key.clone(), - requested_backend: request.backend.clone(), - selected_backend: None, - success: false, - error: Some(error), + append_browser_runtime_audit(BrowserRuntimeAuditRecord::action( + request_id, + result.action.clone(), + profile_key.clone(), + request.backend.clone(), + None, + false, + Some(error), attempts, - }) + )) .await; return Ok(result); } @@ -1503,17 +1711,16 @@ pub async fn browser_execute_action_with_manager( error: Some("没有可用的浏览器后端".to_string()), attempts: attempts.clone(), }; - append_browser_action_audit(BrowserActionAuditRecord { - id: request_id, - created_at: chrono::Utc::now().to_rfc3339(), + append_browser_runtime_audit(BrowserRuntimeAuditRecord::action( + request_id, action, profile_key, - requested_backend: request.backend, - selected_backend: None, - success: false, - error: result.error.clone(), + request.backend, + None, + false, + result.error.clone(), attempts, - }) + )) .await; Ok(result) } @@ -1525,8 +1732,12 @@ pub async fn browser_execute_action_global( browser_execute_action_with_manager(shared_chrome_profile_manager(), request).await } -async fn append_browser_action_audit(record: BrowserActionAuditRecord) { - let mut logs = BROWSER_ACTION_AUDIT_LOGS.lock().await; +pub async fn append_browser_runtime_launch_audit(input: BrowserRuntimeLaunchAuditInput) { + append_browser_runtime_audit(BrowserRuntimeAuditRecord::launch(input)).await; +} + +async fn append_browser_runtime_audit(record: BrowserRuntimeAuditRecord) { + let mut logs = BROWSER_RUNTIME_AUDIT_LOGS.lock().await; logs.push_back(record); while logs.len() > BROWSER_AUDIT_LOG_MAX { logs.pop_front(); @@ -1785,6 +1996,7 @@ fn extension_backend_capabilities() -> Vec { "go_back".to_string(), "go_forward".to_string(), "switch_tab".to_string(), + "list_tabs".to_string(), ] } @@ -2067,6 +2279,17 @@ async fn execute_extension_backend_action( "profiles": sessions, })) } + "list_tabs" => execute_bridge_api_command(ChromeBridgeCommandRequest { + profile_key, + command: "list_tabs".to_string(), + target: None, + text: None, + url: None, + wait_for_page_info: false, + timeout_ms: Some(normalize_action_timeout(timeout_ms)), + }) + .await + .map(bridge_result_to_value), "open_url" | "click" | "type" | "scroll" | "scroll_page" | "get_page_info" | "refresh_page" | "go_back" | "go_forward" | "switch_tab" => { execute_bridge_api_command(ChromeBridgeCommandRequest { @@ -2200,6 +2423,7 @@ fn bridge_result_to_value(result: ChromeBridgeCommandResult) -> Value { "message": result.message, "error": result.error, "page_info": result.page_info, + "data": result.data, }) } @@ -2397,30 +2621,14 @@ async fn ensure_cdp_runtime_session( profile_key: profile_session.profile_key.clone(), remote_debugging_port: profile_session.remote_debugging_port, target_id: target_id.map(ToString::to_string), + environment_preset_id: None, + environment_preset_name: None, }) .await } -fn sanitize_profile_key(input: &str) -> String { - input - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '_' - } - }) - .collect() -} - fn normalize_profile_key(input: &str) -> String { - let safe_key = sanitize_profile_key(input); - if safe_key.trim_matches('_').is_empty() { - "default".to_string() - } else { - safe_key - } + normalize_browser_profile_key(input) } fn normalize_bridge_host(host: &str) -> String { @@ -2541,6 +2749,7 @@ fn spawn_chrome_with_profile( url: &str, new_window: bool, extension_dir: Option<&Path>, + launch_options: &ChromeProfileLaunchOptions, ) -> Result { let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy()); let mut cmd = Command::new(browser_path); @@ -2550,6 +2759,13 @@ fn spawn_chrome_with_profile( .arg("--no-first-run") .arg("--no-default-browser-check"); + if let Some(proxy_server) = launch_options.proxy_server.as_deref() { + cmd.arg(format!("--proxy-server={proxy_server}")); + } + if let Some(language) = launch_options.language.as_deref() { + cmd.arg(format!("--lang={language}")); + } + // 如果提供了扩展目录,添加 --load-extension 参数 if let Some(ext_dir) = extension_dir { cmd.arg(format!("--load-extension={}", ext_dir.to_string_lossy())); @@ -2685,9 +2901,7 @@ fn resolve_profile_data_dir_from_base(base_dir: &Path, profile_key: &str) -> Pat } fn resolve_chrome_profile_data_dir_from_base(base_dir: &Path, profile_key: &str) -> PathBuf { - base_dir - .join("chrome_profiles") - .join(normalize_profile_key(profile_key)) + resolve_managed_chrome_profile_data_dir_from_base(base_dir, profile_key) } fn resolve_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result { @@ -2699,12 +2913,7 @@ fn resolve_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result Result { let _ = app; - let base_dir = proxycast_core::app_paths::preferred_data_dir() - .map_err(|e| format!("获取应用数据目录失败: {e}"))?; - Ok(resolve_chrome_profile_data_dir_from_base( - &base_dir, - profile_key, - )) + resolve_managed_chrome_profile_data_dir(profile_key) } fn get_system_chrome_path() -> Option { @@ -3009,6 +3218,7 @@ pub async fn focus_webview_panel(app: AppHandle, panel_id: String) -> Result WindowsStartupDia "webview2", "未检测到 WebView2 Runtime 注册表项".to_string(), Some( - "如果用户通过便携版启动失败,请优先改用 setup.exe 安装包重新安装。" - .to_string(), + "如果用户通过便携版或在线安装包启动失败,请优先重新运行 Windows setup 安装包;离线或受限网络环境请改用 offline 安装包。".to_string(), ), )); } @@ -360,13 +358,13 @@ pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDia let summary_message = if !errors.is_empty() { Some(format!( - "检测到 {} 个阻塞问题:{}。建议先检查目录权限,并优先使用带 WebView2 的 Windows setup.exe 安装包。", + "检测到 {} 个阻塞问题:{}。建议先检查目录权限,并优先重新运行 Windows setup 安装包;离线或受限网络环境请改用 offline 安装包。", errors.len(), errors.join(";") )) } else if !warnings.is_empty() { Some(format!( - "检测到 {} 个 Windows 环境提示:{}。如用户反馈启动失败,请优先收集日志并确认使用 setup.exe 安装包。", + "检测到 {} 个 Windows 环境提示:{}。如用户反馈启动失败,请优先收集日志并确认使用 Windows setup 安装包;离线或受限网络环境请改用 offline 安装包。", warnings.len(), warnings.join(";") )) diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index 386cf976f..207c8f3d8 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -5,10 +5,9 @@ use proptest::prelude::*; use proxycast_core::config::{ collapse_tilde, contains_tilde, expand_tilde, Config, ConfigManager, CustomProviderConfig, - HotReloadManager, InjectionSettings, LoggingConfig, ProviderConfig, ProvidersConfig, - ReloadResult, RetrySettings, RoutingConfig, ServerConfig, YamlService, + HotReloadManager, LoggingConfig, ProviderConfig, ProvidersConfig, ReloadResult, + RetrySettings, RoutingConfig, ServerConfig, YamlService, }; -use proxycast_core::config::{ContentCreatorConfig, NavigationConfig}; use std::io::Write; use tempfile::NamedTempFile; @@ -162,6 +161,25 @@ fn arb_logging_config() -> impl Strategy { ) } +fn build_test_config( + server: ServerConfig, + providers: ProvidersConfig, + routing: RoutingConfig, + retry: RetrySettings, + logging: LoggingConfig, +) -> Config { + let default_provider = routing.default_provider.clone(); + Config { + server, + providers, + default_provider, + routing, + retry, + logging, + ..Config::default() + } +} + /// 生成随机的完整配置 fn arb_config() -> impl Strategy { ( @@ -171,45 +189,8 @@ fn arb_config() -> impl Strategy { arb_retry_settings(), arb_logging_config(), ) - .prop_map(|(server, providers, routing, retry, logging)| Config { - server, - providers, - default_provider: routing.default_provider.clone(), - routing, - retry, - logging, - injection: InjectionSettings::default(), - auth_dir: "~/.proxycast/auth".to_string(), - credential_pool: proxycast_core::config::CredentialPoolConfig::default(), - remote_management: proxycast_core::config::RemoteManagementConfig::default(), - quota_exceeded: proxycast_core::config::QuotaExceededConfig::default(), - proxy_url: None, - ampcode: proxycast_core::config::AmpConfig::default(), - endpoint_providers: proxycast_core::config::EndpointProvidersConfig::default(), - minimize_to_tray: true, - models: proxycast_core::config::ModelsConfig::default(), - agent: proxycast_core::config::NativeAgentConfig::default(), - language: "zh".to_string(), - experimental: proxycast_core::config::ExperimentalFeatures::default(), - tool_calling: proxycast_core::config::ToolCallingConfig::default(), - content_creator: ContentCreatorConfig::default(), - navigation: NavigationConfig::default(), - chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), - environment: proxycast_core::config::EnvironmentConfig::default(), - web_search: proxycast_core::config::WebSearchConfig::default(), - memory: proxycast_core::config::MemoryConfig::default(), - voice: proxycast_core::config::VoiceConfig::default(), - image_gen: proxycast_core::config::ImageGenConfig::default(), - assistant: proxycast_core::config::AssistantConfig::default(), - user_profile: proxycast_core::config::UserProfile::default(), - rate_limit: proxycast_core::config::RateLimitSettings::default(), - conversation: proxycast_core::config::ConversationSettings::default(), - hint_router: proxycast_core::config::HintRouterSettings::default(), - pairing: proxycast_core::config::PairingSettings::default(), - heartbeat: proxycast_core::config::HeartbeatSettings::default(), - channels: proxycast_core::config::ChannelsConfig::default(), - crash_reporting: proxycast_core::config::CrashReportingConfig::default(), - gateway: proxycast_core::config::GatewayConfig::default(), + .prop_map(|(server, providers, routing, retry, logging)| { + build_test_config(server, providers, routing, retry, logging) }) } @@ -429,45 +410,8 @@ fn arb_valid_config() -> impl Strategy { arb_valid_retry_settings(), arb_valid_logging_config(), ) - .prop_map(|(server, providers, routing, retry, logging)| Config { - server, - providers, - default_provider: routing.default_provider.clone(), - routing, - retry, - logging, - injection: InjectionSettings::default(), - auth_dir: "~/.proxycast/auth".to_string(), - credential_pool: proxycast_core::config::CredentialPoolConfig::default(), - remote_management: proxycast_core::config::RemoteManagementConfig::default(), - quota_exceeded: proxycast_core::config::QuotaExceededConfig::default(), - proxy_url: None, - ampcode: proxycast_core::config::AmpConfig::default(), - endpoint_providers: proxycast_core::config::EndpointProvidersConfig::default(), - minimize_to_tray: true, - models: proxycast_core::config::ModelsConfig::default(), - agent: proxycast_core::config::NativeAgentConfig::default(), - language: "zh".to_string(), - experimental: proxycast_core::config::ExperimentalFeatures::default(), - tool_calling: proxycast_core::config::ToolCallingConfig::default(), - content_creator: ContentCreatorConfig::default(), - navigation: NavigationConfig::default(), - chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), - environment: proxycast_core::config::EnvironmentConfig::default(), - web_search: proxycast_core::config::WebSearchConfig::default(), - memory: proxycast_core::config::MemoryConfig::default(), - voice: proxycast_core::config::VoiceConfig::default(), - image_gen: proxycast_core::config::ImageGenConfig::default(), - assistant: proxycast_core::config::AssistantConfig::default(), - user_profile: proxycast_core::config::UserProfile::default(), - rate_limit: proxycast_core::config::RateLimitSettings::default(), - conversation: proxycast_core::config::ConversationSettings::default(), - hint_router: proxycast_core::config::HintRouterSettings::default(), - pairing: proxycast_core::config::PairingSettings::default(), - heartbeat: proxycast_core::config::HeartbeatSettings::default(), - channels: proxycast_core::config::ChannelsConfig::default(), - crash_reporting: proxycast_core::config::CrashReportingConfig::default(), - gateway: proxycast_core::config::GatewayConfig::default(), + .prop_map(|(server, providers, routing, retry, logging)| { + build_test_config(server, providers, routing, retry, logging) }) } @@ -497,46 +441,7 @@ fn arb_invalid_config() -> impl Strategy { ) .prop_map( |(server, providers, routing, retry, logging, invalid_type)| { - let mut config = Config { - server, - providers, - default_provider: routing.default_provider.clone(), - routing, - retry, - logging, - injection: InjectionSettings::default(), - auth_dir: "~/.proxycast/auth".to_string(), - credential_pool: proxycast_core::config::CredentialPoolConfig::default(), - remote_management: proxycast_core::config::RemoteManagementConfig::default(), - quota_exceeded: proxycast_core::config::QuotaExceededConfig::default(), - proxy_url: None, - ampcode: proxycast_core::config::AmpConfig::default(), - endpoint_providers: proxycast_core::config::EndpointProvidersConfig::default(), - minimize_to_tray: true, - models: proxycast_core::config::ModelsConfig::default(), - agent: proxycast_core::config::NativeAgentConfig::default(), - language: "zh".to_string(), - experimental: proxycast_core::config::ExperimentalFeatures::default(), - tool_calling: proxycast_core::config::ToolCallingConfig::default(), - content_creator: ContentCreatorConfig::default(), - navigation: NavigationConfig::default(), - chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), - environment: proxycast_core::config::EnvironmentConfig::default(), - web_search: proxycast_core::config::WebSearchConfig::default(), - memory: proxycast_core::config::MemoryConfig::default(), - voice: proxycast_core::config::VoiceConfig::default(), - image_gen: proxycast_core::config::ImageGenConfig::default(), - assistant: proxycast_core::config::AssistantConfig::default(), - user_profile: proxycast_core::config::UserProfile::default(), - rate_limit: proxycast_core::config::RateLimitSettings::default(), - conversation: proxycast_core::config::ConversationSettings::default(), - hint_router: proxycast_core::config::HintRouterSettings::default(), - pairing: proxycast_core::config::PairingSettings::default(), - heartbeat: proxycast_core::config::HeartbeatSettings::default(), - channels: proxycast_core::config::ChannelsConfig::default(), - crash_reporting: proxycast_core::config::CrashReportingConfig::default(), - gateway: proxycast_core::config::GatewayConfig::default(), - }; + let mut config = build_test_config(server, providers, routing, retry, logging); // 根据类型使配置无效 match invalid_type { InvalidConfigType::ZeroPort => config.server.port = 0, diff --git a/src-tauri/src/dev_bridge.rs b/src-tauri/src/dev_bridge.rs index 473a158be..af791d79d 100644 --- a/src-tauri/src/dev_bridge.rs +++ b/src-tauri/src/dev_bridge.rs @@ -167,22 +167,24 @@ impl DevBridgeServer { } #[cfg(debug_assertions)] -async fn invoke_command( +fn invoke_command( State(state): State, Json(req): Json, -) -> Response { - // 调用命令分发器 - match dispatcher::handle_command(&state, &req.cmd, req.args).await { - Ok(result) => Json(InvokeResponse { - result: Some(result), - error: None, - }) - .into_response(), - Err(e) => Json(InvokeResponse { - result: None, - error: Some(e.to_string()), - }) - .into_response(), +) -> impl std::future::Future + Send { + async move { + // 调用命令分发器 + match dispatcher::handle_command(&state, &req.cmd, req.args).await { + Ok(result) => Json(InvokeResponse { + result: Some(result), + error: None, + }) + .into_response(), + Err(e) => Json(InvokeResponse { + result: None, + error: Some(e.to_string()), + }) + .into_response(), + } } } diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 305f45ea7..c81e75f7e 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -2,54 +2,26 @@ //! //! 将 HTTP 请求路由到现有的 Tauri 命令函数。 -use crate::commands::content_cmd::{ - parse_theme_workbench_document_state, ContentDetail, ContentListItem, - CreateContentRequest as BridgeCreateContentRequest, - ListContentRequest as BridgeListContentRequest, ThemeWorkbenchDocumentState, - UpdateContentRequest as BridgeUpdateContentRequest, -}; -use crate::commands::workspace_cmd::{ - CreateWorkspaceRequest, UpdateWorkspaceRequest, WorkspaceEnsureResult, WorkspaceListItem, -}; -use crate::content::{ - ContentCreateRequest, ContentListQuery, ContentManager, ContentUpdateRequest, -}; +mod agent_sessions; +mod app_runtime; +mod browser; +mod content; +mod logs; +mod memory; +mod memory_runtime; +mod models; +mod openclaw; +mod project_resources; +mod providers; +mod runtime_queries; +mod skills; +mod workspace; + use crate::dev_bridge::DevBridgeState; -use crate::services::workspace_health_service::{ - ensure_workspace_ready_with_auto_relocate, ensure_workspace_root_ready, -}; -use crate::workspace::{WorkspaceManager, WorkspaceType, WorkspaceUpdate}; -use proxycast_core::app_paths; -use proxycast_memory::{MemoryCategory, MemoryMetadata, MemorySource, MemoryType, UnifiedMemory}; -use proxycast_server_utils::load_model_registry_provider_ids_from_resources; -use rusqlite::{params_from_iter, types::Value}; use serde::de::DeserializeOwned; use serde_json::Value as JsonValue; -use std::path::PathBuf; -use tauri::Manager; -fn load_model_registry_provider_ids_from_db( - state: &DevBridgeState, -) -> Result, Box> { - let Some(db) = &state.db else { - return Ok(vec![]); - }; - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let mut stmt = conn.prepare( - "SELECT DISTINCT provider_id FROM model_registry WHERE provider_id IS NOT NULL ORDER BY provider_id", - )?; - - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - let mut provider_ids = Vec::new(); - for row in rows { - provider_ids.push(row?); - } - - Ok(provider_ids) -} - -fn get_db( +pub(super) fn get_db( state: &DevBridgeState, ) -> Result<&crate::database::DbConnection, Box> { state @@ -58,7 +30,7 @@ fn get_db( .ok_or_else(|| "Database not initialized".into()) } -fn get_string_arg( +pub(super) fn get_string_arg( args: &JsonValue, primary: &str, secondary: &str, @@ -70,13 +42,7 @@ fn get_string_arg( .ok_or_else(|| format!("缺少参数: {primary}/{secondary}").into()) } -fn get_optional_bool_arg(args: &JsonValue, primary: &str, secondary: &str) -> Option { - args.get(primary) - .or_else(|| args.get(secondary)) - .and_then(|value| value.as_bool()) -} - -fn parse_nested_arg( +pub(super) fn parse_nested_arg( args: &JsonValue, key: &str, ) -> Result> { @@ -84,7 +50,7 @@ fn parse_nested_arg( Ok(serde_json::from_value(payload)?) } -fn parse_optional_nested_arg( +pub(super) fn parse_optional_nested_arg( args: &JsonValue, key: &str, ) -> Result, Box> { @@ -95,189 +61,18 @@ fn parse_optional_nested_arg( } } -fn get_workspace_projects_root_dir() -> Result { - app_paths::resolve_projects_dir() +pub(super) fn args_or_default(args: Option<&JsonValue>) -> JsonValue { + args.cloned().unwrap_or_default() } -fn mask_api_key_for_display(key: &str) -> String { - let chars: Vec = key.chars().collect(); - if chars.len() <= 12 { - "****".to_string() - } else { - let prefix: String = chars[..6].iter().collect(); - let suffix: String = chars[chars.len() - 4..].iter().collect(); - format!("{prefix}****{suffix}") - } -} - -fn api_key_provider_with_keys_to_display( - provider_with_keys: &crate::database::dao::api_key_provider::ProviderWithKeys, - service: &proxycast_services::api_key_provider_service::ApiKeyProviderService, -) -> crate::commands::api_key_provider_cmd::ProviderWithKeysDisplay { - let api_keys = provider_with_keys - .api_keys - .iter() - .map(|key| { - let masked = match service.decrypt_api_key(&key.api_key_encrypted) { - Ok(decrypted) => mask_api_key_for_display(&decrypted), - Err(_) => "****".to_string(), - }; - - crate::commands::api_key_provider_cmd::ApiKeyDisplay { - id: key.id.clone(), - provider_id: key.provider_id.clone(), - api_key_masked: masked, - alias: key.alias.clone(), - enabled: key.enabled, - usage_count: key.usage_count, - error_count: key.error_count, - last_used_at: key.last_used_at.map(|value| value.to_rfc3339()), - created_at: key.created_at.to_rfc3339(), - } - }) - .collect(); - - crate::commands::api_key_provider_cmd::ProviderWithKeysDisplay { - provider: crate::commands::api_key_provider_cmd::ProviderDisplay { - id: provider_with_keys.provider.id.clone(), - name: provider_with_keys.provider.name.clone(), - provider_type: provider_with_keys.provider.provider_type.to_string(), - api_host: provider_with_keys.provider.api_host.clone(), - is_system: provider_with_keys.provider.is_system, - group: provider_with_keys.provider.group.to_string(), - enabled: provider_with_keys.provider.enabled, - sort_order: provider_with_keys.provider.sort_order, - api_version: provider_with_keys.provider.api_version.clone(), - project: provider_with_keys.provider.project.clone(), - location: provider_with_keys.provider.location.clone(), - region: provider_with_keys.provider.region.clone(), - custom_models: provider_with_keys.provider.custom_models.clone(), - api_key_count: provider_with_keys.api_keys.len(), - created_at: provider_with_keys.provider.created_at.to_rfc3339(), - updated_at: provider_with_keys.provider.updated_at.to_rfc3339(), - }, - api_keys, - } -} - -fn parse_unified_memory_row(row: &rusqlite::Row) -> Result { - let id: String = row.get(0)?; - let session_id: String = row.get(1)?; - let memory_type_json: String = row.get(2)?; - let category_json: String = row.get(3)?; - let title: String = row.get(4)?; - let content: String = row.get(5)?; - let summary: String = row.get(6)?; - let tags_json: String = row.get(7)?; - let confidence: f32 = row.get(8)?; - let importance: i64 = row.get(9)?; - let access_count: i64 = row.get(10)?; - let last_accessed_at: Option = row.get(11)?; - let source_json: String = row.get(12)?; - let created_at: i64 = row.get(13)?; - let updated_at: i64 = row.get(14)?; - let archived: i64 = row.get(15)?; - - let memory_type: MemoryType = serde_json::from_str(&memory_type_json) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - let category: MemoryCategory = serde_json::from_str(&category_json) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - let tags: Vec = serde_json::from_str(&tags_json) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - let source: MemorySource = serde_json::from_str(&source_json) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - - Ok(UnifiedMemory { - id, - session_id, - memory_type, - category, - title, - content, - summary, - tags, - metadata: MemoryMetadata { - confidence, - importance: importance.clamp(0, 10) as u8, - access_count: access_count.max(0) as u32, - last_accessed_at, - source, - embedding: None, - }, - created_at, - updated_at, - archived: archived != 0, - }) -} - -fn unified_memory_category_to_key(category: &MemoryCategory) -> &'static str { - match category { - MemoryCategory::Identity => "identity", - MemoryCategory::Context => "context", - MemoryCategory::Preference => "preference", - MemoryCategory::Experience => "experience", - MemoryCategory::Activity => "activity", - } -} - -fn ordered_unified_categories() -> [&'static str; 5] { - [ - "identity", - "context", - "preference", - "experience", - "activity", - ] -} - -fn normalize_unified_category_value(value: &str) -> Option<&'static str> { - if let Ok(category) = serde_json::from_str::(value) { - return Some(unified_memory_category_to_key(&category)); - } - - match value.trim_matches('"').to_lowercase().as_str() { - "identity" | "身份" => Some("identity"), - "context" | "情境" | "上下文" => Some("context"), - "preference" | "偏好" => Some("preference"), - "experience" | "经验" => Some("experience"), - "activity" | "活动" => Some("activity"), - _ => None, - } -} - -fn normalize_unified_sort_by(sort_by: Option<&str>) -> &'static str { - match sort_by.unwrap_or("updated_at") { - "created_at" => "created_at", - "importance" => "importance", - "access_count" => "access_count", - _ => "updated_at", - } -} - -fn normalize_unified_sort_order(order: Option<&str>) -> &'static str { - match order.unwrap_or("desc").to_lowercase().as_str() { - "asc" => "ASC", - _ => "DESC", - } -} - -fn sanitize_project_dir_name(name: &str) -> String { - let sanitized: String = name - .trim() - .chars() - .map(|ch| match ch { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', - _ if ch.is_control() => '_', - _ => ch, - }) - .collect(); - - let trimmed = sanitized.trim().trim_matches('.').to_string(); - if trimmed.is_empty() { - "未命名项目".to_string() - } else { - trimmed - } +pub(super) fn require_app_handle( + state: &DevBridgeState, +) -> Result> { + state + .app_handle + .as_ref() + .cloned() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string().into()) } /// 处理 HTTP 桥接命令请求 @@ -288,1699 +83,72 @@ pub async fn handle_command( cmd: &str, args: Option, ) -> Result> { - match cmd { - // ========== P0 - 核心配置 ========== - "get_config" => { - // 从配置文件读取 - let config_path = proxycast_core::config::ConfigManager::default_config_path(); - let manager = proxycast_core::config::ConfigManager::load(&config_path)?; - let config = manager.config(); - Ok(serde_json::to_value(config)?) - } - - "save_config" => { - // 保存配置到文件 - let config: proxycast_core::config::Config = serde_json::from_value(args.unwrap_or_default())?; - proxycast_core::config::save_config(&config)?; - crate::services::environment_service::apply_configured_environment(&config).await; - Ok(serde_json::json!({ "success": true })) - } - - "get_environment_preview" => { - let config_path = proxycast_core::config::ConfigManager::default_config_path(); - let manager = proxycast_core::config::ConfigManager::load(&config_path)?; - let config = manager.config(); - let preview = crate::services::environment_service::build_environment_preview(&config).await; - Ok(serde_json::to_value(preview)?) - } - - "get_default_provider" => { - let default_provider_ref = { state.server.read().await.default_provider_ref.clone() }; - let provider = default_provider_ref.read().await.clone(); - Ok(serde_json::json!(provider)) - } - - "get_endpoint_providers" => { - let providers = { state.server.read().await.config.endpoint_providers.clone() }; - Ok(serde_json::to_value(providers)?) - } - - // ========== P0 - 服务器状态 ========== - "get_server_status" => { - let status = { state.server.read().await.status() }; - Ok(serde_json::to_value(status)?) - } - - "get_server_diagnostics" => { - let (status, capability_routing, response_cache, request_dedup, idempotency) = { - let server = state.server.read().await; - ( - server.status(), - server.capability_routing_metrics_store.snapshot(), - server.response_cache_store.clone(), - server.request_dedup_store.clone(), - server.idempotency_store.clone(), - ) - }; - - let telemetry_summary = state.shared_stats.read().summary(None); - let diagnostics = proxycast_server::build_server_diagnostics( - status.running, - status.host, - status.port, - telemetry_summary, - capability_routing, - response_cache.as_ref(), - request_dedup.as_ref(), - idempotency.as_ref(), - ); - Ok(serde_json::to_value(diagnostics)?) - } - - // ========== P0 - 浏览器/CDP ========== - "open_chrome_profile_window" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())? - .clone(); - let request: crate::commands::webview_cmd::OpenChromeProfileRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::open_chrome_profile_window_global( - app_handle, - state.server.clone(), - request, - ) - .await?, - )?) - } - - "open_browser_runtime_debugger_window" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())? - .clone(); - let request: Option = - parse_optional_nested_arg(&args.unwrap_or_default(), "request")?; - crate::commands::browser_runtime_cmd::open_browser_runtime_debugger_window( - app_handle, request, - )?; - Ok(serde_json::json!({ "success": true })) - } - - "close_browser_runtime_debugger_window" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())? - .clone(); - crate::commands::browser_runtime_cmd::close_browser_runtime_debugger_window(app_handle)?; - Ok(serde_json::json!({ "success": true })) - } - - "launch_browser_runtime_assist" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())? - .clone(); - let request: crate::commands::browser_runtime_cmd::LaunchBrowserRuntimeAssistRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::browser_runtime_cmd::launch_browser_runtime_assist_global( - app_handle, - state.server.clone(), - request, - ) - .await?, - )?) - } - - "get_chrome_profile_sessions" => Ok(serde_json::to_value( - crate::commands::webview_cmd::get_chrome_profile_sessions_global().await?, - )?), - - "close_chrome_profile_session" => { - let args = args.unwrap_or_default(); - let profile_key = get_string_arg(&args, "profileKey", "profile_key")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::close_chrome_profile_session_global(profile_key) - .await?, - )?) - } - - "get_chrome_bridge_endpoint_info" => Ok(serde_json::to_value( - crate::commands::webview_cmd::get_chrome_bridge_endpoint_info_global( - state.server.clone(), - ) - .await?, - )?), - - "get_chrome_bridge_status" => Ok(serde_json::to_value( - crate::commands::webview_cmd::get_chrome_bridge_status_global().await?, - )?), - - "get_browser_backend_policy" => Ok(serde_json::to_value( - crate::commands::webview_cmd::get_browser_backend_policy_global().await?, - )?), - - "set_browser_backend_policy" => { - let args = args.unwrap_or_default(); - let policy: crate::commands::webview_cmd::BrowserBackendPolicy = - parse_nested_arg(&args, "policy")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::set_browser_backend_policy_global(policy).await?, - )?) - } - - "get_browser_backends_status" => Ok(serde_json::to_value( - crate::commands::webview_cmd::get_browser_backends_status_global().await?, - )?), - - "list_cdp_targets" => { - let request: crate::commands::webview_cmd::ListCdpTargetsRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::list_cdp_targets_global(request).await?, - )?) - } - - "open_cdp_session" => { - let request: crate::commands::webview_cmd::OpenCdpSessionRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::open_cdp_session_global(request).await?, - )?) - } - - "close_cdp_session" => { - let request: crate::commands::webview_cmd::BrowserSessionStateRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::close_cdp_session_global(request).await?, - )?) - } - - "start_browser_stream" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())? - .clone(); - let request: crate::commands::webview_cmd::StartBrowserStreamRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::start_browser_stream_global(app_handle, request) - .await?, - )?) - } - - "stop_browser_stream" => { - let request: crate::commands::webview_cmd::StopBrowserStreamRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::stop_browser_stream_global(request).await?, - )?) - } - - "get_browser_session_state" => { - let request: crate::commands::webview_cmd::BrowserSessionStateRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::get_browser_session_state_global(request).await?, - )?) - } - - "take_over_browser_session" => { - let request: crate::commands::webview_cmd::UpdateBrowserSessionControlRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::take_over_browser_session_global(request).await?, - )?) - } - - "release_browser_session" => { - let request: crate::commands::webview_cmd::UpdateBrowserSessionControlRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::release_browser_session_global(request).await?, - )?) - } - - "resume_browser_session" => { - let request: crate::commands::webview_cmd::UpdateBrowserSessionControlRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::resume_browser_session_global(request).await?, - )?) - } - - "get_browser_event_buffer" => { - let request: crate::commands::webview_cmd::BrowserEventBufferRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::get_browser_event_buffer_global(request).await?, - )?) - } - - "browser_execute_action" => { - let request: crate::commands::webview_cmd::BrowserActionRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - Ok(serde_json::to_value( - crate::commands::webview_cmd::browser_execute_action_global(request).await?, - )?) - } - - "get_browser_action_audit_logs" => { - let limit = args - .as_ref() - .and_then(|value| value.get("limit")) - .and_then(|value| value.as_u64()) - .map(|value| value as usize); - Ok(serde_json::to_value( - crate::commands::webview_cmd::get_browser_action_audit_logs_global(limit).await?, - )?) - } - - // ========== P1 - 日志相关 ========== - "get_logs" => { - let logs = state.logs.read().await; - let entries = logs.get_logs(); - // 限制返回最近 100 条 - let limit = entries.len().min(100); - let recent: Vec<_> = entries.into_iter().rev().take(limit).map(|e| serde_json::json!({ - "timestamp": e.timestamp, - "level": e.level, - "message": e.message, - })).collect(); - Ok(serde_json::to_value(recent)?) - } - - "get_persisted_logs_tail" => { - let requested = args - .as_ref() - .and_then(|value| value.get("lines")) - .and_then(|value| value.as_u64()) - .map(|value| value as usize) - .unwrap_or(200) - .clamp(20, 1000); - - let logs = state.logs.read().await; - let entries = crate::app::commands::read_persisted_logs_tail_from_path( - logs.get_log_file_path(), - requested, - )?; - Ok(serde_json::to_value(entries)?) - } - - "get_log_storage_diagnostics" => { - let logs = state.logs.read().await; - let diagnostics = crate::app::commands::get_log_storage_diagnostics_from_path( - logs.get_log_file_path(), - logs.get_logs().len(), - ); - Ok(serde_json::to_value(diagnostics)?) - } - - "get_windows_startup_diagnostics" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let diagnostics = crate::commands::windows_startup_cmd::collect_windows_startup_diagnostics(app_handle); - Ok(serde_json::to_value(diagnostics)?) - } - - "clear_logs" => { - state.logs.write().await.clear(); - Ok(serde_json::json!({ "success": true })) - } - - "clear_diagnostic_log_history" => { - let log_file_path = { state.logs.read().await.get_log_file_path() }; - state.logs.write().await.clear(); - crate::app::commands::clear_diagnostic_log_artifacts_from_path(log_file_path)?; - Ok(serde_json::json!({ "success": true })) - } - - // ========== Provider Pool ========== - "get_provider_pool_overview" => { - if let Some(db) = &state.db { - let overview = state.pool_service.get_overview(db)?; - Ok(serde_json::to_value(overview)?) - } else { - Ok(serde_json::json!([])) - } - } - - "get_api_key_providers" => { - if let Some(db) = &state.db { - let providers = state.api_key_provider_service.get_all_providers(db)?; - let items: Vec<_> = providers - .iter() - .map(|provider| { - api_key_provider_with_keys_to_display( - provider, - state.api_key_provider_service.as_ref(), - ) - }) - .collect(); - Ok(serde_json::to_value(items)?) - } else { - Ok(serde_json::json!([])) - } - } - - "get_system_provider_catalog" => { - let catalog = crate::commands::api_key_provider_cmd::get_system_provider_catalog() - .map_err(|e| format!("获取系统 Provider Catalog 失败: {e}"))?; - Ok(serde_json::to_value(catalog)?) - } - - "get_provider_pool_credentials" => { - // 获取所有凭证详细信息 - if let Some(db) = &state.db { - let conn = db.lock().map_err(|e| e.to_string())?; - let credentials = crate::database::dao::provider_pool::ProviderPoolDao::get_all(&conn) - .unwrap_or_default(); - Ok(serde_json::to_value(credentials)?) - } else { - Ok(serde_json::json!([])) - } - } - - "get_provider_ui_state" => { - let args = args.unwrap_or_default(); - let key = get_string_arg(&args, "key", "key")?; - - if let Some(db) = &state.db { - let value = state.api_key_provider_service.get_ui_state(db, &key)?; - Ok(serde_json::to_value(value)?) - } else { - Ok(serde_json::Value::Null) - } - } - - "set_provider_ui_state" => { - let args = args.unwrap_or_default(); - let key = get_string_arg(&args, "key", "key")?; - let value = get_string_arg(&args, "value", "value")?; - - if let Some(db) = &state.db { - state - .api_key_provider_service - .set_ui_state(db, &key, &value) - .map_err(|e| format!("设置 Provider UI 状态失败: {e}"))?; - Ok(serde_json::json!({ "success": true })) - } else { - Err("Database not initialized".into()) - } - } - - "list_relay_providers" => { - let state_guard = state.connect_state.read().await; - if let Some(connect_state) = state_guard.as_ref() { - Ok(serde_json::to_value(connect_state.registry.list())?) - } else { - Ok(serde_json::json!([])) - } - } - - "refresh_relay_registry" => { - let state_guard = state.connect_state.read().await; - if let Some(connect_state) = state_guard.as_ref() { - connect_state - .registry - .load_from_remote() - .await - .map_err(|e| format!("刷新中转商注册表失败: {e}"))?; - Ok(serde_json::json!(connect_state.registry.len())) - } else { - Err("Connect 模块未初始化".into()) - } - } - - "get_skills_for_app" => { - let args = args.unwrap_or_default(); - let app = args - .get("app") - .and_then(|value| value.as_str()) - .unwrap_or("proxycast") - .to_string(); - let refresh_remote = args - .get("refresh_remote") - .or_else(|| args.get("refreshRemote")) - .and_then(|value| value.as_bool()) - .unwrap_or(false); - let app_type: crate::models::app_type::AppType = app.parse().map_err(|e: String| e)?; - - if let Some(db) = &state.db { - let skills = crate::commands::skill_cmd::resolve_skills_for_app( - db, - &state.skill_service, - &app_type, - refresh_remote, - ) - .await - .map_err(|e| e.to_string())?; - - Ok(serde_json::to_value(skills)?) - } else { - Ok(serde_json::json!([])) - } - } - - "get_local_skills_for_app" => { - let args = args.unwrap_or_default(); - let app = args - .get("app") - .and_then(|value| value.as_str()) - .unwrap_or("proxycast") - .to_string(); - - if let Some(db) = &state.db { - let app_type: crate::models::app_type::AppType = app.parse().map_err(|e: String| e)?; - let installed_states = { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - crate::database::dao::skills::SkillDao::get_skills(&conn) - .map_err(|e| format!("{e}"))? - }; - let skills = state - .skill_service - .list_local_skills(&app_type, &installed_states) - .map_err(|e| format!("{e}"))?; - Ok(serde_json::to_value(skills)?) - } else { - Ok(serde_json::json!([])) - } - } - - "inspect_local_skill_for_app" => { - let args = args.unwrap_or_default(); - let app = args - .get("app") - .and_then(|value| value.as_str()) - .unwrap_or("proxycast") - .to_string(); - let directory = get_string_arg(&args, "directory", "directory")?; - let inspection = crate::commands::skill_cmd::inspect_local_skill_for_app(app, directory) - .map_err(|e| format!("检查本地 Skill 失败: {e}"))?; - Ok(serde_json::to_value(inspection)?) - } - - "create_skill_scaffold_for_app" => { - let args = args.unwrap_or_default(); - let app = args - .get("app") - .and_then(|value| value.as_str()) - .unwrap_or("proxycast") - .to_string(); - let target = get_string_arg(&args, "target", "target")?; - let directory = get_string_arg(&args, "directory", "directory")?; - let name = get_string_arg(&args, "name", "name")?; - let description = get_string_arg(&args, "description", "description")?; - let inspection = crate::commands::skill_cmd::create_skill_scaffold_for_app( - app, - target, - directory, - name, - description, - ) - .map_err(|e| format!("创建 Skill 脚手架失败: {e}"))?; - Ok(serde_json::to_value(inspection)?) - } - - "import_local_skill_for_app" => { - let args = args.unwrap_or_default(); - let app = args - .get("app") - .and_then(|value| value.as_str()) - .unwrap_or("proxycast") - .to_string(); - let source_path = get_string_arg(&args, "source_path", "source_path") - .or_else(|_| get_string_arg(&args, "sourcePath", "sourcePath"))?; - let result = crate::commands::skill_cmd::import_local_skill_for_app(app, source_path) - .map_err(|e| format!("导入本地 Skill 失败: {e}"))?; - Ok(serde_json::to_value(result)?) - } - - "inspect_remote_skill" => { - let args = args.unwrap_or_default(); - let owner = get_string_arg(&args, "owner", "owner")?; - let name = get_string_arg(&args, "name", "name")?; - let branch = get_string_arg(&args, "branch", "branch")?; - let directory = get_string_arg(&args, "directory", "directory")?; - let inspection = state - .skill_service - .inspect_remote_skill(&owner, &name, &branch, &directory) - .await - .map_err(|e| format!("检查远程 Skill 失败: {e}"))?; - Ok(serde_json::to_value(inspection)?) - } - - "test_api" => { - // 测试 API 连接 - // 从 args 获取 provider - let args = args.ok_or("缺少参数")?; - let provider = args - .get("provider") - .and_then(|v| v.as_str()) - .ok_or("缺少 provider 参数")?; - - // 选择凭证 - let credential = if let Some(db) = &state.db { - state - .pool_service - .select_credential(db, provider, None) - .ok() - .flatten() - } else { - None - }; - - match credential { - Some(cred) => { - state - .logs - .write() - .await - .add("info", &format!("[DevBridge] 测试 API 使用凭证: {:?}", cred.name)); - - Ok(serde_json::json!({ - "success": true, - "credential_name": cred.name, - "provider_type": cred.provider_type, - })) - } - None => Ok(serde_json::json!({ - "success": false, - "error": "未找到可用凭证" - })), - } - } - - // ========== Workspace / Content ========== - "workspace_create" => { - let args = args.unwrap_or_default(); - let request: CreateWorkspaceRequest = parse_nested_arg(&args, "request")?; - - if request.root_path.contains("[object Promise]") { - return Err(format!( - "无效的 root_path: {}。请确保前端正确 await 了 Promise。", - request.root_path - ) - .into()); - } - - let manager = WorkspaceManager::new(get_db(state)?.clone()); - let workspace_type = request - .workspace_type - .map(|workspace_type| WorkspaceType::parse(&workspace_type)) - .unwrap_or_default(); - let root_path = PathBuf::from(&request.root_path); - - ensure_workspace_root_ready(&root_path)?; - - let workspace = manager.create_with_type(request.name, root_path, workspace_type)?; - Ok(serde_json::to_value(WorkspaceListItem::from(workspace))?) - } - - "workspace_list" => { - let manager = WorkspaceManager::new(get_db(state)?.clone()); - let workspaces = manager.list()?; - let items: Vec<_> = workspaces.into_iter().map(WorkspaceListItem::from).collect(); - Ok(serde_json::to_value(items)?) - } - - "workspace_get" => { - let args = args.unwrap_or_default(); - let id = get_string_arg(&args, "id", "id")?; - let manager = WorkspaceManager::new(get_db(state)?.clone()); - let workspace = manager.get(&id)?; - Ok(serde_json::to_value(workspace.map(WorkspaceListItem::from))?) - } - - "workspace_update" => { - let args = args.unwrap_or_default(); - let id = get_string_arg(&args, "id", "id")?; - let request: UpdateWorkspaceRequest = parse_nested_arg(&args, "request")?; - let manager = WorkspaceManager::new(get_db(state)?.clone()); - - let new_root_path = if let Some(ref path_str) = request.root_path { - let path = PathBuf::from(path_str); - let created = ensure_workspace_root_ready(&path)?; - if created { - tracing::warn!( - "[Workspace] 更新路径时检测到目录缺失,已自动创建: {}", - path.to_string_lossy() - ); - } - Some(path) - } else { - None - }; - - let updates = WorkspaceUpdate { - name: request.name, - settings: request.settings, - icon: request.icon, - color: request.color, - is_favorite: request.is_favorite, - is_archived: request.is_archived, - tags: request.tags, - root_path: new_root_path, - }; - - let workspace = manager.update(&id, updates)?; - Ok(serde_json::to_value(WorkspaceListItem::from(workspace))?) - } - - "workspace_delete" => { - let args = args.unwrap_or_default(); - let id = get_string_arg(&args, "id", "id")?; - let delete_directory = - get_optional_bool_arg(&args, "deleteDirectory", "delete_directory") - .unwrap_or(false); - let manager = WorkspaceManager::new(get_db(state)?.clone()); - - if delete_directory { - if let Some(workspace) = manager.get(&id)? { - let root_path = workspace.root_path; - if root_path.exists() && root_path.is_dir() { - std::fs::remove_dir_all(&root_path) - .map_err(|e| format!("删除目录失败: {e}"))?; - } - } - } - - Ok(serde_json::to_value(manager.delete(&id)?)?) - } - - "workspace_set_default" => { - let args = args.unwrap_or_default(); - let id = get_string_arg(&args, "id", "id")?; - let manager = WorkspaceManager::new(get_db(state)?.clone()); - manager.set_default(&id)?; - Ok(serde_json::json!(null)) - } - - "workspace_get_default" => { - let manager = WorkspaceManager::new(get_db(state)?.clone()); - let workspace = manager.get_default()?; - Ok(serde_json::to_value(workspace.map(WorkspaceListItem::from))?) - } - - "workspace_ensure_ready" => { - let args = args.unwrap_or_default(); - let id = get_string_arg(&args, "id", "id")?; - let manager = WorkspaceManager::new(get_db(state)?.clone()); - let workspace = manager - .get(&id)? - .ok_or_else(|| format!("Workspace 不存在: {id}"))?; - let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; - let result = WorkspaceEnsureResult { - workspace_id: workspace.id, - root_path: ensured.root_path.to_string_lossy().to_string(), - existed: ensured.existed, - created: ensured.created, - repaired: ensured.repaired, - relocated: ensured.relocated, - previous_root_path: ensured - .previous_root_path - .as_ref() - .map(|path| path.to_string_lossy().to_string()), - warning: ensured.warning, - }; - Ok(serde_json::to_value(result)?) - } - - "workspace_ensure_default_ready" => { - let manager = WorkspaceManager::new(get_db(state)?.clone()); - let Some(workspace) = manager.get_default()? else { - return Ok(serde_json::json!(null)); - }; - let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; - let result = WorkspaceEnsureResult { - workspace_id: workspace.id, - root_path: ensured.root_path.to_string_lossy().to_string(), - existed: ensured.existed, - created: ensured.created, - repaired: ensured.repaired, - relocated: ensured.relocated, - previous_root_path: ensured - .previous_root_path - .as_ref() - .map(|path| path.to_string_lossy().to_string()), - warning: ensured.warning, - }; - Ok(serde_json::to_value(Some(result))?) - } - - "workspace_get_by_path" => { - let args = args.unwrap_or_default(); - let root_path = get_string_arg(&args, "rootPath", "root_path")?; - let manager = WorkspaceManager::new(get_db(state)?.clone()); - let workspace = manager.get_by_path(&PathBuf::from(root_path))?; - Ok(serde_json::to_value(workspace.map(WorkspaceListItem::from))?) - } - - "workspace_get_projects_root" => { - let root_dir = get_workspace_projects_root_dir()?; - Ok(serde_json::json!(root_dir.to_string_lossy().to_string())) - } - - "workspace_resolve_project_path" => { - let args = args.unwrap_or_default(); - let name = get_string_arg(&args, "name", "name")?; - let root_dir = get_workspace_projects_root_dir()?; - let dir_name = sanitize_project_dir_name(&name); - let project_path = root_dir.join(dir_name); - Ok(serde_json::json!(project_path.to_string_lossy().to_string())) - } - - "get_or_create_default_project" => { - let manager = WorkspaceManager::new(get_db(state)?.clone()); - - if let Some(workspace) = manager.get_default()? { - return Ok(serde_json::to_value(WorkspaceListItem::from(workspace))?); - } - - let default_project_path = get_workspace_projects_root_dir()?.join("default"); - std::fs::create_dir_all(&default_project_path) - .map_err(|e| format!("创建默认项目目录失败: {e}"))?; - - let workspace = manager.create_with_type( - "默认项目".to_string(), - default_project_path, - WorkspaceType::Persistent, - )?; - manager.set_default(&workspace.id)?; - let workspace = manager.get(&workspace.id)?.ok_or("创建默认项目失败")?; - Ok(serde_json::to_value(WorkspaceListItem::from(workspace))?) - } - - "content_create" => { - let args = args.unwrap_or_default(); - let request: BridgeCreateContentRequest = parse_nested_arg(&args, "request")?; - let manager = ContentManager::new(get_db(state)?.clone()); - let create_request = ContentCreateRequest { - project_id: request.project_id, - title: request.title, - content_type: request - .content_type - .map(|value| value.parse::().unwrap_or_default()), - order: request.order, - body: request.body, - metadata: request.metadata, - }; - let content = manager.create(create_request)?; - Ok(serde_json::to_value(ContentDetail::from(content))?) - } - - "content_get" => { - let args = args.unwrap_or_default(); - let id = get_string_arg(&args, "id", "id")?; - let manager = ContentManager::new(get_db(state)?.clone()); - let content = manager.get(&id)?; - Ok(serde_json::to_value(content.map(ContentDetail::from))?) - } - - "content_get_theme_workbench_document_state" => { - let args = args.unwrap_or_default(); - let id = get_string_arg(&args, "id", "id")?; - let manager = ContentManager::new(get_db(state)?.clone()); - let content = manager.get(&id)?; - let document_state: Option = content.and_then(|item| { - parse_theme_workbench_document_state(&item.id, item.metadata.as_ref()) - }); - Ok(serde_json::to_value(document_state)?) - } - - "content_list" => { - let args = args.unwrap_or_default(); - let project_id = get_string_arg(&args, "projectId", "project_id")?; - let query: Option = parse_optional_nested_arg(&args, "query")?; - let manager = ContentManager::new(get_db(state)?.clone()); - let list_query = query.map(|query| ContentListQuery { - status: query.status.map(|value| value.parse().unwrap_or_default()), - content_type: query - .content_type - .map(|value| value.parse::().unwrap_or_default()), - search: query.search, - sort_by: query.sort_by, - sort_order: query.sort_order, - offset: query.offset, - limit: query.limit, - }); - let contents = manager.list_by_project(&project_id, list_query)?; - let items: Vec<_> = contents.into_iter().map(ContentListItem::from).collect(); - Ok(serde_json::to_value(items)?) - } - - "content_update" => { - let args = args.unwrap_or_default(); - let id = get_string_arg(&args, "id", "id")?; - let request: BridgeUpdateContentRequest = parse_nested_arg(&args, "request")?; - let manager = ContentManager::new(get_db(state)?.clone()); - let update_request = ContentUpdateRequest { - title: request.title, - status: request.status.map(|value| value.parse().unwrap_or_default()), - order: request.order, - body: request.body, - metadata: request.metadata, - session_id: request.session_id, - }; - let content = manager.update(&id, update_request)?; - Ok(serde_json::to_value(ContentDetail::from(content))?) - } - - "content_delete" => { - let args = args.unwrap_or_default(); - let id = get_string_arg(&args, "id", "id")?; - let manager = ContentManager::new(get_db(state)?.clone()); - Ok(serde_json::to_value(manager.delete(&id)?)?) - } - - "content_reorder" => { - let args = args.unwrap_or_default(); - let project_id = get_string_arg(&args, "projectId", "project_id")?; - let content_ids = args - .get("contentIds") - .or_else(|| args.get("content_ids")) - .cloned() - .ok_or("缺少参数: contentIds/content_ids")?; - let content_ids: Vec = serde_json::from_value(content_ids)?; - let manager = ContentManager::new(get_db(state)?.clone()); - manager.reorder(&project_id, content_ids)?; - Ok(serde_json::json!(null)) - } - - "content_stats" => { - let args = args.unwrap_or_default(); - let project_id = get_string_arg(&args, "projectId", "project_id")?; - let manager = ContentManager::new(get_db(state)?.clone()); - Ok(serde_json::to_value(manager.get_project_stats(&project_id)?)?) - } - - "list_materials" => { - let args = args.unwrap_or_default(); - let project_id = get_string_arg(&args, "project_id", "projectId")?; - let filter: Option = - parse_optional_nested_arg(&args, "filter")?; - - if let Some(db) = &state.db { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let materials = proxycast_services::material_service::MaterialService::list_materials( - &conn, - &project_id, - filter, - ) - .map_err(|e| format!("获取素材列表失败: {e}"))?; - Ok(serde_json::to_value(materials)?) - } else { - Ok(serde_json::json!([])) - } - } - - "get_material_count" => { - let args = args.unwrap_or_default(); - let project_id = get_string_arg(&args, "project_id", "projectId")?; - - if let Some(db) = &state.db { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let count = crate::database::dao::material_dao::MaterialDao::count(&conn, &project_id) - .map_err(|e| format!("获取素材数量失败: {e}"))?; - Ok(serde_json::json!(count)) - } else { - Ok(serde_json::json!(0)) - } - } - - "project_memory_get" => { - let args = args.unwrap_or_default(); - let project_id = get_string_arg(&args, "project_id", "projectId")?; - - if let Some(db) = &state.db { - let manager = crate::memory::MemoryManager::new(db.clone()); - let memory = manager - .get_project_memory(&project_id) - .map_err(|e| format!("获取项目记忆失败: {e}"))?; - Ok(serde_json::to_value(memory)?) - } else { - Err("Database not initialized".into()) - } - } - - // ========== 模型相关 ========== - "get_models" => { - // 返回可用模型列表 - Ok(serde_json::json!({ - "data": [ - {"id": "claude-sonnet-4-20250514", "object": "model", "owned_by": "anthropic"}, - {"id": "claude-opus-4-20250514", "object": "model", "owned_by": "anthropic"}, - {"id": "claude-haiku-4-20250514", "object": "model", "owned_by": "anthropic"}, - {"id": "gpt-4o", "object": "model", "owned_by": "openai"}, - {"id": "gpt-4o-mini", "object": "model", "owned_by": "openai"}, - ] - })) - } - - "get_model_registry" => { - let guard = state.model_registry.read().await; - let service = guard - .as_ref() - .ok_or_else(|| "模型注册服务未初始化".to_string())?; - - Ok(serde_json::to_value(service.get_all_models().await)?) - } - - "get_model_preferences" => { - let guard = state.model_registry.read().await; - let service = guard - .as_ref() - .ok_or_else(|| "模型注册服务未初始化".to_string())?; - - let preferences = service.get_all_preferences().await?; - Ok(serde_json::to_value(preferences)?) - } - - "get_model_sync_state" => { - let guard = state.model_registry.read().await; - let service = guard - .as_ref() - .ok_or_else(|| "模型注册服务未初始化".to_string())?; - - Ok(serde_json::to_value(service.get_sync_state().await)?) - } - - "refresh_model_registry" => { - let guard = state.model_registry.read().await; - let service = guard - .as_ref() - .ok_or_else(|| "模型注册服务未初始化".to_string())?; - - let count = service.force_reload().await?; - Ok(serde_json::json!(count)) - } - - "get_model_registry_provider_ids" => { - match load_model_registry_provider_ids_from_resources() { - Ok(provider_ids) => Ok(serde_json::to_value(provider_ids)?), - Err(resource_error) => { - let fallback = load_model_registry_provider_ids_from_db(state)?; - if fallback.is_empty() { - Err(format!( - "获取模型 Provider ID 失败(resources 与数据库均不可用): {resource_error}" - ) - .into()) - } else { - Ok(serde_json::to_value(fallback)?) - } - } - } - } - - "unified_memory_stats" => { - if let Some(db) = &state.db { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - - let (total_entries, memory_count, storage_used): (i64, i64, i64) = conn - .query_row( - "SELECT COUNT(*), COUNT(DISTINCT session_id), COALESCE(SUM(length(title) + length(content) + length(summary) + length(tags)), 0) FROM unified_memory WHERE archived = 0", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .map_err(|e| format!("统计记忆失败: {e}"))?; - - let mut category_counts: std::collections::HashMap = - std::collections::HashMap::new(); - let mut stmt = conn - .prepare( - "SELECT category, COUNT(*) FROM unified_memory WHERE archived = 0 GROUP BY category", - ) - .map_err(|e| format!("构建分类统计查询失败: {e}"))?; - - let rows = stmt - .query_map([], |row| { - let category_raw: String = row.get(0)?; - let count: i64 = row.get(1)?; - Ok((category_raw, count)) - }) - .map_err(|e| format!("分类统计查询失败: {e}"))?; - - for row in rows.flatten() { - if let Some(category) = normalize_unified_category_value(&row.0) { - category_counts.insert(category.to_string(), row.1.max(0) as u32); - } - } - - let categories = ordered_unified_categories() - .iter() - .map(|category| crate::commands::unified_memory_cmd::MemoryCategoryStat { - category: (*category).to_string(), - count: *category_counts.get(*category).unwrap_or(&0), - }) - .collect(); - - let response = crate::commands::unified_memory_cmd::MemoryStatsResponse { - total_entries: total_entries.max(0) as u32, - storage_used: storage_used.max(0) as u64, - memory_count: memory_count.max(0) as u32, - categories, - }; - - Ok(serde_json::to_value(response)?) - } else { - Ok(serde_json::json!({ - "total_entries": 0, - "storage_used": 0, - "memory_count": 0, - "categories": [], - })) - } - } - - "unified_memory_list" => { - let args = args.unwrap_or_default(); - let filters: Option = - parse_optional_nested_arg(&args, "filters")?; - let filters = filters.unwrap_or_default(); - - if let Some(db) = &state.db { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - - let archived = filters.archived.unwrap_or(false); - let sort_by = normalize_unified_sort_by(filters.sort_by.as_deref()); - let order = normalize_unified_sort_order(filters.order.as_deref()); - let limit = filters.limit.unwrap_or(120).clamp(1, 1000) as i64; - let offset = filters.offset.unwrap_or(0) as i64; - - let mut where_parts = vec!["archived = ?".to_string()]; - let mut values: Vec = vec![Value::from(if archived { 1 } else { 0 })]; - - if let Some(session_id) = filters.session_id.filter(|value| !value.trim().is_empty()) { - where_parts.push("session_id = ?".to_string()); - values.push(Value::from(session_id)); - } - - if let Some(memory_type) = filters.memory_type { - let encoded = serde_json::to_string(&memory_type) - .map_err(|e| format!("序列化 memory_type 失败: {e}"))?; - where_parts.push("memory_type = ?".to_string()); - values.push(Value::from(encoded)); - } - - if let Some(category) = filters.category { - let encoded = serde_json::to_string(&category) - .map_err(|e| format!("序列化 category 失败: {e}"))?; - where_parts.push("category = ?".to_string()); - values.push(Value::from(encoded)); - } - - let sql = format!( - "SELECT id, session_id, memory_type, category, title, content, summary, tags, confidence, importance, access_count, last_accessed_at, source, created_at, updated_at, archived FROM unified_memory WHERE {} ORDER BY {} {} LIMIT ? OFFSET ?", - where_parts.join(" AND "), - sort_by, - order, - ); - - values.push(Value::from(limit)); - values.push(Value::from(offset)); - - let mut stmt = conn - .prepare(&sql) - .map_err(|e| format!("构建查询失败: {e}"))?; - - let memories = stmt - .query_map(params_from_iter(values), parse_unified_memory_row) - .map_err(|e| format!("查询记忆失败: {e}"))? - .collect::, rusqlite::Error>>() - .map_err(|e| format!("解析记忆失败: {e}"))?; - - Ok(serde_json::to_value(memories)?) - } else { - Ok(serde_json::json!([])) - } - } - - "execution_run_list" => { - let args = args.unwrap_or_default(); - let limit = args - .get("limit") - .and_then(|value| value.as_u64()) - .map(|value| value as usize); - let offset = args - .get("offset") - .and_then(|value| value.as_u64()) - .map(|value| value as usize); - - if let Some(db) = &state.db { - let tracker = crate::services::execution_tracker_service::ExecutionTracker::new( - db.clone(), - ); - let runs = tracker.list_runs(limit.unwrap_or(50).clamp(1, 200), offset.unwrap_or(0))?; - Ok(serde_json::to_value(runs)?) - } else { - Ok(serde_json::json!([])) - } - } - - "aster_session_get" => { - let args = args.unwrap_or_default(); - let session_id = get_string_arg(&args, "session_id", "sessionId")?; - - if let Some(db) = &state.db { - let session = crate::agent::AsterAgentWrapper::get_session_sync(db, &session_id) - .map_err(|e| format!("获取 Aster 会话失败: {e}"))?; - Ok(serde_json::to_value(session)?) - } else { - Err("Database not initialized".into()) - } - } - - "aster_session_list" => { - if let Some(db) = &state.db { - let sessions = crate::agent::AsterAgentWrapper::list_sessions_sync(db) - .map_err(|e| format!("获取 Aster 会话列表失败: {e}"))?; - Ok(serde_json::to_value(sessions)?) - } else { - Ok(serde_json::json!([])) - } - } - - "report_frontend_crash" => { - let args = args.unwrap_or_default(); - let report: crate::app::commands::FrontendCrashReport = - parse_nested_arg(&args, "report")?; - - let sanitized_message = crate::logger::sanitize_log_message(&report.message); - let sanitized_component = report - .component - .as_deref() - .map(crate::logger::sanitize_log_message) - .unwrap_or_else(|| "unknown".to_string()); - let sanitized_step = report - .workflow_step - .as_deref() - .map(crate::logger::sanitize_log_message) - .unwrap_or_else(|| "unknown".to_string()); - let sanitized_mode = report - .creation_mode - .as_deref() - .map(crate::logger::sanitize_log_message) - .unwrap_or_else(|| "unknown".to_string()); - let stack_preview = report - .stack - .as_deref() - .map(crate::logger::sanitize_log_message) - .map(|stack| stack.lines().take(3).collect::>().join(" | ")) - .unwrap_or_default(); - - state.logs.write().await.add( - "error", - &format!( - "[FrontendCrash] component={sanitized_component} step={sanitized_step} mode={sanitized_mode} message={sanitized_message} stack={stack_preview}" - ), - ); - - Ok(serde_json::json!({ "success": true })) - } - - "memory_runtime_get_overview" => { - let args = args.unwrap_or_default(); - let limit = args - .get("limit") - .and_then(|value| value.as_u64()) - .map(|value| value as u32); - let overview = crate::commands::memory_management_cmd::memory_runtime_get_overview(limit) - .await - .map_err(|e| format!("获取对话记忆总览失败: {e}"))?; - Ok(serde_json::to_value(overview)?) - } - - "memory_runtime_get_stats" => { - let stats = crate::commands::memory_management_cmd::memory_runtime_get_stats() - .await - .map_err(|e| format!("获取对话记忆统计失败: {e}"))?; - Ok(serde_json::to_value(stats)?) - } - - "memory_runtime_request_analysis" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let args = args.unwrap_or_default(); - let from_timestamp = args.get("fromTimestamp").and_then(|value| value.as_i64()); - let to_timestamp = args.get("toTimestamp").and_then(|value| value.as_i64()); - let memory_service = - app_handle.state::(); - let db = app_handle.state::(); - let global_config = app_handle.state::(); - let result = crate::commands::memory_management_cmd::memory_runtime_request_analysis( - memory_service, - db, - global_config, - from_timestamp, - to_timestamp, - ) - .await - .map_err(|e| format!("请求记忆分析失败: {e}"))?; - Ok(serde_json::to_value(result)?) - } - - "memory_runtime_cleanup" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let memory_service = - app_handle.state::(); - let global_config = app_handle.state::(); - let result = crate::commands::memory_management_cmd::memory_runtime_cleanup( - memory_service, - global_config, - ) - .await - .map_err(|e| format!("清理记忆失败: {e}"))?; - Ok(serde_json::to_value(result)?) - } - - // ========== 网络信息 ========== - "get_network_info" => { - // 返回网络信息 - Ok(serde_json::json!({ - "localhost": "127.0.0.1", - "lan_ip": null, - "all_ips": ["127.0.0.1"] - })) - } - - // ========== OpenClaw ========== - "openclaw_check_installed" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let service = service.0.lock().await; - Ok(serde_json::to_value(service.check_installed().await?)?) - } - - "openclaw_get_environment_status" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = - app_handle.state::(); - let service = service.0.lock().await; - Ok(serde_json::to_value(service.get_environment_status().await?)?) - } - - "openclaw_check_node_version" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let service = service.0.lock().await; - Ok(serde_json::to_value(service.check_node_version().await?)?) - } - - "openclaw_check_git_available" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let service = service.0.lock().await; - Ok(serde_json::to_value(service.check_git_available().await?)?) - } - - "openclaw_get_node_download_url" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let service = service.0.lock().await; - Ok(serde_json::json!(service.get_node_download_url())) - } - - "openclaw_get_git_download_url" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let service = service.0.lock().await; - Ok(serde_json::json!(service.get_git_download_url())) - } - - "openclaw_get_command_preview" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let args = args.unwrap_or_default(); - let operation = get_string_arg(&args, "operation", "operation")?; - let port = args.get("port").and_then(|value| value.as_u64()).map(|value| value as u16); - let service = app_handle.state::(); - let mut service = service.0.lock().await; - Ok(serde_json::to_value(service.get_command_preview(app_handle, &operation, port).await?)?) - } - - "openclaw_install" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let mut service = service.0.lock().await; - service.clear_progress_logs(); - Ok(serde_json::to_value(service.install(app_handle).await?)?) - } - - "openclaw_install_dependency" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let args = args.unwrap_or_default(); - let kind = get_string_arg(&args, "kind", "kind")?; - let service = - app_handle.state::(); - let mut service = service.0.lock().await; - service.clear_progress_logs(); - Ok(serde_json::to_value(service.install_dependency(app_handle, &kind).await?)?) - } - - "openclaw_uninstall" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let mut service = service.0.lock().await; - service.clear_progress_logs(); - Ok(serde_json::to_value(service.uninstall(app_handle).await?)?) - } - - "openclaw_cleanup_temp_artifacts" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = - app_handle.state::(); - let mut service = service.0.lock().await; - Ok(serde_json::to_value(service.cleanup_temp_artifacts(Some(app_handle)).await?)?) - } - - "openclaw_start_gateway" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let port = args - .as_ref() - .and_then(|value| value.get("port")) - .and_then(|value| value.as_u64()) - .map(|value| value as u16); - let service = app_handle.state::(); - let mut service = service.0.lock().await; - service.clear_progress_logs(); - Ok(serde_json::to_value(service.start_gateway(Some(app_handle), port).await?)?) - } - - "openclaw_stop_gateway" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let mut service = service.0.lock().await; - service.clear_progress_logs(); - Ok(serde_json::to_value(service.stop_gateway(Some(app_handle)).await?)?) - } - - "openclaw_restart_gateway" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let mut service = service.0.lock().await; - service.clear_progress_logs(); - Ok(serde_json::to_value(service.restart_gateway(app_handle).await?)?) - } - - "openclaw_get_status" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let mut service = service.0.lock().await; - Ok(serde_json::to_value(service.get_status().await?)?) - } - - "openclaw_check_health" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let mut service = service.0.lock().await; - Ok(serde_json::to_value(service.check_health().await?)?) - } - - "openclaw_get_dashboard_url" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let mut service = service.0.lock().await; - Ok(serde_json::json!(service.get_dashboard_url())) - } - - "openclaw_get_channels" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let mut service = service.0.lock().await; - Ok(serde_json::to_value(service.get_channels().await?)?) - } - - "openclaw_get_progress_logs" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let service = app_handle.state::(); - let service = service.0.lock().await; - Ok(serde_json::to_value(service.get_progress_logs())?) - } - - "openclaw_sync_provider_config" => { - let app_handle = state - .app_handle - .as_ref() - .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; - let request: crate::commands::openclaw_cmd::OpenClawSyncConfigRequest = - parse_nested_arg(&args.unwrap_or_default(), "request")?; - let db = get_db(state)?; - let provider = state - .api_key_provider_service - .get_provider(db, &request.provider_id)? - .ok_or_else(|| "未找到指定 Provider。".to_string())?; - - if !provider.provider.enabled { - return Ok(serde_json::json!({ - "success": false, - "message": "该 Provider 已被禁用。" - })); - } - - let api_key = state - .api_key_provider_service - .get_next_api_key(db, &request.provider_id)? - .unwrap_or_default(); - let service = app_handle.state::(); - let mut service = service.0.lock().await; - Ok(serde_json::to_value(service.sync_provider_config( - &provider.provider, - &api_key, - &request.primary_model_id, - &request.models, - )?)?) - } - - // ========== Agent 会话管理 ========== - "agent_create_session" => { - let args = args.unwrap_or_default(); - let provider_type = args["provider_type"].as_str().unwrap_or("").to_string(); - let model = args["model"].as_str().map(|s| s.to_string()); - let system_prompt = args["system_prompt"].as_str().map(|s| s.to_string()); - let execution_strategy = args["execution_strategy"] - .as_str() - .map(|s| s.to_string()) - .or_else(|| args["executionStrategy"].as_str().map(|s| s.to_string())) - .unwrap_or_else(|| "react".to_string()); - - if let Some(db) = &state.db { - // 简化版本:直接创建会话,不需要 agent_state - use crate::database::dao::agent::AgentDao; - use proxycast_core::agent::types::AgentSession; - - let session_id = uuid::Uuid::new_v4().to_string(); - let model_name = model.clone().unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); - let now = chrono::Utc::now().to_rfc3339(); - - let session = AgentSession { - id: session_id.clone(), - model: model_name.clone(), - messages: Vec::new(), - system_prompt, - title: None, // 初始会话没有标题,后续会自动生成 - working_dir: None, - execution_strategy: Some(execution_strategy.clone()), - created_at: now.clone(), - updated_at: now, - }; - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - AgentDao::create_session(&conn, &session) - .map_err(|e| format!("创建会话失败: {e}"))?; - - Ok(serde_json::json!({ - "session_id": session_id, - "credential_name": "ProxyCast", - "credential_uuid": null, - "provider_type": provider_type, - "model": model_name, - "execution_strategy": execution_strategy - })) - } else { - Err("Database not initialized".into()) - } - } - - "agent_list_sessions" => { - if let Some(db) = &state.db { - use crate::database::dao::agent::AgentDao; - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let sessions = AgentDao::list_sessions(&conn) - .map_err(|e| format!("获取会话列表失败: {e}"))?; - - let result: Vec = sessions - .into_iter() - .map(|s| { - let messages_count = AgentDao::get_message_count(&conn, &s.id).unwrap_or(0); - serde_json::json!({ - "session_id": s.id, - "provider_type": "aster", - "model": s.model, - "created_at": s.created_at, - "last_activity": s.updated_at, - "messages_count": messages_count - }) - }) - .collect(); - - Ok(serde_json::json!(result)) - } else { - Err("Database not initialized".into()) - } - } - - "agent_get_session" => { - let args = args.unwrap_or_default(); - // 支持 session_id 和 sessionId 两种格式 - let session_id = args["session_id"].as_str() - .or_else(|| args["sessionId"].as_str()) - .unwrap_or("").to_string(); - - if let Some(db) = &state.db { - use crate::database::dao::agent::AgentDao; - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let session = AgentDao::get_session(&conn, &session_id) - .map_err(|e| format!("获取会话失败: {e}"))? - .ok_or("会话不存在")?; - - let messages_count = AgentDao::get_message_count(&conn, &session_id).unwrap_or(0); - - Ok(serde_json::json!({ - "session_id": session.id, - "provider_type": "aster", - "model": session.model, - "created_at": session.created_at, - "last_activity": session.updated_at, - "messages_count": messages_count - })) - } else { - Err("Database not initialized".into()) - } - } - - "agent_delete_session" => { - let args = args.unwrap_or_default(); - // 支持 session_id 和 sessionId 两种格式 - let session_id = args["session_id"].as_str() - .or_else(|| args["sessionId"].as_str()) - .unwrap_or("").to_string(); - - if let Some(db) = &state.db { - use crate::database::dao::agent::AgentDao; - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - AgentDao::delete_session(&conn, &session_id) - .map_err(|e| format!("删除会话失败: {e}"))?; - - Ok(serde_json::json!({ "success": true })) - } else { - Err("Database not initialized".into()) - } - } - - "agent_get_session_messages" => { - let args = args.unwrap_or_default(); - // 支持 session_id 和 sessionId 两种格式 - let session_id = args["session_id"].as_str() - .or_else(|| args["sessionId"].as_str()) - .unwrap_or("").to_string(); - - if let Some(db) = &state.db { - use crate::database::dao::agent::AgentDao; - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let messages = AgentDao::get_messages(&conn, &session_id) - .map_err(|e| format!("获取消息失败: {e}"))?; - - Ok(serde_json::to_value(messages)?) - } else { - Err("Database not initialized".into()) - } - } - - _ => Err(format!( - "[DevBridge] 未知命令: '{cmd}'. 如需此命令,请将其添加到 dispatcher.rs 的 handle_command 函数中。" - ) - .into()), + if let Some(result) = app_runtime::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); } + + if let Some(result) = logs::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + + if let Some(result) = providers::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + + if let Some(result) = browser::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + + if let Some(result) = models::try_handle(state, cmd).await? { + return Ok(result); + } + + if let Some(result) = runtime_queries::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + + if let Some(result) = memory_runtime::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + + if let Some(result) = openclaw::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + + if let Some(result) = agent_sessions::try_handle(state, cmd, args.as_ref())? { + return Ok(result); + } + + if let Some(result) = workspace::try_handle(state, cmd, args.as_ref())? { + return Ok(result); + } + + if let Some(result) = content::try_handle(state, cmd, args.as_ref())? { + return Ok(result); + } + + if let Some(result) = project_resources::try_handle(state, cmd, args.as_ref())? { + return Ok(result); + } + + if let Some(result) = memory::try_handle(state, cmd, args.as_ref())? { + return Ok(result); + } + + if let Some(result) = skills::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + + Err(format!( + "[DevBridge] 未知命令: '{cmd}'. 如需此命令,请将其添加到 dispatcher.rs 的 handle_command 函数中。" + ) + .into()) } #[cfg(test)] mod tests { use super::*; + use crate::commands::content_cmd::{ContentDetail, ContentListItem}; use proxycast_core::{config::Config, database::schema::create_tables}; use rusqlite::Connection; use std::sync::{Arc, Mutex}; diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs new file mode 100644 index 000000000..386c815d0 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs @@ -0,0 +1,136 @@ +use super::{args_or_default, get_db}; +use crate::database::dao::agent::AgentDao; +use crate::dev_bridge::DevBridgeState; +use proxycast_core::agent::types::AgentSession; +use serde_json::Value as JsonValue; + +type DynError = Box; + +fn get_session_id(args: &JsonValue) -> String { + args["session_id"] + .as_str() + .or_else(|| args["sessionId"].as_str()) + .unwrap_or("") + .to_string() +} + +pub(super) fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "agent_create_session" => { + let args = args_or_default(args); + let provider_type = args["provider_type"].as_str().unwrap_or("").to_string(); + let model = args["model"].as_str().map(|value| value.to_string()); + let system_prompt = args["system_prompt"] + .as_str() + .map(|value| value.to_string()); + let execution_strategy = args["execution_strategy"] + .as_str() + .map(|value| value.to_string()) + .or_else(|| { + args["executionStrategy"] + .as_str() + .map(|value| value.to_string()) + }) + .unwrap_or_else(|| "react".to_string()); + + let db = get_db(state)?; + let session_id = uuid::Uuid::new_v4().to_string(); + let model_name = model + .clone() + .unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); + let now = chrono::Utc::now().to_rfc3339(); + let session = AgentSession { + id: session_id.clone(), + model: model_name.clone(), + messages: Vec::new(), + system_prompt, + title: None, + working_dir: None, + execution_strategy: Some(execution_strategy.clone()), + created_at: now.clone(), + updated_at: now, + }; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AgentDao::create_session(&conn, &session).map_err(|e| format!("创建会话失败: {e}"))?; + + serde_json::json!({ + "session_id": session_id, + "credential_name": "ProxyCast", + "credential_uuid": null, + "provider_type": provider_type, + "model": model_name, + "execution_strategy": execution_strategy + }) + } + "agent_list_sessions" => { + let db = get_db(state)?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let sessions = + AgentDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?; + + let result: Vec = sessions + .into_iter() + .map(|session| { + let messages_count = + AgentDao::get_message_count(&conn, &session.id).unwrap_or(0); + serde_json::json!({ + "session_id": session.id, + "provider_type": "aster", + "model": session.model, + "created_at": session.created_at, + "last_activity": session.updated_at, + "messages_count": messages_count + }) + }) + .collect(); + + serde_json::json!(result) + } + "agent_get_session" => { + let args = args_or_default(args); + let session_id = get_session_id(&args); + let db = get_db(state)?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let session = AgentDao::get_session(&conn, &session_id) + .map_err(|e| format!("获取会话失败: {e}"))? + .ok_or("会话不存在")?; + let messages_count = AgentDao::get_message_count(&conn, &session_id).unwrap_or(0); + + serde_json::json!({ + "session_id": session.id, + "provider_type": "aster", + "model": session.model, + "created_at": session.created_at, + "last_activity": session.updated_at, + "messages_count": messages_count + }) + } + "agent_delete_session" => { + let args = args_or_default(args); + let session_id = get_session_id(&args); + let db = get_db(state)?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AgentDao::delete_session(&conn, &session_id) + .map_err(|e| format!("删除会话失败: {e}"))?; + serde_json::json!({ "success": true }) + } + "agent_get_session_messages" => { + let args = args_or_default(args); + let session_id = get_session_id(&args); + let db = get_db(state)?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + serde_json::to_value( + AgentDao::get_messages(&conn, &session_id) + .map_err(|e| format!("获取消息失败: {e}"))?, + )? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/app_runtime.rs b/src-tauri/src/dev_bridge/dispatcher/app_runtime.rs new file mode 100644 index 000000000..06d9343fc --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/app_runtime.rs @@ -0,0 +1,74 @@ +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +type DynError = Box; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "get_config" => { + let config_path = proxycast_core::config::ConfigManager::default_config_path(); + let manager = proxycast_core::config::ConfigManager::load(&config_path)?; + serde_json::to_value(manager.config())? + } + "save_config" => { + let config: proxycast_core::config::Config = + serde_json::from_value(args.cloned().unwrap_or_default())?; + proxycast_core::config::save_config(&config)?; + crate::services::environment_service::apply_configured_environment(&config).await; + serde_json::json!({ "success": true }) + } + "get_environment_preview" => { + let config_path = proxycast_core::config::ConfigManager::default_config_path(); + let manager = proxycast_core::config::ConfigManager::load(&config_path)?; + let preview = + crate::services::environment_service::build_environment_preview(manager.config()) + .await; + serde_json::to_value(preview)? + } + "get_default_provider" => { + let default_provider_ref = { state.server.read().await.default_provider_ref.clone() }; + let provider = default_provider_ref.read().await.clone(); + serde_json::json!(provider) + } + "get_endpoint_providers" => { + let providers = { state.server.read().await.config.endpoint_providers.clone() }; + serde_json::to_value(providers)? + } + "get_server_status" => { + let status = { state.server.read().await.status() }; + serde_json::to_value(status)? + } + "get_server_diagnostics" => { + let (status, capability_routing, response_cache, request_dedup, idempotency) = { + let server = state.server.read().await; + ( + server.status(), + server.capability_routing_metrics_store.snapshot(), + server.response_cache_store.clone(), + server.request_dedup_store.clone(), + server.idempotency_store.clone(), + ) + }; + + let telemetry_summary = state.shared_stats.read().summary(None); + let diagnostics = proxycast_server::build_server_diagnostics( + status.running, + status.host, + status.port, + telemetry_summary, + capability_routing, + response_cache.as_ref(), + request_dedup.as_ref(), + idempotency.as_ref(), + ); + serde_json::to_value(diagnostics)? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/browser.rs b/src-tauri/src/dev_bridge/dispatcher/browser.rs new file mode 100644 index 000000000..8e94df978 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/browser.rs @@ -0,0 +1,45 @@ +use super::{args_or_default, parse_nested_arg, parse_optional_nested_arg}; +use crate::dev_bridge::DevBridgeState; +use serde::de::DeserializeOwned; +use serde_json::Value as JsonValue; + +mod bridge; +mod cdp; +mod runtime; +mod sessions; + +type DynError = Box; + +fn parse_request(args: Option<&JsonValue>) -> Result { + parse_nested_arg(&args_or_default(args), "request") +} + +fn parse_optional_request( + args: Option<&JsonValue>, +) -> Result, DynError> { + parse_optional_nested_arg(&args_or_default(args), "request") +} + +fn success_response() -> JsonValue { + serde_json::json!({ "success": true }) +} + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + if let Some(result) = runtime::try_handle(state, cmd, args).await? { + return Ok(Some(result)); + } + + if let Some(result) = bridge::try_handle(state, cmd, args).await? { + return Ok(Some(result)); + } + + if let Some(result) = cdp::try_handle(state, cmd, args).await? { + return Ok(Some(result)); + } + + sessions::try_handle(state, cmd, args).await +} diff --git a/src-tauri/src/dev_bridge/dispatcher/browser/bridge.rs b/src-tauri/src/dev_bridge/dispatcher/browser/bridge.rs new file mode 100644 index 000000000..102470f1e --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/browser/bridge.rs @@ -0,0 +1,49 @@ +use super::super::{args_or_default, get_string_arg, parse_nested_arg}; +use super::DynError; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "get_chrome_profile_sessions" => serde_json::to_value( + crate::commands::webview_cmd::get_chrome_profile_sessions_global().await?, + )?, + "close_chrome_profile_session" => { + let args = args_or_default(args); + let profile_key = get_string_arg(&args, "profileKey", "profile_key")?; + serde_json::to_value( + crate::commands::webview_cmd::close_chrome_profile_session_global(profile_key) + .await?, + )? + } + "get_chrome_bridge_endpoint_info" => serde_json::to_value( + crate::commands::webview_cmd::get_chrome_bridge_endpoint_info_global( + state.server.clone(), + ) + .await?, + )?, + "get_chrome_bridge_status" => serde_json::to_value( + crate::commands::webview_cmd::get_chrome_bridge_status_global().await?, + )?, + "get_browser_backend_policy" => serde_json::to_value( + crate::commands::webview_cmd::get_browser_backend_policy_global().await?, + )?, + "set_browser_backend_policy" => { + let policy: crate::commands::webview_cmd::BrowserBackendPolicy = + parse_nested_arg(&args_or_default(args), "policy")?; + serde_json::to_value( + crate::commands::webview_cmd::set_browser_backend_policy_global(policy).await?, + )? + } + "get_browser_backends_status" => serde_json::to_value( + crate::commands::webview_cmd::get_browser_backends_status_global().await?, + )?, + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/browser/cdp.rs b/src-tauri/src/dev_bridge/dispatcher/browser/cdp.rs new file mode 100644 index 000000000..a883e834b --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/browser/cdp.rs @@ -0,0 +1,34 @@ +use super::{parse_request, DynError}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +pub(super) async fn try_handle( + _state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "list_cdp_targets" => { + let request: crate::commands::webview_cmd::ListCdpTargetsRequest = parse_request(args)?; + serde_json::to_value( + crate::commands::webview_cmd::list_cdp_targets_global(request).await?, + )? + } + "open_cdp_session" => { + let request: crate::commands::webview_cmd::OpenCdpSessionRequest = parse_request(args)?; + serde_json::to_value( + crate::commands::webview_cmd::open_cdp_session_global(request).await?, + )? + } + "close_cdp_session" => { + let request: crate::commands::webview_cmd::BrowserSessionStateRequest = + parse_request(args)?; + serde_json::to_value( + crate::commands::webview_cmd::close_cdp_session_global(request).await?, + )? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/browser/runtime.rs b/src-tauri/src/dev_bridge/dispatcher/browser/runtime.rs new file mode 100644 index 000000000..e62ba02bd --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/browser/runtime.rs @@ -0,0 +1,89 @@ +use super::super::{get_db, require_app_handle}; +use super::{parse_optional_request, parse_request, success_response, DynError}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "open_chrome_profile_window" => { + let app_handle = require_app_handle(state)?; + let request: crate::commands::webview_cmd::OpenChromeProfileRequest = + parse_request(args)?; + serde_json::to_value( + crate::commands::webview_cmd::open_chrome_profile_window_global( + app_handle, + state.server.clone(), + request, + ) + .await?, + )? + } + "open_browser_runtime_debugger_window" => { + let request: Option< + crate::commands::browser_runtime_cmd::OpenBrowserRuntimeDebuggerWindowRequest, + > = parse_optional_request(args)?; + crate::commands::browser_runtime_cmd::open_browser_runtime_debugger_window( + require_app_handle(state)?, + request, + )?; + success_response() + } + "close_browser_runtime_debugger_window" => { + crate::commands::browser_runtime_cmd::close_browser_runtime_debugger_window( + require_app_handle(state)?, + )?; + success_response() + } + "launch_browser_session" => { + let app_handle = require_app_handle(state)?; + let request: crate::commands::browser_runtime_cmd::LaunchBrowserSessionRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + serde_json::to_value( + crate::commands::browser_runtime_cmd::launch_browser_session_with_db( + app_handle, + state.server.clone(), + db, + request, + ) + .await?, + )? + } + "launch_browser_runtime_assist" => { + let app_handle = require_app_handle(state)?; + let request: crate::commands::browser_runtime_cmd::LaunchBrowserRuntimeAssistRequest = + parse_request(args)?; + serde_json::to_value( + crate::commands::browser_runtime_cmd::launch_browser_runtime_assist_global( + app_handle, + state.server.clone(), + request, + ) + .await?, + )? + } + "start_browser_stream" => { + let app_handle = require_app_handle(state)?; + let request: crate::commands::webview_cmd::StartBrowserStreamRequest = + parse_request(args)?; + serde_json::to_value( + crate::commands::webview_cmd::start_browser_stream_global(app_handle, request) + .await?, + )? + } + "stop_browser_stream" => { + let request: crate::commands::webview_cmd::StopBrowserStreamRequest = + parse_request(args)?; + serde_json::to_value( + crate::commands::webview_cmd::stop_browser_stream_global(request).await?, + )? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/browser/sessions.rs b/src-tauri/src/dev_bridge/dispatcher/browser/sessions.rs new file mode 100644 index 000000000..962ad471f --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/browser/sessions.rs @@ -0,0 +1,70 @@ +use super::super::get_db; +use super::{parse_request, DynError}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "get_browser_session_state" => { + let request: crate::commands::webview_cmd::BrowserSessionStateRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + serde_json::to_value( + crate::commands::webview_cmd::get_browser_session_state_global(db, request).await?, + )? + } + "take_over_browser_session" => { + let request: crate::commands::webview_cmd::UpdateBrowserSessionControlRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + serde_json::to_value( + crate::commands::webview_cmd::take_over_browser_session_global(db, request).await?, + )? + } + "release_browser_session" => { + let request: crate::commands::webview_cmd::UpdateBrowserSessionControlRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + serde_json::to_value( + crate::commands::webview_cmd::release_browser_session_global(db, request).await?, + )? + } + "resume_browser_session" => { + let request: crate::commands::webview_cmd::UpdateBrowserSessionControlRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + serde_json::to_value( + crate::commands::webview_cmd::resume_browser_session_global(db, request).await?, + )? + } + "get_browser_event_buffer" => { + let request: crate::commands::webview_cmd::BrowserEventBufferRequest = + parse_request(args)?; + serde_json::to_value( + crate::commands::webview_cmd::get_browser_event_buffer_global(request).await?, + )? + } + "browser_execute_action" => { + let request: crate::commands::webview_cmd::BrowserActionRequest = parse_request(args)?; + serde_json::to_value( + crate::commands::webview_cmd::browser_execute_action_global(request).await?, + )? + } + "get_browser_action_audit_logs" => { + let limit = args + .and_then(|value| value.get("limit")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize); + serde_json::to_value( + crate::commands::webview_cmd::get_browser_action_audit_logs_global(limit).await?, + )? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/content.rs b/src-tauri/src/dev_bridge/dispatcher/content.rs new file mode 100644 index 000000000..deee554fb --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/content.rs @@ -0,0 +1,145 @@ +use super::{args_or_default, get_db, get_string_arg, parse_nested_arg, parse_optional_nested_arg}; +use crate::commands::content_cmd::{ + parse_theme_workbench_document_state, ContentDetail, ContentListItem, + CreateContentRequest as BridgeCreateContentRequest, + ListContentRequest as BridgeListContentRequest, ThemeWorkbenchDocumentState, + UpdateContentRequest as BridgeUpdateContentRequest, +}; +use crate::content::{ + ContentCreateRequest, ContentListQuery, ContentManager, ContentUpdateRequest, +}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +type DynError = Box; + +fn content_manager(state: &DevBridgeState) -> Result { + Ok(ContentManager::new(get_db(state)?.clone())) +} + +fn build_create_request(request: BridgeCreateContentRequest) -> ContentCreateRequest { + ContentCreateRequest { + project_id: request.project_id, + title: request.title, + content_type: request.content_type.map(|value| { + value + .parse::() + .unwrap_or_default() + }), + order: request.order, + body: request.body, + metadata: request.metadata, + } +} + +fn build_list_query(query: Option) -> Option { + query.map(|query| ContentListQuery { + status: query.status.map(|value| value.parse().unwrap_or_default()), + content_type: query.content_type.map(|value| { + value + .parse::() + .unwrap_or_default() + }), + search: query.search, + sort_by: query.sort_by, + sort_order: query.sort_order, + offset: query.offset, + limit: query.limit, + }) +} + +fn build_update_request(request: BridgeUpdateContentRequest) -> ContentUpdateRequest { + ContentUpdateRequest { + title: request.title, + status: request + .status + .map(|value| value.parse().unwrap_or_default()), + order: request.order, + body: request.body, + metadata: request.metadata, + session_id: request.session_id, + } +} + +pub(super) fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "content_create" => { + let args = args_or_default(args); + let request: BridgeCreateContentRequest = parse_nested_arg(&args, "request")?; + let manager = content_manager(state)?; + serde_json::to_value(ContentDetail::from( + manager.create(build_create_request(request))?, + ))? + } + "content_get" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let manager = content_manager(state)?; + serde_json::to_value(manager.get(&id)?.map(ContentDetail::from))? + } + "content_get_theme_workbench_document_state" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let manager = content_manager(state)?; + let content = manager.get(&id)?; + let document_state: Option = content.and_then(|item| { + parse_theme_workbench_document_state(&item.id, item.metadata.as_ref()) + }); + serde_json::to_value(document_state)? + } + "content_list" => { + let args = args_or_default(args); + let project_id = get_string_arg(&args, "projectId", "project_id")?; + let query: Option = + parse_optional_nested_arg(&args, "query")?; + let manager = content_manager(state)?; + let items: Vec<_> = manager + .list_by_project(&project_id, build_list_query(query))? + .into_iter() + .map(ContentListItem::from) + .collect(); + serde_json::to_value(items)? + } + "content_update" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let request: BridgeUpdateContentRequest = parse_nested_arg(&args, "request")?; + let manager = content_manager(state)?; + serde_json::to_value(ContentDetail::from( + manager.update(&id, build_update_request(request))?, + ))? + } + "content_delete" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let manager = content_manager(state)?; + serde_json::to_value(manager.delete(&id)?)? + } + "content_reorder" => { + let args = args_or_default(args); + let project_id = get_string_arg(&args, "projectId", "project_id")?; + let content_ids = args + .get("contentIds") + .or_else(|| args.get("content_ids")) + .cloned() + .ok_or("缺少参数: contentIds/content_ids")?; + let content_ids: Vec = serde_json::from_value(content_ids)?; + let manager = content_manager(state)?; + manager.reorder(&project_id, content_ids)?; + serde_json::json!(null) + } + "content_stats" => { + let args = args_or_default(args); + let project_id = get_string_arg(&args, "projectId", "project_id")?; + let manager = content_manager(state)?; + serde_json::to_value(manager.get_project_stats(&project_id)?)? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/logs.rs b/src-tauri/src/dev_bridge/dispatcher/logs.rs new file mode 100644 index 000000000..c04aaa393 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/logs.rs @@ -0,0 +1,76 @@ +use super::require_app_handle; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +type DynError = Box; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "get_logs" => { + let logs = state.logs.read().await; + let entries = logs.get_logs(); + let limit = entries.len().min(100); + let recent: Vec<_> = entries + .into_iter() + .rev() + .take(limit) + .map(|entry| { + serde_json::json!({ + "timestamp": entry.timestamp, + "level": entry.level, + "message": entry.message, + }) + }) + .collect(); + serde_json::to_value(recent)? + } + "get_persisted_logs_tail" => { + let requested = args + .and_then(|value| value.get("lines")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(200) + .clamp(20, 1000); + + let logs = state.logs.read().await; + let entries = crate::app::commands::read_persisted_logs_tail_from_path( + logs.get_log_file_path(), + requested, + )?; + serde_json::to_value(entries)? + } + "get_log_storage_diagnostics" => { + let logs = state.logs.read().await; + let diagnostics = crate::app::commands::get_log_storage_diagnostics_from_path( + logs.get_log_file_path(), + logs.get_logs().len(), + ); + serde_json::to_value(diagnostics)? + } + "get_windows_startup_diagnostics" => { + let app_handle = require_app_handle(state)?; + let diagnostics = + crate::commands::windows_startup_cmd::collect_windows_startup_diagnostics( + &app_handle, + ); + serde_json::to_value(diagnostics)? + } + "clear_logs" => { + state.logs.write().await.clear(); + serde_json::json!({ "success": true }) + } + "clear_diagnostic_log_history" => { + let log_file_path = { state.logs.read().await.get_log_file_path() }; + state.logs.write().await.clear(); + crate::app::commands::clear_diagnostic_log_artifacts_from_path(log_file_path)?; + serde_json::json!({ "success": true }) + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/memory.rs b/src-tauri/src/dev_bridge/dispatcher/memory.rs new file mode 100644 index 000000000..d418c519d --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/memory.rs @@ -0,0 +1,242 @@ +use super::{args_or_default, parse_optional_nested_arg}; +use crate::dev_bridge::DevBridgeState; +use proxycast_memory::{MemoryCategory, MemoryMetadata, MemorySource, MemoryType, UnifiedMemory}; +use rusqlite::{params_from_iter, types::Value}; +use serde_json::Value as JsonValue; + +type DynError = Box; + +fn parse_unified_memory_row(row: &rusqlite::Row) -> Result { + let id: String = row.get(0)?; + let session_id: String = row.get(1)?; + let memory_type_json: String = row.get(2)?; + let category_json: String = row.get(3)?; + let title: String = row.get(4)?; + let content: String = row.get(5)?; + let summary: String = row.get(6)?; + let tags_json: String = row.get(7)?; + let confidence: f32 = row.get(8)?; + let importance: i64 = row.get(9)?; + let access_count: i64 = row.get(10)?; + let last_accessed_at: Option = row.get(11)?; + let source_json: String = row.get(12)?; + let created_at: i64 = row.get(13)?; + let updated_at: i64 = row.get(14)?; + let archived: i64 = row.get(15)?; + + let memory_type: MemoryType = serde_json::from_str(&memory_type_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let category: MemoryCategory = serde_json::from_str(&category_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let tags: Vec = serde_json::from_str(&tags_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let source: MemorySource = serde_json::from_str(&source_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + + Ok(UnifiedMemory { + id, + session_id, + memory_type, + category, + title, + content, + summary, + tags, + metadata: MemoryMetadata { + confidence, + importance: importance.clamp(0, 10) as u8, + access_count: access_count.max(0) as u32, + last_accessed_at, + source, + embedding: None, + }, + created_at, + updated_at, + archived: archived != 0, + }) +} + +fn unified_memory_category_to_key(category: &MemoryCategory) -> &'static str { + match category { + MemoryCategory::Identity => "identity", + MemoryCategory::Context => "context", + MemoryCategory::Preference => "preference", + MemoryCategory::Experience => "experience", + MemoryCategory::Activity => "activity", + } +} + +fn ordered_unified_categories() -> [&'static str; 5] { + [ + "identity", + "context", + "preference", + "experience", + "activity", + ] +} + +fn normalize_unified_category_value(value: &str) -> Option<&'static str> { + if let Ok(category) = serde_json::from_str::(value) { + return Some(unified_memory_category_to_key(&category)); + } + + match value.trim_matches('"').to_lowercase().as_str() { + "identity" | "身份" => Some("identity"), + "context" | "情境" | "上下文" => Some("context"), + "preference" | "偏好" => Some("preference"), + "experience" | "经验" => Some("experience"), + "activity" | "活动" => Some("activity"), + _ => None, + } +} + +fn normalize_unified_sort_by(sort_by: Option<&str>) -> &'static str { + match sort_by.unwrap_or("updated_at") { + "created_at" => "created_at", + "importance" => "importance", + "access_count" => "access_count", + _ => "updated_at", + } +} + +fn normalize_unified_sort_order(order: Option<&str>) -> &'static str { + match order.unwrap_or("desc").to_lowercase().as_str() { + "asc" => "ASC", + _ => "DESC", + } +} + +pub(super) fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "unified_memory_stats" => { + let Some(db) = &state.db else { + return Ok(Some(serde_json::json!({ + "total_entries": 0, + "storage_used": 0, + "memory_count": 0, + "categories": [], + }))); + }; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + + let (total_entries, memory_count, storage_used): (i64, i64, i64) = conn + .query_row( + "SELECT COUNT(*), COUNT(DISTINCT session_id), COALESCE(SUM(length(title) + length(content) + length(summary) + length(tags)), 0) FROM unified_memory WHERE archived = 0", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|e| format!("统计记忆失败: {e}"))?; + + let mut category_counts: std::collections::HashMap = + std::collections::HashMap::new(); + let mut stmt = conn + .prepare( + "SELECT category, COUNT(*) FROM unified_memory WHERE archived = 0 GROUP BY category", + ) + .map_err(|e| format!("构建分类统计查询失败: {e}"))?; + + let rows = stmt + .query_map([], |row| { + let category_raw: String = row.get(0)?; + let count: i64 = row.get(1)?; + Ok((category_raw, count)) + }) + .map_err(|e| format!("分类统计查询失败: {e}"))?; + + for row in rows.flatten() { + if let Some(category) = normalize_unified_category_value(&row.0) { + category_counts.insert(category.to_string(), row.1.max(0) as u32); + } + } + + let categories = ordered_unified_categories() + .iter() + .map( + |category| crate::commands::unified_memory_cmd::MemoryCategoryStat { + category: (*category).to_string(), + count: *category_counts.get(*category).unwrap_or(&0), + }, + ) + .collect(); + + let response = crate::commands::unified_memory_cmd::MemoryStatsResponse { + total_entries: total_entries.max(0) as u32, + storage_used: storage_used.max(0) as u64, + memory_count: memory_count.max(0) as u32, + categories, + }; + + serde_json::to_value(response)? + } + "unified_memory_list" => { + let args = args_or_default(args); + let filters: Option = + parse_optional_nested_arg(&args, "filters")?; + let filters = filters.unwrap_or_default(); + + let Some(db) = &state.db else { + return Ok(Some(serde_json::json!([]))); + }; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let archived = filters.archived.unwrap_or(false); + let sort_by = normalize_unified_sort_by(filters.sort_by.as_deref()); + let order = normalize_unified_sort_order(filters.order.as_deref()); + let limit = filters.limit.unwrap_or(120).clamp(1, 1000) as i64; + let offset = filters.offset.unwrap_or(0) as i64; + + let mut where_parts = vec!["archived = ?".to_string()]; + let mut values: Vec = vec![Value::from(if archived { 1 } else { 0 })]; + + if let Some(session_id) = filters.session_id.filter(|value| !value.trim().is_empty()) { + where_parts.push("session_id = ?".to_string()); + values.push(Value::from(session_id)); + } + + if let Some(memory_type) = filters.memory_type { + let encoded = serde_json::to_string(&memory_type) + .map_err(|e| format!("序列化 memory_type 失败: {e}"))?; + where_parts.push("memory_type = ?".to_string()); + values.push(Value::from(encoded)); + } + + if let Some(category) = filters.category { + let encoded = serde_json::to_string(&category) + .map_err(|e| format!("序列化 category 失败: {e}"))?; + where_parts.push("category = ?".to_string()); + values.push(Value::from(encoded)); + } + + let sql = format!( + "SELECT id, session_id, memory_type, category, title, content, summary, tags, confidence, importance, access_count, last_accessed_at, source, created_at, updated_at, archived FROM unified_memory WHERE {} ORDER BY {} {} LIMIT ? OFFSET ?", + where_parts.join(" AND "), + sort_by, + order, + ); + + values.push(Value::from(limit)); + values.push(Value::from(offset)); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("构建查询失败: {e}"))?; + + let memories = stmt + .query_map(params_from_iter(values), parse_unified_memory_row) + .map_err(|e| format!("查询记忆失败: {e}"))? + .collect::, rusqlite::Error>>() + .map_err(|e| format!("解析记忆失败: {e}"))?; + + serde_json::to_value(memories)? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs b/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs new file mode 100644 index 000000000..1e7bcd3eb --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs @@ -0,0 +1,70 @@ +use super::{args_or_default, require_app_handle}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; +use tauri::Manager; + +type DynError = Box; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "memory_runtime_get_overview" => { + let args = args_or_default(args); + let limit = args + .get("limit") + .and_then(|value| value.as_u64()) + .map(|value| value as u32); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_runtime_get_overview(limit) + .await + .map_err(|e| format!("获取对话记忆总览失败: {e}"))?, + )? + } + "memory_runtime_get_stats" => serde_json::to_value( + crate::commands::memory_management_cmd::memory_runtime_get_stats() + .await + .map_err(|e| format!("获取对话记忆统计失败: {e}"))?, + )?, + "memory_runtime_request_analysis" => { + let app_handle = require_app_handle(state)?; + let args = args_or_default(args); + let from_timestamp = args.get("fromTimestamp").and_then(|value| value.as_i64()); + let to_timestamp = args.get("toTimestamp").and_then(|value| value.as_i64()); + let memory_service = + app_handle.state::(); + let db = app_handle.state::(); + let global_config = app_handle.state::(); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_runtime_request_analysis( + memory_service, + db, + global_config, + from_timestamp, + to_timestamp, + ) + .await + .map_err(|e| format!("请求记忆分析失败: {e}"))?, + )? + } + "memory_runtime_cleanup" => { + let app_handle = require_app_handle(state)?; + let memory_service = + app_handle.state::(); + let global_config = app_handle.state::(); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_runtime_cleanup( + memory_service, + global_config, + ) + .await + .map_err(|e| format!("清理记忆失败: {e}"))?, + )? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/models.rs b/src-tauri/src/dev_bridge/dispatcher/models.rs new file mode 100644 index 000000000..cd1f1dc5f --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/models.rs @@ -0,0 +1,89 @@ +use crate::dev_bridge::DevBridgeState; +use proxycast_server_utils::load_model_registry_provider_ids_from_resources; +use serde_json::Value as JsonValue; + +type DynError = Box; + +fn load_model_registry_provider_ids_from_db( + state: &DevBridgeState, +) -> Result, DynError> { + let Some(db) = &state.db else { + return Ok(vec![]); + }; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let mut stmt = conn.prepare( + "SELECT DISTINCT provider_id FROM model_registry WHERE provider_id IS NOT NULL ORDER BY provider_id", + )?; + + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + let mut provider_ids = Vec::new(); + for row in rows { + provider_ids.push(row?); + } + + Ok(provider_ids) +} + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, +) -> Result, DynError> { + let result = match cmd { + "get_models" => serde_json::json!({ + "data": [ + {"id": "claude-sonnet-4-20250514", "object": "model", "owned_by": "anthropic"}, + {"id": "claude-opus-4-20250514", "object": "model", "owned_by": "anthropic"}, + {"id": "claude-haiku-4-20250514", "object": "model", "owned_by": "anthropic"}, + {"id": "gpt-4o", "object": "model", "owned_by": "openai"}, + {"id": "gpt-4o-mini", "object": "model", "owned_by": "openai"}, + ] + }), + "get_model_registry" => { + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + serde_json::to_value(service.get_all_models().await)? + } + "get_model_preferences" => { + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + serde_json::to_value(service.get_all_preferences().await?)? + } + "get_model_sync_state" => { + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + serde_json::to_value(service.get_sync_state().await)? + } + "refresh_model_registry" => { + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + serde_json::json!(service.force_reload().await?) + } + "get_model_registry_provider_ids" => { + match load_model_registry_provider_ids_from_resources() { + Ok(provider_ids) => serde_json::to_value(provider_ids)?, + Err(resource_error) => { + let fallback = load_model_registry_provider_ids_from_db(state)?; + if fallback.is_empty() { + return Err(format!( + "获取模型 Provider ID 失败(resources 与数据库均不可用): {resource_error}" + ) + .into()); + } + serde_json::to_value(fallback)? + } + } + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/openclaw.rs b/src-tauri/src/dev_bridge/dispatcher/openclaw.rs new file mode 100644 index 000000000..820e9ad57 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/openclaw.rs @@ -0,0 +1,38 @@ +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; +use tokio::sync::Mutex; + +mod operations; +mod queries; + +type DynError = Box; +type OpenClawServiceHandle = Arc>; + +fn openclaw_service_handle(app_handle: &AppHandle) -> OpenClawServiceHandle { + app_handle + .state::() + .0 + .clone() +} + +fn openclaw_context( + state: &DevBridgeState, +) -> Result<(AppHandle, OpenClawServiceHandle), DynError> { + let app_handle = super::require_app_handle(state)?; + let service = openclaw_service_handle(&app_handle); + Ok((app_handle, service)) +} + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + if let Some(result) = queries::try_handle(state, cmd).await? { + return Ok(Some(result)); + } + + operations::try_handle(state, cmd, args).await +} diff --git a/src-tauri/src/dev_bridge/dispatcher/openclaw/operations.rs b/src-tauri/src/dev_bridge/dispatcher/openclaw/operations.rs new file mode 100644 index 000000000..9d1b55f99 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/openclaw/operations.rs @@ -0,0 +1,119 @@ +use super::super::{args_or_default, get_db, get_string_arg, parse_nested_arg}; +use super::{openclaw_context, DynError}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "openclaw_get_command_preview" => { + let (app_handle, service) = openclaw_context(state)?; + let args = args_or_default(args); + let operation = get_string_arg(&args, "operation", "operation")?; + let port = args + .get("port") + .and_then(|value| value.as_u64()) + .map(|value| value as u16); + let mut service = service.lock().await; + serde_json::to_value( + service + .get_command_preview(&app_handle, &operation, port) + .await?, + )? + } + "openclaw_install" => { + let (app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + service.clear_progress_logs(); + serde_json::to_value(service.install(&app_handle).await?)? + } + "openclaw_install_dependency" => { + let (app_handle, service) = openclaw_context(state)?; + let args = args_or_default(args); + let kind = get_string_arg(&args, "kind", "kind")?; + let mut service = service.lock().await; + service.clear_progress_logs(); + serde_json::to_value(service.install_dependency(&app_handle, &kind).await?)? + } + "openclaw_uninstall" => { + let (app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + service.clear_progress_logs(); + serde_json::to_value(service.uninstall(&app_handle).await?)? + } + "openclaw_check_update" => { + let (_app_handle, service) = openclaw_context(state)?; + let service = service.lock().await; + serde_json::to_value(service.check_update().await?)? + } + "openclaw_cleanup_temp_artifacts" => { + let (app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + serde_json::to_value(service.cleanup_temp_artifacts(Some(&app_handle)).await?)? + } + "openclaw_perform_update" => { + let (app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + service.clear_progress_logs(); + serde_json::to_value(service.perform_update(&app_handle).await?)? + } + "openclaw_start_gateway" => { + let (app_handle, service) = openclaw_context(state)?; + let port = args + .and_then(|value| value.get("port")) + .and_then(|value| value.as_u64()) + .map(|value| value as u16); + let mut service = service.lock().await; + service.clear_progress_logs(); + serde_json::to_value(service.start_gateway(Some(&app_handle), port).await?)? + } + "openclaw_stop_gateway" => { + let (app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + service.clear_progress_logs(); + serde_json::to_value(service.stop_gateway(Some(&app_handle)).await?)? + } + "openclaw_restart_gateway" => { + let (app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + service.clear_progress_logs(); + serde_json::to_value(service.restart_gateway(&app_handle).await?)? + } + "openclaw_sync_provider_config" => { + let (_app_handle, service) = openclaw_context(state)?; + let args = args_or_default(args); + let request: crate::commands::openclaw_cmd::OpenClawSyncConfigRequest = + parse_nested_arg(&args, "request")?; + let db = get_db(state)?; + let provider = state + .api_key_provider_service + .get_provider(db, &request.provider_id)? + .ok_or_else(|| "未找到指定 Provider。".to_string())?; + + if !provider.provider.enabled { + return Ok(Some(serde_json::json!({ + "success": false, + "message": "该 Provider 已被禁用。" + }))); + } + + let api_key = state + .api_key_provider_service + .get_next_api_key(db, &request.provider_id)? + .unwrap_or_default(); + let mut service = service.lock().await; + serde_json::to_value(service.sync_provider_config( + &provider.provider, + &api_key, + &request.primary_model_id, + &request.models, + )?)? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs b/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs new file mode 100644 index 000000000..63b8b581b --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs @@ -0,0 +1,76 @@ +use super::{openclaw_context, DynError}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, +) -> Result, DynError> { + let result = match cmd { + "openclaw_check_installed" => { + let (_app_handle, service) = openclaw_context(state)?; + let service = service.lock().await; + let result = service.check_installed().await?; + serde_json::to_value(result)? + } + "openclaw_get_environment_status" => { + let (_app_handle, service) = openclaw_context(state)?; + let service = service.lock().await; + let result = service.get_environment_status().await?; + serde_json::to_value(result)? + } + "openclaw_check_node_version" => { + let (_app_handle, service) = openclaw_context(state)?; + let service = service.lock().await; + let result = service.check_node_version().await?; + serde_json::to_value(result)? + } + "openclaw_check_git_available" => { + let (_app_handle, service) = openclaw_context(state)?; + let service = service.lock().await; + let result = service.check_git_available().await?; + serde_json::to_value(result)? + } + "openclaw_get_node_download_url" => { + let (_app_handle, service) = openclaw_context(state)?; + let service = service.lock().await; + let result = service.get_node_download_url(); + serde_json::json!(result) + } + "openclaw_get_git_download_url" => { + let (_app_handle, service) = openclaw_context(state)?; + let service = service.lock().await; + let result = service.get_git_download_url(); + serde_json::json!(result) + } + "openclaw_get_status" => { + let (_app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + serde_json::to_value(service.get_status().await?)? + } + "openclaw_check_health" => { + let (_app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + serde_json::to_value(service.check_health().await?)? + } + "openclaw_get_dashboard_url" => { + let (_app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + serde_json::json!(service.get_dashboard_url()) + } + "openclaw_get_channels" => { + let (_app_handle, service) = openclaw_context(state)?; + let mut service = service.lock().await; + serde_json::to_value(service.get_channels().await?)? + } + "openclaw_get_progress_logs" => { + let (_app_handle, service) = openclaw_context(state)?; + let service = service.lock().await; + let result = service.get_progress_logs(); + serde_json::to_value(result)? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/project_resources.rs b/src-tauri/src/dev_bridge/dispatcher/project_resources.rs new file mode 100644 index 000000000..a2f2c20fd --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/project_resources.rs @@ -0,0 +1,76 @@ +use super::{args_or_default, get_string_arg, parse_optional_nested_arg}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +type DynError = Box; + +fn with_db_or_json( + state: &DevBridgeState, + fallback: JsonValue, + action: F, +) -> Result +where + F: FnOnce(&crate::database::DbConnection) -> Result, +{ + match &state.db { + Some(db) => action(db), + None => Ok(fallback), + } +} + +pub(super) fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "list_materials" => { + let args = args_or_default(args); + let project_id = get_string_arg(&args, "project_id", "projectId")?; + let filter: Option = + parse_optional_nested_arg(&args, "filter")?; + + with_db_or_json(state, serde_json::json!([]), |db| { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let materials = + proxycast_services::material_service::MaterialService::list_materials( + &conn, + &project_id, + filter, + ) + .map_err(|e| format!("获取素材列表失败: {e}"))?; + Ok(serde_json::to_value(materials)?) + })? + } + "get_material_count" => { + let args = args_or_default(args); + let project_id = get_string_arg(&args, "project_id", "projectId")?; + + with_db_or_json(state, serde_json::json!(0), |db| { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let count = + crate::database::dao::material_dao::MaterialDao::count(&conn, &project_id) + .map_err(|e| format!("获取素材数量失败: {e}"))?; + Ok(serde_json::json!(count)) + })? + } + "project_memory_get" => { + let args = args_or_default(args); + let project_id = get_string_arg(&args, "project_id", "projectId")?; + + match &state.db { + Some(db) => { + let manager = crate::memory::MemoryManager::new(db.clone()); + let memory = manager + .get_project_memory(&project_id) + .map_err(|e| format!("获取项目记忆失败: {e}"))?; + serde_json::to_value(memory)? + } + None => return Err("Database not initialized".into()), + } + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/providers.rs b/src-tauri/src/dev_bridge/dispatcher/providers.rs new file mode 100644 index 000000000..05aaea0a5 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/providers.rs @@ -0,0 +1,170 @@ +use super::{args_or_default, get_string_arg}; +use crate::connect::RelayRegistry; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; +use std::sync::Arc; + +type DynError = Box; + +fn mask_api_key_for_display(key: &str) -> String { + let chars: Vec = key.chars().collect(); + if chars.len() <= 12 { + "****".to_string() + } else { + let prefix: String = chars[..6].iter().collect(); + let suffix: String = chars[chars.len() - 4..].iter().collect(); + format!("{prefix}****{suffix}") + } +} + +fn api_key_provider_with_keys_to_display( + provider_with_keys: &crate::database::dao::api_key_provider::ProviderWithKeys, + service: &proxycast_services::api_key_provider_service::ApiKeyProviderService, +) -> crate::commands::api_key_provider_cmd::ProviderWithKeysDisplay { + let api_keys = provider_with_keys + .api_keys + .iter() + .map(|key| { + let masked = match service.decrypt_api_key(&key.api_key_encrypted) { + Ok(decrypted) => mask_api_key_for_display(&decrypted), + Err(_) => "****".to_string(), + }; + + crate::commands::api_key_provider_cmd::ApiKeyDisplay { + id: key.id.clone(), + provider_id: key.provider_id.clone(), + api_key_masked: masked, + alias: key.alias.clone(), + enabled: key.enabled, + usage_count: key.usage_count, + error_count: key.error_count, + last_used_at: key.last_used_at.map(|value| value.to_rfc3339()), + created_at: key.created_at.to_rfc3339(), + } + }) + .collect(); + + crate::commands::api_key_provider_cmd::ProviderWithKeysDisplay { + provider: crate::commands::api_key_provider_cmd::ProviderDisplay { + id: provider_with_keys.provider.id.clone(), + name: provider_with_keys.provider.name.clone(), + provider_type: provider_with_keys.provider.provider_type.to_string(), + api_host: provider_with_keys.provider.api_host.clone(), + is_system: provider_with_keys.provider.is_system, + group: provider_with_keys.provider.group.to_string(), + enabled: provider_with_keys.provider.enabled, + sort_order: provider_with_keys.provider.sort_order, + api_version: provider_with_keys.provider.api_version.clone(), + project: provider_with_keys.provider.project.clone(), + location: provider_with_keys.provider.location.clone(), + region: provider_with_keys.provider.region.clone(), + custom_models: provider_with_keys.provider.custom_models.clone(), + api_key_count: provider_with_keys.api_keys.len(), + created_at: provider_with_keys.provider.created_at.to_rfc3339(), + updated_at: provider_with_keys.provider.updated_at.to_rfc3339(), + }, + api_keys, + } +} + +async fn relay_registry(state: &DevBridgeState) -> Option> { + let state_guard = state.connect_state.read().await; + state_guard + .as_ref() + .map(|connect_state| connect_state.registry.clone()) +} + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "get_provider_pool_overview" => { + if let Some(db) = &state.db { + serde_json::to_value(state.pool_service.get_overview(db)?)? + } else { + serde_json::json!([]) + } + } + "get_api_key_providers" => { + if let Some(db) = &state.db { + let providers = state.api_key_provider_service.get_all_providers(db)?; + let items: Vec<_> = providers + .iter() + .map(|provider| { + api_key_provider_with_keys_to_display( + provider, + state.api_key_provider_service.as_ref(), + ) + }) + .collect(); + serde_json::to_value(items)? + } else { + serde_json::json!([]) + } + } + "get_system_provider_catalog" => { + let catalog = crate::commands::api_key_provider_cmd::get_system_provider_catalog() + .map_err(|e| format!("获取系统 Provider Catalog 失败: {e}"))?; + serde_json::to_value(catalog)? + } + "get_provider_pool_credentials" => { + if let Some(db) = &state.db { + let conn = db.lock().map_err(|e| e.to_string())?; + let credentials = + crate::database::dao::provider_pool::ProviderPoolDao::get_all(&conn) + .unwrap_or_default(); + serde_json::to_value(credentials)? + } else { + serde_json::json!([]) + } + } + "get_provider_ui_state" => { + let args = args_or_default(args); + let key = get_string_arg(&args, "key", "key")?; + + if let Some(db) = &state.db { + serde_json::to_value(state.api_key_provider_service.get_ui_state(db, &key)?)? + } else { + JsonValue::Null + } + } + "set_provider_ui_state" => { + let args = args_or_default(args); + let key = get_string_arg(&args, "key", "key")?; + let value = get_string_arg(&args, "value", "value")?; + + if let Some(db) = &state.db { + state + .api_key_provider_service + .set_ui_state(db, &key, &value) + .map_err(|e| format!("设置 Provider UI 状态失败: {e}"))?; + serde_json::json!({ "success": true }) + } else { + return Err("Database not initialized".into()); + } + } + "list_relay_providers" => { + if let Some(registry) = relay_registry(state).await { + serde_json::to_value(registry.list())? + } else { + serde_json::json!([]) + } + } + "refresh_relay_registry" => { + if let Some(registry) = relay_registry(state).await { + registry + .load_from_remote() + .await + .map_err(|e| format!("刷新中转商注册表失败: {e}"))?; + serde_json::json!(registry.len()) + } else { + return Err("Connect 模块未初始化".into()); + } + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs b/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs new file mode 100644 index 000000000..1665abaeb --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs @@ -0,0 +1,137 @@ +use super::{args_or_default, get_string_arg, parse_nested_arg}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +type DynError = Box; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "get_network_info" => serde_json::to_value( + crate::commands::network_cmd::get_network_info() + .map_err(|e| format!("获取网络信息失败: {e}"))?, + )?, + "test_api" => { + let args = args.cloned().ok_or("缺少参数")?; + let provider = args + .get("provider") + .and_then(|value| value.as_str()) + .ok_or("缺少 provider 参数")?; + + let credential = if let Some(db) = &state.db { + state + .pool_service + .select_credential(db, provider, None) + .ok() + .flatten() + } else { + None + }; + + match credential { + Some(cred) => { + state.logs.write().await.add( + "info", + &format!("[DevBridge] 测试 API 使用凭证: {:?}", cred.name), + ); + serde_json::json!({ + "success": true, + "credential_name": cred.name, + "provider_type": cred.provider_type, + }) + } + None => serde_json::json!({ + "success": false, + "error": "未找到可用凭证" + }), + } + } + "execution_run_list" => { + let args = args_or_default(args); + let limit = args + .get("limit") + .and_then(|value| value.as_u64()) + .map(|value| value as usize); + let offset = args + .get("offset") + .and_then(|value| value.as_u64()) + .map(|value| value as usize); + + if let Some(db) = &state.db { + let tracker = + crate::services::execution_tracker_service::ExecutionTracker::new(db.clone()); + serde_json::to_value( + tracker.list_runs(limit.unwrap_or(50).clamp(1, 200), offset.unwrap_or(0))?, + )? + } else { + serde_json::json!([]) + } + } + "aster_session_get" => { + let args = args_or_default(args); + let session_id = get_string_arg(&args, "session_id", "sessionId")?; + + if let Some(db) = &state.db { + serde_json::to_value( + crate::agent::AsterAgentWrapper::get_session_sync(db, &session_id) + .map_err(|e| format!("获取 Aster 会话失败: {e}"))?, + )? + } else { + return Err("Database not initialized".into()); + } + } + "aster_session_list" => { + if let Some(db) = &state.db { + serde_json::to_value( + crate::agent::AsterAgentWrapper::list_sessions_sync(db) + .map_err(|e| format!("获取 Aster 会话列表失败: {e}"))?, + )? + } else { + serde_json::json!([]) + } + } + "report_frontend_crash" => { + let args = args_or_default(args); + let report: crate::app::commands::FrontendCrashReport = + parse_nested_arg(&args, "report")?; + + let sanitized_message = crate::logger::sanitize_log_message(&report.message); + let sanitized_component = report + .component + .as_deref() + .map(crate::logger::sanitize_log_message) + .unwrap_or_else(|| "unknown".to_string()); + let sanitized_step = report + .workflow_step + .as_deref() + .map(crate::logger::sanitize_log_message) + .unwrap_or_else(|| "unknown".to_string()); + let sanitized_mode = report + .creation_mode + .as_deref() + .map(crate::logger::sanitize_log_message) + .unwrap_or_else(|| "unknown".to_string()); + let stack_preview = report + .stack + .as_deref() + .map(crate::logger::sanitize_log_message) + .map(|stack| stack.lines().take(3).collect::>().join(" | ")) + .unwrap_or_default(); + + state.logs.write().await.add( + "error", + &format!( + "[FrontendCrash] component={sanitized_component} step={sanitized_step} mode={sanitized_mode} message={sanitized_message} stack={stack_preview}" + ), + ); + + serde_json::json!({ "success": true }) + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/skills.rs b/src-tauri/src/dev_bridge/dispatcher/skills.rs new file mode 100644 index 000000000..29a07dbfb --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/skills.rs @@ -0,0 +1,130 @@ +use super::{args_or_default, get_string_arg}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +type DynError = Box; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "get_skills_for_app" => { + let args = args_or_default(args); + let app = args + .get("app") + .and_then(|value| value.as_str()) + .unwrap_or("proxycast") + .to_string(); + let refresh_remote = args + .get("refresh_remote") + .or_else(|| args.get("refreshRemote")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let app_type: crate::models::app_type::AppType = app.parse().map_err(|e: String| e)?; + + if let Some(db) = &state.db { + let skills = crate::commands::skill_cmd::resolve_skills_for_app( + db, + &state.skill_service, + &app_type, + refresh_remote, + ) + .await + .map_err(|e| e.to_string())?; + serde_json::to_value(skills)? + } else { + serde_json::json!([]) + } + } + "get_local_skills_for_app" => { + let args = args_or_default(args); + let app = args + .get("app") + .and_then(|value| value.as_str()) + .unwrap_or("proxycast") + .to_string(); + + if let Some(db) = &state.db { + let app_type: crate::models::app_type::AppType = + app.parse().map_err(|e: String| e)?; + let installed_states = { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + crate::database::dao::skills::SkillDao::get_skills(&conn) + .map_err(|e| format!("{e}"))? + }; + let skills = state + .skill_service + .list_local_skills(&app_type, &installed_states) + .map_err(|e| format!("{e}"))?; + serde_json::to_value(skills)? + } else { + serde_json::json!([]) + } + } + "inspect_local_skill_for_app" => { + let args = args_or_default(args); + let app = args + .get("app") + .and_then(|value| value.as_str()) + .unwrap_or("proxycast") + .to_string(); + let directory = get_string_arg(&args, "directory", "directory")?; + let inspection = + crate::commands::skill_cmd::inspect_local_skill_for_app(app, directory) + .map_err(|e| format!("检查本地 Skill 失败: {e}"))?; + serde_json::to_value(inspection)? + } + "create_skill_scaffold_for_app" => { + let args = args_or_default(args); + let app = args + .get("app") + .and_then(|value| value.as_str()) + .unwrap_or("proxycast") + .to_string(); + let target = get_string_arg(&args, "target", "target")?; + let directory = get_string_arg(&args, "directory", "directory")?; + let name = get_string_arg(&args, "name", "name")?; + let description = get_string_arg(&args, "description", "description")?; + let inspection = crate::commands::skill_cmd::create_skill_scaffold_for_app( + app, + target, + directory, + name, + description, + ) + .map_err(|e| format!("创建 Skill 脚手架失败: {e}"))?; + serde_json::to_value(inspection)? + } + "import_local_skill_for_app" => { + let args = args_or_default(args); + let app = args + .get("app") + .and_then(|value| value.as_str()) + .unwrap_or("proxycast") + .to_string(); + let source_path = get_string_arg(&args, "source_path", "source_path") + .or_else(|_| get_string_arg(&args, "sourcePath", "sourcePath"))?; + let result = crate::commands::skill_cmd::import_local_skill_for_app(app, source_path) + .map_err(|e| format!("导入本地 Skill 失败: {e}"))?; + serde_json::to_value(result)? + } + "inspect_remote_skill" => { + let args = args_or_default(args); + let owner = get_string_arg(&args, "owner", "owner")?; + let name = get_string_arg(&args, "name", "name")?; + let branch = get_string_arg(&args, "branch", "branch")?; + let directory = get_string_arg(&args, "directory", "directory")?; + let inspection = state + .skill_service + .inspect_remote_skill(&owner, &name, &branch, &directory) + .await + .map_err(|e| format!("检查远程 Skill 失败: {e}"))?; + serde_json::to_value(inspection)? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/workspace.rs b/src-tauri/src/dev_bridge/dispatcher/workspace.rs new file mode 100644 index 000000000..ce81e1483 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/workspace.rs @@ -0,0 +1,153 @@ +use super::{args_or_default, get_db, get_string_arg, parse_nested_arg}; +use crate::commands::workspace_cmd::{ + CreateWorkspaceRequest, UpdateWorkspaceRequest, WorkspaceEnsureResult, WorkspaceListItem, +}; +use crate::dev_bridge::DevBridgeState; +use crate::services::workspace_health_service::{ + ensure_workspace_ready_with_auto_relocate, ensure_workspace_root_ready, +}; +use crate::workspace::{WorkspaceManager, WorkspaceType, WorkspaceUpdate}; +use serde_json::Value as JsonValue; +use std::path::{Path, PathBuf}; + +mod management; +mod queries; +mod readiness; + +type DynError = Box; + +fn workspace_manager(state: &DevBridgeState) -> Result { + Ok(WorkspaceManager::new(get_db(state)?.clone())) +} + +fn get_optional_bool_arg(args: &JsonValue, primary: &str, secondary: &str) -> Option { + args.get(primary) + .or_else(|| args.get(secondary)) + .and_then(|value| value.as_bool()) +} + +fn get_workspace_projects_root_dir() -> Result { + proxycast_core::app_paths::resolve_projects_dir() +} + +fn sanitize_project_dir_name(name: &str) -> String { + let sanitized: String = name + .trim() + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ if ch.is_control() => '_', + _ => ch, + }) + .collect(); + + let trimmed = sanitized.trim().trim_matches('.').to_string(); + if trimmed.is_empty() { + "未命名项目".to_string() + } else { + trimmed + } +} + +fn to_workspace_list_item_json(workspace: T) -> Result +where + WorkspaceListItem: From, +{ + Ok(serde_json::to_value(WorkspaceListItem::from(workspace))?) +} + +fn build_ensure_result( + workspace_id: String, + ensured: crate::services::workspace_health_service::WorkspaceReadyResult, +) -> WorkspaceEnsureResult { + WorkspaceEnsureResult { + workspace_id, + root_path: ensured.root_path.to_string_lossy().to_string(), + existed: ensured.existed, + created: ensured.created, + repaired: ensured.repaired, + relocated: ensured.relocated, + previous_root_path: ensured + .previous_root_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + warning: ensured.warning, + } +} + +fn create_default_project_if_missing(manager: &WorkspaceManager) -> Result { + if let Some(workspace) = manager.get_default()? { + return to_workspace_list_item_json(workspace); + } + + let default_project_path = get_workspace_projects_root_dir()?.join("default"); + std::fs::create_dir_all(&default_project_path) + .map_err(|e| format!("创建默认项目目录失败: {e}"))?; + + let workspace = manager.create_with_type( + "默认项目".to_string(), + default_project_path, + WorkspaceType::Persistent, + )?; + manager.set_default(&workspace.id)?; + let workspace = manager.get(&workspace.id)?.ok_or("创建默认项目失败")?; + to_workspace_list_item_json(workspace) +} + +fn remove_workspace_directory_if_requested( + manager: &WorkspaceManager, + workspace_id: &str, + delete_directory: bool, +) -> Result<(), DynError> { + if !delete_directory { + return Ok(()); + } + + let workspace_id = workspace_id.to_string(); + if let Some(workspace) = manager.get(&workspace_id)? { + let root_path = workspace.root_path; + if root_path.exists() && root_path.is_dir() { + std::fs::remove_dir_all(&root_path).map_err(|e| format!("删除目录失败: {e}"))?; + } + } + + Ok(()) +} + +fn ensure_update_root_path(root_path: Option) -> Result, DynError> { + match root_path { + Some(path_str) => { + let path = PathBuf::from(path_str); + let created = ensure_workspace_root_ready(&path)?; + if created { + tracing::warn!( + "[Workspace] 更新路径时检测到目录缺失,已自动创建: {}", + path.to_string_lossy() + ); + } + Ok(Some(path)) + } + None => Ok(None), + } +} + +fn ensure_valid_workspace_root(path: &Path) -> Result<(), DynError> { + ensure_workspace_root_ready(path)?; + Ok(()) +} + +pub(super) fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + if let Some(result) = management::try_handle(state, cmd, args)? { + return Ok(Some(result)); + } + + if let Some(result) = queries::try_handle(state, cmd, args)? { + return Ok(Some(result)); + } + + readiness::try_handle(state, cmd, args) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/workspace/management.rs b/src-tauri/src/dev_bridge/dispatcher/workspace/management.rs new file mode 100644 index 000000000..35ad5c23b --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/workspace/management.rs @@ -0,0 +1,84 @@ +use super::{ + args_or_default, create_default_project_if_missing, ensure_update_root_path, + ensure_valid_workspace_root, get_optional_bool_arg, get_string_arg, parse_nested_arg, + remove_workspace_directory_if_requested, to_workspace_list_item_json, workspace_manager, + CreateWorkspaceRequest, DynError, PathBuf, UpdateWorkspaceRequest, WorkspaceType, + WorkspaceUpdate, +}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +pub(super) fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "workspace_create" => { + let args = args_or_default(args); + let request: CreateWorkspaceRequest = parse_nested_arg(&args, "request")?; + + if request.root_path.contains("[object Promise]") { + return Err(format!( + "无效的 root_path: {}。请确保前端正确 await 了 Promise。", + request.root_path + ) + .into()); + } + + let manager = workspace_manager(state)?; + let workspace_type = request + .workspace_type + .map(|workspace_type| WorkspaceType::parse(&workspace_type)) + .unwrap_or_default(); + let root_path = PathBuf::from(&request.root_path); + ensure_valid_workspace_root(&root_path)?; + to_workspace_list_item_json(manager.create_with_type( + request.name, + root_path, + workspace_type, + )?)? + } + "workspace_update" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let request: UpdateWorkspaceRequest = parse_nested_arg(&args, "request")?; + let manager = workspace_manager(state)?; + let updates = WorkspaceUpdate { + name: request.name, + settings: request.settings, + icon: request.icon, + color: request.color, + is_favorite: request.is_favorite, + is_archived: request.is_archived, + tags: request.tags, + root_path: ensure_update_root_path(request.root_path)?, + }; + to_workspace_list_item_json(manager.update(&id, updates)?)? + } + "workspace_delete" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let delete_directory = + get_optional_bool_arg(&args, "deleteDirectory", "delete_directory") + .unwrap_or(false); + let manager = workspace_manager(state)?; + remove_workspace_directory_if_requested(&manager, &id, delete_directory)?; + serde_json::to_value(manager.delete(&id)?)? + } + "workspace_set_default" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let manager = workspace_manager(state)?; + manager.set_default(&id)?; + serde_json::json!(null) + } + "get_or_create_default_project" => { + let manager = workspace_manager(state)?; + create_default_project_if_missing(&manager)? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/workspace/queries.rs b/src-tauri/src/dev_bridge/dispatcher/workspace/queries.rs new file mode 100644 index 000000000..ed661888c --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/workspace/queries.rs @@ -0,0 +1,58 @@ +use super::{ + args_or_default, get_string_arg, get_workspace_projects_root_dir, sanitize_project_dir_name, + workspace_manager, DynError, PathBuf, WorkspaceListItem, +}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +pub(super) fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "workspace_list" => { + let manager = workspace_manager(state)?; + let items: Vec<_> = manager + .list()? + .into_iter() + .map(WorkspaceListItem::from) + .collect(); + serde_json::to_value(items)? + } + "workspace_get" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let manager = workspace_manager(state)?; + serde_json::to_value(manager.get(&id)?.map(WorkspaceListItem::from))? + } + "workspace_get_default" => { + let manager = workspace_manager(state)?; + serde_json::to_value(manager.get_default()?.map(WorkspaceListItem::from))? + } + "workspace_get_by_path" => { + let args = args_or_default(args); + let root_path = get_string_arg(&args, "rootPath", "root_path")?; + let manager = workspace_manager(state)?; + serde_json::to_value( + manager + .get_by_path(&PathBuf::from(root_path))? + .map(WorkspaceListItem::from), + )? + } + "workspace_get_projects_root" => { + let root_dir = get_workspace_projects_root_dir()?; + serde_json::json!(root_dir.to_string_lossy().to_string()) + } + "workspace_resolve_project_path" => { + let args = args_or_default(args); + let name = get_string_arg(&args, "name", "name")?; + let project_path = + get_workspace_projects_root_dir()?.join(sanitize_project_dir_name(&name)); + serde_json::json!(project_path.to_string_lossy().to_string()) + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/workspace/readiness.rs b/src-tauri/src/dev_bridge/dispatcher/workspace/readiness.rs new file mode 100644 index 000000000..7f82c40e5 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/workspace/readiness.rs @@ -0,0 +1,42 @@ +use super::{ + args_or_default, build_ensure_result, ensure_workspace_ready_with_auto_relocate, + get_string_arg, workspace_manager, DynError, +}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; + +pub(super) fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "workspace_ensure_ready" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let manager = workspace_manager(state)?; + let workspace = manager + .get(&id)? + .ok_or_else(|| format!("Workspace 不存在: {id}"))?; + let result = build_ensure_result( + workspace.id.clone(), + ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?, + ); + serde_json::to_value(result)? + } + "workspace_ensure_default_ready" => { + let manager = workspace_manager(state)?; + let Some(workspace) = manager.get_default()? else { + return Ok(Some(serde_json::json!(null))); + }; + let result = build_ensure_result( + workspace.id.clone(), + ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?, + ); + serde_json::to_value(Some(result))? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/services/automation_service/browser_runtime_sync.rs b/src-tauri/src/services/automation_service/browser_runtime_sync.rs new file mode 100644 index 000000000..99e551e25 --- /dev/null +++ b/src-tauri/src/services/automation_service/browser_runtime_sync.rs @@ -0,0 +1,363 @@ +use super::{apply_terminal_job_state, build_browser_session_run_metadata, set_active_job_state}; +use crate::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; +use crate::database::DbConnection; +use chrono::Utc; +use proxycast_browser_runtime::{BrowserSessionLifecycleState, CdpSessionState}; +use proxycast_core::database::dao::automation_job::{AutomationJob, AutomationJobDao}; +use rusqlite::Connection; + +enum BrowserSessionSyncDisposition { + Active(&'static str), + FinishSuccess, + FinishError(String), +} + +pub fn sync_browser_session_runtime_state( + db: &DbConnection, + session: &CdpSessionState, +) -> Result<(), String> { + sync_browser_session_runtime_state_internal(db, session, false) +} + +pub fn complete_browser_session_after_resume( + db: &DbConnection, + session: &CdpSessionState, +) -> Result<(), String> { + sync_browser_session_runtime_state_internal(db, session, true) +} + +fn sync_browser_session_runtime_state_internal( + db: &DbConnection, + session: &CdpSessionState, + finalize_on_resume: bool, +) -> Result<(), String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let Some((run, mut job)) = resolve_related_automation_run(&conn, &session.session_id)? else { + return Ok(()); + }; + + if run.status.is_terminal() { + return Ok(()); + } + + match resolve_sync_disposition(session, finalize_on_resume) { + BrowserSessionSyncDisposition::Active(status) => { + let updated_at = Utc::now().to_rfc3339(); + let retry_count = job.last_retry_count; + let metadata = + build_browser_session_run_metadata(&job, session, status, retry_count, None) + .to_string(); + + AgentRunDao::refresh_running_run( + &conn, + &run.id, + &updated_at, + Some(session.session_id.as_str()), + Some(metadata.as_str()), + ) + .map_err(|e| format!("刷新自动化运行态失败: {e}"))?; + + let started_at = job + .running_started_at + .clone() + .unwrap_or_else(|| run.started_at.clone()); + set_active_job_state( + &mut job, + status, + started_at.as_str(), + &updated_at, + retry_count, + ); + AutomationJobDao::update(&conn, &job) + .map_err(|e| format!("更新自动化任务运行态失败: {e}"))?; + Ok(()) + } + BrowserSessionSyncDisposition::FinishSuccess => finish_browser_session_run( + &conn, + &run, + &mut job, + session, + AgentRunStatus::Success, + None, + ), + BrowserSessionSyncDisposition::FinishError(message) => finish_browser_session_run( + &conn, + &run, + &mut job, + session, + AgentRunStatus::Error, + Some(message.as_str()), + ), + } +} + +fn resolve_related_automation_run( + conn: &Connection, + session_id: &str, +) -> Result, String> { + let runs = AgentRunDao::list_runs_by_session(conn, session_id, 20) + .map_err(|e| format!("查询浏览器会话关联运行失败: {e}"))?; + let Some(run) = runs + .into_iter() + .find(|item| item.source == "automation" && item.source_ref.is_some()) + else { + return Ok(None); + }; + let Some(job_id) = run.source_ref.as_deref() else { + return Ok(None); + }; + let Some(job) = + AutomationJobDao::get(conn, job_id).map_err(|e| format!("读取自动化任务失败: {e}"))? + else { + return Ok(None); + }; + Ok(Some((run, job))) +} + +fn resolve_sync_disposition( + session: &CdpSessionState, + finalize_on_resume: bool, +) -> BrowserSessionSyncDisposition { + if finalize_on_resume { + return BrowserSessionSyncDisposition::FinishSuccess; + } + + match session.lifecycle_state { + BrowserSessionLifecycleState::Launching | BrowserSessionLifecycleState::Live => { + BrowserSessionSyncDisposition::Active("running") + } + BrowserSessionLifecycleState::WaitingForHuman => { + BrowserSessionSyncDisposition::Active("waiting_for_human") + } + BrowserSessionLifecycleState::HumanControlling => { + BrowserSessionSyncDisposition::Active("human_controlling") + } + BrowserSessionLifecycleState::AgentResuming => { + BrowserSessionSyncDisposition::Active("agent_resuming") + } + BrowserSessionLifecycleState::Closed => BrowserSessionSyncDisposition::FinishSuccess, + BrowserSessionLifecycleState::Failed => { + BrowserSessionSyncDisposition::FinishError(resolve_session_error(session)) + } + } +} + +fn resolve_session_error(session: &CdpSessionState) -> String { + session + .last_error + .clone() + .or_else(|| session.human_reason.clone()) + .unwrap_or_else(|| "浏览器会话执行失败".to_string()) +} + +fn finish_browser_session_run( + conn: &Connection, + run: &AgentRun, + job: &mut AutomationJob, + session: &CdpSessionState, + run_status: AgentRunStatus, + error_message: Option<&str>, +) -> Result<(), String> { + let finished_at = Utc::now(); + let retry_count = job.last_retry_count; + let is_success = run_status == AgentRunStatus::Success; + let status_text = if is_success { "success" } else { "error" }; + let duration_ms = apply_terminal_job_state( + job, + status_text, + error_message.unwrap_or_default(), + retry_count, + run.started_at.as_str(), + finished_at, + )?; + let metadata = build_browser_session_run_metadata( + job, + session, + status_text, + retry_count, + Some(duration_ms), + ) + .to_string(); + let finished_at_str = finished_at.to_rfc3339(); + let error_code = if is_success { + None + } else { + Some("browser_session_failed") + }; + + AgentRunDao::finish_run( + conn, + &run.id, + run_status, + &finished_at_str, + Some(duration_ms), + error_code, + error_message, + Some(metadata.as_str()), + ) + .map_err(|e| format!("结束浏览器自动化运行失败: {e}"))?; + + AutomationJobDao::update(conn, job).map_err(|e| format!("保存自动化任务结果失败: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use proxycast_browser_runtime::{BrowserControlMode, BrowserTransportKind}; + use proxycast_core::config::{AutomationExecutionMode, DeliveryConfig, TaskSchedule}; + use proxycast_core::database::dao::agent_run::AgentRun; + use rusqlite::Connection; + use serde_json::json; + 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 sample_browser_job() -> AutomationJob { + AutomationJob { + id: "job-browser-1".to_string(), + name: "浏览器巡检".to_string(), + description: Some("等待人工处理".to_string()), + enabled: true, + workspace_id: "workspace-1".to_string(), + execution_mode: AutomationExecutionMode::Intelligent, + schedule: TaskSchedule::Every { every_secs: 300 }, + payload: json!({ + "kind": "browser_session", + "profile_id": "profile-1", + "profile_key": "shop_us", + "url": "https://seller.example.com/dashboard", + "environment_preset_id": "preset-1", + "target_id": null, + "open_window": false, + "stream_mode": "events" + }), + delivery: DeliveryConfig::default(), + timeout_secs: Some(180), + max_retries: 2, + next_run_at: None, + last_status: Some("running".to_string()), + last_error: None, + last_run_at: Some("2026-03-16T00:00:00Z".to_string()), + last_finished_at: None, + running_started_at: Some("2026-03-16T00:00:00Z".to_string()), + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: None, + created_at: "2026-03-16T00:00:00Z".to_string(), + updated_at: "2026-03-16T00:00:00Z".to_string(), + } + } + + fn sample_run(session_id: &str) -> AgentRun { + AgentRun { + id: "run-browser-1".to_string(), + source: "automation".to_string(), + source_ref: Some("job-browser-1".to_string()), + session_id: Some(session_id.to_string()), + status: AgentRunStatus::Running, + started_at: "2026-03-16T00:00:00Z".to_string(), + finished_at: None, + duration_ms: None, + error_code: None, + error_message: None, + metadata: None, + created_at: "2026-03-16T00:00:00Z".to_string(), + updated_at: "2026-03-16T00:00:00Z".to_string(), + } + } + + fn sample_session( + session_id: &str, + lifecycle_state: BrowserSessionLifecycleState, + ) -> CdpSessionState { + CdpSessionState { + session_id: session_id.to_string(), + profile_key: "shop_us".to_string(), + environment_preset_id: Some("preset-1".to_string()), + environment_preset_name: Some("美区桌面".to_string()), + target_id: "target-1".to_string(), + target_title: "店铺后台".to_string(), + target_url: "https://seller.example.com/dashboard".to_string(), + remote_debugging_port: 13001, + ws_debugger_url: "ws://127.0.0.1:13001/devtools/page/target-1".to_string(), + devtools_frontend_url: None, + stream_mode: None, + transport_kind: BrowserTransportKind::CdpFrames, + lifecycle_state, + control_mode: BrowserControlMode::Agent, + human_reason: None, + last_page_info: None, + last_event_at: Some("2026-03-16T00:00:01Z".to_string()), + last_frame_at: None, + last_error: None, + created_at: "2026-03-16T00:00:00Z".to_string(), + connected: true, + } + } + + #[test] + fn sync_browser_session_runtime_state_should_mark_waiting_for_human() { + let db = setup_db(); + let conn = db.lock().expect("数据库锁定失败"); + AutomationJobDao::create(&conn, &sample_browser_job()).expect("写入 job 失败"); + AgentRunDao::create_run(&conn, &sample_run("session-1")).expect("写入 run 失败"); + drop(conn); + + let mut session = + sample_session("session-1", BrowserSessionLifecycleState::WaitingForHuman); + session.control_mode = BrowserControlMode::Shared; + session.human_reason = Some("等待你确认是否继续执行".to_string()); + sync_browser_session_runtime_state(&db, &session).expect("同步浏览器运行态失败"); + + let conn = db.lock().expect("数据库锁定失败"); + let job = AutomationJobDao::get(&conn, "job-browser-1") + .expect("查询 job 失败") + .expect("job 不存在"); + let run = AgentRunDao::get_run(&conn, "run-browser-1") + .expect("查询 run 失败") + .expect("run 不存在"); + assert_eq!(job.last_status.as_deref(), Some("waiting_for_human")); + assert!(job.running_started_at.is_some()); + assert_eq!(run.status, AgentRunStatus::Running); + assert!(run + .metadata + .as_deref() + .unwrap_or_default() + .contains("\"browser_lifecycle_state\":\"waiting_for_human\"")); + } + + #[test] + fn complete_browser_session_after_resume_should_finalize_success() { + let db = setup_db(); + let mut job = sample_browser_job(); + job.last_status = Some("human_controlling".to_string()); + + let conn = db.lock().expect("数据库锁定失败"); + AutomationJobDao::create(&conn, &job).expect("写入 job 失败"); + AgentRunDao::create_run(&conn, &sample_run("session-2")).expect("写入 run 失败"); + drop(conn); + + let mut session = sample_session("session-2", BrowserSessionLifecycleState::AgentResuming); + session.human_reason = Some("人工处理完成,继续执行".to_string()); + complete_browser_session_after_resume(&db, &session).expect("恢复后收口失败"); + + let conn = db.lock().expect("数据库锁定失败"); + let job = AutomationJobDao::get(&conn, "job-browser-1") + .expect("查询 job 失败") + .expect("job 不存在"); + let run = AgentRunDao::get_run(&conn, "run-browser-1") + .expect("查询 run 失败") + .expect("run 不存在"); + assert_eq!(job.last_status.as_deref(), Some("success")); + assert!(job.running_started_at.is_none()); + assert_eq!(run.status, AgentRunStatus::Success); + assert!(run.finished_at.is_some()); + } +} diff --git a/src-tauri/src/services/automation_service/delivery.rs b/src-tauri/src/services/automation_service/delivery.rs new file mode 100644 index 000000000..a6ece9e28 --- /dev/null +++ b/src-tauri/src/services/automation_service/delivery.rs @@ -0,0 +1,1682 @@ +//! 自动化任务输出结果投递 + +use chrono::{Duration as ChronoDuration, Utc}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use proxycast_core::config::DeliveryConfig; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::Path; +use std::time::Duration as StdDuration; +use urlencoding::encode; + +#[derive(Debug, Clone)] +pub struct DeliveryContext { + pub attempt_id: String, + pub run_id: Option, + pub job_id: String, + pub execution_retry_count: u32, +} + +#[derive(Debug)] +pub struct DeliveryResult { + pub success: bool, + pub message: String, + pub channel: Option, + pub target: Option, + pub output_kind: String, + pub output_schema: String, + pub output_format: String, + pub output_preview: String, + pub delivery_attempt_id: String, + pub run_id: Option, + pub execution_retry_count: u32, + pub delivery_attempts: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TaskOutput { + pub kind: String, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TaskResult { + pub task: String, + pub status: String, + pub output: TaskOutput, + pub duration_ms: i64, + pub timestamp: String, +} + +#[derive(Debug, Clone)] +struct RenderedOutput { + format: String, + schema: String, + text: String, + data: Option, +} + +#[derive(Debug)] +struct DeliveryOutcome { + result: DeliveryResult, + retryable: bool, +} + +#[derive(Debug, Clone)] +struct GoogleSheetsTarget { + spreadsheet_id: String, + sheet: String, + credentials_file: String, + include_header: bool, + value_input_option: String, +} + +#[derive(Debug)] +struct GoogleSheetsPreparedValues { + values: Vec>, + data_rows: usize, +} + +#[derive(Debug, Deserialize)] +struct GoogleServiceAccountCredentials { + client_email: String, + private_key: String, + #[serde(default = "default_google_token_uri")] + token_uri: String, +} + +#[derive(Debug, Serialize)] +struct GoogleServiceAccountClaims { + iss: String, + scope: String, + aud: String, + exp: i64, + iat: i64, +} + +#[derive(Debug, Deserialize)] +struct GoogleAccessTokenResponse { + access_token: String, +} + +#[derive(Debug, Serialize)] +struct GoogleSheetsAppendRequest { + #[serde(rename = "majorDimension")] + major_dimension: String, + values: Vec>, +} + +const GOOGLE_SHEETS_SCOPE: &str = "https://www.googleapis.com/auth/spreadsheets"; +const GOOGLE_JWT_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const NETWORK_DELIVERY_MAX_ATTEMPTS: u32 = 3; + +pub fn build_delivery_attempt_id( + job_id: &str, + started_at: &str, + execution_retry_count: u32, + run_id: Option<&str>, +) -> String { + if let Some(run_id) = run_id.map(str::trim).filter(|value| !value.is_empty()) { + return format!("dlv-{run_id}"); + } + + let mut hasher = Sha256::new(); + hasher.update(job_id.as_bytes()); + hasher.update(b":"); + hasher.update(started_at.as_bytes()); + hasher.update(b":"); + hasher.update(execution_retry_count.to_string().as_bytes()); + let digest = hasher.finalize(); + format!("dlv-{}", hex::encode(&digest[..16])) +} + +pub async fn deliver_result( + config: &DeliveryConfig, + result: &TaskResult, + context: &DeliveryContext, +) -> DeliveryResult { + let rendered = render_output(config, result); + if config.mode == "none" { + return DeliveryResult { + success: true, + message: "输出投递已禁用".to_string(), + channel: None, + target: None, + output_kind: result.output.kind.clone(), + output_schema: rendered.schema, + output_format: rendered.format, + output_preview: preview_output_text(result.output.text.as_str()), + delivery_attempt_id: context.attempt_id.clone(), + run_id: context.run_id.clone(), + execution_retry_count: context.execution_retry_count, + delivery_attempts: 0, + }; + } + + let channel = match config + .channel + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) => value, + None => { + let target = normalize_target(config.target.as_deref()); + return build_delivery_result( + false, + "未配置输出渠道".to_string(), + None, + target.as_deref(), + result, + &rendered, + context, + ); + } + }; + let target = match config + .target + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) => value, + None => { + return build_delivery_result( + false, + "未配置输出目标".to_string(), + Some(channel), + None, + result, + &rendered, + context, + ) + } + }; + + let max_attempts = max_delivery_attempts(channel); + for attempt_no in 1..=max_attempts { + let mut outcome = match channel { + "webhook" => deliver_webhook(target, result, &rendered, context).await, + "telegram" => deliver_telegram(target, result, &rendered, context).await, + "local_file" => deliver_local_file(target, result, &rendered, context), + "google_sheets" => deliver_google_sheets(target, result, &rendered, context).await, + _ => DeliveryOutcome { + result: build_delivery_result( + false, + format!("不支持的通知渠道: {channel}"), + Some(channel), + Some(target), + result, + &rendered, + context, + ), + retryable: false, + }, + }; + outcome.result.delivery_attempts = attempt_no; + if outcome.result.success { + if attempt_no > 1 { + outcome.result.message = + format!("{}(第 {attempt_no} 次尝试成功)", outcome.result.message); + } + return outcome.result; + } + if !outcome.retryable || attempt_no >= max_attempts { + if attempt_no > 1 { + outcome.result.message = + format!("{}(共尝试 {attempt_no} 次)", outcome.result.message); + } + return outcome.result; + } + tokio::time::sleep(retry_backoff(attempt_no)).await; + } + + build_delivery_result( + false, + "输出投递失败".to_string(), + Some(channel), + Some(target), + result, + &rendered, + context, + ) +} + +fn render_output(config: &DeliveryConfig, result: &TaskResult) -> RenderedOutput { + let format = normalize_output_format(config.output_format.as_str()); + let schema = normalize_output_schema(config.output_schema.as_deref(), result); + let data = Some(build_output_data(&schema, result)); + + if format == "json" { + let text = data + .as_ref() + .and_then(|value| serde_json::to_string_pretty(value).ok()) + .unwrap_or_else(|| result.output.text.clone()); + return RenderedOutput { + format, + schema, + text, + data, + }; + } + + RenderedOutput { + format, + schema: schema.clone(), + text: data + .as_ref() + .map(|value| render_text_output(&schema, result, value)) + .unwrap_or_else(|| result.output.text.clone()), + data, + } +} + +fn normalize_output_format(value: &str) -> String { + match value.trim() { + "json" => "json".to_string(), + _ => "text".to_string(), + } +} + +fn normalize_output_schema(value: Option<&str>, result: &TaskResult) -> String { + match value.map(str::trim) { + Some("json") => "json".to_string(), + Some("table") => "table".to_string(), + Some("csv") => "csv".to_string(), + Some("links") => "links".to_string(), + Some("text") => "text".to_string(), + _ => infer_output_schema(result), + } +} + +fn normalize_target(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) +} + +fn default_google_token_uri() -> String { + "https://oauth2.googleapis.com/token".to_string() +} + +fn max_delivery_attempts(channel: &str) -> u32 { + match channel { + "webhook" | "google_sheets" => NETWORK_DELIVERY_MAX_ATTEMPTS, + _ => 1, + } +} + +fn retry_backoff(attempt_no: u32) -> StdDuration { + match attempt_no { + 1 => StdDuration::from_millis(400), + 2 => StdDuration::from_millis(1200), + _ => StdDuration::from_millis(2000), + } +} + +fn infer_output_schema(result: &TaskResult) -> String { + match result.output.kind.trim() { + "json" => "json".to_string(), + "table" => "table".to_string(), + "csv" => "csv".to_string(), + "links" => "links".to_string(), + "text" => "text".to_string(), + _ if result.output.data.is_some() => "json".to_string(), + _ => "text".to_string(), + } +} + +fn build_output_data(schema: &str, result: &TaskResult) -> Value { + if let Some(data) = result.output.data.clone() { + return data; + } + + match schema { + "links" => json!({ + "items": extract_links_from_text(result.output.text.as_str()), + }), + "table" => single_column_table_payload("summary", result.output.text.as_str()), + "csv" => { + let columns = vec!["summary".to_string()]; + let rows = vec![vec![result.output.text.clone()]]; + json!({ + "columns": columns, + "rows": rows, + "csv": render_csv_lines(&["summary".to_string()], &[vec![result.output.text.clone()]]), + }) + } + "json" => default_output_data(schema, result), + _ => json!({ + "text": result.output.text.clone(), + }), + } +} + +fn default_output_data(schema: &str, result: &TaskResult) -> Value { + json!({ + "schema": schema, + "task": result.task.clone(), + "status": result.status.clone(), + "duration_ms": result.duration_ms, + "timestamp": result.timestamp.clone(), + "output": { + "kind": result.output.kind.clone(), + "text": result.output.text.clone(), + "data": result.output.data.clone(), + } + }) +} + +fn render_text_output(schema: &str, result: &TaskResult, data: &Value) -> String { + match schema { + "table" => render_table_text(data).unwrap_or_else(|| result.output.text.clone()), + "csv" => render_csv_text(data).unwrap_or_else(|| result.output.text.clone()), + "links" => render_links_text(data).unwrap_or_else(|| result.output.text.clone()), + _ => result.output.text.clone(), + } +} + +fn single_column_table_payload(column: &str, value: &str) -> Value { + json!({ + "columns": [column], + "rows": [[value]], + }) +} + +fn extract_links_from_text(text: &str) -> Vec { + text.split_whitespace() + .filter(|item| item.starts_with("https://") || item.starts_with("http://")) + .map(|url| json!({ "url": url })) + .collect() +} + +fn render_links_text(data: &Value) -> Option { + let items = data + .get("items") + .or_else(|| data.get("links")) + .unwrap_or(data); + let values = match items { + Value::Array(values) => values, + Value::String(value) => return Some(value.clone()), + _ => return None, + }; + + let lines: Vec = values + .iter() + .filter_map(|item| match item { + Value::String(url) => Some(url.clone()), + Value::Object(object) => { + let url = object.get("url").and_then(Value::as_str)?; + let title = object + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + Some(match title { + Some(title) => format!("{title} - {url}"), + None => url.to_string(), + }) + } + _ => None, + }) + .collect(); + + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +fn render_table_text(data: &Value) -> Option { + let (columns, rows) = extract_table_payload(data)?; + if columns.is_empty() { + return None; + } + + let header = format!( + "| {} |", + columns + .iter() + .map(|value| escape_table_cell(value)) + .collect::>() + .join(" | ") + ); + let separator = format!( + "| {} |", + columns + .iter() + .map(|_| "---") + .collect::>() + .join(" | ") + ); + let body = rows.into_iter().map(|row| { + format!( + "| {} |", + row.into_iter() + .map(|value| escape_table_cell(&value)) + .collect::>() + .join(" | ") + ) + }); + + Some( + std::iter::once(header) + .chain(std::iter::once(separator)) + .chain(body) + .collect::>() + .join("\n"), + ) +} + +fn render_csv_text(data: &Value) -> Option { + if let Some(csv) = data.get("csv").and_then(Value::as_str) { + return Some(csv.to_string()); + } + let (columns, rows) = extract_table_payload(data)?; + if columns.is_empty() { + return None; + } + Some(render_csv_lines(&columns, &rows)) +} + +fn extract_table_payload(data: &Value) -> Option<(Vec, Vec>)> { + let object = data.as_object()?; + let rows = object.get("rows")?.as_array()?; + let mut columns = object + .get("columns") + .and_then(Value::as_array) + .map(|values| values.iter().map(json_value_to_string).collect::>()) + .unwrap_or_default(); + + if columns.is_empty() { + columns = rows + .iter() + .find_map(|row| row.as_object()) + .map(|row| row.keys().cloned().collect::>()) + .unwrap_or_default(); + } + + let rendered_rows = rows + .iter() + .map(|row| match row { + Value::Array(items) => items.iter().map(json_value_to_string).collect::>(), + Value::Object(items) => columns + .iter() + .map(|column| { + items + .get(column) + .map(json_value_to_string) + .unwrap_or_default() + }) + .collect::>(), + _ => vec![json_value_to_string(row)], + }) + .collect::>(); + + Some((columns, rendered_rows)) +} + +fn render_csv_lines(columns: &[String], rows: &[Vec]) -> String { + std::iter::once( + columns + .iter() + .map(|value| escape_csv_cell(value)) + .collect::>() + .join(","), + ) + .chain(rows.iter().map(|row| { + row.iter() + .map(|value| escape_csv_cell(value)) + .collect::>() + .join(",") + })) + .collect::>() + .join("\n") +} + +fn escape_csv_cell(value: &str) -> String { + let escaped = value.replace('"', "\"\""); + if escaped.contains(',') + || escaped.contains('\n') + || escaped.contains('\r') + || escaped.contains('"') + { + format!("\"{escaped}\"") + } else { + escaped + } +} + +fn escape_table_cell(value: &str) -> String { + value.replace('|', "\\|").replace('\n', "
") +} + +fn json_value_to_string(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(item) => item.clone(), + Value::Bool(item) => item.to_string(), + Value::Number(item) => item.to_string(), + _ => serde_json::to_string(value).unwrap_or_default(), + } +} + +fn parse_google_sheets_target(target: &str) -> Result { + let trimmed = target.trim(); + if trimmed.is_empty() { + return Err("Google Sheets 目标不能为空".to_string()); + } + + let mut params = std::collections::BTreeMap::::new(); + for segment in trimmed.split(';') { + let item = segment.trim(); + if item.is_empty() { + continue; + } + let (key, value) = item + .split_once('=') + .ok_or_else(|| format!("Google Sheets 目标格式错误,缺少 key=value: {item}"))?; + let normalized_key = key.trim().to_ascii_lowercase(); + let normalized_value = value.trim().to_string(); + if normalized_key.is_empty() || normalized_value.is_empty() { + return Err(format!("Google Sheets 目标格式错误,字段不能为空: {item}")); + } + params.insert(normalized_key, normalized_value); + } + + if params.is_empty() { + return Err("Google Sheets 目标不能为空".to_string()); + } + + let spreadsheet_id = + take_required_google_sheets_param(&mut params, "spreadsheet_id", "spreadsheet_id")?; + let sheet = take_required_google_sheets_param(&mut params, "sheet", "sheet")?; + let credentials_file = + take_required_google_sheets_param(&mut params, "credentials_file", "credentials_file")?; + let include_header = match params.remove("include_header") { + Some(value) => parse_google_sheets_bool_flag(&value) + .ok_or_else(|| format!("Google Sheets include_header 不支持的值: {value}"))?, + None => false, + }; + let value_input_option = match params.remove("value_input_option") { + Some(value) => match value.trim().to_ascii_uppercase().as_str() { + "RAW" => "RAW".to_string(), + "USER_ENTERED" => "USER_ENTERED".to_string(), + _ => { + return Err(format!( + "Google Sheets value_input_option 不支持的值: {value}" + )) + } + }, + None => "RAW".to_string(), + }; + + if let Some(extra_key) = params.keys().next() { + return Err(format!("Google Sheets 目标包含未知字段: {extra_key}")); + } + + Ok(GoogleSheetsTarget { + spreadsheet_id, + sheet, + credentials_file, + include_header, + value_input_option, + }) +} + +fn take_required_google_sheets_param( + params: &mut std::collections::BTreeMap, + key: &str, + label: &str, +) -> Result { + params + .remove(key) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| format!("Google Sheets 目标缺少 {label}")) +} + +fn parse_google_sheets_bool_flag(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "on" => Some(true), + "false" | "0" | "no" | "off" => Some(false), + _ => None, + } +} + +fn google_sheets_metadata_headers() -> Vec { + vec![ + "delivery_attempt_id".to_string(), + "run_id".to_string(), + "job_id".to_string(), + "execution_retry_count".to_string(), + "timestamp".to_string(), + "task".to_string(), + "status".to_string(), + "duration_ms".to_string(), + ] +} + +fn google_sheets_metadata_prefix(result: &TaskResult, context: &DeliveryContext) -> Vec { + vec![ + context.attempt_id.clone(), + context.run_id.clone().unwrap_or_default(), + context.job_id.clone(), + context.execution_retry_count.to_string(), + result.timestamp.clone(), + result.task.clone(), + result.status.clone(), + result.duration_ms.to_string(), + ] +} + +fn build_google_sheets_values( + target: &GoogleSheetsTarget, + result: &TaskResult, + rendered: &RenderedOutput, + context: &DeliveryContext, +) -> GoogleSheetsPreparedValues { + let prepared = match rendered.schema.as_str() { + "table" | "csv" => rendered + .data + .as_ref() + .and_then(|data| build_google_sheets_tabular_values(result, data, context)), + "links" => rendered + .data + .as_ref() + .and_then(|data| build_google_sheets_link_values(result, data, context)), + "json" => Some(build_google_sheets_json_values(result, rendered, context)), + _ => Some(build_google_sheets_text_values(result, rendered, context)), + } + .unwrap_or_else(|| build_google_sheets_text_values(result, rendered, context)); + + if !target.include_header { + return prepared; + } + + let mut values = Vec::with_capacity(prepared.values.len() + 1); + values.push(match rendered.schema.as_str() { + "table" | "csv" => build_google_sheets_tabular_headers(rendered.data.as_ref()), + "links" => build_google_sheets_links_headers(), + "json" => build_google_sheets_json_headers(), + _ => build_google_sheets_text_headers(), + }); + values.extend(prepared.values); + + GoogleSheetsPreparedValues { + values, + data_rows: prepared.data_rows, + } +} + +fn build_google_sheets_text_headers() -> Vec { + let mut headers = google_sheets_metadata_headers(); + headers.push("summary".to_string()); + headers +} + +fn build_google_sheets_json_headers() -> Vec { + let mut headers = google_sheets_metadata_headers(); + headers.push("json".to_string()); + headers +} + +fn build_google_sheets_links_headers() -> Vec { + let mut headers = google_sheets_metadata_headers(); + headers.extend(["url".to_string(), "title".to_string(), "text".to_string()]); + headers +} + +fn build_google_sheets_tabular_headers(data: Option<&Value>) -> Vec { + let mut headers = google_sheets_metadata_headers(); + if let Some((columns, _)) = data.and_then(extract_table_payload) { + headers.extend(columns); + return headers; + } + headers.push("summary".to_string()); + headers +} + +fn build_google_sheets_text_values( + result: &TaskResult, + rendered: &RenderedOutput, + context: &DeliveryContext, +) -> GoogleSheetsPreparedValues { + let mut row = google_sheets_metadata_prefix(result, context); + row.push(rendered.text.clone()); + GoogleSheetsPreparedValues { + values: vec![row], + data_rows: 1, + } +} + +fn build_google_sheets_json_values( + result: &TaskResult, + rendered: &RenderedOutput, + context: &DeliveryContext, +) -> GoogleSheetsPreparedValues { + let mut row = google_sheets_metadata_prefix(result, context); + let json_text = rendered + .data + .as_ref() + .and_then(|value| serde_json::to_string(value).ok()) + .unwrap_or_else(|| rendered.text.clone()); + row.push(json_text); + GoogleSheetsPreparedValues { + values: vec![row], + data_rows: 1, + } +} + +fn build_google_sheets_tabular_values( + result: &TaskResult, + data: &Value, + context: &DeliveryContext, +) -> Option { + let (columns, rows) = extract_table_payload(data)?; + if columns.is_empty() || rows.is_empty() { + return None; + } + + let values = rows + .into_iter() + .map(|row| { + let mut record = google_sheets_metadata_prefix(result, context); + record.extend(row); + record + }) + .collect::>(); + + Some(GoogleSheetsPreparedValues { + data_rows: values.len(), + values, + }) +} + +fn build_google_sheets_link_values( + result: &TaskResult, + data: &Value, + context: &DeliveryContext, +) -> Option { + let items = data + .get("items") + .or_else(|| data.get("links")) + .unwrap_or(data); + let rows = match items { + Value::Array(values) => values + .iter() + .filter_map(|item| { + let (url, title, text) = match item { + Value::String(url) => (url.clone(), String::new(), String::new()), + Value::Object(object) => ( + object + .get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + object + .get("title") + .or_else(|| object.get("label")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + object + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ), + _ => return None, + }; + if url.trim().is_empty() { + return None; + } + let mut row = google_sheets_metadata_prefix(result, context); + row.extend([url, title, text]); + Some(row) + }) + .collect::>(), + Value::String(url) if !url.trim().is_empty() => { + let mut row = google_sheets_metadata_prefix(result, context); + row.extend([url.to_string(), String::new(), String::new()]); + vec![row] + } + _ => Vec::new(), + }; + + if rows.is_empty() { + None + } else { + Some(GoogleSheetsPreparedValues { + data_rows: rows.len(), + values: rows, + }) + } +} + +fn load_google_service_account( + credentials_file: &str, +) -> Result { + let path = Path::new(credentials_file.trim()); + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 Google service account 文件失败: {error}"))?; + serde_json::from_str::(&content) + .map_err(|error| format!("解析 Google service account 文件失败: {error}")) +} + +fn build_google_service_account_assertion( + credentials: &GoogleServiceAccountCredentials, +) -> Result { + let now = Utc::now(); + let claims = GoogleServiceAccountClaims { + iss: credentials.client_email.clone(), + scope: GOOGLE_SHEETS_SCOPE.to_string(), + aud: credentials.token_uri.clone(), + exp: (now + ChronoDuration::minutes(55)).timestamp(), + iat: now.timestamp(), + }; + let mut header = Header::new(Algorithm::RS256); + header.typ = Some("JWT".to_string()); + let key = EncodingKey::from_rsa_pem(credentials.private_key.as_bytes()) + .map_err(|error| format!("解析 Google service account 私钥失败: {error}"))?; + jsonwebtoken::encode(&header, &claims, &key) + .map_err(|error| format!("生成 Google service account 断言失败: {error}")) +} + +async fn fetch_google_access_token( + credentials: &GoogleServiceAccountCredentials, +) -> Result { + let assertion = build_google_service_account_assertion(credentials)?; + let client = reqwest::Client::new(); + let response = client + .post(&credentials.token_uri) + .form(&[ + ("grant_type", GOOGLE_JWT_GRANT_TYPE), + ("assertion", assertion.as_str()), + ]) + .timeout(StdDuration::from_secs(30)) + .send() + .await + .map_err(|error| format!("请求 Google access token 失败: {error}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if !status.is_success() { + return Err(format!( + "Google access token 接口返回错误: {status} {}", + trim_response_body(body.as_str()) + )); + } + + let payload: GoogleAccessTokenResponse = serde_json::from_str(&body) + .map_err(|error| format!("解析 Google access token 响应失败: {error}"))?; + if payload.access_token.trim().is_empty() { + return Err("Google access token 为空".to_string()); + } + Ok(payload.access_token) +} + +fn build_google_sheets_append_url(target: &GoogleSheetsTarget) -> String { + let range = encode(format!("{}!A1", target.sheet).as_str()).into_owned(); + format!( + "https://sheets.googleapis.com/v4/spreadsheets/{}/values/{}:append?valueInputOption={}&insertDataOption=INSERT_ROWS", + target.spreadsheet_id, + range, + target.value_input_option + ) +} + +fn trim_response_body(body: &str) -> String { + let trimmed = body.trim(); + if trimmed.is_empty() { + return String::new(); + } + let mut preview = preview_output_text(trimmed); + preview.truncate(preview.trim_end().len()); + preview +} + +fn preview_output_text(text: &str) -> String { + const MAX_PREVIEW_CHARS: usize = 1200; + let mut preview = String::new(); + let mut truncated = false; + + for (index, ch) in text.chars().enumerate() { + if index >= MAX_PREVIEW_CHARS { + truncated = true; + break; + } + preview.push(ch); + } + + if truncated { + preview.push_str("\n...(输出已截断)"); + } + + preview +} + +fn build_delivery_result( + success: bool, + message: String, + channel: Option<&str>, + target: Option<&str>, + result: &TaskResult, + rendered: &RenderedOutput, + context: &DeliveryContext, +) -> DeliveryResult { + DeliveryResult { + success, + message, + channel: channel.map(str::to_string), + target: target.map(str::to_string), + output_kind: result.output.kind.clone(), + output_schema: rendered.schema.clone(), + output_format: rendered.format.clone(), + output_preview: preview_output_text(rendered.text.as_str()), + delivery_attempt_id: context.attempt_id.clone(), + run_id: context.run_id.clone(), + execution_retry_count: context.execution_retry_count, + delivery_attempts: 0, + } +} + +async fn deliver_webhook( + url: &str, + result: &TaskResult, + rendered: &RenderedOutput, + context: &DeliveryContext, +) -> DeliveryOutcome { + #[derive(Debug, Serialize)] + struct WebhookPayload { + event: String, + job_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + run_id: Option, + delivery_attempt_id: String, + execution_retry_count: u32, + task: String, + status: String, + output: String, + output_kind: String, + output_schema: String, + output_format: String, + #[serde(skip_serializing_if = "Option::is_none")] + output_data: Option, + duration_ms: i64, + timestamp: String, + } + + let payload = WebhookPayload { + event: "automation_job_complete".to_string(), + job_id: context.job_id.clone(), + run_id: context.run_id.clone(), + delivery_attempt_id: context.attempt_id.clone(), + execution_retry_count: context.execution_retry_count, + task: result.task.clone(), + status: result.status.clone(), + output: rendered.text.clone(), + output_kind: result.output.kind.clone(), + output_schema: rendered.schema.clone(), + output_format: rendered.format.clone(), + output_data: rendered.data.clone(), + duration_ms: result.duration_ms, + timestamp: result.timestamp.clone(), + }; + + let client = reqwest::Client::new(); + match client + .post(url) + .header("Idempotency-Key", context.attempt_id.as_str()) + .header( + "X-Proxycast-Delivery-Attempt-Id", + context.attempt_id.as_str(), + ) + .json(&payload) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + { + Ok(response) if response.status().is_success() => DeliveryOutcome { + result: build_delivery_result( + true, + "Webhook 输出已发送".to_string(), + Some("webhook"), + Some(url), + result, + rendered, + context, + ), + retryable: false, + }, + Ok(response) => { + let status = response.status(); + DeliveryOutcome { + result: build_delivery_result( + false, + format!("Webhook 返回错误: {status}"), + Some("webhook"), + Some(url), + result, + rendered, + context, + ), + retryable: status.is_server_error() + || status == reqwest::StatusCode::TOO_MANY_REQUESTS, + } + } + Err(error) => DeliveryOutcome { + result: build_delivery_result( + false, + format!("Webhook 请求失败: {error}"), + Some("webhook"), + Some(url), + result, + rendered, + context, + ), + retryable: true, + }, + } +} + +async fn deliver_google_sheets( + target: &str, + result: &TaskResult, + rendered: &RenderedOutput, + context: &DeliveryContext, +) -> DeliveryOutcome { + let parsed_target = match parse_google_sheets_target(target) { + Ok(parsed_target) => parsed_target, + Err(message) => { + return DeliveryOutcome { + result: build_delivery_result( + false, + message, + Some("google_sheets"), + Some(target), + result, + rendered, + context, + ), + retryable: false, + } + } + }; + + let credentials = match load_google_service_account(parsed_target.credentials_file.as_str()) { + Ok(credentials) => credentials, + Err(message) => { + return DeliveryOutcome { + result: build_delivery_result( + false, + message, + Some("google_sheets"), + Some(target), + result, + rendered, + context, + ), + retryable: false, + } + } + }; + let access_token = match fetch_google_access_token(&credentials).await { + Ok(access_token) => access_token, + Err(message) => { + return DeliveryOutcome { + result: build_delivery_result( + false, + message, + Some("google_sheets"), + Some(target), + result, + rendered, + context, + ), + retryable: true, + } + } + }; + let prepared = build_google_sheets_values(&parsed_target, result, rendered, context); + let payload = GoogleSheetsAppendRequest { + major_dimension: "ROWS".to_string(), + values: prepared.values, + }; + let url = build_google_sheets_append_url(&parsed_target); + let client = reqwest::Client::new(); + match client + .post(&url) + .bearer_auth(access_token) + .json(&payload) + .timeout(StdDuration::from_secs(30)) + .send() + .await + { + Ok(response) if response.status().is_success() => DeliveryOutcome { + result: build_delivery_result( + true, + format!("Google Sheets 已追加 {} 行", prepared.data_rows), + Some("google_sheets"), + Some(target), + result, + rendered, + context, + ), + retryable: false, + }, + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let retryable = body.contains("\"code\": 429") + || body.contains("\"code\":429") + || body.contains("rateLimitExceeded") + || body.contains("internalError"); + DeliveryOutcome { + result: build_delivery_result( + false, + format!( + "Google Sheets API 错误: {}", + trim_response_body(body.as_str()) + ), + Some("google_sheets"), + Some(target), + result, + rendered, + context, + ), + retryable: retryable + || status.is_server_error() + || status == reqwest::StatusCode::TOO_MANY_REQUESTS, + } + } + Err(error) => DeliveryOutcome { + result: build_delivery_result( + false, + format!("Google Sheets 请求失败: {error}"), + Some("google_sheets"), + Some(target), + result, + rendered, + context, + ), + retryable: true, + }, + } +} + +fn deliver_local_file( + target: &str, + result: &TaskResult, + rendered: &RenderedOutput, + context: &DeliveryContext, +) -> DeliveryOutcome { + let path = Path::new(target.trim()); + if target.trim().is_empty() { + return DeliveryOutcome { + result: DeliveryResult { + success: false, + message: "本地文件目标不能为空".to_string(), + channel: Some("local_file".to_string()), + target: None, + output_kind: result.output.kind.clone(), + output_schema: rendered.schema.clone(), + output_format: rendered.format.clone(), + output_preview: preview_output_text(rendered.text.as_str()), + delivery_attempt_id: context.attempt_id.clone(), + run_id: context.run_id.clone(), + execution_retry_count: context.execution_retry_count, + delivery_attempts: 0, + }, + retryable: false, + }; + } + + if let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) { + if let Err(error) = fs::create_dir_all(parent) { + return DeliveryOutcome { + result: DeliveryResult { + success: false, + message: format!("创建输出目录失败: {error}"), + channel: Some("local_file".to_string()), + target: Some(target.trim().to_string()), + output_kind: result.output.kind.clone(), + output_schema: rendered.schema.clone(), + output_format: rendered.format.clone(), + output_preview: preview_output_text(rendered.text.as_str()), + delivery_attempt_id: context.attempt_id.clone(), + run_id: context.run_id.clone(), + execution_retry_count: context.execution_retry_count, + delivery_attempts: 0, + }, + retryable: false, + }; + } + } + + match fs::write(path, rendered.text.as_bytes()) { + Ok(()) => DeliveryOutcome { + result: DeliveryResult { + success: true, + message: format!("输出已写入 {}", path.display()), + channel: Some("local_file".to_string()), + target: Some(target.trim().to_string()), + output_kind: result.output.kind.clone(), + output_schema: rendered.schema.clone(), + output_format: rendered.format.clone(), + output_preview: preview_output_text(rendered.text.as_str()), + delivery_attempt_id: context.attempt_id.clone(), + run_id: context.run_id.clone(), + execution_retry_count: context.execution_retry_count, + delivery_attempts: 0, + }, + retryable: false, + }, + Err(error) => DeliveryOutcome { + result: DeliveryResult { + success: false, + message: format!("写入本地文件失败: {error}"), + channel: Some("local_file".to_string()), + target: Some(target.trim().to_string()), + output_kind: result.output.kind.clone(), + output_schema: rendered.schema.clone(), + output_format: rendered.format.clone(), + output_preview: preview_output_text(rendered.text.as_str()), + delivery_attempt_id: context.attempt_id.clone(), + run_id: context.run_id.clone(), + execution_retry_count: context.execution_retry_count, + delivery_attempts: 0, + }, + retryable: false, + }, + } +} + +async fn deliver_telegram( + target: &str, + result: &TaskResult, + rendered: &RenderedOutput, + context: &DeliveryContext, +) -> DeliveryOutcome { + #[derive(Debug, Serialize)] + struct TelegramPayload { + chat_id: String, + text: String, + parse_mode: String, + } + + let parts: Vec<&str> = target.splitn(2, ':').collect(); + if parts.len() != 2 { + return DeliveryOutcome { + result: build_delivery_result( + false, + "Telegram 目标格式错误,应为 bot_token:chat_id".to_string(), + Some("telegram"), + Some(target), + result, + rendered, + context, + ), + retryable: false, + }; + } + + let bot_token = parts[0]; + let chat_id = parts[1]; + let status_emoji = match result.status.as_str() { + "success" => "✅", + "error" => "❌", + "timeout" => "⏰", + _ => "📋", + }; + let message = format!( + "{} *自动化任务完成*\n\n*任务*: {}\n*状态*: {}\n*耗时*: {}ms\n\n```\n{}\n```", + status_emoji, + escape_markdown(&result.task), + result.status, + result.duration_ms, + escape_markdown(&rendered.text), + ); + let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage"); + let payload = TelegramPayload { + chat_id: chat_id.to_string(), + text: message, + parse_mode: "MarkdownV2".to_string(), + }; + + let client = reqwest::Client::new(); + match client + .post(&url) + .json(&payload) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + { + Ok(response) if response.status().is_success() => DeliveryOutcome { + result: build_delivery_result( + true, + "Telegram 通知已发送".to_string(), + Some("telegram"), + Some(target), + result, + rendered, + context, + ), + retryable: false, + }, + Ok(response) => DeliveryOutcome { + result: build_delivery_result( + false, + format!( + "Telegram API 错误: {}", + response.text().await.unwrap_or_default() + ), + Some("telegram"), + Some(target), + result, + rendered, + context, + ), + retryable: false, + }, + Err(error) => DeliveryOutcome { + result: build_delivery_result( + false, + format!("Telegram 请求失败: {error}"), + Some("telegram"), + Some(target), + result, + rendered, + context, + ), + retryable: false, + }, + } +} + +fn escape_markdown(text: &str) -> String { + let special_chars = [ + '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', + ]; + let mut result = String::with_capacity(text.len() * 2); + for ch in text.chars() { + if special_chars.contains(&ch) { + result.push('\\'); + } + result.push(ch); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn sample_context() -> DeliveryContext { + DeliveryContext { + attempt_id: "dlv-run-1".to_string(), + run_id: Some("run-1".to_string()), + job_id: "job-1".to_string(), + execution_retry_count: 1, + } + } + + fn sample_result() -> TaskResult { + TaskResult { + task: "浏览器巡检".to_string(), + status: "success".to_string(), + output: TaskOutput { + kind: "json".to_string(), + text: "浏览器任务已启动".to_string(), + data: Some(json!({ + "kind": "browser_session", + "session_id": "session-1", + "target_url": "https://seller.example.com/dashboard" + })), + }, + duration_ms: 1200, + timestamp: "2026-03-16T00:00:00Z".to_string(), + } + } + + #[test] + fn render_output_should_pretty_print_json_payload() { + let rendered = render_output( + &DeliveryConfig { + mode: "announce".to_string(), + channel: Some("webhook".to_string()), + target: Some("https://example.com/webhook".to_string()), + best_effort: true, + output_schema: None, + output_format: "json".to_string(), + }, + &sample_result(), + ); + + assert_eq!(rendered.format, "json"); + assert_eq!(rendered.schema, "json"); + assert!(rendered.text.contains("\"session_id\": \"session-1\"")); + assert!(rendered.data.is_some()); + } + + #[test] + fn render_output_should_render_csv_schema_as_plain_text() { + let rendered = render_output( + &DeliveryConfig { + mode: "announce".to_string(), + channel: Some("local_file".to_string()), + target: Some("/tmp/automation-output.csv".to_string()), + best_effort: true, + output_schema: Some("csv".to_string()), + output_format: "text".to_string(), + }, + &TaskResult { + task: "导出任务".to_string(), + status: "success".to_string(), + output: TaskOutput { + kind: "table".to_string(), + text: "导出完成".to_string(), + data: Some(json!({ + "columns": ["url", "status"], + "rows": [ + ["https://example.com/a", "ok"], + ["https://example.com/b", "retry"] + ], + })), + }, + duration_ms: 500, + timestamp: "2026-03-16T00:00:00Z".to_string(), + }, + ); + + assert_eq!(rendered.schema, "csv"); + assert_eq!( + rendered.text, + "url,status\nhttps://example.com/a,ok\nhttps://example.com/b,retry" + ); + } + + #[test] + fn parse_google_sheets_target_should_support_key_value_pairs() { + let parsed = parse_google_sheets_target( + "spreadsheet_id=sheet-1;sheet=巡检结果;credentials_file=C:/proxycast/service-account.json;include_header=true;value_input_option=USER_ENTERED", + ) + .expect("应成功解析 Google Sheets 目标"); + + assert_eq!(parsed.spreadsheet_id, "sheet-1"); + assert_eq!(parsed.sheet, "巡检结果"); + assert_eq!(parsed.credentials_file, "C:/proxycast/service-account.json"); + assert!(parsed.include_header); + assert_eq!(parsed.value_input_option, "USER_ENTERED"); + } + + #[test] + fn build_google_sheets_values_should_prefix_metadata_for_tabular_output() { + let rendered = render_output( + &DeliveryConfig { + mode: "announce".to_string(), + channel: Some("google_sheets".to_string()), + target: Some("spreadsheet_id=sheet-1;sheet=巡检结果;credentials_file=/tmp/service-account.json;include_header=true".to_string()), + best_effort: true, + output_schema: Some("table".to_string()), + output_format: "json".to_string(), + }, + &TaskResult { + task: "导出任务".to_string(), + status: "success".to_string(), + output: TaskOutput { + kind: "table".to_string(), + text: "导出完成".to_string(), + data: Some(json!({ + "columns": ["url", "status"], + "rows": [ + ["https://example.com/a", "ok"], + ["https://example.com/b", "retry"] + ], + })), + }, + duration_ms: 500, + timestamp: "2026-03-16T00:00:00Z".to_string(), + }, + ); + let target = parse_google_sheets_target( + "spreadsheet_id=sheet-1;sheet=巡检结果;credentials_file=/tmp/service-account.json;include_header=true", + ) + .expect("应成功解析 Google Sheets 目标"); + let prepared = build_google_sheets_values( + &target, + &TaskResult { + task: "导出任务".to_string(), + status: "success".to_string(), + output: TaskOutput { + kind: "table".to_string(), + text: "导出完成".to_string(), + data: Some(json!({ + "columns": ["url", "status"], + "rows": [ + ["https://example.com/a", "ok"], + ["https://example.com/b", "retry"] + ], + })), + }, + duration_ms: 500, + timestamp: "2026-03-16T00:00:00Z".to_string(), + }, + &rendered, + &sample_context(), + ); + + assert_eq!( + prepared.values.first(), + Some(&vec![ + "delivery_attempt_id".to_string(), + "run_id".to_string(), + "job_id".to_string(), + "execution_retry_count".to_string(), + "timestamp".to_string(), + "task".to_string(), + "status".to_string(), + "duration_ms".to_string(), + "url".to_string(), + "status".to_string(), + ]) + ); + assert_eq!(prepared.data_rows, 2); + assert_eq!( + prepared.values.get(1), + Some(&vec![ + "dlv-run-1".to_string(), + "run-1".to_string(), + "job-1".to_string(), + "1".to_string(), + "2026-03-16T00:00:00Z".to_string(), + "导出任务".to_string(), + "success".to_string(), + "500".to_string(), + "https://example.com/a".to_string(), + "ok".to_string(), + ]) + ); + } + + #[tokio::test] + async fn deliver_result_should_reject_invalid_google_sheets_target() { + let delivery = deliver_result( + &DeliveryConfig { + mode: "announce".to_string(), + channel: Some("google_sheets".to_string()), + target: Some( + "spreadsheet_id=sheet-1;credentials_file=/tmp/service-account.json".to_string(), + ), + best_effort: true, + output_schema: Some("json".to_string()), + output_format: "json".to_string(), + }, + &sample_result(), + &sample_context(), + ) + .await; + + assert!(!delivery.success); + assert_eq!(delivery.channel.as_deref(), Some("google_sheets")); + assert!(delivery.message.contains("缺少 sheet")); + assert_eq!(delivery.delivery_attempt_id, "dlv-run-1"); + assert_eq!(delivery.execution_retry_count, 1); + assert_eq!(delivery.delivery_attempts, 1); + } + + #[test] + fn deliver_local_file_should_write_rendered_output() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("系统时间异常") + .as_nanos(); + let output_path = std::env::temp_dir() + .join("proxycast-delivery-tests") + .join(format!("automation-output-{unique}.json")); + let rendered = render_output( + &DeliveryConfig { + mode: "announce".to_string(), + channel: Some("local_file".to_string()), + target: Some(output_path.to_string_lossy().to_string()), + best_effort: true, + output_schema: Some("json".to_string()), + output_format: "json".to_string(), + }, + &sample_result(), + ); + + let sample = sample_result(); + let delivery = deliver_local_file( + output_path.to_string_lossy().as_ref(), + &sample, + &rendered, + &sample_context(), + ) + .result; + let content = fs::read_to_string(&output_path).expect("读取输出文件失败"); + + assert!(delivery.success); + assert_eq!(delivery.channel.as_deref(), Some("local_file")); + assert_eq!(delivery.output_schema, "json"); + assert_eq!(delivery.delivery_attempt_id, "dlv-run-1"); + assert!(content.contains("\"target_url\": \"https://seller.example.com/dashboard\"")); + + let _ = fs::remove_file(&output_path); + } + + #[test] + fn build_delivery_attempt_id_should_fall_back_to_deterministic_hash() { + let first = build_delivery_attempt_id("job-1", "2026-03-16T00:00:00Z", 2, None); + let second = build_delivery_attempt_id("job-1", "2026-03-16T00:00:00Z", 2, None); + + assert_eq!(first, second); + assert!(first.starts_with("dlv-")); + } +} diff --git a/src-tauri/src/services/automation_service/executor.rs b/src-tauri/src/services/automation_service/executor.rs new file mode 100644 index 000000000..2aefd361b --- /dev/null +++ b/src-tauri/src/services/automation_service/executor.rs @@ -0,0 +1,211 @@ +//! 自动化任务执行器 +//! +//! 负责把结构化自动化任务映射到 Aster 执行链路。 + +use super::{AutomationJobRecord, AutomationPayload}; +use crate::agent::{AsterAgentState, AsterAgentWrapper}; +use crate::app::AppState; +use crate::commands::browser_runtime_cmd::{ + launch_browser_session_with_db, LaunchBrowserSessionRequest, +}; +use crate::database::DbConnection; +use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; +use crate::workspace::WorkspaceManager; +use chrono::Utc; +use proxycast_browser_runtime::CdpSessionState; +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager}; + +#[derive(Debug)] +pub struct JobExecutionResult { + pub output: String, + pub output_data: Option, + pub session_id: Option, + pub browser_session: Option, +} + +pub async fn execute_job( + job: &AutomationJobRecord, + db: &DbConnection, + app_handle: &Option, +) -> Result { + match job.execution_mode { + proxycast_core::config::AutomationExecutionMode::LogOnly => Ok(JobExecutionResult { + output: "Log only mode".to_string(), + output_data: Some(json!({ + "kind": "log_only", + "job_id": job.id.clone(), + "job_name": job.name.clone(), + "workspace_id": job.workspace_id.clone(), + })), + session_id: None, + browser_session: None, + }), + proxycast_core::config::AutomationExecutionMode::Intelligent + | proxycast_core::config::AutomationExecutionMode::Skill => { + let payload = serde_json::from_value::(job.payload.clone()) + .map_err(|e| format!("解析自动化任务负载失败: {e}"))?; + match payload { + AutomationPayload::AgentTurn { + prompt, + system_prompt, + web_search, + } => { + execute_agent_turn(job, db, app_handle, prompt, system_prompt, web_search).await + } + AutomationPayload::BrowserSession { + profile_id, + profile_key, + url, + environment_preset_id, + target_id, + open_window, + stream_mode, + } => { + execute_browser_session( + job, + db, + app_handle, + LaunchBrowserSessionRequest { + profile_id: Some(profile_id), + profile_key, + url, + environment_preset_id, + environment: None, + target_id, + open_window, + stream_mode, + }, + ) + .await + } + } + } + } +} + +async fn execute_agent_turn( + job: &AutomationJobRecord, + db: &DbConnection, + app_handle: &Option, + prompt: String, + system_prompt: Option, + web_search: bool, +) -> Result { + let app = app_handle + .as_ref() + .ok_or_else(|| "应用句柄不可用,无法执行自动化任务".to_string())?; + let prompt = build_prompt(job, &prompt, system_prompt.as_deref(), web_search); + + let workspace_manager = WorkspaceManager::new(db.clone()); + let workspace = workspace_manager + .get(&job.workspace_id) + .map_err(|e| format!("读取 workspace 失败: {e}"))? + .ok_or_else(|| format!("Workspace 不存在: {}", job.workspace_id))?; + let ensured = ensure_workspace_ready_with_auto_relocate(&workspace_manager, &workspace)?; + let workspace_root = ensured.root_path.to_string_lossy().to_string(); + + let session_name = format!("[自动化] {}", job.name); + let session_id = AsterAgentWrapper::create_session_sync( + db, + Some(session_name), + Some(workspace_root), + job.workspace_id.clone(), + Some("auto".to_string()), + )?; + + let agent_state = app + .try_state::() + .ok_or_else(|| "AsterAgentState 未初始化".to_string())?; + let event_name = format!("automation:agent:{}:{}", job.id, Utc::now().timestamp()); + AsterAgentWrapper::send_message( + &agent_state, + db, + app, + prompt, + session_id.clone(), + event_name, + ) + .await?; + + Ok(JobExecutionResult { + output: "Agent 执行完成".to_string(), + output_data: Some(json!({ + "kind": "agent_turn", + "job_id": job.id.clone(), + "job_name": job.name.clone(), + "workspace_id": job.workspace_id.clone(), + "session_id": session_id.clone(), + "status": "success", + })), + session_id: Some(session_id), + browser_session: None, + }) +} + +async fn execute_browser_session( + job: &AutomationJobRecord, + db: &DbConnection, + app_handle: &Option, + request: LaunchBrowserSessionRequest, +) -> Result { + let app = app_handle + .as_ref() + .ok_or_else(|| "应用句柄不可用,无法执行浏览器自动化任务".to_string())?; + let app_state = app + .try_state::() + .ok_or_else(|| "AppState 未初始化,无法执行浏览器自动化任务".to_string())?; + let app_state = app_state.inner().clone(); + + let response = + launch_browser_session_with_db(app.clone(), app_state, db.clone(), request).await?; + let session_id = response.session.session_id.clone(); + Ok(JobExecutionResult { + output: format!("浏览器任务已启动: {} -> {}", job.name, session_id), + output_data: Some(json!({ + "kind": "browser_session", + "job_id": job.id.clone(), + "job_name": job.name.clone(), + "workspace_id": job.workspace_id.clone(), + "session_id": response.session.session_id.clone(), + "profile_key": response.session.profile_key.clone(), + "environment_preset_id": response.session.environment_preset_id.clone(), + "environment_preset_name": response.session.environment_preset_name.clone(), + "target_id": response.session.target_id.clone(), + "target_title": response.session.target_title.clone(), + "target_url": response.session.target_url.clone(), + "lifecycle_state": response.session.lifecycle_state, + "control_mode": response.session.control_mode, + "remote_debugging_port": response.session.remote_debugging_port, + "ws_debugger_url": response.session.ws_debugger_url.clone(), + })), + session_id: Some(session_id), + browser_session: Some(response.session), + }) +} + +fn build_prompt( + job: &AutomationJobRecord, + prompt: &str, + system_prompt: Option<&str>, + web_search: bool, +) -> String { + let mut sections = vec![ + "你是一个自动化任务执行助手。".to_string(), + format!("任务名称:{}", job.name), + format!("任务描述:{}", job.description.clone().unwrap_or_default()), + format!("工作区 ID:{}", job.workspace_id), + ]; + if let Some(system_prompt) = system_prompt { + let trimmed = system_prompt.trim(); + if !trimmed.is_empty() { + sections.push(format!("附加系统指令:{trimmed}")); + } + } + if web_search { + sections.push("允许按需使用 WebSearch。".to_string()); + } + sections.push("请执行以下自动化任务:".to_string()); + sections.push(prompt.trim().to_string()); + sections.join("\n\n") +} diff --git a/src-tauri/src/services/automation_service/health.rs b/src-tauri/src/services/automation_service/health.rs new file mode 100644 index 000000000..df1ced6e5 --- /dev/null +++ b/src-tauri/src/services/automation_service/health.rs @@ -0,0 +1,429 @@ +//! 自动化任务健康统计 + +use crate::database::DbConnection; +use chrono::{DateTime, Duration, Timelike, Utc}; +use proxycast_core::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; +use proxycast_core::database::dao::automation_job::{AutomationJob, AutomationJobDao}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AutomationHealthQuery { + pub running_timeout_minutes: Option, + pub top_limit: Option, + pub cooldown_alert_threshold: Option, + pub stale_running_alert_threshold: Option, + pub failed_24h_alert_threshold: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationFailureTrendPoint { + pub bucket_start: String, + pub label: String, + pub error_count: usize, + pub timeout_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationHealthAlert { + pub code: String, + pub severity: String, + pub message: String, + pub current_value: usize, + pub threshold: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationRiskJobInfo { + pub job_id: String, + pub name: String, + pub status: String, + pub consecutive_failures: u32, + pub retry_count: u32, + pub detail_message: Option, + pub auto_disabled_until: Option, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationHealthResult { + pub total_jobs: usize, + pub enabled_jobs: usize, + pub pending_jobs: usize, + pub running_jobs: usize, + pub failed_jobs: usize, + pub cooldown_jobs: usize, + pub stale_running_jobs: usize, + pub failed_last_24h: usize, + pub failure_trend_24h: Vec, + pub alerts: Vec, + pub risky_jobs: Vec, + pub generated_at: String, +} + +pub fn query_automation_health( + db: &DbConnection, + query: Option, +) -> Result { + let query = query.unwrap_or_default(); + let running_timeout_minutes = query.running_timeout_minutes.unwrap_or(10); + let top_limit = query.top_limit.unwrap_or(5); + let cooldown_alert_threshold = query.cooldown_alert_threshold.unwrap_or(1); + let stale_running_alert_threshold = query.stale_running_alert_threshold.unwrap_or(1); + let failed_24h_alert_threshold = query.failed_24h_alert_threshold.unwrap_or(3); + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let jobs = AutomationJobDao::list(&conn).map_err(|e| format!("查询自动化任务失败: {e}"))?; + + let now = Utc::now(); + let stale_deadline = now - Duration::minutes(running_timeout_minutes as i64); + + let total_jobs = jobs.len(); + let enabled_jobs = jobs.iter().filter(|job| job.enabled).count(); + let pending_jobs = jobs + .iter() + .filter(|job| job.enabled) + .filter(|job| !is_running(job)) + .filter(|job| !is_in_cooldown(job, now)) + .filter(|job| { + job.next_run_at + .as_deref() + .and_then(parse_rfc3339_utc) + .map(|value| value <= now) + .unwrap_or(false) + }) + .count(); + let running_jobs = jobs.iter().filter(|job| is_running(job)).count(); + let failed_jobs = jobs + .iter() + .filter(|job| matches!(job.last_status.as_deref(), Some("error" | "timeout"))) + .count(); + let cooldown_jobs = jobs.iter().filter(|job| is_in_cooldown(job, now)).count(); + let stale_running_jobs = jobs + .iter() + .filter(|job| { + job.running_started_at + .as_deref() + .and_then(parse_rfc3339_utc) + .map(|value| value < stale_deadline) + .unwrap_or(false) + }) + .count(); + + let recent_runs_by_job = jobs + .iter() + .map(|job| { + let runs = AgentRunDao::list_runs_by_source_ref(&conn, "automation", &job.id, 200) + .unwrap_or_default(); + (job.id.clone(), runs) + }) + .collect::>(); + let recent_runs = recent_runs_by_job + .values() + .flat_map(|runs| runs.iter().cloned()) + .collect::>(); + let failure_trend_24h = build_failure_trend_24h(&recent_runs, now); + let failed_last_24h = failure_trend_24h + .iter() + .map(|item| item.error_count + item.timeout_count) + .sum(); + + let mut risky_jobs = jobs + .iter() + .filter(|job| { + job.consecutive_failures > 0 + || is_in_cooldown(job, now) + || matches!( + job.last_status.as_deref(), + Some("waiting_for_human" | "human_controlling") + ) + || matches!(job.last_status.as_deref(), Some("error" | "timeout")) + }) + .map(|job| AutomationRiskJobInfo { + job_id: job.id.clone(), + name: job.name.clone(), + status: job + .last_status + .clone() + .unwrap_or_else(|| "idle".to_string()), + consecutive_failures: job.consecutive_failures, + retry_count: job.last_retry_count, + detail_message: recent_runs_by_job + .get(&job.id) + .and_then(|runs| resolve_risky_job_detail(job, runs)), + auto_disabled_until: job.auto_disabled_until.clone(), + updated_at: job.updated_at.clone(), + }) + .collect::>(); + risky_jobs.sort_by(|left, right| { + right + .consecutive_failures + .cmp(&left.consecutive_failures) + .then_with(|| right.retry_count.cmp(&left.retry_count)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + }); + risky_jobs.truncate(top_limit); + + let alerts = build_alerts( + cooldown_jobs, + stale_running_jobs, + failed_last_24h, + cooldown_alert_threshold, + stale_running_alert_threshold, + failed_24h_alert_threshold, + ); + + Ok(AutomationHealthResult { + total_jobs, + enabled_jobs, + pending_jobs, + running_jobs, + failed_jobs, + cooldown_jobs, + stale_running_jobs, + failed_last_24h, + failure_trend_24h, + alerts, + risky_jobs, + generated_at: now.to_rfc3339(), + }) +} + +fn resolve_risky_job_detail(job: &AutomationJob, runs: &[AgentRun]) -> Option { + runs.first() + .and_then(resolve_run_detail_message) + .or_else(|| job.last_error.as_deref().and_then(normalize_non_empty)) +} + +fn resolve_run_detail_message(run: &AgentRun) -> Option { + let human_reason = run + .metadata + .as_deref() + .and_then(|metadata| extract_metadata_string(metadata, "human_reason")); + if let Some(reason) = human_reason { + if run.error_message.as_deref().map(str::trim) != Some(reason.as_str()) { + return Some(reason); + } + } + run.error_message.as_deref().and_then(normalize_non_empty) +} + +fn extract_metadata_string(metadata: &str, key: &str) -> Option { + let parsed = serde_json::from_str::(metadata).ok()?; + let value = parsed.get(key)?.as_str()?; + normalize_non_empty(value) +} + +fn normalize_non_empty(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn parse_rfc3339_utc(raw: &str) -> Option> { + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|value| value.with_timezone(&Utc)) +} + +fn is_running(job: &AutomationJob) -> bool { + job.running_started_at.is_some() +} + +fn is_in_cooldown(job: &AutomationJob, now: DateTime) -> bool { + job.auto_disabled_until + .as_deref() + .and_then(parse_rfc3339_utc) + .map(|value| value > now) + .unwrap_or(false) +} + +fn build_failure_trend_24h( + runs: &[proxycast_core::database::dao::agent_run::AgentRun], + now: DateTime, +) -> Vec { + let mut points = Vec::with_capacity(24); + let end_hour = floor_to_hour(now); + let start_hour = end_hour - Duration::hours(23); + + for offset in 0..24 { + let bucket = start_hour + Duration::hours(offset as i64); + let bucket_end = bucket + Duration::hours(1); + let mut error_count = 0usize; + let mut timeout_count = 0usize; + + for run in runs { + let Some(started_at) = parse_rfc3339_utc(run.started_at.as_str()) else { + continue; + }; + if started_at < bucket || started_at >= bucket_end { + continue; + } + match run.status { + AgentRunStatus::Error => error_count += 1, + AgentRunStatus::Timeout => timeout_count += 1, + _ => {} + } + } + + points.push(AutomationFailureTrendPoint { + bucket_start: bucket.to_rfc3339(), + label: bucket.format("%H:%M").to_string(), + error_count, + timeout_count, + }); + } + + points +} + +fn floor_to_hour(now: DateTime) -> DateTime { + now.with_minute(0) + .and_then(|value| value.with_second(0)) + .and_then(|value| value.with_nanosecond(0)) + .unwrap_or(now) +} + +fn build_alerts( + cooldown_jobs: usize, + stale_running_jobs: usize, + failed_last_24h: usize, + cooldown_threshold: usize, + stale_threshold: usize, + failed_threshold: usize, +) -> Vec { + let mut alerts = Vec::new(); + + if cooldown_jobs >= cooldown_threshold { + alerts.push(AutomationHealthAlert { + code: "cooldown_jobs".to_string(), + severity: "warning".to_string(), + message: format!("当前有 {cooldown_jobs} 个任务处于冷却中"), + current_value: cooldown_jobs, + threshold: cooldown_threshold, + }); + } + + if stale_running_jobs >= stale_threshold { + alerts.push(AutomationHealthAlert { + code: "stale_running_jobs".to_string(), + severity: "critical".to_string(), + message: format!("检测到 {stale_running_jobs} 个悬挂中的运行任务"), + current_value: stale_running_jobs, + threshold: stale_threshold, + }); + } + + if failed_last_24h >= failed_threshold { + alerts.push(AutomationHealthAlert { + code: "failed_runs_24h".to_string(), + severity: "warning".to_string(), + message: format!("最近 24 小时失败或超时 {failed_last_24h} 次"), + current_value: failed_last_24h, + threshold: failed_threshold, + }); + } + + alerts +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use proxycast_core::config::{AutomationExecutionMode, DeliveryConfig, TaskSchedule}; + use proxycast_core::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; + use proxycast_core::database::dao::automation_job::AutomationJobDao; + use rusqlite::Connection; + use serde_json::json; + 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 sample_browser_job() -> AutomationJob { + AutomationJob { + id: "job-browser-1".to_string(), + name: "浏览器巡检".to_string(), + description: Some("等待人工处理".to_string()), + enabled: true, + workspace_id: "workspace-1".to_string(), + execution_mode: AutomationExecutionMode::Intelligent, + schedule: TaskSchedule::Every { every_secs: 300 }, + payload: json!({ + "kind": "browser_session", + "profile_id": "profile-1", + "profile_key": "shop_us", + "url": "https://seller.example.com/dashboard", + "open_window": false, + "stream_mode": "events" + }), + delivery: DeliveryConfig::default(), + timeout_secs: Some(120), + max_retries: 2, + next_run_at: None, + last_status: Some("waiting_for_human".to_string()), + last_error: None, + last_run_at: Some("2026-03-16T00:00:00Z".to_string()), + last_finished_at: None, + running_started_at: Some("2026-03-16T00:00:00Z".to_string()), + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: None, + created_at: "2026-03-16T00:00:00Z".to_string(), + updated_at: "2026-03-16T00:00:05Z".to_string(), + } + } + + fn sample_run() -> AgentRun { + AgentRun { + id: "run-browser-1".to_string(), + source: "automation".to_string(), + source_ref: Some("job-browser-1".to_string()), + session_id: Some("session-1".to_string()), + status: AgentRunStatus::Running, + started_at: "2026-03-16T00:00:00Z".to_string(), + finished_at: None, + duration_ms: None, + error_code: None, + error_message: None, + metadata: Some( + json!({ + "payload_kind": "browser_session", + "session_id": "session-1", + "browser_lifecycle_state": "waiting_for_human", + "human_reason": "等待你确认是否继续执行" + }) + .to_string(), + ), + created_at: "2026-03-16T00:00:00Z".to_string(), + updated_at: "2026-03-16T00:00:05Z".to_string(), + } + } + + #[test] + fn query_automation_health_should_include_human_reason_for_risky_browser_job() { + let db = setup_db(); + let conn = db.lock().expect("数据库锁定失败"); + AutomationJobDao::create(&conn, &sample_browser_job()).expect("写入 job 失败"); + AgentRunDao::create_run(&conn, &sample_run()).expect("写入 run 失败"); + drop(conn); + + let result = query_automation_health(&db, Some(AutomationHealthQuery::default())) + .expect("查询健康状态失败"); + + assert_eq!(result.risky_jobs.len(), 1); + assert_eq!( + result.risky_jobs[0].detail_message.as_deref(), + Some("等待你确认是否继续执行") + ); + } +} diff --git a/src-tauri/src/services/automation_service/mod.rs b/src-tauri/src/services/automation_service/mod.rs new file mode 100644 index 000000000..2a6808c58 --- /dev/null +++ b/src-tauri/src/services/automation_service/mod.rs @@ -0,0 +1,1358 @@ +//! 自动化调度服务 +//! +//! 负责自动化任务的结构化存储、后台轮询与执行。 + +pub mod browser_runtime_sync; +pub mod delivery; +pub mod executor; +pub mod health; +pub mod schedule; + +use self::delivery::{ + build_delivery_attempt_id, deliver_result, DeliveryContext, TaskOutput, TaskResult, +}; +use self::executor::execute_job; +use self::health::{query_automation_health, AutomationHealthQuery, AutomationHealthResult}; +use self::schedule::{ + describe_schedule, next_run_for_schedule, preview_next_run, validate_schedule, +}; +use crate::database::dao::agent_run::AgentRunStatus; +use crate::services::browser_environment_service::get_browser_environment_preset; +use crate::services::browser_profile_service::get_browser_profile; +use crate::services::execution_tracker_service::{ExecutionTracker, RunHandle, RunSource}; +use chrono::Utc; +use proxycast_browser_runtime::{BrowserStreamMode, CdpSessionState}; +use proxycast_core::config::{ + AutomationExecutionMode, AutomationSettings, DeliveryConfig, TaskSchedule, +}; +use proxycast_core::database::dao::agent_run::{AgentRun, AgentRunDao}; +use proxycast_core::database::dao::automation_job::{ + AutomationJob, AutomationJobDao, AutomationJobLastDelivery, +}; +use proxycast_core::database::DbConnection; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::sync::Arc; +use std::time::Duration; +use tauri::Emitter; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; +use url::Url; +use uuid::Uuid; + +pub type AutomationJobRecord = AutomationJob; + +#[derive(Clone)] +pub struct AutomationServiceState(pub Arc>); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationStatus { + pub running: bool, + pub last_polled_at: Option, + pub next_poll_at: Option, + pub last_job_count: usize, + pub total_executions: u64, + pub active_job_id: Option, + pub active_job_name: Option, +} + +fn default_browser_session_open_window() -> bool { + false +} + +fn default_browser_session_stream_mode() -> BrowserStreamMode { + BrowserStreamMode::Events +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AutomationPayload { + AgentTurn { + prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + system_prompt: Option, + #[serde(default)] + web_search: bool, + }, + BrowserSession { + profile_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + profile_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + environment_preset_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + target_id: Option, + #[serde(default = "default_browser_session_open_window")] + open_window: bool, + #[serde(default = "default_browser_session_stream_mode")] + stream_mode: BrowserStreamMode, + }, +} + +impl AutomationPayload { + fn kind(&self) -> &'static str { + match self { + Self::AgentTurn { .. } => "agent_turn", + Self::BrowserSession { .. } => "browser_session", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationJobDraft { + pub name: String, + pub description: Option, + pub enabled: bool, + pub workspace_id: String, + pub execution_mode: AutomationExecutionMode, + pub schedule: TaskSchedule, + pub payload: AutomationPayload, + pub delivery: DeliveryConfig, + pub timeout_secs: Option, + pub max_retries: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AutomationJobUpdate { + pub name: Option, + pub description: Option, + pub enabled: Option, + pub workspace_id: Option, + pub execution_mode: Option, + pub schedule: Option, + pub payload: Option, + pub delivery: Option, + pub timeout_secs: Option>, + pub max_retries: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationCycleResult { + pub job_count: usize, + pub success_count: usize, + pub failed_count: usize, + pub timeout_count: usize, +} + +pub struct AutomationService { + config: AutomationSettings, + cancel_token: Option, + status: AutomationStatus, + db: Option, + app_handle: Option, +} + +impl AutomationService { + pub fn new(config: AutomationSettings) -> Self { + Self { + config, + cancel_token: None, + status: AutomationStatus { + running: false, + last_polled_at: None, + next_poll_at: None, + last_job_count: 0, + total_executions: 0, + active_job_id: None, + active_job_name: None, + }, + db: None, + app_handle: None, + } + } + + pub fn set_db(&mut self, db: DbConnection) { + self.db = Some(db); + } + + pub fn set_app_handle(&mut self, handle: tauri::AppHandle) { + self.app_handle = Some(handle); + } + + pub fn update_config(&mut self, config: AutomationSettings) { + self.config = config; + } + + pub fn get_config(&self) -> &AutomationSettings { + &self.config + } + + pub fn get_status(&self) -> AutomationStatus { + self.status.clone() + } + + pub async fn start(&mut self, self_ref: Arc>) -> Result<(), String> { + if self.status.running { + return Ok(()); + } + let db = self + .db + .clone() + .ok_or_else(|| "数据库未初始化,无法启动自动化服务".to_string())?; + + let cancel_token = CancellationToken::new(); + self.cancel_token = Some(cancel_token.clone()); + self.status.running = true; + self.update_next_poll(); + + let interval_secs = self.config.poll_interval_secs.max(5); + let app_handle = self.app_handle.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(interval_secs)); + loop { + tokio::select! { + _ = ticker.tick() => { + let result = Self::execute_due_jobs(&self_ref, &db, &app_handle).await; + if let Err(error) = result { + tracing::warn!("[Automation] 轮询执行失败: {}", error); + } + } + _ = cancel_token.cancelled() => break, + } + } + }); + + Ok(()) + } + + pub async fn stop(&mut self) -> Result<(), String> { + if let Some(token) = self.cancel_token.take() { + token.cancel(); + } + self.status.running = false; + self.status.active_job_id = None; + self.status.active_job_name = None; + Ok(()) + } + + pub fn list_jobs(&self) -> Result, String> { + let db = self + .db + .as_ref() + .ok_or_else(|| "数据库未初始化".to_string())?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AutomationJobDao::list(&conn).map_err(|e| format!("查询自动化任务失败: {e}")) + } + + pub fn get_job(&self, id: &str) -> Result, String> { + let db = self + .db + .as_ref() + .ok_or_else(|| "数据库未初始化".to_string())?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AutomationJobDao::get(&conn, id).map_err(|e| format!("查询自动化任务失败: {e}")) + } + + pub fn create_job(&self, draft: AutomationJobDraft) -> Result { + validate_draft(&draft)?; + let db = self + .db + .as_ref() + .ok_or_else(|| "数据库未初始化".to_string())?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + validate_payload_with_conn(&conn, &draft.payload)?; + + let now = Utc::now().to_rfc3339(); + let next_run_at = if draft.enabled { + preview_next_run(&draft.schedule)? + } else { + None + }; + let job = AutomationJob { + id: Uuid::new_v4().to_string(), + name: draft.name.trim().to_string(), + description: normalize_optional_string(draft.description), + enabled: draft.enabled, + workspace_id: draft.workspace_id.trim().to_string(), + execution_mode: draft.execution_mode, + schedule: draft.schedule, + payload: serde_json::to_value(draft.payload) + .map_err(|e| format!("序列化自动化负载失败: {e}"))?, + delivery: draft.delivery, + timeout_secs: draft.timeout_secs, + max_retries: draft.max_retries.max(1), + next_run_at, + last_status: None, + last_error: None, + last_run_at: None, + last_finished_at: None, + running_started_at: None, + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: None, + created_at: now.clone(), + updated_at: now, + }; + + AutomationJobDao::create(&conn, &job).map_err(|e| format!("创建自动化任务失败: {e}"))?; + Ok(job) + } + + pub fn update_job( + &self, + id: &str, + update: AutomationJobUpdate, + ) -> Result { + let db = self + .db + .as_ref() + .ok_or_else(|| "数据库未初始化".to_string())?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let mut job = AutomationJobDao::get(&conn, id) + .map_err(|e| format!("读取自动化任务失败: {e}"))? + .ok_or_else(|| format!("自动化任务不存在: {id}"))?; + + if let Some(value) = update.name { + job.name = value.trim().to_string(); + } + if let Some(value) = update.description { + job.description = normalize_optional_string(Some(value)); + } + if let Some(value) = update.enabled { + job.enabled = value; + } + if let Some(value) = update.workspace_id { + job.workspace_id = value.trim().to_string(); + } + if let Some(value) = update.execution_mode { + job.execution_mode = value; + } + if let Some(value) = update.schedule { + validate_schedule(&value, Utc::now())?; + job.schedule = value; + } + if let Some(value) = update.payload { + validate_payload(&value)?; + job.payload = + serde_json::to_value(value).map_err(|e| format!("序列化自动化负载失败: {e}"))?; + } + if let Some(value) = update.delivery { + job.delivery = value; + } + if let Some(value) = update.timeout_secs { + job.timeout_secs = value; + } + if let Some(value) = update.max_retries { + job.max_retries = value.max(1); + } + + if job.enabled && job.running_started_at.is_none() { + job.next_run_at = preview_next_run(&job.schedule)?; + } else if !job.enabled { + job.next_run_at = None; + } + job.updated_at = Utc::now().to_rfc3339(); + + validate_job(&conn, &job)?; + AutomationJobDao::update(&conn, &job).map_err(|e| format!("更新自动化任务失败: {e}"))?; + Ok(job) + } + + pub fn delete_job(&self, id: &str) -> Result { + let db = self + .db + .as_ref() + .ok_or_else(|| "数据库未初始化".to_string())?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AutomationJobDao::delete(&conn, id).map_err(|e| format!("删除自动化任务失败: {e}")) + } + + pub async fn run_job_now(&self, id: &str) -> Result { + let db = self + .db + .as_ref() + .ok_or_else(|| "数据库未初始化".to_string())?; + let job = { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AutomationJobDao::get(&conn, id) + .map_err(|e| format!("读取自动化任务失败: {e}"))? + .ok_or_else(|| format!("自动化任务不存在: {id}"))? + }; + + let result = Self::execute_job_once(&job, db, &self.app_handle, &self.config).await?; + Ok(AutomationCycleResult { + job_count: 1, + success_count: usize::from(result == "success"), + failed_count: usize::from(result == "error"), + timeout_count: usize::from(result == "timeout"), + }) + } + + pub fn get_job_runs(&self, id: &str, limit: usize) -> Result, String> { + let db = self + .db + .as_ref() + .ok_or_else(|| "数据库未初始化".to_string())?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AgentRunDao::list_runs_by_source_ref(&conn, "automation", id, limit) + .map_err(|e| format!("查询自动化运行历史失败: {e}")) + } + + pub fn get_health( + &self, + query: Option, + ) -> Result { + let db = self + .db + .as_ref() + .ok_or_else(|| "数据库未初始化".to_string())?; + query_automation_health(db, query) + } + + fn update_next_poll(&mut self) { + let now = Utc::now(); + self.status.next_poll_at = Some( + (now + chrono::Duration::seconds(self.config.poll_interval_secs.max(5) as i64)) + .to_rfc3339(), + ); + } + + async fn execute_due_jobs( + self_ref: &Arc>, + db: &DbConnection, + app_handle: &Option, + ) -> Result<(), String> { + let jobs = { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AutomationJobDao::list_due(&conn, &Utc::now().to_rfc3339(), 10) + .map_err(|e| format!("查询到期自动化任务失败: {e}"))? + }; + + { + let mut service = self_ref.write().await; + service.status.last_polled_at = Some(Utc::now().to_rfc3339()); + service.status.last_job_count = jobs.len(); + service.update_next_poll(); + } + + for job in jobs { + { + let mut service = self_ref.write().await; + service.status.active_job_id = Some(job.id.clone()); + service.status.active_job_name = Some(job.name.clone()); + } + let config = { self_ref.read().await.config.clone() }; + let result = Self::execute_job_once(&job, db, app_handle, &config).await?; + { + let mut service = self_ref.write().await; + service.status.total_executions += 1; + service.status.active_job_id = None; + service.status.active_job_name = None; + if result == "error" || result == "timeout" { + service.status.last_job_count = service.status.last_job_count.max(1); + } + } + } + + Ok(()) + } + + async fn execute_job_once( + job: &AutomationJobRecord, + db: &DbConnection, + app_handle: &Option, + config: &AutomationSettings, + ) -> Result { + let tracker = ExecutionTracker::new(db.clone()); + let mut working_job = job.clone(); + let started_at = Utc::now(); + let started_at_str = started_at.to_rfc3339(); + let is_browser_session = is_browser_session_payload(&working_job.payload); + + set_active_job_state( + &mut working_job, + "running", + &started_at_str, + &started_at_str, + 0, + ); + { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AutomationJobDao::update(&conn, &working_job) + .map_err(|e| format!("更新任务运行状态失败: {e}"))?; + } + + if let Some(handle) = app_handle { + let _ = handle.emit( + "automation:job_start", + json!({ "job_id": working_job.id, "name": working_job.name }), + ); + } + + let run_handle = if config.enable_history { + tracker.start( + RunSource::Automation, + Some(working_job.id.clone()), + None, + Some(build_tracker_start_metadata(&working_job)), + ) + } else { + None + }; + + let max_attempts = working_job.max_retries.max(1); + let mut retry_count = 0u32; + let mut status = "error".to_string(); + let mut output = String::new(); + let mut output_data: Option = None; + let mut session_id: Option = None; + let mut browser_session: Option = None; + + for attempt in 0..max_attempts { + if attempt > 0 { + retry_count = attempt; + } + + let fut = execute_job(&working_job, db, app_handle); + let execution = if let Some(timeout_secs) = working_job.timeout_secs { + match tokio::time::timeout(Duration::from_secs(timeout_secs), fut).await { + Ok(result) => result, + Err(_) => { + status = "timeout".to_string(); + output = format!("任务执行超时({}s)", timeout_secs); + break; + } + } + } else { + fut.await + }; + + match execution { + Ok(result) => { + status = if is_browser_session && result.browser_session.is_some() { + "running".to_string() + } else { + "success".to_string() + }; + output = result.output; + output_data = result.output_data; + session_id = result.session_id; + browser_session = result.browser_session; + break; + } + Err(error) => { + status = "error".to_string(); + output = error; + output_data = None; + if attempt + 1 >= max_attempts { + break; + } + } + } + } + + if let Some(session) = browser_session.as_ref() { + if let Some(handle) = run_handle.as_ref() { + tracker.refresh_running_metadata( + handle, + Some(session.session_id.as_str()), + Some(build_browser_session_run_metadata( + &working_job, + session, + "running", + retry_count, + None, + )), + ); + } + + working_job.last_retry_count = retry_count; + working_job.updated_at = Utc::now().to_rfc3339(); + working_job.next_run_at = None; + { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AutomationJobDao::update(&conn, &working_job) + .map_err(|e| format!("保存浏览器自动化运行态失败: {e}"))?; + } + return Ok(status); + } + + let execution_status = status.clone(); + let execution_output = output.clone(); + let mut last_delivery: Option = None; + if working_job.delivery.mode != "none" { + let delivery_started_at = Utc::now(); + let delivery_duration_ms = delivery_started_at + .timestamp_millis() + .saturating_sub(started_at.timestamp_millis()); + let delivery_output_data = output_data.clone().or_else(|| { + if execution_status == "success" { + None + } else { + Some(json!({ + "kind": "error", + "job_id": working_job.id.clone(), + "job_name": working_job.name.clone(), + "status": execution_status.clone(), + "message": execution_output.clone(), + })) + } + }); + let delivery_output_kind = if delivery_output_data.is_some() { + "json".to_string() + } else { + "text".to_string() + }; + let delivery_context = build_delivery_context( + &working_job, + &started_at_str, + retry_count, + run_handle.as_ref(), + ); + let delivery_result = deliver_result( + &working_job.delivery, + &TaskResult { + task: working_job.name.clone(), + status: execution_status.clone(), + output: TaskOutput { + kind: delivery_output_kind, + text: execution_output.clone(), + data: delivery_output_data, + }, + duration_ms: delivery_duration_ms, + timestamp: delivery_started_at.to_rfc3339(), + }, + &delivery_context, + ) + .await; + last_delivery = Some(build_last_delivery_record( + &delivery_result, + &working_job.delivery, + &Utc::now().to_rfc3339(), + )); + working_job.last_delivery = last_delivery.clone(); + if !delivery_result.success { + tracing::warn!("[Automation] 输出投递失败: {}", delivery_result.message); + if !working_job.delivery.best_effort { + status = "error".to_string(); + output = build_required_delivery_failure_message( + &execution_status, + &execution_output, + &delivery_result.message, + ); + } + } + } + + let finished_at = Utc::now(); + let duration_ms = apply_terminal_job_state( + &mut working_job, + &status, + &output, + retry_count, + &started_at_str, + finished_at, + )?; + + { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AutomationJobDao::update(&conn, &working_job) + .map_err(|e| format!("保存自动化任务结果失败: {e}"))?; + } + + if let Some(handle) = run_handle.as_ref() { + let run_status = match status.as_str() { + "success" => AgentRunStatus::Success, + "timeout" => AgentRunStatus::Timeout, + _ => AgentRunStatus::Error, + }; + let run_succeeded = run_status == AgentRunStatus::Success; + tracker.finish_with_status( + handle, + run_status, + if run_succeeded { + None + } else { + Some("automation_job_failed") + }, + if run_succeeded { + None + } else { + Some(output.as_str()) + }, + Some(build_tracker_finish_metadata( + &working_job, + session_id.as_deref(), + &status, + &execution_status, + retry_count, + duration_ms, + last_delivery.as_ref(), + )), + ); + } + + if let Some(handle) = app_handle { + let _ = handle.emit( + "automation:job_complete", + json!({ + "job_id": working_job.id, + "name": working_job.name, + "status": status, + "duration_ms": duration_ms, + "retry_count": retry_count, + }), + ); + } + + Ok(status) + } +} + +fn normalize_optional_string(value: Option) -> Option { + value + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) +} + +fn validate_optional_http_url(value: Option<&str>, field_name: &str) -> Result<(), String> { + let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else { + return Ok(()); + }; + let parsed = Url::parse(raw).map_err(|error| format!("{field_name}无效: {error}"))?; + match parsed.scheme() { + "http" | "https" => Ok(()), + _ => Err(format!("{field_name}仅支持 http/https")), + } +} + +fn validate_draft(draft: &AutomationJobDraft) -> Result<(), String> { + validate_schedule(&draft.schedule, Utc::now())?; + validate_payload(&draft.payload)?; + if draft.name.trim().is_empty() { + return Err("任务名称不能为空".to_string()); + } + if draft.workspace_id.trim().is_empty() { + return Err("workspace_id 必填".to_string()); + } + Ok(()) +} + +fn validate_job(conn: &Connection, job: &AutomationJobRecord) -> Result<(), String> { + validate_schedule(&job.schedule, Utc::now())?; + let payload = serde_json::from_value::(job.payload.clone()) + .map_err(|e| format!("解析自动化负载失败: {e}"))?; + validate_payload(&payload)?; + validate_payload_with_conn(conn, &payload)?; + if job.name.trim().is_empty() { + return Err("任务名称不能为空".to_string()); + } + if job.workspace_id.trim().is_empty() { + return Err("workspace_id 必填".to_string()); + } + Ok(()) +} + +fn validate_payload(payload: &AutomationPayload) -> Result<(), String> { + match payload { + AutomationPayload::AgentTurn { prompt, .. } => { + if prompt.trim().is_empty() { + return Err("自动化任务内容不能为空".to_string()); + } + } + AutomationPayload::BrowserSession { profile_id, .. } => { + if profile_id.trim().is_empty() { + return Err("浏览器任务必须绑定浏览器资料".to_string()); + } + } + } + Ok(()) +} + +fn validate_payload_with_conn( + conn: &Connection, + payload: &AutomationPayload, +) -> Result<(), String> { + match payload { + AutomationPayload::AgentTurn { .. } => Ok(()), + AutomationPayload::BrowserSession { + profile_id, + profile_key, + url, + environment_preset_id, + .. + } => { + let profile_id = profile_id.trim(); + let profile = get_browser_profile(conn, profile_id)? + .filter(|record| record.archived_at.is_none()) + .ok_or_else(|| format!("未找到可用的浏览器资料: {profile_id}"))?; + + if let Some(expected_profile_key) = profile_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if profile.profile_key != expected_profile_key { + return Err(format!( + "浏览器资料 {profile_id} 的 profile_key 与任务配置不一致: {} != {expected_profile_key}", + profile.profile_key + )); + } + } + + if let Some(environment_preset_id) = environment_preset_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + get_browser_environment_preset(conn, environment_preset_id)? + .filter(|record| record.archived_at.is_none()) + .ok_or_else(|| { + format!("未找到可用的浏览器环境预设: {environment_preset_id}") + })?; + } + + validate_optional_http_url(url.as_deref(), "浏览器启动地址") + } + } +} + +fn build_tracker_start_metadata(job: &AutomationJobRecord) -> Value { + let mut metadata = Map::from_iter([ + ("job_id".to_string(), Value::String(job.id.clone())), + ("job_name".to_string(), Value::String(job.name.clone())), + ( + "workspace_id".to_string(), + Value::String(job.workspace_id.clone()), + ), + ( + "schedule".to_string(), + Value::String(describe_schedule(&job.schedule)), + ), + ]); + append_payload_tracking_metadata(&mut metadata, &job.payload); + Value::Object(metadata) +} + +pub(super) fn set_active_job_state( + job: &mut AutomationJobRecord, + status: &str, + started_at: &str, + updated_at: &str, + retry_count: u32, +) { + job.running_started_at = Some(started_at.to_string()); + job.last_status = Some(status.to_string()); + job.last_error = None; + job.last_run_at = Some(started_at.to_string()); + job.last_finished_at = None; + job.last_retry_count = retry_count; + job.next_run_at = None; + job.updated_at = updated_at.to_string(); +} + +fn build_tracker_finish_metadata( + job: &AutomationJobRecord, + session_id: Option<&str>, + status: &str, + execution_status: &str, + retry_count: u32, + duration_ms: i64, + last_delivery: Option<&AutomationJobLastDelivery>, +) -> Value { + let mut metadata = Map::from_iter([ + ("job_id".to_string(), Value::String(job.id.clone())), + ("job_name".to_string(), Value::String(job.name.clone())), + ( + "workspace_id".to_string(), + Value::String(job.workspace_id.clone()), + ), + ("status".to_string(), Value::String(status.to_string())), + ("retry_count".to_string(), json!(retry_count)), + ("duration_ms".to_string(), json!(duration_ms)), + ]); + if execution_status != status { + metadata.insert( + "execution_status".to_string(), + Value::String(execution_status.to_string()), + ); + } + if let Some(session_id) = session_id { + metadata.insert( + "session_id".to_string(), + Value::String(session_id.to_string()), + ); + } + if let Some(last_delivery) = last_delivery { + if let Ok(value) = serde_json::to_value(last_delivery) { + metadata.insert("delivery".to_string(), value); + } + } + append_payload_tracking_metadata(&mut metadata, &job.payload); + Value::Object(metadata) +} + +fn build_last_delivery_record( + result: &self::delivery::DeliveryResult, + config: &DeliveryConfig, + attempted_at: &str, +) -> AutomationJobLastDelivery { + AutomationJobLastDelivery { + success: result.success, + message: result.message.clone(), + channel: result.channel.clone().or_else(|| config.channel.clone()), + target: result.target.clone().or_else(|| config.target.clone()), + output_kind: result.output_kind.clone(), + output_schema: result.output_schema.clone(), + output_format: result.output_format.clone(), + output_preview: result.output_preview.clone(), + delivery_attempt_id: Some(result.delivery_attempt_id.clone()), + run_id: result.run_id.clone(), + execution_retry_count: result.execution_retry_count, + delivery_attempts: result.delivery_attempts, + attempted_at: attempted_at.to_string(), + } +} + +fn build_delivery_context( + job: &AutomationJobRecord, + started_at: &str, + retry_count: u32, + run_handle: Option<&RunHandle>, +) -> DeliveryContext { + let run_id = run_handle.map(|handle| handle.id.clone()); + let attempt_id = build_delivery_attempt_id(&job.id, started_at, retry_count, run_id.as_deref()); + DeliveryContext { + attempt_id, + run_id, + job_id: job.id.clone(), + execution_retry_count: retry_count, + } +} + +fn build_required_delivery_failure_message( + execution_status: &str, + execution_output: &str, + delivery_message: &str, +) -> String { + match execution_status { + "success" => format!("任务执行成功,但输出投递失败: {delivery_message}"), + "timeout" => format!( + "任务执行超时,且输出投递失败: {delivery_message}\n原始执行结果: {execution_output}" + ), + _ => format!( + "任务执行失败,且输出投递失败: {delivery_message}\n原始执行结果: {execution_output}" + ), + } +} + +pub(super) fn build_browser_session_run_metadata( + job: &AutomationJobRecord, + session: &CdpSessionState, + status: &str, + retry_count: u32, + duration_ms: Option, +) -> Value { + let mut metadata = Map::from_iter([ + ("job_id".to_string(), Value::String(job.id.clone())), + ("job_name".to_string(), Value::String(job.name.clone())), + ( + "workspace_id".to_string(), + Value::String(job.workspace_id.clone()), + ), + ( + "schedule".to_string(), + Value::String(describe_schedule(&job.schedule)), + ), + ("status".to_string(), Value::String(status.to_string())), + ("retry_count".to_string(), json!(retry_count)), + ( + "session_id".to_string(), + Value::String(session.session_id.clone()), + ), + ( + "browser_lifecycle_state".to_string(), + json!(session.lifecycle_state), + ), + ("control_mode".to_string(), json!(session.control_mode)), + ("connected".to_string(), Value::Bool(session.connected)), + ( + "browser_target_id".to_string(), + Value::String(session.target_id.clone()), + ), + ( + "browser_target_url".to_string(), + Value::String(session.target_url.clone()), + ), + ]); + if let Some(reason) = session.human_reason.as_deref() { + metadata.insert( + "human_reason".to_string(), + Value::String(reason.to_string()), + ); + } + if let Some(last_error) = session.last_error.as_deref() { + metadata.insert( + "browser_last_error".to_string(), + Value::String(last_error.to_string()), + ); + } + if let Some(duration_ms) = duration_ms { + metadata.insert("duration_ms".to_string(), json!(duration_ms)); + } + append_payload_tracking_metadata(&mut metadata, &job.payload); + Value::Object(metadata) +} + +pub(super) fn apply_terminal_job_state( + job: &mut AutomationJobRecord, + status: &str, + output: &str, + retry_count: u32, + started_at: &str, + finished_at: chrono::DateTime, +) -> Result { + let started_at = chrono::DateTime::parse_from_rfc3339(started_at) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or(finished_at); + let duration_ms = finished_at + .timestamp_millis() + .saturating_sub(started_at.timestamp_millis()); + + job.last_status = Some(status.to_string()); + job.last_error = if status == "success" { + None + } else { + Some(output.to_string()) + }; + job.last_run_at = Some(started_at.to_rfc3339()); + job.last_finished_at = Some(finished_at.to_rfc3339()); + job.running_started_at = None; + job.last_retry_count = retry_count; + job.updated_at = finished_at.to_rfc3339(); + + if status == "success" { + job.consecutive_failures = 0; + job.auto_disabled_until = None; + } else { + job.consecutive_failures = job.consecutive_failures.saturating_add(1); + if job.consecutive_failures >= 3 { + job.auto_disabled_until = + Some((finished_at + chrono::Duration::minutes(5)).to_rfc3339()); + } + } + + match &job.schedule { + TaskSchedule::At { .. } => { + job.enabled = false; + job.next_run_at = None; + } + _ => { + job.next_run_at = + next_run_for_schedule(&job.schedule, finished_at)?.map(|value| value.to_rfc3339()); + } + } + + Ok(duration_ms) +} + +fn is_browser_session_payload(payload: &Value) -> bool { + matches!( + serde_json::from_value::(payload.clone()), + Ok(AutomationPayload::BrowserSession { .. }) + ) +} + +pub(super) fn append_payload_tracking_metadata(metadata: &mut Map, payload: &Value) { + let Ok(parsed_payload) = serde_json::from_value::(payload.clone()) else { + return; + }; + metadata.insert( + "payload_kind".to_string(), + Value::String(parsed_payload.kind().to_string()), + ); + + if let AutomationPayload::BrowserSession { + profile_id, + profile_key, + url, + environment_preset_id, + target_id, + open_window, + stream_mode, + } = parsed_payload + { + metadata.insert("profile_id".to_string(), Value::String(profile_id)); + if let Some(profile_key) = profile_key { + metadata.insert("profile_key".to_string(), Value::String(profile_key)); + } + if let Some(url) = url { + metadata.insert("url".to_string(), Value::String(url)); + } + if let Some(environment_preset_id) = environment_preset_id { + metadata.insert( + "environment_preset_id".to_string(), + Value::String(environment_preset_id), + ); + } + if let Some(target_id) = target_id { + metadata.insert("target_id".to_string(), Value::String(target_id)); + } + metadata.insert("open_window".to_string(), Value::Bool(open_window)); + metadata.insert("stream_mode".to_string(), json!(stream_mode)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use crate::services::browser_environment_service::{ + save_browser_environment_preset, SaveBrowserEnvironmentPresetInput, + }; + use crate::services::browser_profile_service::{save_browser_profile, SaveBrowserProfileInput}; + use proxycast_core::database::dao::browser_profile::BrowserProfileTransportKind; + use rusqlite::Connection; + + fn setup_db() -> Connection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("创建数据表失败"); + conn + } + + #[test] + fn validate_payload_with_conn_should_accept_browser_session_payload() { + let conn = setup_db(); + let profile = save_browser_profile( + &conn, + SaveBrowserProfileInput { + id: None, + profile_key: "shop_us".to_string(), + name: "美区店铺".to_string(), + description: None, + site_scope: None, + launch_url: Some("https://seller.example.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + }, + ) + .expect("保存浏览器资料失败"); + let preset = save_browser_environment_preset( + &conn, + SaveBrowserEnvironmentPresetInput { + id: None, + name: "美区桌面".to_string(), + description: None, + proxy_server: None, + timezone_id: Some("America/Los_Angeles".to_string()), + locale: Some("en-US".to_string()), + accept_language: Some("en-US,en;q=0.9".to_string()), + geolocation_lat: None, + geolocation_lng: None, + geolocation_accuracy_m: None, + user_agent: None, + platform: None, + viewport_width: Some(1440), + viewport_height: Some(900), + device_scale_factor: Some(2.0), + }, + ) + .expect("保存浏览器环境预设失败"); + + let payload = AutomationPayload::BrowserSession { + profile_id: profile.id, + profile_key: Some("shop_us".to_string()), + url: Some("https://seller.example.com/dashboard".to_string()), + environment_preset_id: Some(preset.id), + target_id: None, + open_window: false, + stream_mode: BrowserStreamMode::Events, + }; + + validate_payload_with_conn(&conn, &payload).expect("浏览器任务负载校验失败"); + } + + #[test] + fn validate_payload_with_conn_should_reject_missing_browser_profile() { + let conn = setup_db(); + let payload = AutomationPayload::BrowserSession { + profile_id: "missing-profile".to_string(), + profile_key: Some("shop_us".to_string()), + url: Some("https://seller.example.com/dashboard".to_string()), + environment_preset_id: None, + target_id: None, + open_window: false, + stream_mode: BrowserStreamMode::Events, + }; + + let error = + validate_payload_with_conn(&conn, &payload).expect_err("缺失浏览器资料时应返回错误"); + assert!(error.contains("未找到可用的浏览器资料")); + } + + #[test] + fn build_tracker_finish_metadata_should_include_browser_payload_context() { + let payload = AutomationPayload::BrowserSession { + profile_id: "profile-1".to_string(), + profile_key: Some("shop_us".to_string()), + url: Some("https://seller.example.com/dashboard".to_string()), + environment_preset_id: Some("preset-1".to_string()), + target_id: Some("target-1".to_string()), + open_window: false, + stream_mode: BrowserStreamMode::Events, + }; + let job = AutomationJob { + id: "job-1".to_string(), + name: "店铺巡检".to_string(), + description: None, + enabled: true, + workspace_id: "workspace-1".to_string(), + execution_mode: AutomationExecutionMode::Intelligent, + schedule: TaskSchedule::Every { every_secs: 300 }, + payload: serde_json::to_value(payload).expect("序列化负载失败"), + delivery: DeliveryConfig::default(), + timeout_secs: None, + max_retries: 3, + next_run_at: None, + last_status: None, + last_error: None, + last_run_at: None, + last_finished_at: None, + running_started_at: None, + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: None, + created_at: "2026-03-15T00:00:00Z".to_string(), + updated_at: "2026-03-15T00:00:00Z".to_string(), + }; + + let metadata = build_tracker_finish_metadata( + &job, + Some("session-1"), + "success", + "success", + 0, + 1200, + None, + ); + + assert_eq!( + metadata.get("payload_kind"), + Some(&json!("browser_session")) + ); + assert_eq!(metadata.get("profile_key"), Some(&json!("shop_us"))); + assert_eq!( + metadata.get("environment_preset_id"), + Some(&json!("preset-1")) + ); + assert_eq!(metadata.get("session_id"), Some(&json!("session-1"))); + } + + #[test] + fn build_tracker_finish_metadata_should_include_delivery_summary() { + let job = AutomationJob { + id: "job-1".to_string(), + name: "店铺巡检".to_string(), + description: None, + enabled: true, + workspace_id: "workspace-1".to_string(), + execution_mode: AutomationExecutionMode::Intelligent, + schedule: TaskSchedule::Every { every_secs: 300 }, + payload: json!({ + "kind": "agent_turn", + "prompt": "汇总今日异常", + "web_search": false + }), + delivery: DeliveryConfig::default(), + timeout_secs: None, + max_retries: 3, + next_run_at: None, + last_status: None, + last_error: None, + last_run_at: None, + last_finished_at: None, + running_started_at: None, + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: None, + created_at: "2026-03-15T00:00:00Z".to_string(), + updated_at: "2026-03-15T00:00:00Z".to_string(), + }; + let last_delivery = AutomationJobLastDelivery { + success: false, + message: "Webhook 返回错误: 500".to_string(), + channel: Some("webhook".to_string()), + target: Some("https://example.com/webhook".to_string()), + output_kind: "json".to_string(), + output_schema: "json".to_string(), + output_format: "json".to_string(), + output_preview: "{\"status\":\"error\"}".to_string(), + delivery_attempt_id: Some("dlv-run-1".to_string()), + run_id: Some("run-1".to_string()), + execution_retry_count: 2, + delivery_attempts: 3, + attempted_at: "2026-03-16T00:00:00Z".to_string(), + }; + + let metadata = build_tracker_finish_metadata( + &job, + None, + "error", + "success", + 0, + 1200, + Some(&last_delivery), + ); + + assert_eq!(metadata.get("execution_status"), Some(&json!("success"))); + assert_eq!( + metadata + .get("delivery") + .and_then(Value::as_object) + .and_then(|delivery| delivery.get("success")), + Some(&json!(false)) + ); + assert_eq!( + metadata + .get("delivery") + .and_then(Value::as_object) + .and_then(|delivery| delivery.get("delivery_attempt_id")), + Some(&json!("dlv-run-1")) + ); + } + + #[test] + fn build_delivery_context_should_build_stable_attempt_id_without_run_id() { + let job = AutomationJob { + id: "job-1".to_string(), + name: "店铺巡检".to_string(), + description: None, + enabled: true, + workspace_id: "workspace-1".to_string(), + execution_mode: AutomationExecutionMode::Intelligent, + schedule: TaskSchedule::Every { every_secs: 300 }, + payload: json!({ + "kind": "agent_turn", + "prompt": "汇总今日异常", + "web_search": false + }), + delivery: DeliveryConfig::default(), + timeout_secs: None, + max_retries: 3, + next_run_at: None, + last_status: None, + last_error: None, + last_run_at: None, + last_finished_at: None, + running_started_at: None, + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: None, + created_at: "2026-03-15T00:00:00Z".to_string(), + updated_at: "2026-03-15T00:00:00Z".to_string(), + }; + let context = build_delivery_context(&job, "2026-03-16T00:00:00Z", 2, None); + let repeated = build_delivery_context(&job, "2026-03-16T00:00:00Z", 2, None); + + assert!(context.run_id.is_none()); + assert_eq!(context.attempt_id, repeated.attempt_id); + assert!(context.attempt_id.starts_with("dlv-")); + assert_eq!(context.execution_retry_count, 2); + } +} diff --git a/src-tauri/src/services/automation_service/schedule.rs b/src-tauri/src/services/automation_service/schedule.rs new file mode 100644 index 000000000..f62989316 --- /dev/null +++ b/src-tauri/src/services/automation_service/schedule.rs @@ -0,0 +1,199 @@ +//! 自动化任务调度计算 +//! +//! 支持 `every`、`cron`、`at` 三种调度类型。 + +use chrono::{DateTime, Utc}; +use proxycast_core::config::TaskSchedule; +use std::str::FromStr; + +#[derive(Debug, Clone)] +pub struct ScheduleError { + pub message: String, +} + +impl std::fmt::Display for ScheduleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ScheduleError {} + +impl From for ScheduleError { + fn from(message: String) -> Self { + Self { message } + } +} + +impl From for String { + fn from(error: ScheduleError) -> Self { + error.message + } +} + +pub fn next_run_for_schedule( + schedule: &TaskSchedule, + from: DateTime, +) -> Result>, ScheduleError> { + match schedule { + TaskSchedule::Every { every_secs } => { + let secs = (*every_secs).max(60); + Ok(Some(from + chrono::Duration::seconds(secs as i64))) + } + TaskSchedule::Cron { expr, tz } => { + let normalized = normalize_cron_expression(expr); + let cron_schedule = cron::Schedule::from_str(&normalized) + .map_err(|e| ScheduleError::from(format!("无效的 Cron 表达式: {e}")))?; + + let next = if let Some(tz_str) = tz { + let timezone: chrono_tz::Tz = tz_str + .parse() + .map_err(|_| ScheduleError::from(format!("无效的时区: {tz_str}")))?; + let from_tz = from.with_timezone(&timezone); + cron_schedule + .after(&from_tz) + .next() + .map(|value| value.with_timezone(&Utc)) + } else { + cron_schedule.after(&from).next() + }; + + Ok(next) + } + TaskSchedule::At { at } => { + let target = DateTime::parse_from_rfc3339(at) + .map_err(|e| ScheduleError::from(format!("无效的时间格式(需要 RFC3339): {e}")))? + .with_timezone(&Utc); + + if target > from { + Ok(Some(target)) + } else { + Ok(None) + } + } + } +} + +pub fn validate_schedule(schedule: &TaskSchedule, now: DateTime) -> Result<(), ScheduleError> { + match schedule { + TaskSchedule::Every { every_secs } => { + if *every_secs < 60 { + return Err(ScheduleError::from("间隔时间不能小于 60 秒".to_string())); + } + Ok(()) + } + TaskSchedule::Cron { expr, tz } => { + let normalized = normalize_cron_expression(expr); + cron::Schedule::from_str(&normalized) + .map_err(|e| ScheduleError::from(format!("无效的 Cron 表达式: {e}")))?; + if let Some(tz_str) = tz { + let _: chrono_tz::Tz = tz_str + .parse() + .map_err(|_| ScheduleError::from(format!("无效的时区: {tz_str}")))?; + } + Ok(()) + } + TaskSchedule::At { at } => { + let target = DateTime::parse_from_rfc3339(at) + .map_err(|e| ScheduleError::from(format!("无效的时间格式: {e}")))? + .with_timezone(&Utc); + if target <= now { + return Err(ScheduleError::from("指定时间已过期".to_string())); + } + Ok(()) + } + } +} + +pub fn normalize_cron_expression(expr: &str) -> String { + let parts: Vec<&str> = expr.split_whitespace().collect(); + if parts.len() == 5 { + format!("0 {}", expr.trim()) + } else { + expr.trim().to_string() + } +} + +pub fn describe_schedule(schedule: &TaskSchedule) -> String { + match schedule { + TaskSchedule::Every { every_secs } => { + let secs = *every_secs; + if secs >= 86400 && secs % 86400 == 0 { + format!("每 {} 天", secs / 86400) + } else if secs >= 3600 && secs % 3600 == 0 { + format!("每 {} 小时", secs / 3600) + } else if secs >= 60 && secs % 60 == 0 { + format!("每 {} 分钟", secs / 60) + } else { + format!("每 {} 秒", secs) + } + } + TaskSchedule::Cron { expr, tz } => { + let tz_info = tz + .as_ref() + .map(|value| format!(" ({value})")) + .unwrap_or_default(); + format!("Cron: {expr}{tz_info}") + } + TaskSchedule::At { at } => format!("定时: {at}"), + } +} + +pub fn preview_next_run(schedule: &TaskSchedule) -> Result, ScheduleError> { + Ok(next_run_for_schedule(schedule, Utc::now())?.map(|value| value.to_rfc3339())) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + + #[test] + fn should_reject_every_schedule_shorter_than_sixty_seconds() { + let now = Utc.with_ymd_and_hms(2026, 3, 15, 12, 0, 0).unwrap(); + let result = validate_schedule(&TaskSchedule::Every { every_secs: 59 }, now); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "间隔时间不能小于 60 秒"); + } + + #[test] + fn should_accept_five_field_cron_and_compute_next_run() { + let from = Utc.with_ymd_and_hms(2026, 3, 15, 8, 30, 0).unwrap(); + let schedule = TaskSchedule::Cron { + expr: "0 9 * * *".to_string(), + tz: Some("Asia/Shanghai".to_string()), + }; + + validate_schedule(&schedule, from).expect("cron 应通过校验"); + let next_run = next_run_for_schedule(&schedule, from) + .expect("应能计算 cron 下次执行时间") + .expect("cron 应返回下次执行时间"); + + assert_eq!( + next_run, + Utc.with_ymd_and_hms(2026, 3, 16, 1, 0, 0).unwrap() + ); + } + + #[test] + fn should_return_none_for_expired_at_schedule() { + let from = Utc.with_ymd_and_hms(2026, 3, 15, 12, 0, 0).unwrap(); + let schedule = TaskSchedule::At { + at: "2026-03-15T11:59:00Z".to_string(), + }; + + assert!(validate_schedule(&schedule, from).is_err()); + assert_eq!( + next_run_for_schedule(&schedule, from).expect("过期 at 仍应可计算"), + None + ); + } + + #[test] + fn should_describe_every_schedule_in_minutes() { + let description = describe_schedule(&TaskSchedule::Every { every_secs: 1800 }); + + assert_eq!(description, "每 30 分钟"); + } +} diff --git a/src-tauri/src/services/browser_environment_service.rs b/src-tauri/src/services/browser_environment_service.rs new file mode 100644 index 000000000..c57127c96 --- /dev/null +++ b/src-tauri/src/services/browser_environment_service.rs @@ -0,0 +1,444 @@ +use proxycast_browser_runtime::BrowserRuntimeManager; +use proxycast_core::database::dao::browser_environment_preset::{ + BrowserEnvironmentPresetDao, BrowserEnvironmentPresetRecord, + UpsertBrowserEnvironmentPresetInput, +}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +const DEFAULT_CDP_TIMEOUT_MS: u64 = 10_000; + +#[derive(Debug, Clone)] +pub struct SaveBrowserEnvironmentPresetInput { + pub id: Option, + pub name: String, + pub description: Option, + pub proxy_server: Option, + pub timezone_id: Option, + pub locale: Option, + pub accept_language: Option, + pub geolocation_lat: Option, + pub geolocation_lng: Option, + pub geolocation_accuracy_m: Option, + pub user_agent: Option, + pub platform: Option, + pub viewport_width: Option, + pub viewport_height: Option, + pub device_scale_factor: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BrowserEnvironmentLaunchConfig { + #[serde(default)] + pub preset_id: Option, + #[serde(default)] + pub preset_name: Option, + #[serde(default)] + pub proxy_server: Option, + #[serde(default)] + pub timezone_id: Option, + #[serde(default)] + pub locale: Option, + #[serde(default)] + pub accept_language: Option, + #[serde(default)] + pub geolocation_lat: Option, + #[serde(default)] + pub geolocation_lng: Option, + #[serde(default)] + pub geolocation_accuracy_m: Option, + #[serde(default)] + pub user_agent: Option, + #[serde(default)] + pub platform: Option, + #[serde(default)] + pub viewport_width: Option, + #[serde(default)] + pub viewport_height: Option, + #[serde(default)] + pub device_scale_factor: Option, +} + +impl BrowserEnvironmentLaunchConfig { + pub fn browser_launch_language(&self) -> Option { + if let Some(accept_language) = self.accept_language.as_deref() { + let first_language = accept_language + .split(',') + .next() + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(language) = first_language { + return Some(language.to_string()); + } + } + self.locale + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.replace('_', "-")) + } + + pub fn requires_browser_relaunch(&self) -> bool { + self.proxy_server.is_some() + } + + pub fn has_runtime_overrides(&self) -> bool { + self.timezone_id.is_some() + || self.locale.is_some() + || self.accept_language.is_some() + || self.geolocation_lat.is_some() + || self.geolocation_lng.is_some() + || self.user_agent.is_some() + || self.platform.is_some() + || self.viewport_width.is_some() + || self.viewport_height.is_some() + || self.device_scale_factor.is_some() + } +} + +pub fn list_browser_environment_presets( + conn: &Connection, + include_archived: bool, +) -> Result, String> { + BrowserEnvironmentPresetDao::list(conn, include_archived) + .map_err(|error| format!("读取浏览器环境预设失败: {error}")) +} + +pub fn get_browser_environment_preset( + conn: &Connection, + id: &str, +) -> Result, String> { + BrowserEnvironmentPresetDao::get_by_id(conn, id) + .map_err(|error| format!("读取浏览器环境预设失败: {error}")) +} + +pub fn save_browser_environment_preset( + conn: &Connection, + input: SaveBrowserEnvironmentPresetInput, +) -> Result { + let name = input.name.trim().to_string(); + if name.is_empty() { + return Err("环境预设名称不能为空".to_string()); + } + + let description = normalize_optional_text(input.description); + let proxy_server = normalize_optional_text(input.proxy_server); + let timezone_id = normalize_optional_text(input.timezone_id); + let locale = normalize_optional_text(input.locale); + let accept_language = normalize_optional_text(input.accept_language); + let user_agent = normalize_optional_text(input.user_agent); + let platform = normalize_optional_text(input.platform); + let (geolocation_lat, geolocation_lng, geolocation_accuracy_m) = normalize_geolocation( + input.geolocation_lat, + input.geolocation_lng, + input.geolocation_accuracy_m, + )?; + let (viewport_width, viewport_height) = + normalize_viewport(input.viewport_width, input.viewport_height)?; + let device_scale_factor = normalize_device_scale_factor(input.device_scale_factor)?; + + BrowserEnvironmentPresetDao::upsert( + conn, + &UpsertBrowserEnvironmentPresetInput { + id: input.id, + name, + description, + proxy_server, + timezone_id, + locale, + accept_language, + geolocation_lat, + geolocation_lng, + geolocation_accuracy_m, + user_agent, + platform, + viewport_width, + viewport_height, + device_scale_factor, + }, + ) + .map_err(|error| format!("保存浏览器环境预设失败: {error}")) +} + +pub fn archive_browser_environment_preset(conn: &Connection, id: &str) -> Result { + BrowserEnvironmentPresetDao::archive(conn, id) + .map_err(|error| format!("归档浏览器环境预设失败: {error}")) +} + +pub fn restore_browser_environment_preset(conn: &Connection, id: &str) -> Result { + BrowserEnvironmentPresetDao::restore(conn, id) + .map_err(|error| format!("恢复浏览器环境预设失败: {error}")) +} + +pub fn touch_browser_environment_preset_last_used( + conn: &Connection, + id: &str, +) -> Result { + BrowserEnvironmentPresetDao::touch_last_used(conn, id) + .map_err(|error| format!("更新浏览器环境预设最近使用时间失败: {error}")) +} + +pub fn build_browser_environment_launch_config( + preset: &BrowserEnvironmentPresetRecord, +) -> Result { + let viewport_width = preset + .viewport_width + .map(|value| u32::try_from(value).map_err(|_| format!("视口宽度超出范围: {value}"))) + .transpose()?; + let viewport_height = preset + .viewport_height + .map(|value| u32::try_from(value).map_err(|_| format!("视口高度超出范围: {value}"))) + .transpose()?; + + Ok(BrowserEnvironmentLaunchConfig { + preset_id: Some(preset.id.clone()), + preset_name: Some(preset.name.clone()), + proxy_server: preset.proxy_server.clone(), + timezone_id: preset.timezone_id.clone(), + locale: preset.locale.clone(), + accept_language: preset.accept_language.clone(), + geolocation_lat: preset.geolocation_lat, + geolocation_lng: preset.geolocation_lng, + geolocation_accuracy_m: preset.geolocation_accuracy_m, + user_agent: preset.user_agent.clone(), + platform: preset.platform.clone(), + viewport_width, + viewport_height, + device_scale_factor: preset.device_scale_factor, + }) +} + +pub async fn apply_browser_environment_to_session( + runtime: &BrowserRuntimeManager, + session_id: &str, + config: &BrowserEnvironmentLaunchConfig, +) -> Result<(), String> { + if !config.has_runtime_overrides() { + return Ok(()); + } + + if config.user_agent.is_some() || config.accept_language.is_some() || config.platform.is_some() + { + let effective_user_agent = match config.user_agent.as_deref() { + Some(user_agent) => user_agent.to_string(), + None => read_current_user_agent(runtime, session_id).await?, + }; + runtime + .send_command( + session_id, + "Emulation.setUserAgentOverride", + json!({ + "userAgent": effective_user_agent, + "acceptLanguage": config.accept_language, + "platform": config.platform, + }), + DEFAULT_CDP_TIMEOUT_MS, + ) + .await?; + } + + if let Some(timezone_id) = config.timezone_id.as_deref() { + runtime + .send_command( + session_id, + "Emulation.setTimezoneOverride", + json!({ + "timezoneId": timezone_id, + }), + DEFAULT_CDP_TIMEOUT_MS, + ) + .await?; + } + + if let Some(locale) = config.locale.as_deref() { + let locale = normalize_locale_for_cdp(locale); + if let Err(error) = runtime + .send_command( + session_id, + "Emulation.setLocaleOverride", + json!({ + "locale": locale, + }), + DEFAULT_CDP_TIMEOUT_MS, + ) + .await + { + if should_ignore_optional_emulation_error(&error) { + tracing::warn!( + "[BrowserEnvironment] locale override not available: {}", + error + ); + } else { + return Err(error); + } + } + } + + if let (Some(lat), Some(lng)) = (config.geolocation_lat, config.geolocation_lng) { + runtime + .send_command( + session_id, + "Emulation.setGeolocationOverride", + json!({ + "latitude": lat, + "longitude": lng, + "accuracy": config.geolocation_accuracy_m.unwrap_or(100.0), + }), + DEFAULT_CDP_TIMEOUT_MS, + ) + .await?; + } + + if let (Some(width), Some(height)) = (config.viewport_width, config.viewport_height) { + runtime + .send_command( + session_id, + "Emulation.setDeviceMetricsOverride", + json!({ + "width": width, + "height": height, + "deviceScaleFactor": config.device_scale_factor.unwrap_or(1.0), + "mobile": false, + }), + DEFAULT_CDP_TIMEOUT_MS, + ) + .await?; + } + + Ok(()) +} + +fn normalize_optional_text(value: Option) -> Option { + value + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +fn normalize_geolocation( + lat: Option, + lng: Option, + accuracy_m: Option, +) -> Result<(Option, Option, Option), String> { + match (lat, lng) { + (Some(lat), Some(lng)) => { + if !(-90.0..=90.0).contains(&lat) { + return Err("地理位置纬度必须位于 -90 到 90 之间".to_string()); + } + if !(-180.0..=180.0).contains(&lng) { + return Err("地理位置经度必须位于 -180 到 180 之间".to_string()); + } + let accuracy = accuracy_m.unwrap_or(100.0); + if accuracy <= 0.0 { + return Err("地理位置精度必须大于 0".to_string()); + } + Ok((Some(lat), Some(lng), Some(accuracy))) + } + (None, None) => Ok((None, None, None)), + _ => Err("地理位置纬度和经度必须同时填写".to_string()), + } +} + +fn normalize_viewport( + width: Option, + height: Option, +) -> Result<(Option, Option), String> { + match (width, height) { + (Some(width), Some(height)) => { + if width <= 0 || height <= 0 { + return Err("视口宽高必须大于 0".to_string()); + } + Ok((Some(width), Some(height))) + } + (None, None) => Ok((None, None)), + _ => Err("视口宽度和高度必须同时填写".to_string()), + } +} + +fn normalize_device_scale_factor(value: Option) -> Result, String> { + match value { + Some(number) if number <= 0.0 => Err("设备像素比必须大于 0".to_string()), + other => Ok(other), + } +} + +fn normalize_locale_for_cdp(locale: &str) -> String { + locale.trim().replace('-', "_") +} + +fn should_ignore_optional_emulation_error(error: &str) -> bool { + let lower = error.to_lowercase(); + lower.contains("method not found") || lower.contains("wasn't found") +} + +async fn read_current_user_agent( + runtime: &BrowserRuntimeManager, + session_id: &str, +) -> Result { + let response = runtime + .send_command( + session_id, + "Runtime.evaluate", + json!({ + "expression": "navigator.userAgent", + "returnByValue": true, + "awaitPromise": false, + }), + DEFAULT_CDP_TIMEOUT_MS, + ) + .await?; + + extract_runtime_value(response) + .and_then(|value| value.as_str().map(ToString::to_string)) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "读取当前浏览器 User-Agent 失败".to_string()) +} + +fn extract_runtime_value(response: Value) -> Option { + let result = response.get("result")?; + result + .get("value") + .cloned() + .or_else(|| result.get("description").cloned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_require_complete_geolocation_pair() { + let error = normalize_geolocation(Some(31.2), None, None).unwrap_err(); + assert!(error.contains("必须同时填写")); + } + + #[test] + fn should_normalize_locale_for_cdp() { + assert_eq!(normalize_locale_for_cdp("zh-CN"), "zh_CN"); + assert_eq!(normalize_locale_for_cdp("en_US"), "en_US"); + } + + #[test] + fn should_derive_browser_launch_language() { + let config = BrowserEnvironmentLaunchConfig { + accept_language: Some("en-US,en;q=0.9".to_string()), + ..Default::default() + }; + assert_eq!(config.browser_launch_language().as_deref(), Some("en-US")); + + let locale_only = BrowserEnvironmentLaunchConfig { + locale: Some("zh_CN".to_string()), + ..Default::default() + }; + assert_eq!( + locale_only.browser_launch_language().as_deref(), + Some("zh-CN") + ); + } + + #[test] + fn should_require_complete_viewport_pair() { + let error = normalize_viewport(Some(1440), None).unwrap_err(); + assert!(error.contains("必须同时填写")); + } +} diff --git a/src-tauri/src/services/browser_profile_service.rs b/src-tauri/src/services/browser_profile_service.rs new file mode 100644 index 000000000..bf908afbe --- /dev/null +++ b/src-tauri/src/services/browser_profile_service.rs @@ -0,0 +1,309 @@ +use std::path::{Path, PathBuf}; + +use proxycast_core::app_paths; +use proxycast_core::database::dao::browser_profile::{ + BrowserProfileDao, BrowserProfileRecord, BrowserProfileTransportKind, UpsertBrowserProfileInput, +}; +use rusqlite::Connection; +use url::Url; + +#[derive(Debug, Clone)] +pub struct SaveBrowserProfileInput { + pub id: Option, + pub profile_key: String, + pub name: String, + pub description: Option, + pub site_scope: Option, + pub launch_url: Option, + pub transport_kind: BrowserProfileTransportKind, +} + +pub fn sanitize_browser_profile_key(input: &str) -> String { + input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} + +pub fn normalize_browser_profile_key(input: &str) -> String { + let safe_key = sanitize_browser_profile_key(input); + if safe_key.trim_matches('_').is_empty() { + "default".to_string() + } else { + safe_key + } +} + +pub fn resolve_chrome_profile_data_dir_from_base(base_dir: &Path, profile_key: &str) -> PathBuf { + base_dir + .join("chrome_profiles") + .join(normalize_browser_profile_key(profile_key)) +} + +pub fn resolve_chrome_profile_data_dir(profile_key: &str) -> Result { + let base_dir = app_paths::preferred_data_dir() + .map_err(|error| format!("获取应用数据目录失败: {error}"))?; + Ok(resolve_chrome_profile_data_dir_from_base( + &base_dir, + profile_key, + )) +} + +pub fn list_browser_profiles( + conn: &Connection, + include_archived: bool, +) -> Result, String> { + BrowserProfileDao::list(conn, include_archived) + .map_err(|error| format!("读取浏览器资料失败: {error}")) +} + +pub fn get_browser_profile( + conn: &Connection, + id: &str, +) -> Result, String> { + BrowserProfileDao::get_by_id(conn, id).map_err(|error| format!("读取浏览器资料失败: {error}")) +} + +pub fn save_browser_profile( + conn: &Connection, + input: SaveBrowserProfileInput, +) -> Result { + let name = input.name.trim().to_string(); + if name.is_empty() { + return Err("资料名称不能为空".to_string()); + } + + let profile_key = normalize_browser_profile_key(&input.profile_key); + let description = normalize_optional_text(input.description); + let site_scope = normalize_optional_text(input.site_scope); + let launch_url = normalize_launch_url(input.launch_url)?; + let (profile_dir, managed_profile_dir) = + resolve_browser_profile_storage(&profile_key, input.transport_kind)?; + + if let Some(ref id) = input.id { + let existing = BrowserProfileDao::get_by_id(conn, id) + .map_err(|error| format!("读取浏览器资料失败: {error}"))? + .ok_or_else(|| format!("未找到浏览器资料: {id}"))?; + if existing.profile_key != profile_key { + return Err("暂不支持修改资料 Key,请新建一个资料".to_string()); + } + } else if let Some(existing) = BrowserProfileDao::get_by_profile_key(conn, &profile_key) + .map_err(|error| format!("读取浏览器资料失败: {error}"))? + { + if existing.archived_at.is_none() { + return Err(format!("资料 Key 已存在: {profile_key}")); + } + } + + BrowserProfileDao::upsert( + conn, + &UpsertBrowserProfileInput { + id: input.id, + profile_key, + name, + description, + site_scope, + launch_url, + transport_kind: input.transport_kind, + profile_dir, + managed_profile_dir, + }, + ) + .map_err(|error| format!("保存浏览器资料失败: {error}")) +} + +pub fn archive_browser_profile(conn: &Connection, id: &str) -> Result { + BrowserProfileDao::archive(conn, id).map_err(|error| format!("归档浏览器资料失败: {error}")) +} + +pub fn restore_browser_profile(conn: &Connection, id: &str) -> Result { + BrowserProfileDao::restore(conn, id).map_err(|error| format!("恢复浏览器资料失败: {error}")) +} + +pub fn touch_browser_profile_last_used(conn: &Connection, id: &str) -> Result { + BrowserProfileDao::touch_last_used(conn, id) + .map_err(|error| format!("更新浏览器资料最近使用时间失败: {error}")) +} + +fn normalize_optional_text(value: Option) -> Option { + value + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +fn normalize_launch_url(value: Option) -> Result, String> { + let Some(raw) = normalize_optional_text(value) else { + return Ok(None); + }; + let parsed = Url::parse(&raw).map_err(|error| format!("启动地址无效: {error}"))?; + match parsed.scheme() { + "http" | "https" => Ok(Some(parsed.to_string())), + _ => Err("启动地址仅支持 http/https".to_string()), + } +} + +fn resolve_browser_profile_storage( + profile_key: &str, + transport_kind: BrowserProfileTransportKind, +) -> Result<(String, Option), String> { + match transport_kind { + BrowserProfileTransportKind::ManagedCdp => { + let managed_profile_dir = resolve_chrome_profile_data_dir(profile_key)? + .to_string_lossy() + .to_string(); + Ok((managed_profile_dir.clone(), Some(managed_profile_dir))) + } + BrowserProfileTransportKind::ExistingSession => Ok((String::new(), None)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE browser_profiles ( + id TEXT PRIMARY KEY, + profile_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + site_scope TEXT, + launch_url TEXT, + transport_kind TEXT NOT NULL DEFAULT 'managed_cdp', + profile_dir TEXT NOT NULL, + managed_profile_dir TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + )", + [], + ) + .unwrap(); + conn + } + + #[test] + fn should_normalize_browser_profile_key() { + assert_eq!( + normalize_browser_profile_key("shop/google:zh-CN"), + "shop_google_zh-CN" + ); + assert_eq!(normalize_browser_profile_key("///"), "default"); + } + + #[test] + fn should_reject_invalid_launch_url() { + let conn = setup_db(); + let error = save_browser_profile( + &conn, + SaveBrowserProfileInput { + id: None, + profile_key: "shop_us".to_string(), + name: "美区店铺".to_string(), + description: None, + site_scope: None, + launch_url: Some("file:///tmp/demo".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + }, + ) + .unwrap_err(); + + assert!(error.contains("仅支持 http/https")); + } + + #[test] + fn should_disallow_changing_profile_key_on_existing_record() { + let conn = setup_db(); + let inserted = save_browser_profile( + &conn, + SaveBrowserProfileInput { + id: None, + profile_key: "shop_us".to_string(), + name: "美区店铺".to_string(), + description: None, + site_scope: None, + launch_url: Some("https://example.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + }, + ) + .unwrap(); + + let error = save_browser_profile( + &conn, + SaveBrowserProfileInput { + id: Some(inserted.id), + profile_key: "shop_eu".to_string(), + name: "欧区店铺".to_string(), + description: None, + site_scope: None, + launch_url: Some("https://example.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + }, + ) + .unwrap_err(); + + assert!(error.contains("暂不支持修改资料 Key")); + } + + #[test] + fn should_save_managed_profile_with_managed_storage() { + let conn = setup_db(); + let saved = save_browser_profile( + &conn, + SaveBrowserProfileInput { + id: None, + profile_key: "shop_us".to_string(), + name: "美区店铺".to_string(), + description: None, + site_scope: Some("seller.example.com".to_string()), + launch_url: Some("https://seller.example.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + }, + ) + .unwrap(); + + assert_eq!( + saved.transport_kind, + BrowserProfileTransportKind::ManagedCdp + ); + assert!(!saved.profile_dir.is_empty()); + assert_eq!( + saved.managed_profile_dir.as_deref(), + Some(saved.profile_dir.as_str()) + ); + } + + #[test] + fn should_save_existing_session_profile_without_managed_dir() { + let conn = setup_db(); + let saved = save_browser_profile( + &conn, + SaveBrowserProfileInput { + id: None, + profile_key: "weibo_attach".to_string(), + name: "微博附着".to_string(), + description: Some("依赖当前 Chrome".to_string()), + site_scope: Some("weibo.com".to_string()), + launch_url: Some("https://weibo.com".to_string()), + transport_kind: BrowserProfileTransportKind::ExistingSession, + }, + ) + .unwrap(); + + assert_eq!( + saved.transport_kind, + BrowserProfileTransportKind::ExistingSession + ); + assert_eq!(saved.profile_dir, ""); + assert_eq!(saved.managed_profile_dir, None); + } +} diff --git a/src-tauri/src/services/execution_tracker_service.rs b/src-tauri/src/services/execution_tracker_service.rs index cc86f7625..abc8a24d3 100644 --- a/src-tauri/src/services/execution_tracker_service.rs +++ b/src-tauri/src/services/execution_tracker_service.rs @@ -1,6 +1,6 @@ //! 统一执行追踪服务 //! -//! 负责跨入口(chat / skill / heartbeat)的运行摘要记录, +//! 负责跨入口(chat / skill / automation)的运行摘要记录, //! 通过单点服务避免各模块重复实现生命周期写库逻辑。 use crate::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; @@ -16,7 +16,7 @@ use uuid::Uuid; pub enum RunSource { Chat, Skill, - Heartbeat, + Automation, } impl RunSource { @@ -24,7 +24,7 @@ impl RunSource { match self { Self::Chat => "chat", Self::Skill => "skill", - Self::Heartbeat => "heartbeat", + Self::Automation => "automation", } } } @@ -144,6 +144,37 @@ impl ExecutionTracker { self.finish(handle, status, error_code, error_message, metadata); } + pub fn refresh_running_metadata( + &self, + handle: &RunHandle, + session_id: Option<&str>, + metadata: Option, + ) { + if !self.enabled { + return; + } + + let updated_at = Utc::now().to_rfc3339(); + let metadata_json = metadata.map(|value| value.to_string()); + let conn = match self.db.lock() { + Ok(conn) => conn, + Err(e) => { + tracing::warn!("[ExecutionTracker] 数据库锁定失败,跳过 refresh: {}", e); + return; + } + }; + + if let Err(e) = AgentRunDao::refresh_running_run( + &conn, + &handle.id, + &updated_at, + session_id, + metadata_json.as_deref(), + ) { + tracing::warn!("[ExecutionTracker] 刷新运行中 run 失败: {}", e); + } + } + fn finish( &self, handle: &RunHandle, @@ -399,4 +430,30 @@ mod tests { assert_eq!(runs[0].status, AgentRunStatus::Error); assert_eq!(runs[0].error_code.as_deref(), Some("skill_failed")); } + + #[test] + fn refresh_running_metadata_should_update_session_and_metadata() { + let tracker = ExecutionTracker::new(setup_db()); + let handle = tracker + .start(RunSource::Automation, Some("job-1".to_string()), None, None) + .expect("应创建运行句柄"); + + tracker.refresh_running_metadata( + &handle, + Some("session-1"), + Some(serde_json::json!({ + "browser_lifecycle_state": "human_controlling" + })), + ); + + let run = tracker + .get_run(&handle.id) + .expect("查询 run 失败") + .expect("run 不存在"); + assert_eq!(run.session_id.as_deref(), Some("session-1")); + assert_eq!( + run.metadata.as_deref(), + Some("{\"browser_lifecycle_state\":\"human_controlling\"}") + ); + } } diff --git a/src-tauri/src/services/heartbeat_service/delivery.rs b/src-tauri/src/services/heartbeat_service/delivery.rs deleted file mode 100644 index 27bb28cd8..000000000 --- a/src-tauri/src/services/heartbeat_service/delivery.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! 心跳任务通知投递模块 -//! -//! 支持将任务执行结果通知到外部渠道(Webhook、Telegram 等) - -use proxycast_core::config::DeliveryConfig; -use serde::Serialize; - -/// 投递结果 -#[derive(Debug)] -pub struct DeliveryResult { - pub success: bool, - pub message: String, -} - -/// 任务执行结果(用于通知) -#[derive(Debug, Clone, Serialize)] -pub struct TaskResult { - pub task: String, - pub status: String, - pub output: String, - pub duration_ms: i64, - pub timestamp: String, -} - -/// 投递任务执行结果到配置的渠道 -pub async fn deliver_result(config: &DeliveryConfig, result: &TaskResult) -> DeliveryResult { - // 检查是否启用通知 - if config.mode == "none" { - return DeliveryResult { - success: true, - message: "通知已禁用".to_string(), - }; - } - - let channel = match &config.channel { - Some(c) => c.as_str(), - None => { - return DeliveryResult { - success: false, - message: "未配置通知渠道".to_string(), - }; - } - }; - - let target = match &config.target { - Some(t) => t.as_str(), - None => { - return DeliveryResult { - success: false, - message: "未配置通知目标".to_string(), - }; - } - }; - - match channel { - "webhook" => deliver_webhook(target, result).await, - "telegram" => deliver_telegram(target, result).await, - _ => DeliveryResult { - success: false, - message: format!("不支持的通知渠道: {}", channel), - }, - } -} - -/// 通过 Webhook 投递通知 -async fn deliver_webhook(url: &str, result: &TaskResult) -> DeliveryResult { - let payload = WebhookPayload { - event: "heartbeat_task_complete".to_string(), - task: result.task.clone(), - status: result.status.clone(), - output: result.output.clone(), - duration_ms: result.duration_ms, - timestamp: result.timestamp.clone(), - }; - - let client = reqwest::Client::new(); - match client - .post(url) - .json(&payload) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - { - Ok(response) => { - if response.status().is_success() { - DeliveryResult { - success: true, - message: "Webhook 通知已发送".to_string(), - } - } else { - DeliveryResult { - success: false, - message: format!("Webhook 返回错误: {}", response.status()), - } - } - } - Err(e) => DeliveryResult { - success: false, - message: format!("Webhook 请求失败: {}", e), - }, - } -} - -/// Webhook 通知载荷 -#[derive(Debug, Serialize)] -struct WebhookPayload { - event: String, - task: String, - status: String, - output: String, - duration_ms: i64, - timestamp: String, -} - -/// 通过 Telegram Bot API 投递通知 -async fn deliver_telegram(target: &str, result: &TaskResult) -> DeliveryResult { - // target 格式: "bot_token:chat_id" - let parts: Vec<&str> = target.splitn(2, ':').collect(); - if parts.len() != 2 { - return DeliveryResult { - success: false, - message: "Telegram 目标格式错误,应为 bot_token:chat_id".to_string(), - }; - } - - let bot_token = parts[0]; - let chat_id = parts[1]; - - let status_emoji = match result.status.as_str() { - "success" => "✅", - "failed" => "❌", - "timeout" => "⏰", - _ => "📋", - }; - - let message = format!( - "{} *心跳任务完成*\n\n*任务*: {}\n*状态*: {}\n*耗时*: {}ms\n\n```\n{}\n```", - status_emoji, - escape_markdown(&result.task), - result.status, - result.duration_ms, - escape_markdown(&result.output), - ); - - let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token); - - let payload = TelegramPayload { - chat_id: chat_id.to_string(), - text: message, - parse_mode: "MarkdownV2".to_string(), - }; - - let client = reqwest::Client::new(); - match client - .post(&url) - .json(&payload) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - { - Ok(response) => { - if response.status().is_success() { - DeliveryResult { - success: true, - message: "Telegram 通知已发送".to_string(), - } - } else { - let body = response.text().await.unwrap_or_default(); - DeliveryResult { - success: false, - message: format!("Telegram API 错误: {}", body), - } - } - } - Err(e) => DeliveryResult { - success: false, - message: format!("Telegram 请求失败: {}", e), - }, - } -} - -/// Telegram 消息载荷 -#[derive(Debug, Serialize)] -struct TelegramPayload { - chat_id: String, - text: String, - parse_mode: String, -} - -/// 转义 Telegram MarkdownV2 特殊字符 -fn escape_markdown(text: &str) -> String { - let special_chars = [ - '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', - ]; - let mut result = String::with_capacity(text.len() * 2); - for c in text.chars() { - if special_chars.contains(&c) { - result.push('\\'); - } - result.push(c); - } - result -} - -/// 批量投递周期结果 -pub async fn deliver_cycle_summary( - config: &DeliveryConfig, - task_count: usize, - success_count: usize, - failed_count: usize, - timeout_count: usize, -) -> DeliveryResult { - if config.mode == "none" { - return DeliveryResult { - success: true, - message: "通知已禁用".to_string(), - }; - } - - let summary = TaskResult { - task: format!("心跳周期完成 ({} 个任务)", task_count), - status: if failed_count == 0 && timeout_count == 0 { - "success".to_string() - } else { - "partial".to_string() - }, - output: format!( - "成功: {}, 失败: {}, 超时: {}", - success_count, failed_count, timeout_count - ), - duration_ms: 0, - timestamp: chrono::Utc::now().to_rfc3339(), - }; - - deliver_result(config, &summary).await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_escape_markdown() { - assert_eq!(escape_markdown("hello"), "hello"); - assert_eq!(escape_markdown("hello_world"), "hello\\_world"); - assert_eq!(escape_markdown("*bold*"), "\\*bold\\*"); - } - - #[test] - fn test_delivery_disabled() { - let config = DeliveryConfig { - mode: "none".to_string(), - channel: None, - target: None, - best_effort: true, - }; - let result = TaskResult { - task: "test".to_string(), - status: "success".to_string(), - output: "ok".to_string(), - duration_ms: 100, - timestamp: "2024-01-01T00:00:00Z".to_string(), - }; - let rt = tokio::runtime::Runtime::new().unwrap(); - let delivery_result = rt.block_on(deliver_result(&config, &result)); - assert!(delivery_result.success); - } - - #[test] - fn test_delivery_no_channel() { - let config = DeliveryConfig { - mode: "announce".to_string(), - channel: None, - target: Some("http://example.com".to_string()), - best_effort: true, - }; - let result = TaskResult { - task: "test".to_string(), - status: "success".to_string(), - output: "ok".to_string(), - duration_ms: 100, - timestamp: "2024-01-01T00:00:00Z".to_string(), - }; - let rt = tokio::runtime::Runtime::new().unwrap(); - let delivery_result = rt.block_on(deliver_result(&config, &result)); - assert!(!delivery_result.success); - assert!(delivery_result.message.contains("未配置通知渠道")); - } -} diff --git a/src-tauri/src/services/heartbeat_service/engine.rs b/src-tauri/src/services/heartbeat_service/engine.rs deleted file mode 100644 index ecbcb42ca..000000000 --- a/src-tauri/src/services/heartbeat_service/engine.rs +++ /dev/null @@ -1,389 +0,0 @@ -//! HEARTBEAT.md 任务解析引擎 -//! -//! 解析 HEARTBEAT.md 文件中的任务列表,支持优先级、超时、一次性任务和模型覆盖。 -//! -//! 格式: -//! ```markdown -//! - 任务描述 [priority:N] [timeout:Ns] [once] [model:xxx] -//! ``` - -use std::path::PathBuf; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -/// 心跳任务 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct HeartbeatTask { - pub description: String, - pub priority: Option, - pub timeout: Option, - /// 一次性任务,执行后自动移除 - #[serde(default)] - pub once: bool, - /// 模型覆盖(用于智能模式) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, -} - -/// HEARTBEAT.md 解析引擎 -pub struct HeartbeatEngine { - task_file: PathBuf, -} - -impl HeartbeatEngine { - pub fn new(task_file: PathBuf) -> Self { - Self { task_file } - } - - /// 确保 HEARTBEAT.md 文件存在,如果不存在则创建默认模板 - pub fn ensure_file_exists(&self) -> Result { - if self.task_file.exists() { - return Ok(false); - } - - let template = r#"# 心跳任务 - -# 每行一个任务,以 `- ` 开头 -# 支持标记:[priority:N] [timeout:Ns] [once] [model:xxx] -# -# 示例: -# - 检查系统健康状态 [priority:8] -# - 每日数据备份 [timeout:300s] -# - 一次性清理任务 [once] -# - 使用特定模型执行 [model:claude-3-haiku] - -"#; - - // 确保父目录存在 - if let Some(parent) = self.task_file.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; - } - - std::fs::write(&self.task_file, template) - .map_err(|e| format!("创建任务文件失败: {}", e))?; - - Ok(true) - } - - /// 从 HEARTBEAT.md 收集任务 - pub fn collect_tasks(&self) -> Result, String> { - if !self.task_file.exists() { - return Ok(vec![]); - } - - let content = std::fs::read_to_string(&self.task_file) - .map_err(|e| format!("读取任务文件失败: {}", e))?; - - self.parse_tasks(&content) - } - - /// 将任务列表序列化回 HEARTBEAT.md 格式并写入文件 - pub fn write_tasks(task_file: &PathBuf, tasks: &[HeartbeatTask]) -> Result<(), String> { - let mut lines = Vec::new(); - for task in tasks { - let mut line = format!("- {}", task.description); - if let Some(p) = task.priority { - line.push_str(&format!(" [priority:{}]", p)); - } - if let Some(t) = task.timeout { - line.push_str(&format!(" [timeout:{}s]", t.as_secs())); - } - if task.once { - line.push_str(" [once]"); - } - if let Some(ref m) = task.model { - line.push_str(&format!(" [model:{}]", m)); - } - lines.push(line); - } - let content = lines.join("\n"); - std::fs::write( - task_file, - if content.is_empty() { - String::new() - } else { - content + "\n" - }, - ) - .map_err(|e| format!("写入任务文件失败: {}", e)) - } - - /// 解析 markdown 内容为任务列表 - pub fn parse_tasks(&self, content: &str) -> Result, String> { - let mut tasks: Vec = Vec::new(); - - for line in content.lines() { - let trimmed = line.trim(); - - // 跳过空行、注释、标题 - if trimmed.is_empty() || trimmed.starts_with("\n- 任务1\n\n- 任务2"; - let tasks = engine.parse_tasks(content).unwrap(); - assert_eq!(tasks.len(), 2); - } - - #[test] - fn test_empty_file() { - let engine = HeartbeatEngine::new(PathBuf::from("/tmp/test.md")); - let tasks = engine.parse_tasks("").unwrap(); - assert_eq!(tasks.len(), 0); - } - - #[test] - fn test_priority_clamped() { - let engine = HeartbeatEngine::new(PathBuf::from("/tmp/test.md")); - let content = "- 超高 [priority:99]\n- 超低 [priority:0]"; - let tasks = engine.parse_tasks(content).unwrap(); - assert_eq!(tasks[0].priority, Some(10)); - assert_eq!(tasks[1].priority, Some(1)); - } - - #[test] - fn test_write_then_read_roundtrip() { - let tmp = tempfile::TempDir::new().unwrap(); - let file = tmp.path().join("HEARTBEAT.md"); - - let tasks = vec![ - HeartbeatTask { - description: "任务A".to_string(), - priority: Some(8), - timeout: Some(Duration::from_secs(120)), - once: false, - model: None, - }, - HeartbeatTask { - description: "任务B".to_string(), - priority: None, - timeout: None, - once: false, - model: None, - }, - ]; - - HeartbeatEngine::write_tasks(&file, &tasks).unwrap(); - - let engine = HeartbeatEngine::new(file); - let parsed = engine.collect_tasks().unwrap(); - assert_eq!(parsed.len(), 2); - // 高优先级排前面 - assert_eq!(parsed[0].description, "任务A"); - assert_eq!(parsed[0].priority, Some(8)); - assert_eq!(parsed[0].timeout, Some(Duration::from_secs(120))); - assert_eq!(parsed[1].description, "任务B"); - assert_eq!(parsed[1].priority, None); - } - - #[test] - fn test_write_empty_tasks() { - let tmp = tempfile::TempDir::new().unwrap(); - let file = tmp.path().join("HEARTBEAT.md"); - - HeartbeatEngine::write_tasks(&file, &[]).unwrap(); - - let engine = HeartbeatEngine::new(file); - let parsed = engine.collect_tasks().unwrap(); - assert_eq!(parsed.len(), 0); - } - - #[test] - fn test_parse_once_task() { - let engine = HeartbeatEngine::new(PathBuf::from("/tmp/test.md")); - let content = "- 一次性任务 [once]"; - let tasks = engine.parse_tasks(content).unwrap(); - assert_eq!(tasks.len(), 1); - assert!(tasks[0].once); - } - - #[test] - fn test_parse_model_override() { - let engine = HeartbeatEngine::new(PathBuf::from("/tmp/test.md")); - let content = "- 使用特定模型 [model:claude-3-haiku]"; - let tasks = engine.parse_tasks(content).unwrap(); - assert_eq!(tasks.len(), 1); - assert_eq!(tasks[0].model, Some("claude-3-haiku".to_string())); - } - - #[test] - fn test_parse_combined_tags() { - let engine = HeartbeatEngine::new(PathBuf::from("/tmp/test.md")); - let content = "- 复杂任务 [priority:7] [timeout:60s] [once] [model:gpt-4o]"; - let tasks = engine.parse_tasks(content).unwrap(); - assert_eq!(tasks.len(), 1); - assert_eq!(tasks[0].description, "复杂任务"); - assert_eq!(tasks[0].priority, Some(7)); - assert_eq!(tasks[0].timeout, Some(Duration::from_secs(60))); - assert!(tasks[0].once); - assert_eq!(tasks[0].model, Some("gpt-4o".to_string())); - } - - #[test] - fn test_write_once_and_model() { - let tmp = tempfile::TempDir::new().unwrap(); - let file = tmp.path().join("HEARTBEAT.md"); - - let tasks = vec![HeartbeatTask { - description: "测试任务".to_string(), - priority: Some(5), - timeout: None, - once: true, - model: Some("claude-3-haiku".to_string()), - }]; - - HeartbeatEngine::write_tasks(&file, &tasks).unwrap(); - - let content = std::fs::read_to_string(&file).unwrap(); - assert!(content.contains("[once]")); - assert!(content.contains("[model:claude-3-haiku]")); - } - - #[test] - fn test_ensure_file_creates_template() { - let tmp = tempfile::TempDir::new().unwrap(); - let file = tmp.path().join("HEARTBEAT.md"); - - let engine = HeartbeatEngine::new(file.clone()); - assert!(!file.exists()); - - let created = engine.ensure_file_exists().unwrap(); - assert!(created); - assert!(file.exists()); - - let content = std::fs::read_to_string(&file).unwrap(); - assert!(content.contains("# 心跳任务")); - assert!(content.contains("[priority:N]")); - assert!(content.contains("[once]")); - assert!(content.contains("[model:xxx]")); - } -} diff --git a/src-tauri/src/services/heartbeat_service/mod.rs b/src-tauri/src/services/heartbeat_service/mod.rs deleted file mode 100644 index a977853c9..000000000 --- a/src-tauri/src/services/heartbeat_service/mod.rs +++ /dev/null @@ -1,1125 +0,0 @@ -//! 心跳引擎服务 -//! -//! 提供 HEARTBEAT.md 任务解析、智能执行、技能调用和任务模板管理。 -//! 支持灵活调度(固定间隔、Cron 表达式、指定时间点)和通知投递。 - -pub mod delivery; -pub mod engine; -pub mod schedule; -pub mod templates; - -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; -use tokio_util::sync::CancellationToken; - -use crate::database::dao::agent_run::AgentRunStatus; -use crate::services::execution_tracker_service::{ExecutionTracker, RunSource}; -use proxycast_core::config::{HeartbeatExecutionMode, HeartbeatSettings, TaskSchedule}; -use proxycast_core::database::dao::heartbeat::{HeartbeatDao, HeartbeatExecution}; -use proxycast_core::database::DbConnection; -use tauri::{Emitter, Manager}; - -use self::delivery::{deliver_cycle_summary, deliver_result, TaskResult}; -use self::engine::{HeartbeatEngine, HeartbeatTask}; -use self::schedule::{next_run_for_schedule, preview_next_run, validate_schedule}; - -// ============ 状态类型 ============ - -/// HeartbeatService 的 Tauri managed state -#[derive(Clone)] -pub struct HeartbeatServiceState(pub Arc>); - -/// 心跳引擎运行状态 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HeartbeatStatus { - pub running: bool, - pub last_run: Option, - pub next_run: Option, - pub last_task_count: usize, - pub total_executions: u64, - pub current_task: Option, - pub schedule_description: Option, -} - -/// 任务预览(前端展示用) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HeartbeatTaskPreview { - pub description: String, - pub priority: Option, - pub timeout_secs: Option, - pub once: bool, - pub model: Option, -} - -/// 任务执行结果 -#[derive(Debug)] -pub struct TaskExecutionResult { - pub status: ExecutionStatus, - pub output: String, - pub duration_ms: i64, -} - -#[derive(Debug)] -pub enum ExecutionStatus { - Success, - Failed, - Timeout, -} - -/// 一次心跳周期的汇总结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CycleResult { - pub task_count: usize, - pub success_count: usize, - pub failed_count: usize, - pub timeout_count: usize, -} - -// ============ HeartbeatService ============ - -pub struct HeartbeatService { - config: HeartbeatSettings, - cancel_token: Option, - status: HeartbeatStatus, - db: Option, - app_handle: Option, -} - -impl HeartbeatService { - pub fn new(config: HeartbeatSettings) -> Self { - let schedule_description = config.schedule.as_ref().map(schedule::describe_schedule); - Self { - config, - cancel_token: None, - status: HeartbeatStatus { - running: false, - last_run: None, - next_run: None, - last_task_count: 0, - total_executions: 0, - current_task: None, - schedule_description, - }, - db: None, - app_handle: None, - } - } - - pub fn set_db(&mut self, db: DbConnection) { - self.db = Some(db); - } - - pub fn set_app_handle(&mut self, handle: tauri::AppHandle) { - self.app_handle = Some(handle); - } - - pub fn update_config(&mut self, config: HeartbeatSettings) { - self.config = config; - } - - pub fn get_config(&self) -> &HeartbeatSettings { - &self.config - } - - /// 启动心跳循环 - pub async fn start( - &mut self, - app_data_dir: PathBuf, - self_ref: Arc>, - ) -> Result<(), String> { - if self.status.running { - return Ok(()); - } - - // 验证调度配置 - if let Some(ref schedule) = self.config.schedule { - if let Err(e) = validate_schedule(schedule, Utc::now()) { - return Err(format!("调度配置无效: {}", e)); - } - } - - // 自动创建 HEARTBEAT.md 文件(如果不存在) - let task_file = app_data_dir.join(&self.config.task_file); - let engine = HeartbeatEngine::new(task_file); - if let Ok(created) = engine.ensure_file_exists() { - if created { - tracing::info!("[Heartbeat] 已创建默认任务文件"); - } - } - - let cancel_token = CancellationToken::new(); - self.cancel_token = Some(cancel_token.clone()); - self.status.running = true; - - // 计算并设置下次执行时间 - self.update_next_run(); - - let config = self.config.clone(); - let db = self.db.clone(); - let app_handle = self.app_handle.clone(); - - tokio::spawn(async move { - Self::run_loop(config, db, app_handle, cancel_token, app_data_dir, self_ref).await; - }); - - let schedule_desc = self - .config - .schedule - .as_ref() - .map(schedule::describe_schedule) - .unwrap_or_else(|| format!("每 {} 秒", self.config.interval_secs)); - tracing::info!("[Heartbeat] 心跳引擎已启动,调度: {}", schedule_desc); - Ok(()) - } - - /// 更新下次执行时间 - fn update_next_run(&mut self) { - let schedule = self.config.schedule.clone().unwrap_or(TaskSchedule::Every { - every_secs: self.config.interval_secs, - }); - - self.status.next_run = preview_next_run(&schedule).ok().flatten(); - self.status.schedule_description = Some(schedule::describe_schedule(&schedule)); - } - - async fn run_loop( - config: HeartbeatSettings, - db: Option, - app_handle: Option, - cancel_token: CancellationToken, - app_data_dir: PathBuf, - self_ref: Arc>, - ) { - // 获取有效的调度配置 - let schedule = config.schedule.clone().unwrap_or(TaskSchedule::Every { - every_secs: config.interval_secs.max(300), - }); - - loop { - // 计算下次执行时间 - let now = Utc::now(); - let next_run = match next_run_for_schedule(&schedule, now) { - Ok(Some(next)) => next, - Ok(None) => { - // At 类型已过期,停止循环 - tracing::info!("[Heartbeat] 一次性任务已完成,停止心跳循环"); - let mut service = self_ref.write().await; - service.status.running = false; - service.status.next_run = None; - break; - } - Err(e) => { - tracing::error!("[Heartbeat] 计算下次执行时间失败: {}", e); - // 回退到默认间隔 - now + chrono::Duration::seconds(300) - } - }; - - // 更新状态中的下次执行时间 - { - let mut service = self_ref.write().await; - service.status.next_run = Some(next_run.to_rfc3339()); - } - - // 计算等待时间 - let wait_duration = (next_run - now) - .to_std() - .unwrap_or(Duration::from_secs(300)); - - tracing::debug!( - "[Heartbeat] 下次执行时间: {}, 等待 {} 秒", - next_run.to_rfc3339(), - wait_duration.as_secs() - ); - - // 等待直到下次执行时间或取消 - tokio::select! { - _ = tokio::time::sleep(wait_duration) => { - let result = Self::execute_cycle(&config, &db, &app_handle, &app_data_dir).await; - - // 发送周期汇总通知 - if config.delivery.mode != "none" && result.task_count > 0 { - let delivery_result = deliver_cycle_summary( - &config.delivery, - result.task_count, - result.success_count, - result.failed_count, - result.timeout_count, - ).await; - if !delivery_result.success && !config.delivery.best_effort { - tracing::warn!("[Heartbeat] 通知投递失败: {}", delivery_result.message); - } - } - - { - let mut service = self_ref.write().await; - service.update_status_after_cycle(&result); - } - - // At 类型执行一次后停止 - if matches!(schedule, TaskSchedule::At { .. }) { - tracing::info!("[Heartbeat] 一次性任务已执行,停止心跳循环"); - let mut service = self_ref.write().await; - service.status.running = false; - service.status.next_run = None; - break; - } - } - _ = cancel_token.cancelled() => { - tracing::info!("[Heartbeat] 心跳循环已停止"); - break; - } - } - } - } - async fn execute_cycle( - config: &HeartbeatSettings, - db: &Option, - app_handle: &Option, - app_data_dir: &PathBuf, - ) -> CycleResult { - let task_file = app_data_dir.join(&config.task_file); - let engine = HeartbeatEngine::new(task_file.clone()); - - let tasks = match engine.collect_tasks() { - Ok(t) => t, - Err(e) => { - tracing::warn!("[Heartbeat] 收集任务失败: {}", e); - return CycleResult { - task_count: 0, - success_count: 0, - failed_count: 0, - timeout_count: 0, - }; - } - }; - - if tasks.is_empty() { - tracing::debug!("[Heartbeat] 无待执行任务"); - return CycleResult { - task_count: 0, - success_count: 0, - failed_count: 0, - timeout_count: 0, - }; - } - - tracing::info!("[Heartbeat] 收集到 {} 个任务", tasks.len()); - - let mut success_count: usize = 0; - let mut failed_count: usize = 0; - let mut timeout_count: usize = 0; - let tracker = db.as_ref().map(|conn| ExecutionTracker::new(conn.clone())); - - for task in &tasks { - // 发送事件:任务开始 - if let Some(ref handle) = app_handle { - let _ = handle.emit("heartbeat:task_start", &task.description); - } - - let start = Instant::now(); - let started_at = Utc::now().to_rfc3339(); - let run_handle = tracker.as_ref().and_then(|tracker| { - tracker.start( - RunSource::Heartbeat, - Some(task.description.clone()), - None, - Some(serde_json::json!({ - "priority": task.priority, - "timeout_secs": task.timeout.map(|d| d.as_secs()), - "once": task.once, - "model": task.model.clone(), - "execution_mode": format!("{:?}", config.execution_mode).to_lowercase(), - })), - ) - }); - - // Fix 3: 重试逻辑 - let max_attempts = config.max_retries.max(1); // 至少执行 1 次 - let mut result: Result = Err("未执行".to_string()); - let mut retry_count: u32 = 0; - - for attempt in 0..max_attempts { - if attempt > 0 { - tracing::info!( - "[Heartbeat] 重试任务 ({}/{}): {}", - attempt, - config.max_retries, - task.description - ); - } - - let exec = async { - match config.execution_mode { - HeartbeatExecutionMode::Intelligent => { - Self::execute_intelligent(task, app_handle).await - } - HeartbeatExecutionMode::Skill => { - Self::execute_skill(task, app_handle).await - } - HeartbeatExecutionMode::LogOnly => { - tracing::info!("[Heartbeat] 任务(仅记录): {}", task.description); - Ok(TaskExecutionResult { - status: ExecutionStatus::Success, - output: "Log only mode".to_string(), - duration_ms: 0, - }) - } - } - }; - - // Fix 4: 任务超时 - result = if let Some(timeout_duration) = task.timeout { - match tokio::time::timeout(timeout_duration, exec).await { - Ok(r) => r, - Err(_) => { - tracing::warn!( - "[Heartbeat] 任务超时({}s): {}", - timeout_duration.as_secs(), - task.description - ); - Ok(TaskExecutionResult { - status: ExecutionStatus::Timeout, - output: format!("任务执行超时({}s)", timeout_duration.as_secs()), - duration_ms: timeout_duration.as_millis() as i64, - }) - } - } - } else { - exec.await - }; - - // 判断是否需要重试(成功和超时不重试) - let should_retry = match &result { - Ok(r) => matches!(r.status, ExecutionStatus::Failed), - Err(_) => true, - }; - if !should_retry { - break; - } - retry_count = attempt + 1; - } - - let elapsed = start.elapsed().as_millis() as i64; - let (status_str, output_str) = match &result { - Ok(r) => ( - match r.status { - ExecutionStatus::Success => "success", - ExecutionStatus::Failed => "failed", - ExecutionStatus::Timeout => "timeout", - }, - Some(r.output.as_str()), - ), - Err(e) => ("failed", Some(e.as_str())), - }; - - // 累计计数 - match status_str { - "success" => success_count += 1, - "failed" => failed_count += 1, - "timeout" => timeout_count += 1, - _ => failed_count += 1, - } - - if let (Some(tracker), Some(handle)) = (tracker.as_ref(), run_handle.as_ref()) { - let run_status = match status_str { - "success" => AgentRunStatus::Success, - "timeout" => AgentRunStatus::Timeout, - _ => AgentRunStatus::Error, - }; - let error_code = match run_status { - AgentRunStatus::Error => Some("heartbeat_task_failed"), - AgentRunStatus::Timeout => Some("heartbeat_task_timeout"), - _ => None, - }; - let error_message = if matches!(run_status, AgentRunStatus::Success) { - None - } else { - output_str - }; - tracker.finish_with_status( - handle, - run_status, - error_code, - error_message, - Some(serde_json::json!({ - "task_description": task.description.clone(), - "execution_mode": format!("{:?}", config.execution_mode).to_lowercase(), - "status": status_str, - "duration_ms": elapsed, - "retry_count": retry_count, - })), - ); - } - - // 保存执行记录 - if config.enable_history { - if let Some(ref db) = db { - let exec = HeartbeatExecution { - id: 0, - task_description: task.description.clone(), - priority: task.priority, - execution_mode: format!("{:?}", config.execution_mode).to_lowercase(), - status: status_str.to_string(), - started_at: started_at.clone(), - completed_at: Some(Utc::now().to_rfc3339()), - duration_ms: Some(elapsed), - output: output_str.map(|s| s.to_string()), - retry_count, - metadata: None, - }; - if let Ok(conn) = db.lock() { - if let Err(e) = HeartbeatDao::create_execution(&conn, &exec) { - tracing::warn!("[Heartbeat] 保存执行记录失败: {}", e); - } - } - } - } - - // 发送事件:任务完成 - if let Some(ref handle) = app_handle { - let _ = handle.emit( - "heartbeat:task_complete", - serde_json::json!({ - "description": task.description, - "status": status_str, - "duration_ms": elapsed, - "retry_count": retry_count, - }), - ); - } - - // 单任务通知投递(如果配置了) - if config.delivery.mode != "none" { - let task_result = TaskResult { - task: task.description.clone(), - status: status_str.to_string(), - output: output_str.unwrap_or("").to_string(), - duration_ms: elapsed, - timestamp: Utc::now().to_rfc3339(), - }; - let delivery_result = deliver_result(&config.delivery, &task_result).await; - if !delivery_result.success && !config.delivery.best_effort { - tracing::warn!( - "[Heartbeat] 任务 '{}' 通知投递失败: {}", - task.description, - delivery_result.message - ); - } - } - } - - // 移除已执行的一次性任务 - let once_tasks: Vec<_> = tasks - .iter() - .filter(|t| t.once) - .map(|t| t.description.clone()) - .collect(); - if !once_tasks.is_empty() { - let task_file = app_data_dir.join(&config.task_file); - if let Ok(all_tasks) = HeartbeatEngine::new(task_file.clone()).collect_tasks() { - let remaining: Vec<_> = all_tasks - .into_iter() - .filter(|t| !t.once || !once_tasks.contains(&t.description)) - .collect(); - if let Err(e) = HeartbeatEngine::write_tasks(&task_file, &remaining) { - tracing::warn!("[Heartbeat] 移除一次性任务失败: {}", e); - } else { - tracing::info!("[Heartbeat] 已移除 {} 个一次性任务", once_tasks.len()); - } - } - } - - CycleResult { - task_count: tasks.len(), - success_count, - failed_count, - timeout_count, - } - } - - /// 智能模式:通过 Aster Agent 执行任务 - async fn execute_intelligent( - task: &HeartbeatTask, - app_handle: &Option, - ) -> Result { - // 获取 AsterAgentState 并发送消息 - if let Some(ref handle) = app_handle { - use crate::agent::AsterAgentState; - use crate::database::DbConnection; - - let agent_state = handle.try_state::(); - let db_state = handle.try_state::(); - - if let (Some(agent_state), Some(db)) = (agent_state, db_state) { - let model_info = task - .model - .as_ref() - .map(|m| format!("\n使用模型:{}", m)) - .unwrap_or_default(); - - let prompt = format!( - "你是一个自动化任务执行助手。请执行以下心跳任务:\n\n{}\n\n优先级:{}{}{}请理解任务意图并完成任务。如果任务不明确或无法执行,请说明原因。", - task.description, - task.priority.unwrap_or(5), - model_info, - if model_info.is_empty() { "\n\n" } else { "\n\n" }, - ); - - let session_id = format!("heartbeat-{}", Utc::now().timestamp()); - let event_name = format!("heartbeat:agent:{}", session_id); - - // TODO: 当 AsterAgentWrapper 支持模型覆盖时,传入 task.model - match crate::agent::AsterAgentWrapper::send_message( - &agent_state, - &db, - handle, - prompt, - session_id, - event_name, - ) - .await - { - Ok(()) => { - return Ok(TaskExecutionResult { - status: ExecutionStatus::Success, - output: format!( - "Agent 执行完成{}", - task.model - .as_ref() - .map(|m| format!(" (模型: {})", m)) - .unwrap_or_default() - ), - duration_ms: 0, - }); - } - Err(e) => { - tracing::warn!("[Heartbeat] Agent 执行失败,降级为日志模式: {}", e); - return Ok(TaskExecutionResult { - status: ExecutionStatus::Success, - output: format!("任务已记录(Agent 不可用: {})", e), - duration_ms: 0, - }); - } - } - } - } - - // Agent 不可用,降级为日志模式 - tracing::warn!( - "[Heartbeat] Agent 未初始化,任务 '{}' 降级为日志模式", - task.description - ); - Ok(TaskExecutionResult { - status: ExecutionStatus::Success, - output: "任务已记录(Agent 未初始化)".to_string(), - duration_ms: 0, - }) - } - - /// 技能模式:解析 skill:name 格式,通过 Agent 代理执行 - async fn execute_skill( - task: &HeartbeatTask, - app_handle: &Option, - ) -> Result { - let (skill_name, skill_args) = - if let Some(stripped) = task.description.strip_prefix("skill:") { - let parts: Vec<&str> = stripped.splitn(2, ' ').collect(); - ( - parts[0].trim().to_string(), - parts - .get(1) - .map(|s| s.trim().to_string()) - .unwrap_or_default(), - ) - } else { - (task.description.clone(), String::new()) - }; - - tracing::info!( - "[Heartbeat] 技能模式执行: {} (args: {})", - skill_name, - skill_args - ); - - // 通过 Agent 代理执行技能 - if let Some(ref handle) = app_handle { - use crate::agent::AsterAgentState; - use crate::database::DbConnection; - - let agent_state = handle.try_state::(); - let db_state = handle.try_state::(); - - if let (Some(agent_state), Some(db)) = (agent_state, db_state) { - let prompt = format!( - "你是一个技能执行助手。请执行以下技能任务:\n\n技能名称:{}\n参数:{}\n\n请理解技能意图并完成任务。", - skill_name, - if skill_args.is_empty() { "无".to_string() } else { skill_args.clone() }, - ); - - let session_id = format!("heartbeat-skill-{}", Utc::now().timestamp()); - let event_name = format!("heartbeat:agent:{}", session_id); - - match crate::agent::AsterAgentWrapper::send_message( - &agent_state, - &db, - handle, - prompt, - session_id, - event_name, - ) - .await - { - Ok(()) => { - return Ok(TaskExecutionResult { - status: ExecutionStatus::Success, - output: format!("技能 '{}' 已通过 Agent 执行", skill_name), - duration_ms: 0, - }); - } - Err(e) => { - tracing::warn!("[Heartbeat] Agent 执行技能失败,降级为日志模式: {}", e); - return Ok(TaskExecutionResult { - status: ExecutionStatus::Success, - output: format!("技能 '{}' 已记录(Agent 不可用: {})", skill_name, e), - duration_ms: 0, - }); - } - } - } - } - - // Agent 不可用,降级为日志模式 - tracing::warn!( - "[Heartbeat] Agent 未初始化,技能 '{}' 降级为日志模式", - skill_name - ); - Ok(TaskExecutionResult { - status: ExecutionStatus::Success, - output: format!("技能 '{}' 已记录(Agent 未初始化)", skill_name), - duration_ms: 0, - }) - } - - /// 停止心跳循环 - pub async fn stop(&mut self) -> Result<(), String> { - if let Some(token) = self.cancel_token.take() { - token.cancel(); - } - self.status.running = false; - tracing::info!("[Heartbeat] 心跳引擎已停止"); - Ok(()) - } - - pub fn get_status(&self) -> HeartbeatStatus { - self.status.clone() - } - - /// 预览当前 HEARTBEAT.md 中的任务 - pub fn preview_tasks( - &self, - app_data_dir: &PathBuf, - ) -> Result, String> { - let task_file = app_data_dir.join(&self.config.task_file); - let engine = HeartbeatEngine::new(task_file); - let tasks = engine.collect_tasks().map_err(|e| e.to_string())?; - Ok(tasks - .into_iter() - .map(|t| HeartbeatTaskPreview { - description: t.description, - priority: t.priority, - timeout_secs: t.timeout.map(|d| d.as_secs()), - once: t.once, - model: t.model, - }) - .collect()) - } - - /// 读取原始任务列表(不排序,保持文件顺序) - fn read_raw_tasks(&self, app_data_dir: &PathBuf) -> Result, String> { - let task_file = app_data_dir.join(&self.config.task_file); - let engine = HeartbeatEngine::new(task_file); - engine.collect_tasks() - } - - /// 添加任务 - pub fn add_task( - &self, - app_data_dir: &PathBuf, - description: String, - priority: Option, - timeout_secs: Option, - once: Option, - model: Option, - ) -> Result<(), String> { - let task_file = app_data_dir.join(&self.config.task_file); - let mut tasks = self.read_raw_tasks(app_data_dir)?; - tasks.push(HeartbeatTask { - description, - priority, - timeout: timeout_secs.map(Duration::from_secs), - once: once.unwrap_or(false), - model, - }); - HeartbeatEngine::write_tasks(&task_file, &tasks) - } - - /// 删除任务 - pub fn delete_task(&self, app_data_dir: &PathBuf, index: usize) -> Result<(), String> { - let task_file = app_data_dir.join(&self.config.task_file); - let mut tasks = self.read_raw_tasks(app_data_dir)?; - if index >= tasks.len() { - return Err(format!( - "任务索引越界: {} (共 {} 个任��)", - index, - tasks.len() - )); - } - tasks.remove(index); - HeartbeatEngine::write_tasks(&task_file, &tasks) - } - - /// 更新任务 - pub fn update_task( - &self, - app_data_dir: &PathBuf, - index: usize, - description: String, - priority: Option, - timeout_secs: Option, - once: Option, - model: Option, - ) -> Result<(), String> { - let task_file = app_data_dir.join(&self.config.task_file); - let mut tasks = self.read_raw_tasks(app_data_dir)?; - if index >= tasks.len() { - return Err(format!( - "任务索引越界: {} (共 {} 个任务)", - index, - tasks.len() - )); - } - tasks[index] = HeartbeatTask { - description, - priority, - timeout: timeout_secs.map(Duration::from_secs), - once: once.unwrap_or(false), - model, - }; - HeartbeatEngine::write_tasks(&task_file, &tasks) - } - - /// 获取执行历史 - pub fn get_execution_history(&self, limit: usize) -> Result, String> { - if let Some(ref db) = self.db { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; - HeartbeatDao::get_recent_executions(&conn, limit).map_err(|e| e.to_string()) - } else { - Ok(vec![]) - } - } - - /// 获取单条执行记录详情 - pub fn get_execution_detail(&self, id: i64) -> Result, String> { - if let Some(ref db) = self.db { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; - HeartbeatDao::get_execution_by_id(&conn, id).map_err(|e| e.to_string()) - } else { - Ok(None) - } - } - - /// 手动触发一次心跳 - pub async fn trigger_now( - &self, - app_data_dir: PathBuf, - app_handle: Option, - ) -> CycleResult { - let handle = if app_handle.is_some() { - app_handle - } else { - self.app_handle.clone() - }; - Self::execute_cycle(&self.config, &self.db, &handle, &app_data_dir).await - } - - /// 根据 CycleResult 更新内部状态 - pub fn update_status_after_cycle(&mut self, result: &CycleResult) { - self.status.last_run = Some(chrono::Utc::now().to_rfc3339()); - self.status.last_task_count = result.task_count; - self.status.total_executions += 1; - self.status.current_task = None; - // 更新下次执行时间 - self.update_next_run(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use proxycast_core::config::{HeartbeatExecutionMode, HeartbeatSettings}; - use proxycast_core::database::dao::heartbeat::HeartbeatDao; - use proxycast_core::database::schema::create_tables; - use std::sync::{Arc, Mutex}; - use tempfile::TempDir; - - fn make_test_db() -> proxycast_core::database::DbConnection { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - create_tables(&conn).unwrap(); - Arc::new(Mutex::new(conn)) - } - - fn make_log_only_config() -> HeartbeatSettings { - HeartbeatSettings { - enabled: true, - interval_secs: 300, - schedule: None, - task_file: "HEARTBEAT.md".to_string(), - execution_mode: HeartbeatExecutionMode::LogOnly, - enable_history: true, - max_retries: 1, - delivery: proxycast_core::config::DeliveryConfig::default(), - security: proxycast_core::config::HeartbeatSecurityConfig::default(), - } - } - - /// 模板应用 → 写入 HEARTBEAT.md → engine 能解析出任务 - #[test] - fn test_apply_template_then_parse() { - let tmp = TempDir::new().unwrap(); - let app_data_dir = tmp.path().to_path_buf(); - - let template = templates::TaskTemplateRegistry::get_template_by_id("daily_blog_post") - .expect("模板应存在"); - assert!(!template.tasks.is_empty()); - - templates::TaskTemplateRegistry::apply_template(&template, &app_data_dir).unwrap(); - - // 验证文件已写入 - let heartbeat_file = app_data_dir.join("HEARTBEAT.md"); - assert!(heartbeat_file.exists(), "HEARTBEAT.md 应已创建"); - - // 验证 engine 能解析出相同数量的任务 - let engine = engine::HeartbeatEngine::new(heartbeat_file); - let tasks = engine.collect_tasks().unwrap(); - assert_eq!( - tasks.len(), - template.tasks.len(), - "解析出的任务数应与模板任务数一致" - ); - } - - /// execute_cycle (log_only) 端到端:文件 → 执行 → DB 记录 - #[tokio::test] - async fn test_execute_cycle_log_only_writes_history() { - let tmp = TempDir::new().unwrap(); - let app_data_dir = tmp.path().to_path_buf(); - let db = make_test_db(); - let config = make_log_only_config(); - - // 写入任务文件 - let task_content = "- 测试任务A [priority:8]\n- 测试任务B\n"; - std::fs::write(app_data_dir.join("HEARTBEAT.md"), task_content).unwrap(); - - // 执行 - let result = - HeartbeatService::execute_cycle(&config, &Some(db.clone()), &None, &app_data_dir).await; - - // 验证 CycleResult - assert_eq!(result.task_count, 2, "应有 2 个任务"); - assert_eq!(result.success_count, 2, "应有 2 个成功"); - assert_eq!(result.failed_count, 0); - assert_eq!(result.timeout_count, 0); - - // 验证 DB 中有 2 条记录 - let conn = db.lock().unwrap(); - let records = HeartbeatDao::get_recent_executions(&conn, 10).unwrap(); - assert_eq!(records.len(), 2, "应有 2 条执行记录"); - - // 验证记录内容 - for rec in &records { - assert_eq!(rec.status, "success"); - assert_eq!(rec.execution_mode, "logonly"); - assert_eq!(rec.output.as_deref(), Some("Log only mode")); - assert_eq!(rec.retry_count, 0); - } - } - - /// 无任务文件时 execute_cycle 不崩溃、不写记录 - #[tokio::test] - async fn test_execute_cycle_no_file_is_noop() { - let tmp = TempDir::new().unwrap(); - let app_data_dir = tmp.path().to_path_buf(); - let db = make_test_db(); - let config = make_log_only_config(); - - // 不创建 HEARTBEAT.md - let result = - HeartbeatService::execute_cycle(&config, &Some(db.clone()), &None, &app_data_dir).await; - - assert_eq!(result.task_count, 0, "无任务文件时 task_count 应为 0"); - - let conn = db.lock().unwrap(); - let records = HeartbeatDao::get_recent_executions(&conn, 10).unwrap(); - assert_eq!(records.len(), 0, "无任务文件时不应有执行记录"); - } - - /// 空任务文件时 execute_cycle 不崩溃、不写记录 - #[tokio::test] - async fn test_execute_cycle_empty_file_is_noop() { - let tmp = TempDir::new().unwrap(); - let app_data_dir = tmp.path().to_path_buf(); - let db = make_test_db(); - let config = make_log_only_config(); - - std::fs::write(app_data_dir.join("HEARTBEAT.md"), "# 空文件\n").unwrap(); - - let result = - HeartbeatService::execute_cycle(&config, &Some(db.clone()), &None, &app_data_dir).await; - - assert_eq!(result.task_count, 0, "空任务文件时 task_count 应为 0"); - - let conn = db.lock().unwrap(); - let records = HeartbeatDao::get_recent_executions(&conn, 10).unwrap(); - assert_eq!(records.len(), 0, "空任务文件时不应有执行记录"); - } - - /// 完整链路:模板应用 → trigger_now → DB 有记录 - #[tokio::test] - async fn test_full_pipeline_template_to_execution() { - let tmp = TempDir::new().unwrap(); - let app_data_dir = tmp.path().to_path_buf(); - let db = make_test_db(); - - // 1. 应用模板 - let template = templates::TaskTemplateRegistry::get_template_by_id("project_health_check") - .expect("模板应存在"); - templates::TaskTemplateRegistry::apply_template(&template, &app_data_dir).unwrap(); - - // 2. 创建 service 并 trigger - let mut service = HeartbeatService::new(make_log_only_config()); - service.set_db(db.clone()); - // 不设置 app_handle,模拟用户未启动引擎的场景 - let result = service.trigger_now(app_data_dir, None).await; - - // 验证 CycleResult - assert_eq!( - result.task_count, - template.tasks.len(), - "task_count 应与模板任务数一致" - ); - assert_eq!(result.success_count, template.tasks.len(), "所有任务应成功"); - - // 3. 验证 DB 记录数 == 模板任务数 - let conn = db.lock().unwrap(); - let records = HeartbeatDao::get_recent_executions(&conn, 50).unwrap(); - assert_eq!( - records.len(), - template.tasks.len(), - "执行记录数应与模板任务数一致" - ); - for rec in &records { - assert_eq!(rec.status, "success"); - } - } - - /// trigger_now 优先使用外部传入的 app_handle(None 回退到 self) - #[tokio::test] - async fn test_trigger_now_falls_back_to_self_handle() { - let tmp = TempDir::new().unwrap(); - let app_data_dir = tmp.path().to_path_buf(); - let db = make_test_db(); - - std::fs::write(app_data_dir.join("HEARTBEAT.md"), "- 回退测试\n").unwrap(); - - let mut service = HeartbeatService::new(make_log_only_config()); - service.set_db(db.clone()); - // self.app_handle = None, 传入也是 None → 应该仍能执行 log_only - let result = service.trigger_now(app_data_dir, None).await; - - assert_eq!(result.task_count, 1); - assert_eq!(result.success_count, 1); - - let conn = db.lock().unwrap(); - let records = HeartbeatDao::get_recent_executions(&conn, 10).unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].status, "success"); - } - - /// update_status_after_cycle 正确更新 status 字段 - #[test] - fn test_update_status_after_cycle() { - let mut service = HeartbeatService::new(make_log_only_config()); - assert_eq!(service.status.total_executions, 0); - assert!(service.status.last_run.is_none()); - - let result = CycleResult { - task_count: 3, - success_count: 2, - failed_count: 1, - timeout_count: 0, - }; - service.update_status_after_cycle(&result); - - assert_eq!(service.status.total_executions, 1); - assert_eq!(service.status.last_task_count, 3); - assert!(service.status.last_run.is_some()); - assert!(service.status.current_task.is_none()); - - // 再执行一次 - service.update_status_after_cycle(&result); - assert_eq!(service.status.total_executions, 2); - } - - /// add_task / delete_task / update_task 端到端 - #[test] - fn test_task_crud_operations() { - let tmp = TempDir::new().unwrap(); - let app_data_dir = tmp.path().to_path_buf(); - let service = HeartbeatService::new(make_log_only_config()); - - // 添加 - service - .add_task(&app_data_dir, "任务1".into(), Some(5), None, None, None) - .unwrap(); - service - .add_task(&app_data_dir, "任务2".into(), None, Some(60), None, None) - .unwrap(); - let tasks = service.preview_tasks(&app_data_dir).unwrap(); - assert_eq!(tasks.len(), 2); - - // 更新 - service - .update_task( - &app_data_dir, - 0, - "任务1-改".into(), - Some(9), - Some(120), - None, - None, - ) - .unwrap(); - let tasks = service.preview_tasks(&app_data_dir).unwrap(); - // 排序后高优先级在前 - assert!(tasks - .iter() - .any(|t| t.description == "任务1-改" && t.priority == Some(9))); - - // 删除 - service.delete_task(&app_data_dir, 0).unwrap(); - let tasks = service.preview_tasks(&app_data_dir).unwrap(); - assert_eq!(tasks.len(), 1); - - // 越界检查 - assert!(service.delete_task(&app_data_dir, 99).is_err()); - assert!(service - .update_task(&app_data_dir, 99, "x".into(), None, None, None, None) - .is_err()); - } -} diff --git a/src-tauri/src/services/heartbeat_service/schedule.rs b/src-tauri/src/services/heartbeat_service/schedule.rs deleted file mode 100644 index 71e20551f..000000000 --- a/src-tauri/src/services/heartbeat_service/schedule.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! 心跳任务调度计算模块 -//! -//! 支持三种调度方式: -//! - Every: 固定间隔 -//! - Cron: Cron 表达式 -//! - At: 指定时间点(一次性) - -use chrono::{DateTime, Utc}; -use proxycast_core::config::TaskSchedule; - -/// 调度计算错误 -#[derive(Debug, Clone)] -pub struct ScheduleError { - pub message: String, -} - -impl std::fmt::Display for ScheduleError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for ScheduleError {} - -impl From for ScheduleError { - fn from(message: String) -> Self { - Self { message } - } -} - -/// 计算下次执行时间 -pub fn next_run_for_schedule( - schedule: &TaskSchedule, - from: DateTime, -) -> Result>, ScheduleError> { - match schedule { - TaskSchedule::Every { every_secs } => { - let secs = (*every_secs).max(300); // 最小 5 分钟 - Ok(Some(from + chrono::Duration::seconds(secs as i64))) - } - TaskSchedule::Cron { expr, tz } => { - let normalized = normalize_cron_expression(expr); - let cron_schedule = cron::Schedule::from_str(&normalized) - .map_err(|e| ScheduleError::from(format!("无效的 Cron 表达式: {}", e)))?; - - // 处理时区 - let next = if let Some(tz_str) = tz { - let timezone: chrono_tz::Tz = tz_str - .parse() - .map_err(|_| ScheduleError::from(format!("无效的时区: {}", tz_str)))?; - let from_tz = from.with_timezone(&timezone); - cron_schedule - .after(&from_tz) - .next() - .map(|dt| dt.with_timezone(&Utc)) - } else { - cron_schedule.after(&from).next() - }; - - Ok(next) - } - TaskSchedule::At { at } => { - let target = DateTime::parse_from_rfc3339(at) - .map_err(|e| ScheduleError::from(format!("无效的时间格式 (需要 RFC3339): {}", e)))? - .with_timezone(&Utc); - - if target > from { - Ok(Some(target)) - } else { - // 已过期,返回 None 表示不再执行 - Ok(None) - } - } - } -} - -/// 验证调度配置 -pub fn validate_schedule(schedule: &TaskSchedule, now: DateTime) -> Result<(), ScheduleError> { - match schedule { - TaskSchedule::Every { every_secs } => { - if *every_secs < 60 { - return Err(ScheduleError::from("间隔时间不能小于 60 秒".to_string())); - } - Ok(()) - } - TaskSchedule::Cron { expr, tz } => { - let normalized = normalize_cron_expression(expr); - cron::Schedule::from_str(&normalized) - .map_err(|e| ScheduleError::from(format!("无效的 Cron 表达式: {}", e)))?; - - if let Some(tz_str) = tz { - let _: chrono_tz::Tz = tz_str - .parse() - .map_err(|_| ScheduleError::from(format!("无效的时区: {}", tz_str)))?; - } - Ok(()) - } - TaskSchedule::At { at } => { - let target = DateTime::parse_from_rfc3339(at) - .map_err(|e| ScheduleError::from(format!("无效的时间格式: {}", e)))? - .with_timezone(&Utc); - - if target <= now { - return Err(ScheduleError::from("指定时间已过期".to_string())); - } - Ok(()) - } - } -} - -/// 标准化 Cron 表达式 -/// -/// 支持 5 字段(分 时 日 月 周)和 6 字段(秒 分 时 日 月 周)格式 -/// 5 字段格式会自动补充秒字段为 "0" -pub fn normalize_cron_expression(expr: &str) -> String { - let parts: Vec<&str> = expr.split_whitespace().collect(); - if parts.len() == 5 { - // 5 字段格式,补充秒字段 - format!("0 {}", expr.trim()) - } else { - expr.trim().to_string() - } -} - -/// 获取调度类型的人类可读描述 -pub fn describe_schedule(schedule: &TaskSchedule) -> String { - match schedule { - TaskSchedule::Every { every_secs } => { - let secs = *every_secs; - if secs >= 86400 && secs % 86400 == 0 { - format!("每 {} 天", secs / 86400) - } else if secs >= 3600 && secs % 3600 == 0 { - format!("每 {} 小时", secs / 3600) - } else if secs >= 60 && secs % 60 == 0 { - format!("每 {} 分钟", secs / 60) - } else { - format!("每 {} 秒", secs) - } - } - TaskSchedule::Cron { expr, tz } => { - let tz_info = tz.as_ref().map(|t| format!(" ({})", t)).unwrap_or_default(); - format!("Cron: {}{}", expr, tz_info) - } - TaskSchedule::At { at } => { - format!("定时: {}", at) - } - } -} - -/// 预览下次执行时间(用于前端显示) -pub fn preview_next_run(schedule: &TaskSchedule) -> Result, ScheduleError> { - let now = Utc::now(); - let next = next_run_for_schedule(schedule, now)?; - Ok(next.map(|dt| dt.to_rfc3339())) -} - -use std::str::FromStr; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_every_schedule() { - let schedule = TaskSchedule::Every { every_secs: 300 }; - let now = Utc::now(); - let next = next_run_for_schedule(&schedule, now).unwrap().unwrap(); - assert_eq!((next - now).num_seconds(), 300); - } - - #[test] - fn test_every_schedule_min_interval() { - // 小于 300 秒会被强制为 300 - let schedule = TaskSchedule::Every { every_secs: 60 }; - let now = Utc::now(); - let next = next_run_for_schedule(&schedule, now).unwrap().unwrap(); - assert_eq!((next - now).num_seconds(), 300); - } - - #[test] - fn test_cron_schedule() { - // 每分钟执行 - let schedule = TaskSchedule::Cron { - expr: "* * * * *".to_string(), - tz: None, - }; - let now = Utc::now(); - let next = next_run_for_schedule(&schedule, now).unwrap(); - assert!(next.is_some()); - } - - #[test] - fn test_cron_schedule_with_timezone() { - let schedule = TaskSchedule::Cron { - expr: "0 9 * * *".to_string(), // 每天 9 点 - tz: Some("Asia/Shanghai".to_string()), - }; - let now = Utc::now(); - let next = next_run_for_schedule(&schedule, now).unwrap(); - assert!(next.is_some()); - } - - #[test] - fn test_at_schedule_future() { - let future = Utc::now() + chrono::Duration::hours(1); - let schedule = TaskSchedule::At { - at: future.to_rfc3339(), - }; - let now = Utc::now(); - let next = next_run_for_schedule(&schedule, now).unwrap(); - assert!(next.is_some()); - } - - #[test] - fn test_at_schedule_past() { - let past = Utc::now() - chrono::Duration::hours(1); - let schedule = TaskSchedule::At { - at: past.to_rfc3339(), - }; - let now = Utc::now(); - let next = next_run_for_schedule(&schedule, now).unwrap(); - assert!(next.is_none()); - } - - #[test] - fn test_normalize_cron_5_fields() { - let expr = "0 9 * * *"; - let normalized = normalize_cron_expression(expr); - assert_eq!(normalized, "0 0 9 * * *"); - } - - #[test] - fn test_normalize_cron_6_fields() { - let expr = "30 0 9 * * *"; - let normalized = normalize_cron_expression(expr); - assert_eq!(normalized, "30 0 9 * * *"); - } - - #[test] - fn test_validate_schedule_invalid_cron() { - let schedule = TaskSchedule::Cron { - expr: "invalid".to_string(), - tz: None, - }; - let result = validate_schedule(&schedule, Utc::now()); - assert!(result.is_err()); - } - - #[test] - fn test_validate_schedule_invalid_timezone() { - let schedule = TaskSchedule::Cron { - expr: "* * * * *".to_string(), - tz: Some("Invalid/Timezone".to_string()), - }; - let result = validate_schedule(&schedule, Utc::now()); - assert!(result.is_err()); - } - - #[test] - fn test_describe_schedule() { - assert_eq!( - describe_schedule(&TaskSchedule::Every { every_secs: 300 }), - "每 5 分钟" - ); - assert_eq!( - describe_schedule(&TaskSchedule::Every { every_secs: 3600 }), - "每 1 小时" - ); - assert_eq!( - describe_schedule(&TaskSchedule::Every { every_secs: 86400 }), - "每 1 天" - ); - } -} diff --git a/src-tauri/src/services/heartbeat_service/templates.rs b/src-tauri/src/services/heartbeat_service/templates.rs deleted file mode 100644 index 8eb452103..000000000 --- a/src-tauri/src/services/heartbeat_service/templates.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! 心跳任务模板系统 -//! -//! 提供预设的任务模板,用户可以快速应用到 HEARTBEAT.md - -use serde::{Deserialize, Serialize}; -use std::path::Path; - -/// 任务模板 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskTemplate { - pub id: String, - pub name: String, - pub description: String, - pub category: TaskCategory, - pub tasks: Vec, - pub recommended_interval: u64, -} - -/// 任务分类 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TaskCategory { - ContentCreation, - ProjectMaintenance, - DataAnalysis, - Automation, - Custom, -} - -pub struct TaskTemplateRegistry; - -impl TaskTemplateRegistry { - pub fn get_all_templates() -> Vec { - vec![ - TaskTemplate { - id: "daily_blog_post".into(), - name: "每日博客文章生成".into(), - description: "每天自动生成一篇博客文章".into(), - category: TaskCategory::ContentCreation, - tasks: vec![ - "分析最近的热点话题,选择一个适合的主题 [priority:8]".into(), - "生成一篇 800-1200 字的博客文章 [priority:7] [timeout:300s]".into(), - "检查文章质量,确保语法正确、逻辑清晰 [priority:6]".into(), - ], - recommended_interval: 86400, - }, - TaskTemplate { - id: "social_media_content".into(), - name: "社交媒体内容生成".into(), - description: "定期生成社交媒体内容".into(), - category: TaskCategory::ContentCreation, - tasks: vec![ - "生成 3 条适合社交媒体的短内容(每条 100-200 字) [priority:7]".into(), - "为每条内容添加合适的话题标签 [priority:6]".into(), - ], - recommended_interval: 3600, - }, - TaskTemplate { - id: "project_health_check".into(), - name: "项目健康检查".into(), - description: "检查项目依赖、代码质量、安全漏洞等".into(), - category: TaskCategory::ProjectMaintenance, - tasks: vec![ - "检查项目依赖是否有更新 [priority:8]".into(), - "运行代码质量检查工具 [priority:7] [timeout:600s]".into(), - "扫描安全漏洞 [priority:9] [timeout:300s]".into(), - "生成项目健康报告 [priority:6]".into(), - ], - recommended_interval: 86400, - }, - TaskTemplate { - id: "database_backup".into(), - name: "数据库备份".into(), - description: "定期备份数据库到指定位置".into(), - category: TaskCategory::ProjectMaintenance, - tasks: vec![ - "skill:backup_database /backups/daily [priority:10] [timeout:600s]".into(), - "验证备份文件完整性 [priority:9]".into(), - "清理 7 天前的旧备份 [priority:5]".into(), - ], - recommended_interval: 86400, - }, - TaskTemplate { - id: "usage_analytics".into(), - name: "使用情况分析".into(), - description: "分析应用使用情况,生成统计报告".into(), - category: TaskCategory::DataAnalysis, - tasks: vec![ - "统计过去 24 小时的 API 调用次数 [priority:7]".into(), - "分析最常用的模型和功能 [priority:6]".into(), - "生成使用情况报告 [priority:5]".into(), - ], - recommended_interval: 86400, - }, - TaskTemplate { - id: "workspace_cleanup".into(), - name: "工作区清理".into(), - description: "清理临时文件、日志文件等".into(), - category: TaskCategory::Automation, - tasks: vec![ - "清理 7 天前的日志文件 [priority:6]".into(), - "清理临时文件夹 [priority:5]".into(), - "压缩旧的会话记录 [priority:4]".into(), - ], - recommended_interval: 604800, - }, - ] - } - - pub fn get_template_by_id(id: &str) -> Option { - Self::get_all_templates().into_iter().find(|t| t.id == id) - } - - /// 将模板任务追加到 HEARTBEAT.md - pub fn apply_template(template: &TaskTemplate, app_data_dir: &Path) -> Result<(), String> { - let heartbeat_file = app_data_dir.join("HEARTBEAT.md"); - let mut content = String::new(); - - if heartbeat_file.exists() { - content = std::fs::read_to_string(&heartbeat_file) - .map_err(|e| format!("读取文件失败: {}", e))?; - if !content.ends_with('\n') { - content.push('\n'); - } - content.push('\n'); - } - - content.push_str(&format!( - "# {} ({})\n\n", - template.name, template.description - )); - for task in &template.tasks { - content.push_str(&format!("- {}\n", task)); - } - - std::fs::write(&heartbeat_file, content).map_err(|e| format!("写入文件失败: {}", e))?; - - Ok(()) - } -} - -/// 内容创作任务生成器 -/// -/// 根据用户的 ContentCreatorConfig 生成对应的心跳任务 -pub struct ContentCreatorTaskGenerator; - -impl ContentCreatorTaskGenerator { - /// 根据启用的主题生成心跳任务 - pub fn generate_tasks(enabled_themes: &[String]) -> Vec { - let mut tasks = Vec::new(); - - for theme in enabled_themes { - match theme.as_str() { - "social-media" => { - tasks - .push("生成 3 条社交媒体内容(Twitter/微博风格) [priority:7]".to_string()); - } - "poster" => { - tasks.push("设计一张海报的文案和布局建议 [priority:6]".to_string()); - } - "novel" => { - tasks - .push("续写小说章节(500-1000字) [priority:5] [timeout:600s]".to_string()); - } - "music" => { - tasks.push("生成歌词创作灵感和主题建议 [priority:6]".to_string()); - } - "video" => { - tasks.push("生成短视频脚本大纲 [priority:6]".to_string()); - } - _ => {} - } - } - - tasks - } - - /// 将生成的任务追加到 HEARTBEAT.md - pub fn append_to_heartbeat(tasks: Vec, app_data_dir: &Path) -> Result<(), String> { - if tasks.is_empty() { - return Ok(()); - } - - let heartbeat_file = app_data_dir.join("HEARTBEAT.md"); - let mut content = String::new(); - - if heartbeat_file.exists() { - content = std::fs::read_to_string(&heartbeat_file) - .map_err(|e| format!("读取文件失败: {}", e))?; - if !content.ends_with('\n') { - content.push('\n'); - } - content.push('\n'); - } - - content.push_str("# 内容创作任务(自动生成)\n\n"); - for task in &tasks { - content.push_str(&format!("- {}\n", task)); - } - - std::fs::write(&heartbeat_file, content).map_err(|e| format!("写入文件失败: {}", e))?; - - Ok(()) - } -} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index cf7d243f4..7c52d8042 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -6,13 +6,15 @@ // 保留在主 crate 的 Tauri 相关服务 pub mod agent_timeline_service; pub mod auto_memory_service; +pub mod automation_service; +pub mod browser_environment_service; +pub mod browser_profile_service; pub mod browser_runtime_window; pub mod chat_history_service; pub mod conversation_statistics_service; pub mod environment_service; pub mod execution_tracker_service; pub mod file_browser_service; -pub mod heartbeat_service; pub mod memory_import_parser_service; pub mod memory_profile_prompt_service; pub mod memory_rules_loader_service; diff --git a/src-tauri/src/services/openclaw_service.rs b/src-tauri/src/services/openclaw_service.rs index f6bea657a..990c96667 100644 --- a/src-tauri/src/services/openclaw_service.rs +++ b/src-tauri/src/services/openclaw_service.rs @@ -156,6 +156,18 @@ pub struct HealthInfo { 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 ChannelInfo { @@ -914,7 +926,7 @@ impl OpenClawService { .spawn() .map_err(|e| format!("启动 Gateway 失败: {e}"))?; - let latest_gateway_error = Arc::new(StdMutex::new(None::)); + let gateway_error_lines = Arc::new(StdMutex::new(Vec::::new())); if let Some(stdout) = child.stdout.take() { let app = app.cloned(); @@ -931,13 +943,13 @@ impl OpenClawService { if let Some(stderr) = child.stderr.take() { let app = app.cloned(); - let latest_gateway_error = latest_gateway_error.clone(); + 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) = latest_gateway_error.lock() { - *slot = Some(line.clone()); + 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")); @@ -957,13 +969,16 @@ impl OpenClawService { while start_at.elapsed() < Duration::from_secs(30) { sleep(Duration::from_millis(300)).await; self.refresh_process_state().await?; - let latest_gateway_error = latest_gateway_error + let gateway_error_lines = gateway_error_lines .lock() .ok() - .and_then(|slot| slot.clone()); + .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(latest_gateway_error.as_deref()); + 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"); } @@ -1004,11 +1019,14 @@ impl OpenClawService { } self.gateway_status = GatewayStatus::Error; - let latest_gateway_error = latest_gateway_error + let gateway_error_lines = gateway_error_lines .lock() .ok() - .and_then(|slot| slot.clone()); - let message = format_gateway_start_failure_message(latest_gateway_error.as_deref()); + .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"); } @@ -1136,6 +1154,176 @@ impl OpenClawService { }) } + pub async fn check_update(&self) -> Result { + let Some(binary) = find_command_in_shell("openclaw").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 mut command = Command::new(&binary); + apply_binary_runtime_path(&mut command, &binary); + let output = command + .arg("update") + .arg("status") + .arg("--json") + .env(OPENCLAW_CONFIG_ENV, openclaw_proxycast_config_path()) + .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(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if !output.status.success() { + let message = first_non_empty_str(&[stderr.as_str(), stdout.as_str()]) + .map(str::to_string) + .or_else(|| Some(format!("检查更新失败,退出码: {:?}", output.status.code()))); + return Ok(UpdateInfo { + has_update: false, + current_version, + latest_version: None, + channel: None, + install_kind: None, + package_manager: None, + message, + }); + } + + let payload: Value = + serde_json::from_slice(&output.stdout).map_err(|e| format!("解析更新状态失败: {e}"))?; + 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(binary) = find_command_in_shell("openclaw").await? else { + return Ok(ActionResult { + success: false, + message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(), + }); + }; + + self.refresh_process_state().await?; + 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 mut command = Command::new(&binary); + apply_binary_runtime_path(&mut command, &binary); + let output = command + .arg("update") + .arg("--yes") + .env(OPENCLAW_CONFIG_ENV, openclaw_proxycast_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")); + } + + if !output.status.success() { + let message = format_openclaw_update_failure_message( + stderr_lines + .last() + .map(String::as_str) + .or_else(|| stdout_lines.last().map(String::as_str)), + ); + emit_install_progress(app, &message, "error"); + return Ok(ActionResult { + success: false, + message, + }); + } + + 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))); + let message = updated_version + .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); @@ -1245,13 +1433,10 @@ impl OpenClawService { "baseUrl": base_url, "apiKey": api_key, "api": api_type, - "models": normalized_models.iter().map(|model| { - json!({ - "id": model.id, - "name": model.name, - "contextWindow": model.context_window, - }) - }).collect::>() + "models": normalized_models + .iter() + .map(sync_model_entry_to_config_value) + .collect::>() }), )), Some(format!("{provider_key}/{primary_model_id}")), @@ -1460,6 +1645,7 @@ impl OpenClawService { let proxycast_config_path = openclaw_proxycast_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(); @@ -2243,6 +2429,45 @@ fn set_json_path(root: &mut Value, path: &[&str], value: Value) { 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( @@ -2292,12 +2517,128 @@ fn gateway_start_args(gateway_port: u16, gateway_auth_token: &str) -> 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 first_non_empty_str<'a>(candidates: &[&'a str]) -> Option<&'a str> { + candidates + .iter() + .copied() + .map(str::trim) + .find(|value| !value.is_empty()) +} + +fn command_output_lines(output: &[u8]) -> Vec { + String::from_utf8_lossy(output) + .lines() + .map(sanitize_progress_line) + .filter(|line| !line.is_empty()) + .collect() +} + +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())) +} + +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("fetch failed") || normalized.contains("network") { + return "OpenClaw 升级失败:当前无法访问更新源,请检查网络或代理设置后重试。".to_string(); + } + + if normalized.contains("not modified") || normalized.contains("already up to date") { + return "OpenClaw 当前已经是最新版本,无需升级。".to_string(); + } + + format!("OpenClaw 升级失败:{detail}") +} + 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 字段。ProxyCast 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。" + .to_string(); + } + return "Gateway 启动失败:OpenClaw 配置文件无效,请重新同步模型配置后再试。".to_string(); + } + if normalized.contains("missing config") || normalized.contains("gateway.mode=local") { return "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。" .to_string(); @@ -2319,6 +2660,11 @@ fn format_gateway_start_failure_message(detail: Option<&str>) -> String { return "Gateway 启动失败:当前绑定方式需要配置 Control UI 允许来源,请检查 gateway.controlUi.allowedOrigins。".to_string(); } + if normalized.contains("doctor --fix") { + return "Gateway 启动失败:OpenClaw 检测到本地环境或配置异常,请先在安装页执行“重新检测”或“修复环境”后再试。" + .to_string(); + } + format!("Gateway 启动失败:{detail}") } @@ -3281,13 +3627,14 @@ mod tests { determine_api_type, extract_gateway_auth_token, find_installed_openclaw_package, format_gateway_start_failure_message, format_provider_base_url, gateway_start_args, has_api_version, npm_global_command_dirs_for, npm_global_node_modules_dirs_for, - parse_semver_from_text, resolve_windows_dependency_install_plan, - select_best_semver_candidate, select_preferred_path_candidate, shell_command_escape_for, - shell_npm_prefix_assignment_for, shell_path_assignment_for, trim_trailing_slash, - windows_dependency_action_result, windows_dependency_setup_message, - windows_install_block_result, windows_manual_install_message, DependencyKind, - DependencyStatus, EnvironmentDiagnostics, ShellPlatform, WindowsDependencyInstallPlan, - NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE, + parse_semver_from_text, resolve_windows_dependency_install_plan, sanitize_runtime_config, + select_best_semver_candidate, select_gateway_start_failure_detail, + select_preferred_path_candidate, shell_command_escape_for, shell_npm_prefix_assignment_for, + shell_path_assignment_for, trim_trailing_slash, windows_dependency_action_result, + windows_dependency_setup_message, windows_install_block_result, + windows_manual_install_message, DependencyKind, DependencyStatus, EnvironmentDiagnostics, + ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, + OPENCLAW_DEFAULT_PACKAGE, }; use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; use chrono::Utc; @@ -3497,6 +3844,68 @@ mod tests { ); } + #[test] + fn sanitizes_null_context_window_from_runtime_config() { + let mut config = json!({ + "models": { + "providers": { + "proxycast-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/proxycast-openai/models/0/contextWindow") + .is_none()); + assert_eq!( + config + .pointer("/models/providers/proxycast-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.proxycast.json:\\n- models.providers.proxycast-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.proxycast.json:\\n- models.providers.proxycast-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.proxycast.json:\\n- models.providers.proxycast-openai.models.0.contextWindow: Invalid input: expected number, received null" + )), + "Gateway 启动失败:当前 OpenClaw 配置包含空的 contextWindow 字段。ProxyCast 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。" + ); + } + #[test] fn parses_semver_from_git_version_text() { assert_eq!( diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 9bf6fa43e..0baa93bcb 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": "ProxyCast", - "version": "0.87.0", + "version": "0.88.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c23878b7c..78aff1780 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": "ProxyCast", - "version": "0.87.0", + "version": "0.88.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tauri.windows.online.conf.json b/src-tauri/tauri.windows.online.conf.json new file mode 100644 index 000000000..4bb2414f4 --- /dev/null +++ b/src-tauri/tauri.windows.online.conf.json @@ -0,0 +1,11 @@ +{ + "bundle": { + "targets": ["nsis"], + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper", + "silent": true + } + } + } +} diff --git a/src/App.tsx b/src/App.tsx index 9d45833f4..1643a6465 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,7 @@ import { StylePage } from "./components/style"; import { AgentChatPage } from "./components/agent"; import { PluginsPage } from "./components/plugins/PluginsPage"; import { ImageGenPage } from "./components/image-gen"; -import { BatchPage } from "./components/batch"; +import { AutomationPage } from "./components/automation"; import { OpenClawPage } from "./components/openclaw"; import { RecentImageInsertFloating } from "./components/image-gen/RecentImageInsertFloating"; import { CreateProjectDialog } from "./components/projects/CreateProjectDialog"; @@ -114,7 +114,6 @@ const THEME_WORKSPACE_PAGES: ThemeWorkspacePage[] = [ "workspace-novel", ]; - function isTauriDesktopEnvironment(): boolean { if (typeof window === "undefined") { return false; @@ -466,11 +465,11 @@ function AppContent() { style={{ flex: 1, minHeight: 0, - display: currentPage === "batch" ? "flex" : "none", + display: currentPage === "automation" ? "flex" : "none", flexDirection: "column", }} > - +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/claw-home/capability-automations-placeholder.svg b/src/assets/claw-home/capability-automations-placeholder.svg new file mode 100644 index 000000000..99043d3f6 --- /dev/null +++ b/src/assets/claw-home/capability-automations-placeholder.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/claw-home/capability-browser-assist-placeholder.svg b/src/assets/claw-home/capability-browser-assist-placeholder.svg new file mode 100644 index 000000000..858b99813 --- /dev/null +++ b/src/assets/claw-home/capability-browser-assist-placeholder.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/claw-home/capability-skills-placeholder.svg b/src/assets/claw-home/capability-skills-placeholder.svg new file mode 100644 index 000000000..dd20900fe --- /dev/null +++ b/src/assets/claw-home/capability-skills-placeholder.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index e0e6fb849..4a95a43e8 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -7,17 +7,10 @@ import { useState, useEffect, useMemo } from "react"; import styled from "styled-components"; import { - Plus, Image, - Compass, - Settings, Moon, Sun, Search, - Library, - Wrench, - BrainCircuit, - Palette, PenTool, Video, Music, @@ -27,10 +20,6 @@ import { FileType, ChevronDown, Activity, - Layers, - Terminal, - Bot, - MessageSquare, LucideIcon, } from "lucide-react"; import * as LucideIcons from "lucide-react"; @@ -39,7 +28,6 @@ import { AgentPageParams, getThemeWorkspacePage, LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, - OpenClawPageParams, Page, PageParams, ThemeWorkspacePage, @@ -50,6 +38,17 @@ import { buildHomeAgentParams, buildWorkspaceResetParams, } from "@/lib/workspace/navigation"; +import { + DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, + FOOTER_SIDEBAR_NAV_ITEMS, + MAIN_SIDEBAR_NAV_ITEMS, + resolveEnabledSidebarNavItems, + type SidebarNavItemDefinition, +} from "@/lib/navigation/sidebarNav"; +import { + DEFAULT_ENABLED_CONTENT_THEME_IDS, + resolveEnabledContentThemes, +} from "@/lib/contentCreator/themeDefaults"; interface AppSidebarProps { currentPage: Page; @@ -57,14 +56,7 @@ interface AppSidebarProps { onNavigate: (page: Page, params?: PageParams) => void; } -interface SidebarNavItem { - id: string; - label: string; - icon: LucideIcon; - page: Page; - params?: PageParams; - isActive?: (currentPage: Page, currentParams?: PageParams) => boolean; -} +type SidebarNavItem = SidebarNavItemDefinition; const Container = styled.aside` display: flex; @@ -235,7 +227,7 @@ const FooterArea = styled.div` const ActionRow = styled.div` display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-end; padding: 0 2px; `; @@ -264,43 +256,6 @@ const IconActionButton = styled.button<{ $active?: boolean }>` } `; -const MAIN_MENU_ITEMS: SidebarNavItem[] = [ - { - id: "home-general", - label: "新建任务", - icon: Plus, - page: "agent", - params: buildHomeAgentParams(), - isActive: (currentPage, currentParams) => - currentPage === "agent" && - (currentParams as AgentPageParams | undefined)?.agentEntry === - "new-task", - }, - { - id: "claw", - label: "Claw", - icon: MessageSquare, - page: "agent", - params: buildClawAgentParams(), - isActive: (currentPage, currentParams) => - currentPage === "agent" && - (currentParams as AgentPageParams | undefined)?.agentEntry !== - "new-task", - }, - { - id: "video", - label: "视频", - icon: Video, - page: getThemeWorkspacePage("video"), - params: { workspaceViewMode: "workspace" }, - isActive: (currentPage) => currentPage === getThemeWorkspacePage("video"), - }, - { id: "image-gen", label: "插图", icon: Image, page: "image-gen" }, - { id: "batch", label: "批量任务", icon: Layers, page: "batch" }, - { id: "terminal", label: "终端", icon: Terminal, page: "terminal" }, - { id: "plugins", label: "插件中心", icon: Compass, page: "plugins" }, -]; - const THEME_MENU_ITEMS: SidebarNavItem[] = [ { id: "theme-social-media", @@ -365,111 +320,6 @@ const THEME_MENU_ITEMS: SidebarNavItem[] = [ }, ]; -const FOOTER_MENU_ITEMS: SidebarNavItem[] = [ - { - id: "openclaw", - label: "OpenClaw", - icon: Bot, - page: "openclaw", - params: { subpage: "runtime" } as OpenClawPageParams, - isActive: (currentPage) => currentPage === "openclaw", - }, - { - id: "settings", - label: "设置", - icon: Settings, - page: "settings", - isActive: (currentPage) => currentPage === "settings", - }, - { - id: "resources", - label: "资源", - icon: Library, - page: "resources", - isActive: (currentPage) => currentPage === "resources", - }, - { - id: "browser-runtime", - label: "浏览器调试", - icon: Activity, - page: "browser-runtime", - isActive: (currentPage) => currentPage === "browser-runtime", - }, - { - id: "tools", - label: "工具箱", - icon: Wrench, - page: "tools", - isActive: (currentPage) => currentPage === "tools", - }, - { - id: "style-library", - label: "我的风格", - icon: Palette, - page: "style", - params: { section: "overview" }, - isActive: (currentPage) => currentPage === "style", - }, - { - id: "memory", - label: "记忆", - icon: BrainCircuit, - page: "memory", - isActive: (currentPage) => currentPage === "memory", - }, -]; - -const DEFAULT_ENABLED_NAV_ITEMS = [ - "home-general", - "claw", - "video", - "image-gen", -]; - -const ALL_NAV_ITEM_IDS = [ - ...MAIN_MENU_ITEMS.map((item) => item.id), - ...FOOTER_MENU_ITEMS.map((item) => item.id), -]; - -const LEGACY_DEFAULT_NAV_ITEM_SETS: string[][] = [ - ["home-general", "video", "image-gen", "plugins"], - ["home-general", "video", "image-gen", "terminal", "plugins"], -]; - -const normalizeEnabledNavItems = (items: string[]): string[] => { - const unique = Array.from(new Set(items)); - return unique.filter((item) => ALL_NAV_ITEM_IDS.includes(item)); -}; - -const hasSameMembers = (left: string[], right: string[]): boolean => { - if (left.length !== right.length) return false; - const rightSet = new Set(right); - return left.every((item) => rightSet.has(item)); -}; - -const isLegacyDefaultEnabledItems = (items: string[]): boolean => { - return LEGACY_DEFAULT_NAV_ITEM_SETS.some((legacyItems) => - hasSameMembers(items, legacyItems), - ); -}; - -const resolveEnabledNavItems = (savedItems?: string[]): string[] => { - if (!savedItems || savedItems.length === 0) { - return [...DEFAULT_ENABLED_NAV_ITEMS]; - } - const normalized = normalizeEnabledNavItems(savedItems); - if (isLegacyDefaultEnabledItems(normalized)) { - return [...DEFAULT_ENABLED_NAV_ITEMS]; - } - const merged = [...normalized]; - for (const item of DEFAULT_ENABLED_NAV_ITEMS) { - if (!merged.includes(item)) { - merged.push(item); - } - } - return merged; -}; - function getIconByName(iconName: string): LucideIcon { const IconComponent = ( LucideIcons as unknown as Record @@ -496,16 +346,11 @@ export function AppSidebar({ }); const [enabledNavItems, setEnabledNavItems] = useState( - DEFAULT_ENABLED_NAV_ITEMS, + DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, + ); + const [enabledThemes, setEnabledThemes] = useState( + DEFAULT_ENABLED_CONTENT_THEME_IDS, ); - const [enabledThemes, setEnabledThemes] = useState([ - "general", - "social-media", - "poster", - "music", - "video", - "novel", - ]); const [sidebarPlugins, setSidebarPlugins] = useState([]); const [refreshTrigger, setRefreshTrigger] = useState(0); const [_activeThemeKey, setActiveThemeKey] = useState( @@ -517,12 +362,10 @@ export function AppSidebar({ try { const config = await getConfig(); const saved = config.navigation?.enabled_items; - setEnabledNavItems(resolveEnabledNavItems(saved)); + setEnabledNavItems(resolveEnabledSidebarNavItems(saved)); const savedThemes = config.content_creator?.enabled_themes; - if (savedThemes && savedThemes.length > 0) { - setEnabledThemes(savedThemes); - } + setEnabledThemes(resolveEnabledContentThemes(savedThemes)); } catch (error) { console.error("加载配置失败:", error); } @@ -544,16 +387,16 @@ export function AppSidebar({ }, []); const filteredMainMenuItems = useMemo(() => { - return MAIN_MENU_ITEMS.filter((item) => enabledNavItems.includes(item.id)); + return MAIN_SIDEBAR_NAV_ITEMS.filter((item) => + enabledNavItems.includes(item.id), + ); }, [enabledNavItems]); const filteredFooterMenuItems = useMemo(() => { - return FOOTER_MENU_ITEMS.filter((item) => { - if (item.id === "tools") { - return enabledNavItems.includes("tools"); - } - return true; - }); + return FOOTER_SIDEBAR_NAV_ITEMS.filter( + (item) => + item.configurable === false || enabledNavItems.includes(item.id), + ); }, [enabledNavItems]); const filteredThemeMenuItems = useMemo(() => { @@ -656,13 +499,13 @@ export function AppSidebar({ ? buildHomeAgentParams(item.params as AgentPageParams | undefined) : item.id === "claw" ? buildClawAgentParams(item.params as AgentPageParams | undefined) - : isThemeWorkspacePage(item.page) - ? buildWorkspaceResetParams( - item.params as AgentPageParams | undefined, - (item.params as AgentPageParams | undefined)?.workspaceViewMode ?? - "project-management", - ) - : item.params; + : isThemeWorkspacePage(item.page) + ? buildWorkspaceResetParams( + item.params as AgentPageParams | undefined, + (item.params as AgentPageParams | undefined) + ?.workspaceViewMode ?? "project-management", + ) + : item.params; onNavigate(item.page, params); }; @@ -752,14 +595,6 @@ export function AppSidebar({ > {theme === "dark" ? : } - - onNavigate("settings")} - title="设置" - > - - diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index b7078b6cf..2271a6133 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -4,71 +4,298 @@ * 应用启动时显示 Logo 动画,然后淡出进入主界面 */ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import styled, { keyframes } from "styled-components"; -const fadeIn = keyframes` - from { opacity: 0; transform: scale(0.9); } +const sceneEnter = keyframes` + from { opacity: 0; transform: scale(0.985); } to { opacity: 1; transform: scale(1); } `; -const fadeOut = keyframes` - from { opacity: 1; } - to { opacity: 0; } +const sceneExit = keyframes` + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(1.015); } +`; + +const panelFloat = keyframes` + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } `; const pulse = keyframes` - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + 0%, 100% { opacity: 0.56; } + 50% { opacity: 1; } +`; + +const progress = keyframes` + 0% { transform: translateX(-42%) scaleX(0.72); opacity: 0.55; } + 50% { transform: translateX(12%) scaleX(1); opacity: 1; } + 100% { transform: translateX(78%) scaleX(0.82); opacity: 0.55; } +`; + +const orbShift = keyframes` + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 50% { transform: translate3d(10px, -12px, 0) scale(1.06); } `; const Container = styled.div<{ $isExiting: boolean }>` position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; display: flex; - flex-direction: column; align-items: center; justify-content: center; - background: linear-gradient( - 135deg, - hsl(var(--background)) 0%, - hsl(var(--muted)) 100% - ); + overflow: hidden; + background: + radial-gradient(circle at 16% 18%, rgba(56, 189, 248, 0.12), transparent 32%), + radial-gradient(circle at 84% 12%, rgba(16, 185, 129, 0.1), transparent 30%), + radial-gradient(circle at 72% 82%, rgba(245, 158, 11, 0.08), transparent 26%), + linear-gradient( + 135deg, + hsl(var(--background)) 0%, + hsl(var(--muted) / 0.84) 48%, + hsl(var(--background)) 100% + ); z-index: 9999; - animation: ${({ $isExiting }) => ($isExiting ? fadeOut : fadeIn)} 0.5s + animation: ${({ $isExiting }) => ($isExiting ? sceneExit : sceneEnter)} 0.55s ease-out forwards; `; -const LogoContainer = styled.div` - display: flex; - flex-direction: column; +const AmbientOrb = styled.div<{ + $size: number; + $top?: string; + $right?: string; + $bottom?: string; + $left?: string; + $color: string; + $delay?: string; +}>` + position: absolute; + width: ${({ $size }) => `${$size}px`}; + height: ${({ $size }) => `${$size}px`}; + top: ${({ $top }) => $top ?? "auto"}; + right: ${({ $right }) => $right ?? "auto"}; + bottom: ${({ $bottom }) => $bottom ?? "auto"}; + left: ${({ $left }) => $left ?? "auto"}; + border-radius: 999px; + background: ${({ $color }) => $color}; + filter: blur(26px); + opacity: 0.8; + animation: ${orbShift} 11s ease-in-out infinite; + animation-delay: ${({ $delay }) => $delay ?? "0s"}; + pointer-events: none; +`; + +const Stage = styled.div` + position: relative; + z-index: 1; + width: min(560px, calc(100vw - 32px)); +`; + +const Panel = styled.div` + position: relative; + overflow: hidden; + border: 1px solid hsl(var(--border) / 0.78); + border-radius: 32px; + padding: 32px; + background: + linear-gradient( + 180deg, + hsl(var(--card) / 0.94) 0%, + hsl(var(--card) / 0.84) 100% + ); + backdrop-filter: blur(18px); + box-shadow: + 0 26px 80px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.36); + animation: ${panelFloat} 5.5s ease-in-out infinite; + + @media (max-width: 640px) { + padding: 24px 20px; + border-radius: 28px; + } +`; + +const PanelGlow = styled.div` + position: absolute; + inset: auto -120px -120px auto; + width: 240px; + height: 240px; + border-radius: 999px; + background: radial-gradient(circle, rgba(56, 189, 248, 0.12), transparent 66%); + pointer-events: none; +`; + +const HeaderPill = styled.div` + display: inline-flex; align-items: center; - gap: 24px; - animation: ${fadeIn} 0.8s ease-out; + border-radius: 999px; + border: 1px solid hsl(var(--border) / 0.72); + background: hsl(var(--card) / 0.92); + padding: 6px 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.18em; + color: hsl(var(--muted-foreground)); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); +`; + +const Hero = styled.div` + position: relative; + display: flex; + align-items: center; + gap: 20px; + margin-top: 18px; + + @media (max-width: 640px) { + flex-direction: column; + align-items: flex-start; + } +`; + +const LogoWrap = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 108px; + height: 108px; + border-radius: 28px; + border: 1px solid hsl(var(--border) / 0.75); + background: + linear-gradient( + 180deg, + hsl(var(--card)) 0%, + hsl(var(--muted) / 0.74) 100% + ); + box-shadow: + 0 18px 34px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.42); + + @media (max-width: 640px) { + width: 96px; + height: 96px; + border-radius: 24px; + } `; const Logo = styled.img` - width: 120px; - height: 120px; + width: 72px; + height: 72px; object-fit: contain; - filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.15)); + filter: drop-shadow(0 16px 28px rgba(15, 23, 42, 0.16)); + + @media (max-width: 640px) { + width: 64px; + height: 64px; + } +`; + +const CopyBlock = styled.div` + flex: 1; + min-width: 0; `; const AppName = styled.h1` - font-size: 32px; - font-weight: 700; - color: hsl(var(--foreground)); margin: 0; + font-size: 38px; + font-weight: 700; + letter-spacing: -0.03em; + color: hsl(var(--foreground)); + + @media (max-width: 640px) { + font-size: 32px; + } +`; + +const Subtitle = styled.p` + margin: 10px 0 0; + font-size: 14px; + line-height: 1.7; + color: hsl(var(--muted-foreground)); +`; + +const MetaRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 24px; +`; + +const MetaPill = styled.div` + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + border: 1px solid hsl(var(--border) / 0.72); + background: hsl(var(--card) / 0.88); + padding: 8px 12px; + font-size: 12px; + font-weight: 500; + color: hsl(var(--muted-foreground)); +`; + +const Dot = styled.span` + width: 6px; + height: 6px; + border-radius: 999px; + background: rgba(14, 165, 233, 0.85); + animation: ${pulse} 1.5s ease-in-out infinite; +`; + +const LoadingArea = styled.div` + margin-top: 26px; + padding-top: 22px; + border-top: 1px solid hsl(var(--border) / 0.7); +`; + +const ProgressTrack = styled.div` + position: relative; + overflow: hidden; + width: 100%; + height: 6px; + border-radius: 999px; + background: hsl(var(--muted) / 0.92); +`; + +const ProgressBar = styled.div` + position: absolute; + inset: 0 auto 0 0; + width: 46%; + border-radius: inherit; + background: + linear-gradient( + 90deg, + rgba(56, 189, 248, 0.82) 0%, + rgba(16, 185, 129, 0.72) 100% + ); + animation: ${progress} 1.8s ease-in-out infinite; +`; + +const LoadingRow = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-top: 14px; + + @media (max-width: 640px) { + flex-direction: column; + align-items: flex-start; + } `; const LoadingText = styled.p` - font-size: 14px; - color: hsl(var(--muted-foreground)); margin: 0; - animation: ${pulse} 1.5s ease-in-out infinite; + font-size: 14px; + font-weight: 600; + color: hsl(var(--foreground)); +`; + +const LoadingHint = styled.p` + margin: 0; + font-size: 12px; + line-height: 1.6; + color: hsl(var(--muted-foreground)); + animation: ${pulse} 1.8s ease-in-out infinite; `; interface SplashScreenProps { @@ -99,11 +326,72 @@ export function SplashScreen({ return ( - - - ProxyCast - 正在加载... - + + + + + + + + CREATIVE WORKBENCH + + + + + + + + ProxyCast + + 正在准备创作工作台与本地运行状态,保持当前节奏,不打断你的上下文。 + + + + + + + + 本地优先 + + + + 创作工作台 + + + + 启动中 + + + + + + + + + + 正在加载... + 首次启动或更新后进入时间可能稍长 + + + + ); } diff --git a/src/components/agent/chat/components/ActionRequestA2UIPreviewCard.tsx b/src/components/agent/chat/components/ActionRequestA2UIPreviewCard.tsx new file mode 100644 index 000000000..f567ad529 --- /dev/null +++ b/src/components/agent/chat/components/ActionRequestA2UIPreviewCard.tsx @@ -0,0 +1,100 @@ +import { cn } from "@/lib/utils"; +import { + CHAT_A2UI_TASK_CARD_PRESET, + TIMELINE_A2UI_TASK_CARD_PRESET, +} from "@/components/content-creator/a2ui/taskCardPresets"; +import type { ActionRequired } from "../types"; +import { + buildActionRequestA2UI, + resolveActionRequestInitialFormData, +} from "../utils/actionRequestA2UI"; +import { A2UITaskCard } from "./A2UITaskCard"; + +interface ActionRequestA2UIPreviewCardProps { + request: ActionRequired; + compact?: boolean; + context?: "chat" | "timeline"; + className?: string; + readOnly?: boolean; +} + +function resolveStatusLabel(request: ActionRequired): string { + switch (request.status) { + case "queued": + return "已记录"; + case "submitted": + return "已确认"; + default: + return "待补充"; + } +} + +function resolveTitle(request: ActionRequired): string { + return request.status === "submitted" || request.status === "queued" + ? "已确认的补充信息" + : "补充信息"; +} + +function resolveSubtitle( + request: ActionRequired, + context: "chat" | "timeline", +): string { + if (request.status === "queued") { + return "答案已记录,等待系统请求就绪后会自动继续执行。"; + } + + if (request.status === "submitted") { + return context === "timeline" + ? "该阶段的问答已完成,回合记录改为结构化回显。" + : "已收到你的补充信息,助手会继续执行后续流程。"; + } + + return context === "timeline" + ? "该阶段需要补充信息,请在输入区表单中完成确认后继续。" + : "请先完成这一步,我再继续当前对话。"; +} + +export function ActionRequestA2UIPreviewCard({ + request, + compact = false, + context = "chat", + className, + readOnly = true, +}: ActionRequestA2UIPreviewCardProps) { + const response = buildActionRequestA2UI(request); + if (!response) { + return null; + } + + const preset = + context === "timeline" + ? TIMELINE_A2UI_TASK_CARD_PRESET + : CHAT_A2UI_TASK_CARD_PRESET; + const previewResponse = + readOnly || request.status === "submitted" || request.status === "queued" + ? { + ...response, + submitAction: undefined, + } + : response; + + return ( + + ); +} + +export default ActionRequestA2UIPreviewCard; diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx new file mode 100644 index 000000000..3740cc670 --- /dev/null +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -0,0 +1,500 @@ +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 { AgentThreadTimeline } from "./AgentThreadTimeline"; +import type { + ActionRequired, + AgentThreadItem, + AgentThreadTurn, +} from "../types"; + +const parseAIResponseMock = vi.fn(); + +vi.mock("@/components/content-creator/a2ui/parser", () => ({ + parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args), +})); + +vi.mock("./MarkdownRenderer", () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( +
{content}
+ ), +})); + +vi.mock("./A2UITaskCard", () => ({ + A2UITaskCard: () =>
, + A2UITaskLoadingCard: () =>
, +})); + +vi.mock("./ToolCallDisplay", () => ({ + ToolCallItem: ({ toolCall }: { toolCall: { name: string } }) => ( +
{toolCall.name}
+ ), +})); + +vi.mock("./DecisionPanel", () => ({ + DecisionPanel: ({ request }: { request: { prompt?: string } }) => ( +
{request.prompt || "decision"}
+ ), +})); + +vi.mock("./AgentPlanBlock", () => ({ + AgentPlanBlock: ({ content }: { content: string }) => ( +
{content}
+ ), +})); + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedHarness[] = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + parseAIResponseMock.mockImplementation((content: string) => ({ + parts: content.trim() ? [{ type: "text", content: content.trim() }] : [], + hasA2UI: false, + hasWriteFile: false, + hasPending: false, + })); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function at(second: number): string { + return `2026-03-15T09:10:${String(second).padStart(2, "0")}Z`; +} + +function createTurn( + overrides?: Partial, +): AgentThreadTurn { + return { + id: "turn-1", + thread_id: "thread-1", + prompt_text: "请检查并发布文章", + status: "completed", + started_at: at(0), + completed_at: at(9), + created_at: at(0), + updated_at: at(9), + ...overrides, + }; +} + +function createBaseItem( + id: string, + sequence: number, +): Pick< + AgentThreadItem, + | "id" + | "thread_id" + | "turn_id" + | "sequence" + | "status" + | "started_at" + | "completed_at" + | "updated_at" +> { + const timestamp = at(sequence); + return { + id, + thread_id: "thread-1", + turn_id: "turn-1", + sequence, + status: "completed", + started_at: timestamp, + completed_at: timestamp, + updated_at: timestamp, + }; +} + +function renderTimeline( + items: AgentThreadItem[], + props?: { + isCurrentTurn?: boolean; + turn?: Partial; + actionRequests?: ActionRequired[]; + }, +): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ container, root }); + return container; +} + +describe("AgentThreadTimeline", () => { + it("应渲染本回合概览与按时序组织的分组块", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("plan-1", 1), + type: "plan", + text: "1. 打开 CDP 页面\n2. 检查登录态", + }, + { + ...createBaseItem("summary-1", 2), + type: "turn_summary", + text: "已完成页面检查\n可以继续执行发布。", + }, + { + ...createBaseItem("browser-1", 3), + type: "tool_call", + tool_name: "browser_navigate", + arguments: { url: "https://mp.weixin.qq.com" }, + }, + { + ...createBaseItem("browser-2", 4), + type: "tool_call", + tool_name: "browser_click", + arguments: { selector: "#publish" }, + }, + { + ...createBaseItem("approval-1", 5), + type: "approval_request", + request_id: "req-1", + action_type: "tool_confirmation", + prompt: "请确认是否发布文章", + tool_name: "browser_click", + }, + { + ...createBaseItem("other-1", 6), + type: "tool_call", + tool_name: "workspace_sync", + }, + ]; + + const container = renderTimeline(items, { isCurrentTurn: true }); + + expect( + container.querySelector('[data-testid="agent-thread-summary"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-summary-shell"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("本回合摘要"); + expect(container.textContent).toContain("4 段流程"); + expect( + container.querySelector('[data-testid="agent-thread-goal"]')?.textContent, + ).toContain("请检查并发布文章"); + expect( + container.querySelector('[data-testid="agent-thread-focus"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("已完成页面检查"); + expect( + container.querySelector('[data-testid="agent-thread-flow"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("思考与计划"); + expect(container.textContent).toContain("浏览器操作"); + expect(container.textContent).toContain("需要你处理"); + expect(container.textContent).toContain("技术细节"); + }); + + it("审批块应默认展开,技术细节块默认折叠", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("approval-1", 1), + type: "approval_request", + request_id: "req-1", + action_type: "tool_confirmation", + prompt: "请确认是否继续", + tool_name: "browser_click", + }, + { + ...createBaseItem("other-1", 2), + type: "tool_call", + tool_name: "workspace_sync", + }, + ]; + + const container = renderTimeline(items); + const approvalGroup = container.querySelector( + '[data-testid="agent-thread-block:1:approval"]', + ); + const otherGroup = container.querySelector( + '[data-testid="agent-thread-block:2:other"]', + ); + + expect(approvalGroup?.hasAttribute("open")).toBe(true); + expect(otherGroup?.hasAttribute("open")).toBe(false); + expect( + container.querySelector('[data-testid="agent-thread-block:1:approval:rail"]'), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="agent-thread-block:1:approval:details"]', + ), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="agent-thread-block:2:other:details"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain("低优先级技术细节"); + }); + + it("应按真实发生顺序渲染思考与工具块", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("browser-1", 1), + type: "tool_call", + tool_name: "browser_navigate", + arguments: { url: "https://example.com" }, + }, + { + ...createBaseItem("summary-1", 2), + type: "turn_summary", + text: "页面已打开", + }, + { + ...createBaseItem("search-1", 3), + type: "web_search", + action: "web_search", + query: "封面尺寸", + }, + ]; + + const container = renderTimeline(items); + const blockIds = Array.from( + container.querySelectorAll( + "details[data-testid^='agent-thread-block:']", + ), + ) + .map((node) => node.dataset.testid) + .filter((value): value is string => Boolean(value)); + + expect(blockIds).toEqual([ + "agent-thread-block:1:browser", + "agent-thread-block:2:thinking", + "agent-thread-block:3:search", + ]); + }); + + it("运行中的块应被高亮,已完成块应降噪", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("browser-1", 1), + type: "tool_call", + tool_name: "browser_navigate", + arguments: { url: "https://example.com" }, + }, + { + ...createBaseItem("search-1", 2), + status: "in_progress", + completed_at: undefined, + updated_at: at(2), + type: "web_search", + action: "web_search", + query: "Mac mini 最新价格", + }, + { + ...createBaseItem("other-1", 3), + type: "tool_call", + tool_name: "workspace_sync", + }, + ]; + + const container = renderTimeline(items, { isCurrentTurn: true }); + const browserBlock = container.querySelector( + '[data-testid="agent-thread-block:1:browser"]', + ); + const searchBlock = container.querySelector( + '[data-testid="agent-thread-block:2:search"]', + ); + const otherBlock = container.querySelector( + '[data-testid="agent-thread-block:3:other"]', + ); + + expect(browserBlock?.dataset.emphasis).toBe("quiet"); + expect(searchBlock?.dataset.emphasis).toBe("active"); + expect(otherBlock?.dataset.emphasis).toBe("quiet"); + expect(browserBlock?.hasAttribute("open")).toBe(true); + expect(searchBlock?.hasAttribute("open")).toBe(true); + expect(otherBlock?.hasAttribute("open")).toBe(false); + expect(container.textContent).toContain("执行中"); + }); + + it("浏览器前置等待时不应显示已中断,而应显示待继续", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("browser-1", 1), + type: "tool_call", + tool_name: "browser_navigate", + arguments: { url: "https://mp.weixin.qq.com" }, + }, + ]; + + const container = renderTimeline(items, { + turn: { + status: "aborted", + }, + actionRequests: [ + { + requestId: "req-browser", + actionType: "ask_user", + status: "pending", + uiKind: "browser_preflight", + browserPrepState: "awaiting_user", + prompt: "请先在浏览器完成登录。", + detail: "浏览器已经打开,请先完成登录、扫码或验证码后继续。", + }, + ], + }); + + expect(container.textContent).toContain("待继续"); + expect(container.textContent).toContain("完成登录"); + expect(container.textContent).not.toContain("已中断"); + expect( + container + .querySelector('[data-testid="agent-thread-block:1:browser"]') + ?.hasAttribute("open"), + ).toBe(true); + }); + + it("普通 aborted 回合应显示已暂停,而不是已中断", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("other-1", 1), + type: "tool_call", + tool_name: "workspace_sync", + }, + ]; + + const container = renderTimeline(items, { + turn: { + status: "aborted", + }, + }); + + expect(container.textContent).toContain("已暂停"); + expect(container.textContent).not.toContain("已中断"); + }); + + it("单个已完成阶段不应再默认展开", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("summary-1", 1), + type: "turn_summary", + text: "已整理为 notebook 工作方式。", + }, + ]; + + const container = renderTimeline(items, { + isCurrentTurn: true, + turn: { + status: "completed", + }, + }); + + expect( + container + .querySelector('[data-testid="agent-thread-block:1:thinking"]') + ?.hasAttribute("open"), + ).toBe(false); + }); + + it("思考摘要中的 A2UI 代码块应切换为结构化预览", () => { + parseAIResponseMock.mockReturnValue({ + parts: [ + { type: "text", content: "请先确认以下选项:" }, + { + type: "a2ui", + content: { + id: "form-1", + root: "root", + components: [], + submitAction: { + label: "提交", + action: { name: "submit" }, + }, + }, + }, + ], + hasA2UI: true, + hasWriteFile: false, + hasPending: false, + }); + + const items: AgentThreadItem[] = [ + { + ...createBaseItem("summary-1", 1), + status: "in_progress", + completed_at: undefined, + updated_at: at(1), + type: "turn_summary", + text: "```a2ui\n{}\n```", + }, + ]; + + const container = renderTimeline(items, { + isCurrentTurn: true, + turn: { + status: "running", + }, + }); + + expect( + container.querySelector('[data-testid="timeline-a2ui-card"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("请先确认以下选项:"); + expect(container.textContent).not.toContain("```a2ui"); + }); + + it("已完成的 request_user_input 应以只读 A2UI 卡片回显", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("input-1", 1), + type: "request_user_input", + request_id: "req-ask-1", + action_type: "ask_user", + prompt: "请选择执行模式", + questions: [ + { + question: "请选择执行模式", + options: [{ label: "自动执行" }, { label: "确认后执行" }], + }, + ], + response: { answer: "自动执行" }, + }, + ]; + + const container = renderTimeline(items, { + turn: { + status: "completed", + }, + }); + + expect( + container.querySelector('[data-testid="timeline-a2ui-card"]'), + ).not.toBeNull(); + expect(container.querySelector('[data-testid="decision-panel"]')).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 19b4a3f78..7e8a2e5ab 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -22,7 +22,18 @@ import type { AgentThreadTurn, ConfirmResponse, } from "../types"; +import { + buildAgentThreadDisplayModel, + type AgentThreadOrderedBlock, + type AgentThreadSummaryChip, +} from "../utils/agentThreadGrouping"; +import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI"; +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 { MarkdownRenderer } from "./MarkdownRenderer"; +import { ActionRequestA2UIPreviewCard } from "./ActionRequestA2UIPreviewCard"; +import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard"; import { ToolCallItem } from "./ToolCallDisplay"; import { DecisionPanel } from "./DecisionPanel"; import { AgentPlanBlock } from "./AgentPlanBlock"; @@ -30,11 +41,35 @@ import { AgentPlanBlock } from "./AgentPlanBlock"; interface AgentThreadTimelineProps { turn: AgentThreadTurn; items: AgentThreadItem[]; + actionRequests?: ActionRequired[]; isCurrentTurn?: boolean; onFileClick?: (fileName: string, content: string) => void; onPermissionResponse?: (response: ConfirmResponse) => void; } +interface TurnStatusMeta { + label: string; + badgeVariant: "secondary" | "outline" | "destructive"; + badgeClassName?: string; + overviewText: string; +} + +function shortenInlineText( + value: string | undefined | null, + maxLength = 72, +): string | null { + const normalized = (value || "").trim().replace(/\s+/g, " "); + if (!normalized) { + return null; + } + + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + function formatTimestamp(value?: string): string | null { if (!value) { return null; @@ -214,7 +249,236 @@ function resolveStatusBadgeVariant( return status === "completed" ? "outline" : "secondary"; } -function TimelineCard({ +function findLatestPendingAction( + actionRequests: ActionRequired[] | undefined, +): ActionRequired | null { + if (!actionRequests?.length) { + return null; + } + + for (let index = actionRequests.length - 1; index >= 0; index -= 1) { + const actionRequest = actionRequests[index]; + if (actionRequest.status !== "submitted") { + return actionRequest; + } + } + + return null; +} + +function findLatestPendingItemAction(items: AgentThreadItem[]) { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if ( + (item.type === "approval_request" || item.type === "request_user_input") && + item.status !== "completed" + ) { + return item; + } + } + + return null; +} + +function resolvePendingItemOverview(item: AgentThreadItem): string { + if ( + (item.type === "approval_request" || item.type === "request_user_input") && + item.prompt?.trim() + ) { + return item.prompt.trim(); + } + + if (item.type === "request_user_input") { + const firstQuestion = item.questions?.find((question) => question.question?.trim()); + if (firstQuestion?.question) { + return firstQuestion.question.trim(); + } + } + + return "当前回合暂停在待确认步骤,处理后会继续后续流程。"; +} + +function resolveItemStatusLabel(status: AgentThreadItem["status"]): string { + switch (status) { + case "in_progress": + return "执行中"; + case "failed": + return "失败"; + case "completed": + default: + return "已完成"; + } +} + +function resolveGroupIcon( + kind: AgentThreadOrderedBlock["kind"], +): React.ComponentType<{ className?: string }> { + switch (kind) { + case "thinking": + return Sparkles; + case "approval": + return ShieldAlert; + case "alert": + return AlertTriangle; + case "browser": + return Globe; + case "search": + return Search; + case "file": + return FileText; + case "command": + return TerminalSquare; + case "subagent": + return Bot; + case "other": + default: + return Wrench; + } +} + +function resolveOverviewText( + turn: AgentThreadTurn, + summaryText: string | null, + actionableCount: number, +): string { + if (summaryText) { + return summaryText; + } + + if (turn.status === "running") { + return actionableCount > 0 + ? "正在处理你的请求,执行轨迹会持续更新。" + : "正在准备执行上下文。"; + } + + if (turn.status === "failed") { + return turn.error_message || "本回合执行失败,请查看下方异常分组。"; + } + + return actionableCount > 0 + ? "已整理本回合的关键执行过程。" + : "本回合没有记录额外的执行轨迹。"; +} + +function resolveTurnStatusMeta(params: { + turn: AgentThreadTurn; + items: AgentThreadItem[]; + actionRequests?: ActionRequired[]; + summaryText: string | null; + actionableCount: number; +}): TurnStatusMeta { + const { + turn, + items, + actionRequests, + summaryText, + actionableCount, + } = params; + const pendingAction = findLatestPendingAction(actionRequests); + + if (pendingAction?.uiKind === "browser_preflight") { + const phase = pendingAction.browserPrepState || "idle"; + + if (phase === "launching") { + return { + label: "连接浏览器", + badgeVariant: "secondary", + badgeClassName: + "bg-sky-100 text-sky-800 dark:bg-sky-500/15 dark:text-sky-200", + overviewText: + pendingAction.detail?.trim() || + "正在建立浏览器会话,连接成功后会继续当前回合。", + }; + } + + if (phase === "awaiting_user" || phase === "ready_to_resume") { + return { + label: "待继续", + badgeVariant: "secondary", + badgeClassName: + "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200", + overviewText: + pendingAction.detail?.trim() || + pendingAction.prompt?.trim() || + "浏览器已经打开,等待你完成登录、授权或验证后继续。", + }; + } + + return { + label: "浏览器未就绪", + badgeVariant: "secondary", + badgeClassName: + "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200", + overviewText: + pendingAction.detail?.trim() || + pendingAction.prompt?.trim() || + "浏览器/CDP 还未连接,请重试启动后继续。", + }; + } + + if (pendingAction) { + return { + label: "待处理", + badgeVariant: "secondary", + badgeClassName: + "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200", + overviewText: + pendingAction.prompt?.trim() || + "当前回合暂停在待确认步骤,处理后会继续后续流程。", + }; + } + + const pendingItemAction = findLatestPendingItemAction(items); + if (pendingItemAction) { + return { + label: "待处理", + badgeVariant: "secondary", + badgeClassName: + "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200", + overviewText: resolvePendingItemOverview(pendingItemAction), + }; + } + + switch (turn.status) { + case "running": + return { + label: "执行中", + badgeVariant: "secondary", + overviewText: resolveOverviewText(turn, summaryText, actionableCount), + }; + case "failed": + return { + label: "失败", + badgeVariant: "destructive", + overviewText: + turn.error_message || "本回合执行失败,请查看下方异常分组。", + }; + case "aborted": + return { + label: "已暂停", + badgeVariant: "outline", + overviewText: + turn.error_message || "本回合已暂停,你可以继续处理或发起下一轮。", + }; + case "completed": + default: + return { + label: "已完成", + badgeVariant: "outline", + overviewText: resolveOverviewText(turn, summaryText, actionableCount), + }; + } +} + +function stringifyItemForDebug(item: AgentThreadItem): string { + try { + return JSON.stringify(item, null, 2); + } catch { + return String(item); + } +} + +function SurfaceCard({ icon: Icon, title, badge, @@ -244,9 +508,545 @@ function TimelineCard({ ); } +function SummaryChip({ + chip, +}: { + chip: AgentThreadSummaryChip; +}) { + return ( +
+ {chip.label} + {chip.count} +
+ ); +} + +function ThinkingItemCard({ + item, +}: { + item: Extract; +}) { + const parsedContent = useMemo(() => parseAIResponse(item.text, false), [item.text]); + const title = + item.type === "reasoning" + ? "思考摘要" + : item.status === "in_progress" + ? "执行准备" + : "阶段总结"; + const hasStructuredPreview = parsedContent.hasA2UI || parsedContent.hasPending; + + const content = hasStructuredPreview ? ( +
+ {parsedContent.parts.map((part, index) => { + if (part.type === "a2ui" && typeof part.content !== "string") { + const readonlyResponse: A2UIResponse = { + ...part.content, + submitAction: undefined, + }; + + return ( + + ); + } + + if (part.type === "pending_a2ui") { + return ( + + ); + } + + const textContent = + typeof part.content === "string" ? part.content.trim() : ""; + if (!textContent) { + return null; + } + + return ( + + ); + })} +
+ ) : ( + + ); + + return ( + + {item.status === "in_progress" ? ( + + + 整理中 + + ) : ( + resolveItemStatusLabel(item.status) + )} + + } + timestamp={formatTimestamp(item.completed_at || item.updated_at)} + > + {content} + + ); +} + +function renderThinkingItemDetails(item: AgentThreadItem) { + if (item.type === "plan") { + return ( + + ); + } + + if (item.type === "reasoning" || item.type === "turn_summary") { + return ; + } + + return null; +} + +function renderGroupItemDetails( + item: AgentThreadItem, + onFileClick?: (fileName: string, content: string) => void, + onPermissionResponse?: (response: ConfirmResponse) => void, +) { + const toolCall = toToolCallState(item); + const actionRequest = toActionRequired(item); + const timestamp = formatTimestamp(item.completed_at || item.updated_at); + + if (actionRequest) { + if (isActionRequestA2UICompatible(actionRequest)) { + return ( + + ); + } + + return ( + onPermissionResponse?.(response)} + /> + ); + } + + if (toolCall) { + return ( +
+ +
+ ); + } + + if (item.type === "file_artifact") { + return ( + + ); + } + + if (item.type === "subagent_activity") { + return ( + + {item.status_label} + + } + timestamp={timestamp} + > + {item.summary ? ( +
{item.summary}
+ ) : null} + {item.role || item.model ? ( +
+ {item.role ? {item.role} : null} + {item.model ? {item.model} : null} +
+ ) : null} +
+ ); + } + + if (item.type === "warning" || item.type === "error") { + return ( + + {item.type === "warning" ? item.code || "warning" : "失败"} + + } + timestamp={timestamp} + > +
+ {item.message} +
+
+ ); + } + + return ( +
+
+ {item.type} + + {resolveItemStatusLabel(item.status)} + +
+
+        {stringifyItemForDebug(item)}
+      
+
+ ); +} + +function isCompactTechnicalBlock(block: AgentThreadOrderedBlock): boolean { + return block.kind === "other" && block.status === "completed"; +} + +function resolveCompactTechnicalSummary(block: AgentThreadOrderedBlock): string { + const firstPreview = block.previewLines[0]; + if (firstPreview) { + return `已收纳 ${block.items.length} 项低优先级技术细节,最近一项:${firstPreview}`; + } + return `已收纳 ${block.items.length} 项低优先级技术细节。`; +} + +function resolveActiveBlockIndex(blocks: AgentThreadOrderedBlock[]): number { + for (let index = blocks.length - 1; index >= 0; index -= 1) { + if (blocks[index]?.status === "in_progress") { + return index; + } + } + + return -1; +} + +function findLastBlockIndex( + blocks: AgentThreadOrderedBlock[], + predicate: (block: AgentThreadOrderedBlock) => boolean, +): number { + for (let index = blocks.length - 1; index >= 0; index -= 1) { + if (predicate(blocks[index])) { + return index; + } + } + + return -1; +} + +function resolveFocusBlockIndex(params: { + blocks: AgentThreadOrderedBlock[]; + turn: AgentThreadTurn; + actionRequests?: ActionRequired[]; + activeBlockIndex: number; +}): number { + const { blocks, turn, actionRequests, activeBlockIndex } = params; + + if (blocks.length === 0) { + return -1; + } + + if (activeBlockIndex >= 0) { + return activeBlockIndex; + } + + const pendingAction = findLatestPendingAction(actionRequests); + + if (pendingAction?.uiKind === "browser_preflight") { + const browserIndex = findLastBlockIndex( + blocks, + (block) => block.kind === "browser", + ); + if (browserIndex >= 0) { + return browserIndex; + } + } + + if (pendingAction) { + const pendingIndex = findLastBlockIndex( + blocks, + (block) => block.kind === "approval" || block.kind === "alert", + ); + if (pendingIndex >= 0) { + return pendingIndex; + } + } + + if (turn.status === "failed" || turn.status === "aborted") { + const failedIndex = findLastBlockIndex( + blocks, + (block) => block.status === "failed" || block.kind === "alert", + ); + if (failedIndex >= 0) { + return failedIndex; + } + } + + const lastMeaningfulIndex = findLastBlockIndex( + blocks, + (block) => block.kind !== "other", + ); + if (lastMeaningfulIndex >= 0) { + return lastMeaningfulIndex; + } + + return blocks.length - 1; +} + +function resolveExpandedBlockIndexes(params: { + blocks: AgentThreadOrderedBlock[]; + isCurrentTurn: boolean; + focusBlockIndex: number; + turn: AgentThreadTurn; +}): Set { + const { blocks, isCurrentTurn, focusBlockIndex, turn } = params; + const expanded = new Set(); + + blocks.forEach((block, index) => { + if (block.defaultExpanded) { + expanded.add(index); + } + }); + + if (focusBlockIndex >= 0) { + const focusBlock = blocks[focusBlockIndex]; + const shouldExpandFocus = + focusBlock?.status !== "completed" || + turn.status === "running" || + turn.status === "failed" || + turn.status === "aborted"; + + if (shouldExpandFocus) { + expanded.add(focusBlockIndex); + } + + if (shouldExpandFocus && isCurrentTurn && focusBlockIndex > 0) { + const previousBlock = blocks[focusBlockIndex - 1]; + if (previousBlock?.kind !== "other") { + expanded.add(focusBlockIndex - 1); + } + } + } + + return expanded; +} + +function TimelineBlockCard({ + block, + index, + isLast, + emphasis, + isExpanded, + onFileClick, + onPermissionResponse, +}: { + block: AgentThreadOrderedBlock; + index: number; + isLast: boolean; + emphasis: "active" | "default" | "quiet"; + isExpanded: boolean; + onFileClick?: (fileName: string, content: string) => void; + onPermissionResponse?: (response: ConfirmResponse) => void; +}) { + const Icon = resolveGroupIcon(block.kind); + const timestamp = formatTimestamp(block.startedAt); + const dataTestId = `agent-thread-block:${index + 1}:${block.kind}`; + const isCompact = isCompactTechnicalBlock(block); + const isActive = emphasis === "active"; + const isQuiet = emphasis === "quiet"; + const stageLabel = `阶段 ${String(index + 1).padStart(2, "0")}`; + + return ( +
+ {!isLast ? ( +
+ ) : null} +
+
+ {index + 1} +
+
+ +
+
+
+ +
+
+ + {stageLabel} + + + {block.title} + + {block.countLabel} + + {block.status === "in_progress" ? ( + + + {resolveItemStatusLabel(block.status)} + + ) : ( + resolveItemStatusLabel(block.status) + )} + + {timestamp ? ( + + {timestamp} + + ) : null} +
+ {isCompact ? ( +
+ {resolveCompactTechnicalSummary(block)} +
+ ) : block.previewLines.length > 0 ? ( +
+ {block.previewLines.map((line) => ( +
+ {line} +
+ ))} +
+ ) : ( +
+ 已归档该分组的执行细节。 +
+ )} +
+
+ + {isCompact ? "展开查看" : block.rawDetailLabel} + + +
+
+
+ {block.items.map((item) => ( +
+ {block.kind === "thinking" + ? renderThinkingItemDetails(item) + : renderGroupItemDetails(item, onFileClick, onPermissionResponse)} +
+ ))} +
+
+
+ ); +} + export const AgentThreadTimeline: React.FC = ({ turn, items, + actionRequests = [], isCurrentTurn = false, onFileClick, onPermissionResponse, @@ -259,243 +1059,157 @@ export const AgentThreadTimeline: React.FC = ({ [items], ); + const displayModel = useMemo( + () => buildAgentThreadDisplayModel(visibleItems), + [visibleItems], + ); + const actionableCount = displayModel.groups.reduce( + (count, group) => count + group.items.length, + 0, + ); + const flowBlockCount = displayModel.orderedBlocks.length; + const activeBlockIndex = resolveActiveBlockIndex(displayModel.orderedBlocks); + const focusBlockIndex = resolveFocusBlockIndex({ + blocks: displayModel.orderedBlocks, + turn, + actionRequests, + activeBlockIndex, + }); + const expandedBlockIndexes = resolveExpandedBlockIndexes({ + blocks: displayModel.orderedBlocks, + isCurrentTurn, + focusBlockIndex, + turn, + }); + const focusBlock = + focusBlockIndex >= 0 ? displayModel.orderedBlocks[focusBlockIndex] : null; + const focusBlockStageLabel = + focusBlockIndex >= 0 + ? `阶段 ${String(focusBlockIndex + 1).padStart(2, "0")}` + : null; + const promptPreview = shortenInlineText(turn.prompt_text, 78); + const turnStatusMeta = resolveTurnStatusMeta({ + turn, + items: visibleItems, + actionRequests, + summaryText: displayModel.summaryText, + actionableCount, + }); + if (visibleItems.length === 0) { return null; } return ( -
-
-
执行轨迹
- {isCurrentTurn ? 当前回合 : null} - - {turn.status === "running" - ? "执行中" - : turn.status === "failed" - ? "失败" - : turn.status === "aborted" - ? "已中断" - : "已完成"} - -
- - {formatTimestamp(turn.started_at) || "刚刚"} +
+
+
+ +
+
+
+
+ 本回合摘要 +
+ {flowBlockCount} 段流程 + {isCurrentTurn ? 当前回合 : null} + + {turnStatusMeta.label} + +
+ + {formatTimestamp(turn.started_at) || "刚刚"} +
+
+ +
+ {turnStatusMeta.overviewText} +
+ + {promptPreview || focusBlock ? ( +
+ {promptPreview ? ( +
+
+ 用户目标 +
+
+ {promptPreview} +
+
+ ) : null} + + {focusBlock ? ( +
+
+ 当前聚焦 +
+
+ {focusBlockStageLabel ? ( + {focusBlockStageLabel} + ) : null} + + {focusBlock.title} + +
+ {focusBlock.previewLines[0] ? ( +
+ {focusBlock.previewLines[0]} +
+ ) : null} +
+ ) : null} +
+ ) : null} + + {displayModel.summaryChips.length > 0 ? ( +
+ {displayModel.summaryChips.map((chip) => ( + + ))} +
+ ) : null} + + {turn.error_message && + turnStatusMeta.badgeVariant === "destructive" ? ( +
+ {turn.error_message} +
+ ) : null}
-
- {visibleItems.map((item) => { - const timestamp = formatTimestamp(item.completed_at || item.updated_at); - const actionRequest = toActionRequired(item); - const toolCall = toToolCallState(item); - - if (item.type === "plan") { - return ( - - ); - } - - if (item.type === "reasoning") { - return ( -
- - - 思考摘要 - - {item.status === "in_progress" ? ( - - - 推理中 - - ) : item.status === "failed" ? ( - "推理失败" - ) : ( - "已整理" - )} - - - -
- -
-
- ); - } - - if (toolCall) { - return ( -
- -
- ); - } - - if (actionRequest) { - return ( -
- onPermissionResponse?.(response)} - /> -
- ); - } - - if (item.type === "file_artifact") { - return ( - - {item.source} - - } - timestamp={timestamp} - > - - - ); - } - - if (item.type === "subagent_activity") { - return ( - - {item.status_label} - - } - timestamp={timestamp} - > - {item.summary ? ( -
{item.summary}
- ) : null} - {item.role || item.model ? ( -
- {item.role ? {item.role} : null} - {item.model ? {item.model} : null} -
- ) : null} -
- ); - } - - if (item.type === "turn_summary") { - return ( - - - 进行中 - - ) : ( - 摘要 - ) - } - timestamp={timestamp} - > - - - ); - } - - if (item.type === "warning") { - return ( - {item.code || "warning"}} - timestamp={timestamp} - > -
{item.message}
-
- ); - } - - if (item.type === "error") { - return ( - 失败} - timestamp={timestamp} - > -
{item.message}
-
- ); - } - - return ( - - {item.status} - - } - timestamp={timestamp} - > -
- 该事件类型已记录到 timeline 中。 -
-
- ); - })} +
+ {displayModel.orderedBlocks.map((block, index) => ( + + ))}
); diff --git a/src/components/agent/chat/components/ChatNavbar.test.tsx b/src/components/agent/chat/components/ChatNavbar.test.tsx index 010d3b652..418f5c6a0 100644 --- a/src/components/agent/chat/components/ChatNavbar.test.tsx +++ b/src/components/agent/chat/components/ChatNavbar.test.tsx @@ -184,7 +184,7 @@ describe("ChatNavbar", () => { }); const button = container.querySelector( - 'button[aria-label="打开浏览器协助"]', + 'button[aria-label="在右侧画布打开浏览器协助"]', ) as HTMLButtonElement | null; expect(button).not.toBeNull(); @@ -197,6 +197,54 @@ describe("ChatNavbar", () => { expect(onOpenBrowserAssist).toHaveBeenCalledTimes(1); }); + it("应支持显示浏览器协助状态文案", () => { + const container = renderChatNavbar({ + showBrowserAssistEntry: true, + browserAssistLabel: "等待登录", + }); + + const button = container.querySelector( + 'button[aria-label="在右侧画布打开浏览器协助"]', + ) as HTMLButtonElement | null; + + expect(button?.textContent).toContain("等待登录"); + }); + + it("浏览器待继续时顶栏按钮应显示恢复态语义", () => { + const container = renderChatNavbar({ + showBrowserAssistEntry: true, + browserAssistAttentionLevel: "warning", + browserAssistLabel: "等待登录", + }); + + const button = container.querySelector( + 'button[aria-label="恢复浏览器协助"]', + ) as HTMLButtonElement | null; + + expect(button).not.toBeNull(); + expect(button?.textContent).toContain("等待登录"); + expect(button?.className).toContain("border-amber-300"); + expect(button?.className).toContain("text-amber-800"); + }); + + it("浏览器启动中时顶栏按钮应显示启动态语义", () => { + const container = renderChatNavbar({ + showBrowserAssistEntry: true, + browserAssistAttentionLevel: "info", + browserAssistLoading: true, + }); + + const button = container.querySelector( + 'button[aria-label="查看浏览器启动状态"]', + ) as HTMLButtonElement | null; + + expect(button).not.toBeNull(); + expect(button?.disabled).toBe(true); + expect(button?.textContent).toContain("启动中..."); + expect(button?.className).toContain("border-sky-300"); + expect(button?.className).toContain("text-sky-800"); + }); + it("通用对话项目选择器应启用管理能力", () => { renderChatNavbar({ workspaceType: "general", @@ -208,6 +256,27 @@ describe("ChatNavbar", () => { | Record | undefined; expect(lastCall?.enableManagement).toBe(true); + expect(lastCall?.density).toBe("compact"); + expect(lastCall?.chrome).toBe("embedded"); + }); + + it("应支持从右上角工具组打开设置", () => { + const onToggleSettings = vi.fn(); + const container = renderChatNavbar({ + onToggleSettings, + }); + + const button = container.querySelector( + 'button[aria-label="打开设置"]', + ) as HTMLButtonElement | null; + + expect(button).not.toBeNull(); + + act(() => { + button?.click(); + }); + + expect(onToggleSettings).toHaveBeenCalledTimes(1); }); it("应支持在顶栏展开和折叠画布", () => { diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index 79c156d7b..e68ee1249 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -49,9 +49,40 @@ interface ChatNavbarProps { showBrowserAssistEntry?: boolean; browserAssistActive?: boolean; browserAssistLoading?: boolean; + browserAssistAttentionLevel?: "idle" | "info" | "warning"; + browserAssistLabel?: string; onOpenBrowserAssist?: () => void; } +function resolveBrowserAssistTitle( + attentionLevel: NonNullable, +): string { + if (attentionLevel === "warning") { + return "恢复浏览器协助"; + } + + if (attentionLevel === "info") { + return "查看浏览器启动状态"; + } + + return "在右侧画布打开浏览器协助"; +} + +const toolbarGroupClassName = + "flex items-center rounded-full border border-slate-200/80 bg-white/94 p-1 shadow-sm shadow-slate-950/5"; + +const toolbarDividerClassName = + "mx-1 h-5 w-px shrink-0 bg-slate-200/80"; + +const toolbarEmbeddedButtonClassName = + "h-8 rounded-full border border-transparent px-3 text-xs shadow-none"; + +const toolbarGhostIconButtonClassName = + "h-8 w-8 rounded-full text-muted-foreground hover:bg-slate-100"; + +const toolbarTextButtonClassName = + "gap-1.5 text-slate-700 hover:bg-slate-50 hover:text-slate-900"; + export const ChatNavbar: React.FC = ({ isRunning: _isRunning, onToggleHistory, @@ -77,190 +108,264 @@ export const ChatNavbar: React.FC = ({ showBrowserAssistEntry = false, browserAssistActive = false, browserAssistLoading = false, + browserAssistAttentionLevel = "idle", + browserAssistLabel, onOpenBrowserAssist, }) => { + const browserAssistTitle = resolveBrowserAssistTitle( + browserAssistAttentionLevel, + ); + const showStatusTools = showBrowserAssistEntry || showHarnessToggle; + const showNavigationTools = + Boolean(onBackHome) || + Boolean(onBackToResources) || + Boolean(onBackToProjectManagement); + const showWorkspaceTools = showHistoryToggle || showCanvasToggle || Boolean(novelCanvasControls); + return (
- {onBackHome && ( - - )} - {onBackToResources && ( - - )} - {showHistoryToggle && ( - - )} - {showCanvasToggle ? ( - )} - + {onBackHome && (onBackToResources || onBackToProjectManagement) ? ( +