From 0ee56c10980ccb11a23d08347c4e110532a5dbb7 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 25 Mar 2026 22:04:38 +0800 Subject: [PATCH] chore: release v0.96.0 --- .github/workflows/quality.yml | 217 +++ .gitignore | 6 + AGENTS.md | 202 +- RELEASE_NOTES.md | 34 +- docs/README.md | 1 + docs/aiprompts/README.md | 120 +- docs/aiprompts/commands.md | 243 ++- docs/aiprompts/content-creator.md | 6 +- docs/aiprompts/credential-pool.md | 23 +- docs/aiprompts/governance.md | 319 ++-- .../aiprompts/limecore-collaboration-entry.md | 49 + docs/aiprompts/playwright-e2e.md | 167 +- docs/aiprompts/quality-workflow.md | 242 +++ docs/develop/ai-agent-development-guide.md | 2 +- ...lime-borrow-codex-engineering-practices.md | 541 ++++++ .../artifacts/architecture-blueprint.md | 744 ++++++++ .../roadmap/artifacts/artifact-document-v1.md | 777 ++++++++ docs/roadmap/artifacts/framework-boundary.md | 356 ++++ docs/roadmap/artifacts/roadmap.md | 903 +++++++++ .../system-prompt-and-schema-contract.md | 402 ++++ extensions/lime-chrome/background.js | 76 +- extensions/lime-chrome/content_script.js | 568 +++--- index.html | 1 + package.json | 4 +- scripts/local-ci.mjs | 200 +- scripts/quality-task-planner.mjs | 362 ++++ scripts/quality-task-selector.mjs | 98 + scripts/verify-gui-smoke.mjs | 375 ++++ src-tauri/Cargo.lock | 62 +- src-tauri/Cargo.toml | 8 +- .../crates/agent/src/artifact_protocol.rs | 218 +++ src-tauri/crates/agent/src/aster_state.rs | 6 +- src-tauri/crates/agent/src/event_converter.rs | 355 +--- .../agent/src/filesystem_event_protocol.rs | 298 +++ src-tauri/crates/agent/src/lib.rs | 20 +- src-tauri/crates/agent/src/protocol.rs | 424 +++++ .../crates/agent/src/protocol_projection.rs | 22 + .../crates/agent/src/request_tool_policy.rs | 69 +- .../agent/src/runtime_projection_snapshot.rs | 2 + src-tauri/crates/agent/src/runtime_queue.rs | 15 +- .../agent/src/session_execution_runtime.rs | 392 ++++ src-tauri/crates/agent/src/session_store.rs | 114 +- src-tauri/crates/agent/src/skill_execution.rs | 50 +- .../crates/agent/src/turn_input_envelope.rs | 9 + .../crates/agent/src/write_artifact_events.rs | 257 ++- .../agent/tests/protocol_fact_source_guard.rs | 78 + .../agent/tests/real_codex_tool_events.rs | 11 +- .../crates/browser-runtime/src/action.rs | 230 ++- .../crates/gateway/src/wechat/runtime.rs | 5 +- src-tauri/crates/scheduler/src/executor.rs | 4 +- src-tauri/crates/server/src/chrome_bridge.rs | 74 +- .../site-adapters/bundled/index.json | 189 ++ .../bundled/scripts/36kr-newsflash.js | 34 + .../bundled/scripts/bilibili-search.js | 52 + .../bundled/scripts/github-issues.js | 58 + .../bundled/scripts/github-search.js | 44 + .../bundled/scripts/zhihu-hot.js | 62 + .../bundled/scripts/zhihu-search.js | 63 + src-tauri/src/agent/aster_agent.rs | 18 +- src-tauri/src/agent/mod.rs | 7 +- src-tauri/src/agent/runtime_queue_service.rs | 7 +- src-tauri/src/agent_tools/catalog.rs | 48 +- src-tauri/src/agent_tools/inventory.rs | 4 +- src-tauri/src/app/commands/logs.rs | 6 +- src-tauri/src/app/runner.rs | 44 +- .../aster_agent_cmd/action_runtime.rs | 7 +- src-tauri/src/commands/aster_agent_cmd/dto.rs | 37 +- src-tauri/src/commands/aster_agent_cmd/mod.rs | 33 +- .../aster_agent_cmd/prompt_context.rs | 97 +- .../commands/aster_agent_cmd/reply_runtime.rs | 60 +- .../request_model_resolution.rs | 1011 ++++++++++ .../run_metadata/provider_continuation.rs | 4 +- .../run_metadata/request_metadata.rs | 70 +- .../run_metadata/social_artifacts.rs | 106 +- .../commands/aster_agent_cmd/runtime_turn.rs | 687 +++---- .../aster_agent_cmd/subagent_runtime.rs | 27 +- .../src/commands/aster_agent_cmd/tests.rs | 433 +++-- .../commands/aster_agent_cmd/tool_runtime.rs | 4 + .../tool_runtime/site_tools.rs | 1020 ++++++++++ .../tool_runtime/subagent_tools.rs | 8 +- src-tauri/src/commands/execution_run_cmd.rs | 48 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/screenshot_cmd.rs | 16 + src-tauri/src/commands/site_capability_cmd.rs | 89 + src-tauri/src/commands/webview_cmd.rs | 10 + src-tauri/src/dev_bridge/dispatcher.rs | 29 + .../src/dev_bridge/dispatcher/browser.rs | 5 + .../src/dev_bridge/dispatcher/browser/site.rs | 64 + .../src/dev_bridge/dispatcher/openclaw.rs | 2 +- .../dev_bridge/dispatcher/openclaw/queries.rs | 39 + src-tauri/src/dev_bridge/dispatcher/voice.rs | 189 ++ src-tauri/src/screenshot/shortcut.rs | 21 + .../src/services/agent_timeline_service.rs | 53 +- .../src/services/artifact_diff_service.rs | 369 ++++ .../src/services/artifact_document_service.rs | 1310 +++++++++++++ .../services/artifact_document_validator.rs | 1135 +++++++++++ .../src/services/artifact_ops_service.rs | 1081 +++++++++++ .../artifact_output_schema_service.rs | 1077 +++++++++++ .../src/services/artifact_prompt_service.rs | 429 +++++ .../artifact_request_metadata_service.rs | 345 ++++ src-tauri/src/services/mod.rs | 9 + .../src/services/site_adapter_registry.rs | 982 ++++++++++ .../src/services/site_capability_service.rs | 1675 +++++++++++++++++ .../thread_reliability_projection_service.rs | 1 + src-tauri/src/skills/catalog.rs | 1 + src-tauri/src/skills/execution.rs | 20 +- src-tauri/src/skills/mod.rs | 2 +- src-tauri/src/skills/runtime.rs | 52 +- src-tauri/src/skills/social_post.rs | 212 ++- src-tauri/src/voice/commands.rs | 19 + src-tauri/src/voice/shortcut.rs | 62 + src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 3 +- src-tauri/test_serialize.rs | 10 +- src-tauri/tests/real_web_search_policy.rs | 13 +- .../real_web_search_preflight_short_input.rs | 4 +- src/App.tsx | 13 +- src/components/AppSidebar.tsx | 140 +- .../agent/chat/AgentChatHomeShell.test.tsx | 470 +++-- .../agent/chat/AgentChatHomeShell.tsx | 119 ++ .../agent/chat/AgentChatWorkspace.tsx | 203 +- .../agent/chat/agentChatWorkspaceContract.ts | 1 + .../chat/components/AgentRuntimeStrip.tsx | 38 +- .../components/AgentThreadTimeline.test.tsx | 164 +- .../chat/components/AgentThreadTimeline.tsx | 155 +- .../chat/components/CanvasWorkbenchLayout.tsx | 22 +- .../ChatModelSelector.integration.test.tsx | 17 +- .../components/EmptyStateQuickActions.tsx | 59 +- .../components/HarnessStatusPanel.test.tsx | 22 + .../chat/components/HarnessStatusPanel.tsx | 25 +- .../components/InputbarComposerSection.tsx | 11 +- .../components/InputbarModelExtra.tsx | 39 + .../chat/components/Inputbar/index.test.tsx | 31 + .../agent/chat/components/Inputbar/index.tsx | 11 +- .../chat/components/MessageList.test.tsx | 91 +- .../agent/chat/components/MessageList.tsx | 25 +- .../components/StreamingRenderer.test.tsx | 106 +- .../chat/components/StreamingRenderer.tsx | 7 +- .../components/TeamWorkbenchSummaryPanel.tsx | 5 +- .../components/TeamWorkspaceBoard.test.tsx | 8 +- .../chat/components/TeamWorkspaceBoard.tsx | 79 +- .../components/TeamWorkspaceDock.test.tsx | 24 +- .../chat/components/TeamWorkspaceDock.tsx | 7 +- .../chat/components/TokenUsageDisplay.tsx | 2 +- .../chat/components/ToolCallDisplay.test.tsx | 171 +- .../agent/chat/components/ToolCallDisplay.tsx | 295 ++- .../themeWorkbenchWorkflowData.test.ts | 56 + .../components/themeWorkbenchWorkflowData.ts | 3 +- src/components/agent/chat/homeShellEntry.ts | 8 + .../agent/chat/hooks/agentChatHistory.test.ts | 8 +- .../agent/chat/hooks/agentChatHistory.ts | 8 +- .../agent/chat/hooks/agentChatToolResult.ts | 2 +- .../agent/chat/hooks/agentRuntimeAdapter.ts | 66 +- .../chat/hooks/agentStreamEventProcessor.ts | 47 +- .../chat/hooks/agentStreamRuntimeHandler.ts | 71 +- .../agent/chat/hooks/skillCommand.test.ts | 19 +- .../agent/chat/hooks/skillCommand.ts | 6 +- .../agent/chat/hooks/useAgentSession.ts | 49 +- .../agent/chat/hooks/useAgentStream.ts | 135 +- .../chat/hooks/useArtifactAutoPreviewSync.ts | 13 +- .../chat/hooks/useArtifactDisplayState.ts | 13 +- .../chat/hooks/useAsterAgentChat.test.tsx | 392 +++- .../agent/chat/hooks/useAsterAgentChat.ts | 19 +- .../hooks/useRuntimeTeamFormation.test.tsx | 203 +- .../chat/hooks/useRuntimeTeamFormation.ts | 181 +- .../hooks/useTeamWorkspaceRuntime.test.tsx | 14 +- .../chat/hooks/useTeamWorkspaceRuntime.ts | 20 +- .../hooks/useThemeContextWorkspace.test.tsx | 51 + .../chat/hooks/useThemeContextWorkspace.ts | 70 +- ...useThemeScopedChatToolPreferences.test.tsx | 80 + .../useThemeScopedChatToolPreferences.ts | 55 +- .../agent/chat/hooks/useTrayModelShortcuts.ts | 2 +- src/components/agent/chat/index.test.tsx | 591 ++---- src/components/agent/chat/index.tsx | 5 + .../chat/protocol-fact-source-guard.test.ts | 120 ++ .../ServiceSkillHomePanel.test.tsx | 36 + .../service-skills/ServiceSkillHomePanel.tsx | 63 +- .../ServiceSkillLaunchDialog.test.tsx | 114 +- .../ServiceSkillLaunchDialog.tsx | 23 +- .../automationLinkStorage.test.ts | 126 ++ .../service-skills/automationLinkStorage.ts | 296 +++ .../agent/chat/service-skills/types.ts | 27 + .../service-skills/useServiceSkills.test.tsx | 256 +++ .../chat/service-skills/useServiceSkills.ts | 172 +- .../service-skills/workspaceLaunch.test.ts | 67 + .../chat/service-skills/workspaceLaunch.ts | 56 + .../agent/chat/teamWorkspaceRuntime.ts | 2 +- src/components/agent/chat/types.ts | 16 +- .../agent/chat/utils/agentRuntimeStatus.ts | 2 +- .../chat/utils/agentThreadGrouping.test.ts | 46 + .../agent/chat/utils/agentThreadGrouping.ts | 21 +- .../utils/artifactTimelineNavigation.test.ts | 132 ++ .../chat/utils/artifactTimelineNavigation.ts | 249 +++ .../agent/chat/utils/browserAssistSession.ts | 2 +- .../clawWorkspaceProviderSelection.test.ts | 163 ++ .../utils/clawWorkspaceProviderSelection.ts | 55 +- .../chat/utils/harnessRequestMetadata.test.ts | 204 +- .../chat/utils/harnessRequestMetadata.ts | 96 +- .../agent/chat/utils/harnessState.test.ts | 45 + .../agent/chat/utils/harnessState.ts | 89 +- .../agent/chat/utils/messageArtifacts.test.ts | 132 ++ .../agent/chat/utils/messageArtifacts.ts | 126 +- .../utils/sessionExecutionRuntime.test.ts | 144 ++ .../chat/utils/sessionExecutionRuntime.ts | 223 +++ .../agent/chat/utils/streamDiagnostics.ts | 4 +- .../agent/chat/utils/teamAutoGeneration.ts | 317 ---- .../agent/chat/utils/toolDisplayInfo.ts | 107 +- .../workspace/ArtifactWorkbenchShell.test.tsx | 669 +++++++ .../chat/workspace/ArtifactWorkbenchShell.tsx | 1662 ++++++++++++++++ .../workspace/WorkspaceHarnessDialogs.tsx | 8 + .../chat/workspace/runtimeTeamPreview.ts | 28 + .../workspace/teamWorkbenchPresentation.tsx | 20 +- .../workspace/themeWorkbenchHelpers.test.ts | 53 + .../chat/workspace/themeWorkbenchHelpers.ts | 9 +- .../useWorkspaceArtifactPreviewActions.ts | 6 +- .../useWorkspaceCanvasPreviewPresentation.tsx | 20 +- .../useWorkspaceCanvasSceneRuntime.tsx | 22 +- .../useWorkspaceConversationSceneRuntime.tsx | 15 + ...eWorkspaceHarnessInventoryRuntime.test.tsx | 188 ++ .../useWorkspaceHarnessInventoryRuntime.ts | 17 +- .../useWorkspaceInputbarPresentation.tsx | 6 +- .../useWorkspaceInputbarSceneRuntime.tsx | 21 +- ...kspaceRuntimeTeamDispatchPreviewRuntime.ts | 52 +- .../useWorkspaceSendActions.test.tsx | 242 +++ .../chat/workspace/useWorkspaceSendActions.ts | 350 +--- .../useWorkspaceShellChromeRuntime.ts | 6 +- .../useWorkspaceTeamSessionRuntime.ts | 5 +- ...seWorkspaceTeamWorkbenchAutoOpenRuntime.ts | 18 +- ...spaceThemeWorkbenchSidebarRuntime.test.tsx | 171 ++ ...seWorkspaceThemeWorkbenchSidebarRuntime.ts | 58 +- .../workspace/useWorkspaceWriteFileAction.ts | 9 +- .../agent/chat/workspace/workbenchPreview.tsx | 43 + src/components/artifact/ArtifactRenderer.tsx | 40 +- .../artifact/ArtifactRenderer.ui.test.tsx | 110 +- .../renderers/ArtifactDocumentRenderer.tsx | 955 ++++++++++ .../artifact/renderers/DocumentRenderer.tsx | 36 +- src/components/artifact/renderers/index.ts | 1 + src/components/automation/AutomationPage.tsx | 10 +- .../canvas/document/DocumentCanvas.tsx | 20 +- .../canvas/document/DocumentEditor.tsx | 19 +- .../document/documentCanvasHotkeys.test.ts | 24 + .../canvas/document/documentCanvasHotkeys.ts | 42 + .../document/documentEditorHotkeys.test.ts | 20 + .../canvas/document/documentEditorHotkeys.ts | 42 + .../canvas/document/editor/NotionEditor.tsx | 10 +- .../poster/hooks/posterCanvasHotkeys.test.ts | 63 + .../poster/hooks/posterCanvasHotkeys.ts | 115 ++ .../poster/hooks/useElementOperations.ts | 25 +- .../canvas/poster/hooks/useHistory.ts | 23 +- src/components/resources/ResourcesPage.tsx | 3 +- .../settings-v2/_layout/index.test.tsx | 166 ++ src/components/settings-v2/_layout/index.tsx | 19 +- .../user-center-session/index.test.tsx | 24 +- .../account/user-center-session/index.tsx | 240 ++- .../agent/providers/index.test.tsx | 152 +- .../settings-v2/agent/providers/index.tsx | 1270 ++++++++----- .../general/hotkeys/hotkeyCatalog.test.ts | 102 + .../general/hotkeys/hotkeyCatalog.ts | 391 ++++ .../general/hotkeys/index.test.tsx | 125 +- .../settings-v2/general/hotkeys/index.tsx | 659 +++---- .../system/automation/index.test.tsx | 105 ++ .../settings-v2/system/automation/index.tsx | 43 +- .../system/developer/index.test.tsx | 156 +- .../settings-v2/system/developer/index.tsx | 264 ++- src/components/smart-input/README.md | 2 +- src/components/smart-input/useSmartInput.ts | 48 +- src/components/terminal/TerminalPage.tsx | 111 +- src/components/terminal/TerminalPanel.tsx | 45 +- src/components/terminal/TerminalView.tsx | 69 +- src/components/terminal/ai/README.md | 2 +- .../terminal/ai/TerminalAIMessages.tsx | 2 +- src/components/terminal/ai/types.ts | 5 +- src/components/terminal/ai/useTerminalAI.ts | 42 +- .../terminal/terminalPageHotkeys.test.ts | 53 + .../terminal/terminalPageHotkeys.ts | 204 ++ src/components/ui/tooltip.test.tsx | 88 + src/components/ui/tooltip.tsx | 163 +- .../workspace/hooks/useWorkbenchController.ts | 9 +- .../workspace/hooks/workbenchHotkeys.test.ts | 27 + .../workspace/hooks/workbenchHotkeys.ts | 30 + .../BrowserRuntimeDebugPanel.test.tsx | 103 + .../BrowserRuntimeDebugPanel.tsx | 19 + .../BrowserRuntimeWorkspace.test.tsx | 30 + .../BrowserRuntimeWorkspace.tsx | 16 +- .../BrowserSiteAdapterPanel.test.tsx | 328 ++++ .../BrowserSiteAdapterPanel.tsx | 882 +++++++++ src/features/browser-runtime/api.ts | 26 + src/hooks/useOemCloudAccess.test.tsx | 5 + src/hooks/useOemCloudAccess.ts | 38 +- src/hooks/useProviderModels.ts | 347 +--- src/hooks/useSiteAdapterCatalogBootstrap.ts | 12 + src/index.css | 37 + src/lib/api/agent.test.ts | 30 + src/lib/api/agentExecutionRuntime.ts | 48 + ...ntStream.test.ts => agentProtocol.test.ts} | 113 +- .../api/{agentStream.ts => agentProtocol.ts} | 704 +++---- src/lib/api/agentRuntime.ts | 53 +- src/lib/api/hotkeys.test.ts | 68 + src/lib/api/hotkeys.ts | 42 + src/lib/api/modelRegistry.ts | 24 + src/lib/api/oemCloudControlPlane.ts | 75 +- src/lib/api/oemCloudRuntime.ts | 5 +- src/lib/api/serviceSkillRuns.ts | 204 ++ src/lib/api/serviceSkills.ts | 30 + src/lib/artifact-document/index.ts | 44 + src/lib/artifact-document/ops.test.ts | 51 + src/lib/artifact-document/ops.ts | 235 +++ src/lib/artifact-document/parser.test.ts | 135 ++ src/lib/artifact-document/parser.ts | 767 ++++++++ src/lib/artifact-document/types.ts | 236 +++ src/lib/artifact-protocol/index.test.ts | 127 ++ src/lib/artifact-protocol/index.ts | 187 ++ .../filesystem-event-protocol/index.test.ts | 84 + src/lib/filesystem-event-protocol/index.ts | 178 ++ src/lib/hotkeys/platform.test.ts | 70 + src/lib/hotkeys/platform.ts | 117 ++ src/lib/hotkeys/types.ts | 25 + src/lib/model/providerModelsCatalog.ts | 239 +++ src/lib/oemCloudDesktopAuth.ts | 9 +- src/lib/resourceProjectSelection.ts | 1 + src/lib/siteAdapterCatalogBootstrap.test.ts | 153 ++ src/lib/siteAdapterCatalogBootstrap.ts | 137 ++ src/lib/tauri-mock/core.ts | 239 +++ src/lib/webview-api.ts | 137 ++ src/pages/browser-runtime-debugger.test.tsx | 97 + src/pages/browser-runtime-debugger.tsx | 37 + src/types/page.ts | 9 + src/vite-env.d.ts | 14 + 328 files changed, 40949 insertions(+), 6948 deletions(-) create mode 100644 .github/workflows/quality.yml create mode 100644 docs/aiprompts/limecore-collaboration-entry.md create mode 100644 docs/aiprompts/quality-workflow.md create mode 100644 docs/develop/lime-borrow-codex-engineering-practices.md create mode 100644 docs/roadmap/artifacts/architecture-blueprint.md create mode 100644 docs/roadmap/artifacts/artifact-document-v1.md create mode 100644 docs/roadmap/artifacts/framework-boundary.md create mode 100644 docs/roadmap/artifacts/roadmap.md create mode 100644 docs/roadmap/artifacts/system-prompt-and-schema-contract.md create mode 100644 scripts/quality-task-planner.mjs create mode 100644 scripts/quality-task-selector.mjs create mode 100644 scripts/verify-gui-smoke.mjs create mode 100644 src-tauri/crates/agent/src/artifact_protocol.rs create mode 100644 src-tauri/crates/agent/src/filesystem_event_protocol.rs create mode 100644 src-tauri/crates/agent/src/protocol.rs create mode 100644 src-tauri/crates/agent/src/protocol_projection.rs create mode 100644 src-tauri/crates/agent/src/session_execution_runtime.rs create mode 100644 src-tauri/crates/agent/tests/protocol_fact_source_guard.rs create mode 100644 src-tauri/resources/site-adapters/bundled/index.json create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/36kr-newsflash.js create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/bilibili-search.js create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/github-issues.js create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/github-search.js create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/zhihu-hot.js create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/zhihu-search.js create mode 100644 src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs create mode 100644 src-tauri/src/commands/site_capability_cmd.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/browser/site.rs create mode 100644 src-tauri/src/dev_bridge/dispatcher/voice.rs create mode 100644 src-tauri/src/services/artifact_diff_service.rs create mode 100644 src-tauri/src/services/artifact_document_service.rs create mode 100644 src-tauri/src/services/artifact_document_validator.rs create mode 100644 src-tauri/src/services/artifact_ops_service.rs create mode 100644 src-tauri/src/services/artifact_output_schema_service.rs create mode 100644 src-tauri/src/services/artifact_prompt_service.rs create mode 100644 src-tauri/src/services/artifact_request_metadata_service.rs create mode 100644 src-tauri/src/services/site_adapter_registry.rs create mode 100644 src-tauri/src/services/site_capability_service.rs create mode 100644 src/components/agent/chat/components/themeWorkbenchWorkflowData.test.ts create mode 100644 src/components/agent/chat/protocol-fact-source-guard.test.ts create mode 100644 src/components/agent/chat/service-skills/automationLinkStorage.test.ts create mode 100644 src/components/agent/chat/service-skills/automationLinkStorage.ts create mode 100644 src/components/agent/chat/service-skills/useServiceSkills.test.tsx create mode 100644 src/components/agent/chat/service-skills/workspaceLaunch.test.ts create mode 100644 src/components/agent/chat/service-skills/workspaceLaunch.ts create mode 100644 src/components/agent/chat/utils/artifactTimelineNavigation.test.ts create mode 100644 src/components/agent/chat/utils/artifactTimelineNavigation.ts create mode 100644 src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts create mode 100644 src/components/agent/chat/utils/messageArtifacts.test.ts create mode 100644 src/components/agent/chat/utils/sessionExecutionRuntime.test.ts create mode 100644 src/components/agent/chat/utils/sessionExecutionRuntime.ts delete mode 100644 src/components/agent/chat/utils/teamAutoGeneration.ts create mode 100644 src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx create mode 100644 src/components/agent/chat/workspace/ArtifactWorkbenchShell.tsx create mode 100644 src/components/agent/chat/workspace/themeWorkbenchHelpers.test.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchSidebarRuntime.test.tsx create mode 100644 src/components/artifact/renderers/ArtifactDocumentRenderer.tsx create mode 100644 src/components/content-creator/canvas/document/documentCanvasHotkeys.test.ts create mode 100644 src/components/content-creator/canvas/document/documentCanvasHotkeys.ts create mode 100644 src/components/content-creator/canvas/document/documentEditorHotkeys.test.ts create mode 100644 src/components/content-creator/canvas/document/documentEditorHotkeys.ts create mode 100644 src/components/content-creator/canvas/poster/hooks/posterCanvasHotkeys.test.ts create mode 100644 src/components/content-creator/canvas/poster/hooks/posterCanvasHotkeys.ts create mode 100644 src/components/settings-v2/_layout/index.test.tsx create mode 100644 src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts create mode 100644 src/components/settings-v2/general/hotkeys/hotkeyCatalog.ts create mode 100644 src/components/terminal/terminalPageHotkeys.test.ts create mode 100644 src/components/terminal/terminalPageHotkeys.ts create mode 100644 src/components/ui/tooltip.test.tsx create mode 100644 src/components/workspace/hooks/workbenchHotkeys.test.ts create mode 100644 src/components/workspace/hooks/workbenchHotkeys.ts create mode 100644 src/features/browser-runtime/BrowserSiteAdapterPanel.test.tsx create mode 100644 src/features/browser-runtime/BrowserSiteAdapterPanel.tsx create mode 100644 src/hooks/useSiteAdapterCatalogBootstrap.ts create mode 100644 src/lib/api/agentExecutionRuntime.ts rename src/lib/api/{agentStream.test.ts => agentProtocol.test.ts} (69%) rename src/lib/api/{agentStream.ts => agentProtocol.ts} (64%) create mode 100644 src/lib/api/hotkeys.test.ts create mode 100644 src/lib/api/hotkeys.ts create mode 100644 src/lib/api/serviceSkillRuns.ts create mode 100644 src/lib/artifact-document/index.ts create mode 100644 src/lib/artifact-document/ops.test.ts create mode 100644 src/lib/artifact-document/ops.ts create mode 100644 src/lib/artifact-document/parser.test.ts create mode 100644 src/lib/artifact-document/parser.ts create mode 100644 src/lib/artifact-document/types.ts create mode 100644 src/lib/artifact-protocol/index.test.ts create mode 100644 src/lib/artifact-protocol/index.ts create mode 100644 src/lib/filesystem-event-protocol/index.test.ts create mode 100644 src/lib/filesystem-event-protocol/index.ts create mode 100644 src/lib/hotkeys/platform.test.ts create mode 100644 src/lib/hotkeys/platform.ts create mode 100644 src/lib/hotkeys/types.ts create mode 100644 src/lib/model/providerModelsCatalog.ts create mode 100644 src/lib/siteAdapterCatalogBootstrap.test.ts create mode 100644 src/lib/siteAdapterCatalogBootstrap.ts create mode 100644 src/pages/browser-runtime-debugger.test.tsx diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 000000000..c3d35f6a7 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,217 @@ +name: Quality + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + changed: + name: Detect changed areas + runs-on: ubuntu-latest + outputs: + bridge: ${{ steps.detect.outputs.bridge }} + changed_count: ${{ steps.detect.outputs.changed_count }} + docs: ${{ steps.detect.outputs.docs }} + docs_only: ${{ steps.detect.outputs.docs_only }} + fallback: ${{ steps.detect.outputs.fallback }} + frontend: ${{ steps.detect.outputs.frontend }} + gui_smoke: ${{ steps.detect.outputs.gui_smoke }} + integrity: ${{ steps.detect.outputs.integrity }} + rust: ${{ steps.detect.outputs.rust }} + workflow: ${{ steps.detect.outputs.workflow }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Detect tasks + id: detect + shell: bash + env: + BEFORE_SHA: ${{ github.event.before }} + run: | + args=(--format github) + if [[ "${{ github.event_name }}" == "push" && -n "${BEFORE_SHA}" && "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]]; then + args+=(--base "${BEFORE_SHA}") + fi + node scripts/quality-task-selector.mjs "${args[@]}" >> "$GITHUB_OUTPUT" + + # 当前仓库的 Rust 回归验证仍以既有 `.github/workflows/ci.yml` + # 中的 Windows OpenClaw regression 为主。这里先收口前端/契约/一致性 + # 质量信号,后续再逐步补齐可稳定运行的 Rust 质量矩阵。 + + integrity: + name: Integrity + runs-on: ubuntu-latest + needs: changed + if: ${{ needs.changed.outputs.integrity == 'true' || needs.changed.outputs.fallback == 'true' || needs.changed.outputs.workflow == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Verify app version consistency + run: npm run verify:app-version + + frontend: + name: Frontend + runs-on: ubuntu-latest + needs: changed + if: ${{ needs.changed.outputs.frontend == 'true' || needs.changed.outputs.fallback == 'true' || needs.changed.outputs.workflow == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Vitest + run: npm test + + bridge_contracts: + name: Bridge & Contracts + runs-on: ubuntu-latest + needs: changed + if: ${{ needs.changed.outputs.bridge == 'true' || needs.changed.outputs.fallback == 'true' || needs.changed.outputs.workflow == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Bridge tests + run: npm run test:bridge + + - name: Command contracts + run: npm run test:contracts + + gui_smoke: + name: GUI Smoke + runs-on: ubuntu-latest + timeout-minutes: 25 + needs: changed + if: ${{ needs.changed.outputs.gui_smoke == 'true' || needs.changed.outputs.fallback == 'true' || needs.changed.outputs.workflow == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux GUI dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + curl \ + file \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + patchelf \ + pkg-config \ + wget \ + xvfb + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + shared-key: quality-gui-smoke + cache-on-failure: true + + - name: Install dependencies + run: npm ci + + - name: Run headless GUI smoke + run: xvfb-run -a npm run verify:gui-smoke -- --timeout-ms 180000 + + results: + name: Quality results + runs-on: ubuntu-latest + needs: + - changed + - integrity + - frontend + - bridge_contracts + - gui_smoke + if: always() + steps: + - name: Summarize + shell: bash + run: | + echo "changed_count: ${{ needs.changed.outputs.changed_count }}" + echo "docs_only : ${{ needs.changed.outputs.docs_only }}" + echo "integrity : ${{ needs.integrity.result }}" + echo "frontend : ${{ needs.frontend.result }}" + echo "bridge : ${{ needs.bridge_contracts.result }}" + echo "gui_smoke : ${{ needs.gui_smoke.result }}" + + if [[ '${{ needs.changed.outputs.docs_only }}' == 'true' ]]; then + echo 'Only docs/markdown changes detected -> quality checks not required.' + exit 0 + fi + + if [[ '${{ needs.changed.outputs.integrity }}' == 'true' || '${{ needs.changed.outputs.fallback }}' == 'true' || '${{ needs.changed.outputs.workflow }}' == 'true' ]]; then + [[ '${{ needs.integrity.result }}' == 'success' ]] || { echo 'integrity failed'; exit 1; } + fi + + if [[ '${{ needs.changed.outputs.frontend }}' == 'true' || '${{ needs.changed.outputs.fallback }}' == 'true' || '${{ needs.changed.outputs.workflow }}' == 'true' ]]; then + [[ '${{ needs.frontend.result }}' == 'success' ]] || { echo 'frontend failed'; exit 1; } + fi + + if [[ '${{ needs.changed.outputs.bridge }}' == 'true' || '${{ needs.changed.outputs.fallback }}' == 'true' || '${{ needs.changed.outputs.workflow }}' == 'true' ]]; then + [[ '${{ needs.bridge_contracts.result }}' == 'success' ]] || { echo 'bridge/contracts failed'; exit 1; } + fi + + if [[ '${{ needs.changed.outputs.gui_smoke }}' == 'true' || '${{ needs.changed.outputs.fallback }}' == 'true' || '${{ needs.changed.outputs.workflow }}' == 'true' ]]; then + [[ '${{ needs.gui_smoke.result }}' == 'success' ]] || { echo 'gui smoke failed'; exit 1; } + fi diff --git a/.gitignore b/.gitignore index edd29f9d4..dc721636e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,12 @@ docs/prd/* # Internal roadmap&gongzonghao (private) docs/roadmap/* !docs/roadmap/lime-service-skill-cloud-config-prd.md +!docs/roadmap/artifacts/ +!docs/roadmap/artifacts/roadmap.md +!docs/roadmap/artifacts/artifact-document-v1.md +!docs/roadmap/artifacts/architecture-blueprint.md +!docs/roadmap/artifacts/framework-boundary.md +!docs/roadmap/artifacts/system-prompt-and-schema-contract.md docs/gongzonghao/ docs/bussniss/ docs/oem/ diff --git a/AGENTS.md b/AGENTS.md index 8018b4525..f6744b1fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,168 +1,84 @@ # AI Agent 指南 -本文件为 AI Agent 在此代码库中工作时提供指导。 -仅用于开发 Lime 源码仓库本身,不作为 Lime 应用运行时 agent 会话的指令来源。 +本文件只用于 **开发 Lime 源码仓库本身**。 +根 `AGENTS.md` 只保留仓库级规则、高频入口和硬约束;长流程与模块说明统一下沉到 `docs/aiprompts/`。 -## 基本规则 +## 仓库级原则 -1. **始终使用中文输出** - 所有回复、注释、文档都使用中文 -2. **文件超过 20 行,分批输出** - 避免一次性输出过长内容 -3. **先读后写** - 修改文件前必须先读取现有内容 +1. **始终使用中文输出** - 回复、注释、文档统一使用中文 +2. **先读后写** - 修改文件前必须先读取现有内容 +3. **避免无关变更** - 不顺手重构、不扩大范围、不主动做 git 提交/分支操作 +4. **规则留根,流程下沉** - 根文件只放长期稳定的仓库规则;长说明写到 `docs/aiprompts/` -## 跨平台兼容约束 +## 跨平台硬约束 -1. **默认双平台** - 所有新增功能、脚本、文档默认同时考虑 macOS 与 Windows;若只支持单平台,必须明确标注原因、影响范围和降级方案 -2. **避免硬编码平台细节** - 路径分隔符、可执行文件后缀、换行符、大小写敏感性、文件权限等差异必须通过跨平台 API 或统一封装处理,不要直接写死平台判断分支 -3. **优先平台无关入口** - 优先使用 `npm`、`cargo`、Tauri 命令和项目内封装,避免新增只适用于 Bash/zsh 的开发流程;如果文档示例包含 shell 特性,需补充 Windows 可执行方式 -4. **目录定位走系统 API** - 用户数据、日志、缓存、凭证等落盘位置必须通过应用目录 API 或统一工具函数解析,禁止在实现中写死 `~/Library/...`、`C:/Users/...` 之类路径 -5. **变更前做兼容性自检** - 涉及文件系统、进程启动、终端、快捷键、窗口、托盘、权限、路径解析的改动时,提交前必须检查 macOS/Windows 的行为差异;未验证的平台假设要明确说明 -6. **不确定先查文档** - 对 Tauri、Rust、Node.js 或系统 API 的平台行为拿不准时,先查官方文档;可以优先使用 Context7 MCP 获取最新资料 +1. **默认双平台** - 新增功能、脚本、文档默认同时考虑 macOS 与 Windows +2. **禁止硬编码平台路径** - 用户数据、日志、缓存、凭证等目录必须走系统 API 或统一封装 +3. **优先平台无关入口** - 优先复用 `npm`、`cargo`、Tauri 命令和仓库脚本,不新增只适用于 Bash/zsh 的流程 +4. **未验证的平台假设要显式说明** - 涉及文件系统、进程、终端、快捷键、窗口、托盘、权限时尤其如此 -## AGENTS.md 维护原则 +## 工程硬规则 -1. **根 AGENTS.md 只放仓库级规则** - 保留全局约束、高频命令、文档索引,避免塞入过长操作手册 -2. **长流程拆到独立文档** - 像 Playwright E2E、内容创作工作流这类步骤型说明,放到 `docs/aiprompts/`,根 AGENTS 只保留入口 -3. **谨慎新增子目录 AGENTS.md** - 仅当某个目录树存在长期稳定、只对该子树生效的规则时才新增;临时排障说明不要新增 AGENTS -4. **优先索引化而不是堆叠说明** - 根 AGENTS 更适合作为目录与约定入口,详细上下文交给专门文档 +1. **默认先跑统一入口** - 功能改动发起 PR 前默认执行 `npm run verify:local` +2. **版本改动必须校验一致性** - 改 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf*.json` 时必须执行 `npm run verify:app-version` +3. **命令边界改动必须校验契约** - 改 `safeInvoke` / `invoke`、Tauri command、Bridge/mock 边界时必须执行 `npm run test:contracts` +4. **Lime 是 GUI 桌面产品** - 不能只以 `lint`、`typecheck`、单测通过作为“可交付”判断 +5. **高风险 GUI 改动必须做最小冒烟** - 涉及 GUI 壳、DevBridge、Workspace、主页面路径时必须执行 `npm run verify:gui-smoke` +6. **不要继续扩展 compat / deprecated 路径** - 新 API、新命令、新前端入口默认落在当前 `current` 主路径 +7. **协议改动必须同步四侧** - `safeInvoke(...)` / `invoke(...)`、`tauri::generate_handler!`、`agentCommandCatalog`、`mockPriorityCommands` / `defaultMocks` 必须保持一致 +8. **协议改动必须同步文档** - 至少同步更新 `docs/aiprompts/commands.md`、`docs/aiprompts/playwright-e2e.md`、`docs/aiprompts/quality-workflow.md` +9. **用户可见 UI 改动必须补稳定回归** - 优先补现有 `*.test.tsx` 的关键文案、状态、交互断言;若已有 snapshot 机制,沿用现有机制 +10. **配置结构改动要成组更新** - schema、校验器、消费者、文档必须同步演进 +11. **依赖改动要同步锁文件** - 提交 `package-lock.json`、`src-tauri/Cargo.lock` 等实际锁文件;本仓库不适用 Bazel 规则 +12. **Rust 测试先小后大** - 先跑受影响 crate / 模块 / 定向测试,再决定是否跑全量 `cargo test` +13. **控制 Rust 文件规模** - 新增模块尽量控制在 `500 LoC` 以内;文件接近 `800 LoC` 时,新功能优先拆新模块 +14. **不要继续放大历史大文件** - 现有超大文件属于历史包袱,但新增逻辑应优先拆边界,不继续堆叠 +15. **质量门禁保持单一主线** - `.github/workflows/quality.yml`、`scripts/quality-task-planner.mjs`、本地统一入口要保持一致 -## UI 全局指导 +## UI 规则 -1. **界面改动先看视觉规范** - 涉及配色、渐变、卡片布局、设置页重排、工作台改版时,先读 `docs/aiprompts/design-language.md` -2. **宽度按页面类型选** - 表单页保持窄阅读宽度,卡片/工作台页面使用更宽的自适应内容区,不要整仓统一 `max-width` -3. **中文排版优先** - 避免过大英文 tracking、重复标题和挤压式统计卡文案 -4. **渐变只做氛围层** - 禁止用互相打架的多层渐变制造分割感,背景存在感必须弱于内容 -5. **默认禁用半透明主表面** - 弹窗、浮层、工作台容器、主卡片默认使用实体底色,避免 `bg-white/80`、`backdrop-blur` 一类设计造成层级混乱与内容遮挡错觉 +1. **改界面先读视觉规范** - 先看 `docs/aiprompts/design-language.md` +2. **中文排版优先** - 避免英文 tracking 过大、重复标题、拥挤统计卡文案 +3. **宽度按页面类型选** - 表单页窄、工作台页宽,不要整仓一个 `max-width` +4. **渐变只做氛围层** - 背景存在感必须弱于内容 +5. **主表面默认实体底色** - 不要默认用 `bg-white/80`、`backdrop-blur` 制造层级 -## 详细文档 +## 常用文档入口 -模块级详细文档位于 `docs/aiprompts/`: +- **架构概览**:`docs/aiprompts/overview.md` +- **工程质量**:`docs/aiprompts/quality-workflow.md` +- **治理收口**:`docs/aiprompts/governance.md` +- **UI 规范**:`docs/aiprompts/design-language.md` +- **Tauri 命令边界**:`docs/aiprompts/commands.md` +- **凭证与路径**:`docs/aiprompts/credential-pool.md` +- **Playwright / GUI 续测**:`docs/aiprompts/playwright-e2e.md` +- **Codex Skills 索引**:`.codex/skills/README.md` -| 文档 | 说明 | -|------|------| -| [overview.md](docs/aiprompts/overview.md) | 项目架构概览 | -| [providers.md](docs/aiprompts/providers.md) | Provider 系统 | -| [credential-pool.md](docs/aiprompts/credential-pool.md) | 凭证池管理 | -| [converter.md](docs/aiprompts/converter.md) | 协议转换 | -| [server.md](docs/aiprompts/server.md) | HTTP 服务器 | -| [components.md](docs/aiprompts/components.md) | 组件系统 | -| [design-language.md](docs/aiprompts/design-language.md) | 全局 UI 视觉语言 | -| [hooks.md](docs/aiprompts/hooks.md) | React Hooks | -| [services.md](docs/aiprompts/services.md) | 业务服务 | -| [commands.md](docs/aiprompts/commands.md) | Tauri 命令 | -| [mcp.md](docs/aiprompts/mcp.md) | MCP 服务器 | -| [database.md](docs/aiprompts/database.md) | 数据库层 | -| [terminal.md](docs/aiprompts/terminal.md) | 内置终端 | -| [plugins.md](docs/aiprompts/plugins.md) | 插件系统 | -| [lib.md](docs/aiprompts/lib.md) | 工具库 | -| [workspace.md](docs/aiprompts/workspace.md) | Workspace 设计文档 | -| [content-creator.md](docs/aiprompts/content-creator.md) | 内容创作系统 | -| [aster-integration.md](docs/aiprompts/aster-integration.md) | Aster 集成方案 | -| [project-heatmap.md](docs/aiprompts/project-heatmap.md) | 项目热力图与治理图再生成指南 | -| [playwright-e2e.md](docs/aiprompts/playwright-e2e.md) | Playwright MCP 续测与 E2E 指南 | - -## 构建命令 +## 高频命令 ```bash -# 构建 Tauri 应用 -cd src-tauri && cargo build +# 本地统一校验 +npm run verify:local +npm run verify:local:full -# 构建前端 -npm run build +# GUI 最小冒烟 +npm run verify:gui-smoke +npm run bridge:health -- --timeout-ms 120000 -# 开发模式 -npm run tauri:dev +# 契约与治理 +npm run test:contracts +npm run governance:legacy-report -# 浏览器 + DevBridge + Playwright MCP 调试模式 +# GUI / headless 调试 npm run tauri:dev:headless -``` -## 测试命令 - -```bash -# 运行 Rust 测试 +# Rust 定向 / 全量测试 cd src-tauri && cargo test - -# 运行前端测试 -npm test - -# 运行针对浏览器桥接的前端测试 -npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts ``` -## 代码检查 - -```bash -# Rust 代码检查 -cd src-tauri && cargo clippy - -# 前端代码检查 -npm run lint -``` - -## Playwright E2E 入口 - -- 需要继续浏览器 E2E、复用现有 Playwright MCP 会话、排查 DevBridge/console 错误时,先读 `docs/aiprompts/playwright-e2e.md` -- 如果只是仓库级规则,不要继续往本文件堆叠步骤说明 - -## 项目热力图入口 - -- 需要重新生成仓库观察热力图、治理候选图、打开现有 HTML 报告或向后续 AI 交接这套流程时,先读 `docs/aiprompts/project-heatmap.md` - -## UI 设计入口 - -- 需要统一配色、修正渐变、调整页面宽度策略、重排卡片工作台时,先读 `docs/aiprompts/design-language.md` - -## 项目架构 - -### 技术栈 -- 前端:React + TypeScript + Vite + TailwindCSS -- 后端:Rust + Tauri -- 数据库:SQLite (rusqlite) - -### prrovide模块 - -1. **Provider 系统** (`src-tauri/src/providers/`) - - Kiro/CodeWhisperer OAuth 认证 - - Gemini OAuth 认证 - - Qwen OAuth 认证 - - Antigravity OAuth 认证 - - OpenAI/Claude API Key 认证 - -2. **凭证池管理** (`src-tauri/src/services/provider_pool_service.rs`) - - 多凭证轮询负载均衡 - - 健康检查机制 - - Token 自动刷新 - -3. **API 服务器** (`src-tauri/src/server.rs`) - - OpenAI 兼容 API 端点 - - Claude 兼容 API 端点 - - 流式响应支持 - -4. **协议转换** (`src-tauri/src/converter/`) - - OpenAI ↔ CodeWhisperer 转换 - - OpenAI ↔ Claude 转换 - -## 开发指南 - -### 添加新 Provider - -1. 在 `src-tauri/src/providers/` 创建新的 provider 模块 -2. 实现凭证加载、Token 刷新、API 调用方法 -3. 在 `CredentialData` 枚举中添加新类型 -4. 在 `ProviderPoolService` 中添加健康检查逻辑 - -### 修改凭证管理 - -- 凭证文件存储在应用数据目录下的 `lime/credentials/`;`~/Library/Application Support/lime/credentials/` 仅作为 macOS 示例,Windows 请使用对应的应用数据目录 -- 数据库存储凭证元数据和状态 -- Token 缓存在数据库中,避免频繁读取文件 - -### 调试技巧 - -- 日志输出使用 `tracing` 宏 -- API 请求调试文件保存在应用日志目录;`~/.lime/logs/` 仅作为 macOS 示例,实现与排障时不要写死该路径 -- 使用 `debug_kiro_credentials` 命令调试凭证加载 - ## 文档维护 -文档维护规范详见 `.kiro/steering/doc-maintenance.md`(Kiro 自动加载)。 +1. 改仓库级规则时,同时更新本文件与对应 `docs/aiprompts/` 文档 +2. 新增长期使用的工程脚本时,同时补 `package.json` 与对应文档入口 +3. 如果某段说明已经超过“规则/入口”的粒度,就迁出根 `AGENTS.md` +4. 如果某条流程已经高频复用到值得做成 skill,同步更新 `.codex/skills/README.md` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f8c21591c..8fa912d9f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,38 +1,38 @@ -## Lime v0.95.1 +## Lime v0.96.0 ### ✨ 主要更新 -- **云端账户闭环接入收口**:个人中心统一承接登录、会话刷新和退出,Providers 页改成只消费这份云端会话,不再额外维护独立登录表单 -- **Google 一键登录回流桌面端**:桌面 OAuth 回调支持拿到 session token 后立即补拉 bootstrap、写回本地会话,并把登录成功后的跳转路径带回客户端 -- **云端 Provider 最终态改为服务端驱动**:客户端不再猜测接入模式、配置模式、模型来源和开发者入口,而是直接消费 control-plane 返回的最终态字段 -- **模型目录与默认来源同步治理**:默认来源切到云端时,内部 compat `lime-hub` Provider 会自动同步网关地址、品牌名和服务端模型目录;切回本地时会清空云端模型目录,避免旧配置残留 -- **本地 / 第三方 Provider 面板重新分层**:设置页把“云端入口”和“本地 / 第三方开发者配置”拆开,云端消费态、套餐状态和模型目录单独展示,避免与 API Key 管理混在一起 -- **服务技能目录接入云端 bootstrap**:启动时与 OAuth 登录完成后都会同步服务技能目录,保障客户端在登录后即可直接拿到云端下发的技能目录 -- **OEM 运行时配置继续去硬编码**:默认桌面客户端标识改成中性 `desktop-client`,品牌、域名与登录入口继续由运行时配置文件注入,方便后续打包替换 +- **Aster Agent 运行时与 Artifact 工作台继续落主链**:桌面端补齐了新的运行时协议、Artifact 文档处理链路与工作台渲染入口,产物预览、自动持久化、运行态元数据和时间线展示现在统一走同一套事实源 +- **Browser Runtime 新增站点适配器目录与调试能力**:内置站点适配器注册表开始随应用打包,Browser Runtime 可以列出、搜索、调试、执行并保存站点适配结果,为后续站点自动化与内容采集提供统一入口 +- **云端账户与 Provider 消费态完成收口**:设置页新增云端 Offer / 模型目录 / 本地 Provider 的分层视图,OAuth / 会话 / 控制面 bootstrap / 默认来源切换都统一到 OEM Cloud 运行时链路 +- **快捷键与工作区交互体验更完整**:快捷键设置页改成“已审计、已接入、可测试”的目录视图,工作台、终端、文档画布、海报画布与系统能力的可用热键与运行时状态都能集中查看 +- **发布质量入口与 GUI 冒烟主线收敛**:仓库新增 `verify:tasks`、`verify:gui-smoke` 与统一质量工作流,版本发布不再只看静态检查,而是把契约、Bridge 与 GUI 壳准备态一起纳入门槛 ### ⚠️ 兼容性说明 - 现网包发布仍由 `v*` tag 触发,`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文;只推 `main` 不会自动出包 -- 云端登录、模型目录和服务技能目录都依赖运行时注入的控制面 / Gateway 域名与租户配置;打包前请确认 `public/oem-runtime-config.js` 已替换为目标环境值 -- 云端默认来源与开发者 API Key 模式现在由服务端治理;如果后台关闭开发者入口,客户端仍会保留本地 / 第三方 Provider 管理,但不会再把云端入口误当作普通 API Key Provider 展示 +- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖已同步到 `v0.22.0`;如本地仍在用 `.cargo/config.toml` 覆盖本地 Aster,请确认覆盖版本与本次发布一致 +- 站点适配器目录现在会随桌面端资源一起打包,同时支持服务端同步目录;打包前请确认目标环境允许下发对应的站点脚本与运行时配置 +- 云端 Provider、服务技能目录与站点适配目录都依赖 OEM 控制面 bootstrap;发布到不同品牌/环境前,请确认 `public/oem-runtime-config.js` 已替换为目标环境值 ### 🔗 依赖同步 -- 应用版本同步提升到 `v0.95.1`,覆盖 `package.json`、Tauri 配置与 Rust workspace/package 入口 -- 云端运行时默认客户端标识调整为 `desktop-client`,供不同品牌包在打包阶段覆写 +- 应用版本已同步提升到 `v0.96.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json` +- Lime 内置的 `aster-rust` 依赖已从 `v0.21.0` 升级到 `v0.22.0` +- `src-tauri/Cargo.lock` 已随本次 Rust 校验更新,确保发布时依赖解析结果可复现 ### 🧪 测试 +- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all` - 发布前执行:`npm run verify:app-version` -- 发布前执行:`npm test -- src/hooks/useOemLimeHubProviderSync.test.tsx src/lib/oemCloudSession.test.ts src/hooks/useOemCloudAccess.test.tsx src/components/settings-v2/agent/providers/index.test.tsx src/components/settings-v2/account/user-center-session/index.test.tsx src/lib/api/oemCloudControlPlane.test.ts src/lib/api/oemCloudRuntime.test.ts` -- 发布前执行:`npm run governance:legacy-report` -- 发布前执行:`npm run test:contracts` +- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.96.0 npm run verify:local` +- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.96.0 cargo clippy --manifest-path src-tauri/Cargo.toml` ### 📝 文档 - 发布说明随 `RELEASE_NOTES.md` 更新,供 GitHub Release 工作流直接读取 -- 运行时配置示例随 `public/oem-runtime-config.js` 一并更新,避免桌面 OAuth 客户端标识继续写死旧品牌值 +- 工程质量与命令边界文档已同步更新到新的 GUI 冒烟 / 契约 / 版本校验主线 --- -**完整变更**: v0.95.0...v0.95.1 +**完整变更**: v0.95.0...v0.96.0 diff --git a/docs/README.md b/docs/README.md index 168f66c75..c64b63fa6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ - `develop/scheduler-task-governance-p1.md`:调度任务治理 P1(连续失败、自动停用、冷却恢复) - `roadmap/lime-skills-standardization-roadmap.md`:Skills 标准化与产品化路线图 - `roadmap/lime-service-skill-cloud-config-prd.md`:服务型技能的端优先执行与云配置同步 PRD +- `roadmap/lime-browser-site-capability-prd.md`:站点能力按真实浏览器优先接入的产品需求文档 - `ops.md`:运维与发布说明 - `app.config.ts` / `nuxt.config.ts` / `package.json`:文档站配置 diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 9f8d60125..4f34a4129 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -1,83 +1,75 @@ -# aiprompts +# `docs/aiprompts` 索引 - +本目录存放 Lime 仓库的模块级说明、工程流程和治理文档。 +根 `AGENTS.md` 只保留仓库级规则;超过“规则/入口”粒度的说明统一放到这里。 -## 架构说明 +## 使用原则 -AI Agent 专用文档目录,提供模块级别的详细说明。 -参考 aster-rust 的 aiprompts 模式设计。 +1. **先按场景找入口** - 不确定从哪里开始时,优先读本页 +2. **先读对应文档再改代码** - 尤其是命令边界、GUI 主路径、迁移收口、Provider 与凭证 +3. **GUI 改动优先看质量链路** - Lime 是 GUI 桌面产品,先看 `quality-workflow.md` 与 `playwright-e2e.md` +4. **新旧并存问题先看治理文档** - 避免在 compat / deprecated 路径上继续长新表面 -## 文件索引 +## 按场景导航 -### 核心系统 +### 架构与治理 -- `overview.md` - 项目架构概览 -- `governance.md` - **治理第一原则**(新旧并存、迁移收口、禁止回流) -- `providers.md` - Provider 系统(OAuth/API Key 认证) -- `credential-pool.md` - 凭证池管理(负载均衡、健康检查) -- `converter.md` - 协议转换(OpenAI ↔ CW/Claude) -- `server.md` - HTTP 服务器(API 端点) +- `overview.md` - 项目架构总览与模块分层 +- `governance.md` - 新旧并存治理、迁移收口、禁止回流 +- `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁 +- `project-heatmap.md` - 仓库热力图与治理候选分析 +- `limecore-collaboration-entry.md` - 跨仓库联动入口 -### 前端模块 +### GUI 与前端 -- `components.md` - React 组件系统 -- `design-language.md` - 全局 UI 视觉语言(配色、渐变、排版、宽度策略) -- `hooks.md` - 自定义 React Hooks -- `lib.md` - 工具库和 API 封装 +- `design-language.md` - 全局 UI 视觉语言 +- `components.md` - React 组件约定 +- `hooks.md` - 自定义 Hooks +- `lib.md` - 前端工具库与运行时封装 +- `workspace.md` - Workspace 边界与工作区模型 +- `content-creator.md` - 内容创作工作台与画布联动 +- `playwright-e2e.md` - GUI 续测、Playwright MCP、交互验证 +- `performance-profiling.md` - 性能分析与剖析方法 -### 后端模块 +### 后端与运行时 -- `services.md` - 业务服务层 -- `commands.md` - Tauri 命令 -- `database.md` - 数据库层(SQLite) - -### 功能模块 - -- `terminal.md` - 内置终端 -- `mcp.md` - MCP 服务器管理 +- `commands.md` - Tauri 命令边界、协议同步点 +- `services.md` - Rust 服务层 +- `server.md` - HTTP Server 与接口边界 +- `mcp.md` - MCP 服务器与工具管理 +- `terminal.md` - 内置终端与 PTY - `plugins.md` - 插件系统 -- `playwright-e2e.md` - Playwright MCP 续测与 E2E 指南 +- `aster-integration.md` - Aster Agent 集成 -### Aster 集成 +### Provider 与数据 -- `aster-integration.md` - **Aster 框架集成方案** -- `workspace.md` - **Workspace 设计文档**(工作目录管理) +- `providers.md` - Provider 接入与认证方式 +- `credential-pool.md` - 凭证池、运行时路径、调试入口 +- `converter.md` - 协议转换与兼容层 +- `database.md` - 数据库层与持久化 -### 内容创作 +## 常见入口建议 -- `content-creator.md` - **内容创作系统**(write_file 标签、画布联动) +- **改 UI / 页面结构**:先读 `design-language.md`,再看 `quality-workflow.md` +- **改 Tauri 命令 / Bridge / mock**:先读 `commands.md`,再看 `quality-workflow.md` +- **改 Workspace / GUI 壳 / 主路径**:先读 `workspace.md`、`quality-workflow.md`、`playwright-e2e.md` +- **做迁移 / 收口 / 去兼容层**:先读 `governance.md` +- **改 Provider / 凭证加载 / Token 刷新**:先读 `providers.md`、`credential-pool.md` +- **做跨仓库联动**:先读 `limecore-collaboration-entry.md` -## 使用方式 +## 对应 Codex Skills -AI Agent 在处理特定模块时,应先阅读对应的 aiprompts 文档: +- **治理收口**:`.codex/skills/lime-governance/` +- **GUI 设计语言**:`.codex/skills/lime-design-language/` +- **工程质量 / 交付判断**:`.codex/skills/lime-quality-workflow/` +- **命令边界 / 契约同步**:`.codex/skills/lime-command-boundary/` +- **GUI 续测 / Playwright MCP**:`.codex/skills/lime-playwright-e2e/` +- **热力图 / 治理优先级**:`.codex/skills/lime-project-heatmap/` +- **项目技能提炼**:`.codex/skills/project-skill-factory/` -``` -# 处理 Provider 相关任务 -→ 先读 docs/aiprompts/providers.md +## 维护规则 -# 处理 UI 配色、页面重排、视觉统一 -→ 先读 docs/aiprompts/design-language.md - -# 处理新旧并存、迁移、重构、架构收口 -→ 先读 docs/aiprompts/governance.md -→ 再执行 npm run governance:legacy-report - -# 处理凭证池相关任务 -→ 先读 docs/aiprompts/credential-pool.md - -# 处理 Aster Agent 集成 -→ 先读 docs/aiprompts/aster-integration.md - -# 处理 Workspace 相关任务 -→ 先读 docs/aiprompts/workspace.md - -# 处理内容创作、画布联动 -→ 先读 docs/aiprompts/content-creator.md - -# 处理浏览器 E2E、Playwright MCP 续测 -→ 先读 docs/aiprompts/playwright-e2e.md -``` - -## 更新提醒 - -任何文件变更后,请更新此文档和相关的上级文档。 +1. 新增长期文档后,要同步更新本索引 +2. 根 `AGENTS.md` 不再堆叠长流程,统一链接到这里 +3. 如果某段说明已经变成长期流程或模块说明,应从根规则迁到本目录 +4. 如果某条工作流已经高频复用到值得做成 skill,同步检查 `.codex/skills/README.md` diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 3a231fdc7..45553517c 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -1,126 +1,86 @@ -# Tauri 命令 +# Tauri 命令边界 -## 概述 +## 这份文档回答什么 -Tauri 命令是前端与 Rust 后端通信的边界,但前端业务代码**不应直接散落 `invoke`**。 +本文件用于说明 Lime 中 Tauri 命令的工程边界,主要回答: + +- 命令改动应该从哪里进入,而不是到处直接 `invoke` +- 哪些文件共同构成命令契约的事实源 +- 新增、迁移、下线命令时,最低要同步哪些位置 +- 怎样避免 compat / deprecated 路径重新长出新表面 + +## 推荐调用路径 + +前端业务代码**不应直接散落 `invoke`**。 推荐路径是: `组件 / Hook -> src/lib/api/* 网关 -> safeInvoke -> Rust command` -这样做的目的不是“多包一层”,而是确保: +这样做的目的不是“多包一层”,而是为了保证: - 前端只有一个可治理的调用出口 -- Rust 命令可以按 `current / compat / deprecated` 分类演进 +- Rust 命令可以按 `current / compat / deprecated / dead-candidate` 演进 - 新旧命令并存时,迁移边界清晰,不会继续扩散 +- 契约检查脚本能稳定扫描并阻止回流 -## 治理约束 +## 命令契约的五个事实源 -- 新的前端功能,禁止在页面、组件、普通 Hook 中直接调用 `invoke`。 -- 新的 Rust 命令,必须同时落一个对应的 `src/lib/api/*` 网关文件或收口到现有网关。 -- 旧命令如果暂时不能删,必须明确标记为 `compat` 或 `deprecated`,只允许保兼容,不允许继续长新逻辑。 -- 当前端已经迁到新网关后,要继续用 ESLint、脚本或日志告警封住旧入口,避免 AI 回流。 +命令边界不是单文件事实,至少要同时看下面五处: -## 当前事实源 +1. **前端实际调用** + `src/` 下运行时代码里的 `safeInvoke(...)` / `invoke(...)` -- Agent / Codex 主命令:`agent_runtime_*` -- 运行态摘要主链:Aster `runtime_status` item -> timeline `turn_summary` -- `chat_*` 已停止注册,且不再纳入 `commands::mod` 编译图;旧 General / Creator / 历史桥接如仍需恢复,必须显式走新的 compat 评审 -- 旧 `general_chat_*` 前端 compat 网关与 Rust 命令已删除 -- `Tauri runtime_status` 事件只保留前端瞬时状态用途,不再作为 timeline 事实源 -- 当前剩余治理重点:统计、记忆等旁路继续按 `runtime context` 与 `durable knowledge` 分层收口 +2. **Rust 实际注册** + `src-tauri/src/app/runner.rs` 中的 `tauri::generate_handler![...]` -## 治理案例:记忆系统 +3. **治理目录册** + `src/lib/governance/agentCommandCatalog.json` -以当前仓库里的记忆能力为例: +4. **Bridge mock 优先集合** + `src/lib/dev-bridge/mockPriorityCommands.ts` -- `unified_memory_*`:现役统一记忆主链路,后续功能优先往这里收 -- `memory_runtime_*`:现役 runtime / 上下文记忆主入口 -- `memory_get_*` / `memory_toggle_auto`:当前仍在使用的治理配置入口 -- `switch_prompt`:旧 prompt 切换命令已移除,统一使用 `enable_prompt` -- `get_legacy_api_key_credentials` 等迁移命令:前端与 Tauri 入口都已移除,避免 UI/AI 再接入历史迁移链路 +5. **默认 mock 实现** + `src/lib/tauri-mock/core.ts` 中的 `defaultMocks` -这类场景下,AI 不应该再做一套“第三套记忆命令”,而应该: +只看其中一侧都不够。只要能力仍然依赖命令边界,就至少要同时核对前端调用、Rust 注册、治理目录册、mock 集合这几面。 -1. 先判断当前需求属于主链路、兼容层,还是治理配置 -2. 如果是统一沉淀记忆,优先补到 `unified_memory_*` -3. 如果是 runtime / 上下文记忆视图,优先补到 `memory_runtime_*` -4. 如果存在旧命令又无任何调用,就直接删掉命令注册、桥接和 mock,不要继续保留空兼容壳 +## 命令分类语言 -同理,对话系统也不应该重新引回已经删除的 `general_chat_*` 命令; -后续如需扩展 Agent / Codex 工作流,应继续收敛到 `agent_runtime_*` 与对应网关; -`chat_*` 只允许作为 dead-candidate 参考,不应重新回到 `commands::mod` 或 `generate_handler!`。 +命令治理统一沿用 `governance.md` 的分类语言: -## 目录结构 +- `current`:当前主路径,后续能力继续向这里收敛 +- `compat`:兼容层,只允许委托、适配、告警,不允许长新逻辑 +- `deprecated`:废弃层,只允许迁移与下线,不允许新增依赖 +- `dead`:已停用或确认无入口,优先删除 -``` -src-tauri/src/commands/ -├── mod.rs # 模块入口 -├── credential.rs # 凭证管理命令 -├── provider.rs # Provider 命令 -├── server.rs # 服务器控制命令 -├── flow.rs # 流量监控命令 -├── config.rs # 配置命令 -├── mcp.rs # MCP 服务器命令 -└── terminal.rs # 终端命令 -``` +脚本或治理报告里还可能看到: -## 命令分类 +- `dead-candidate` -### 凭证管理 +它表示“删除候选信号”,不是自动等于 `dead`。 -```rust -#[tauri::command] -async fn add_credential( - provider: String, - file_path: String, -) -> Result; +如果本次改动说不清自己属于哪一类,先不要写代码,先读 `docs/aiprompts/governance.md`。 -#[tauri::command] -async fn remove_credential(id: String) -> Result<(), String>; +## 新增或改命令的标准步骤 -#[tauri::command] -async fn list_credentials() -> Result, String>; +### 1. 先判断是不是应该新增命令 -#[tauri::command] -async fn refresh_credential(id: String) -> Result<(), String>; +先问三个问题: -#[tauri::command] -async fn get_credential_status(id: String) -> Result; -``` +- 当前需求能不能落到已有 `current` 主链? +- 这次是补能力,还是只是在给 compat 层续命? +- 有没有已经存在但尚未收口的旧入口? -### 服务器控制 +如果答案是“已有主链可承接”,优先补现有主链,不再新开平级命令。 -```rust -#[tauri::command] -async fn start_server(config: ServerConfig) -> Result<(), String>; +### 2. 前端只从 API 网关进入 -#[tauri::command] -async fn stop_server() -> Result<(), String>; +- 在 `src/lib/api/*` 下新增或扩展对应网关 +- 页面、组件、普通 Hook 不要直接调用裸 `invoke` +- 尽量把命令名、参数整理、返回类型都收在网关层 -#[tauri::command] -async fn get_server_status() -> Result; - -#[tauri::command] -async fn update_server_config(config: ServerConfig) -> Result<(), String>; -``` - -### 流量监控 - -```rust -#[tauri::command] -async fn get_flow_records(query: FlowQuery) -> Result, String>; - -#[tauri::command] -async fn get_flow_stats(time_range: TimeRange) -> Result; - -#[tauri::command] -async fn clear_flow_records(before: Option) -> Result; -``` - -## 前端调用 - -推荐写法不是在业务层直接 `invoke`,而是在 API 网关里集中调用: +推荐写法: ```typescript // src/lib/api/serverRuntime.ts @@ -131,7 +91,7 @@ export async function getServerStatus() { } ``` -业务层只消费 API 网关: +业务层只消费网关: ```typescript import { getServerStatus } from "@/lib/api/serverRuntime"; @@ -139,21 +99,98 @@ import { getServerStatus } from "@/lib/api/serverRuntime"; const status = await getServerStatus(); ``` -## 错误处理 +### 3. Rust 命令与注册表同步 -```rust -// 命令返回 Result -// 错误信息会传递到前端 +- 在 `src-tauri/src/commands/` 下落到对应模块 +- 在 `src-tauri/src/app/runner.rs` 的 `tauri::generate_handler!` 中注册 +- 不要只写命令实现,不补注册 -#[tauri::command] -async fn example_command() -> Result { - do_something() - .await - .map_err(|e| e.to_string()) -} +### 4. 治理目录册与 mock 同步 + +命令边界发生变化时,按需同步: + +- `src/lib/governance/agentCommandCatalog.json` +- `src/lib/dev-bridge/mockPriorityCommands.ts` +- `src/lib/tauri-mock/core.ts` + +尤其是以下场景: + +- 新命令属于 runtime gateway +- 旧命令进入 `deprecated` +- 旧 helper 被替换 +- Bridge 优先命令需要本地 mock + +### 5. 文档同步 + +至少同步更新: + +- 本文档 `docs/aiprompts/commands.md` +- `docs/aiprompts/quality-workflow.md` +- 如涉及 GUI 续测,再看 `docs/aiprompts/playwright-e2e.md` + +### 6. 跑最低校验 + +至少运行: + +```bash +npm run test:contracts +``` + +必要时补: + +```bash +npm run governance:legacy-report +npm run verify:local +``` + +## 变更完成定义 + +一次命令边界改动,至少满足以下条件才算完成: + +1. 前端调用已经收口到 `src/lib/api/*` +2. Rust 命令已在 `runner.rs` 注册 +3. `agentCommandCatalog.json` 中的治理口径已同步 +4. `mockPriorityCommands` 与 `defaultMocks` 没有漂移 +5. `npm run test:contracts` 通过 +6. 涉及 compat / deprecated 的改动,已补 `governance:legacy-report` 或明确说明不需要 + +## 明确禁止 + +- 在页面、组件、普通 Hook 中直接散落 `invoke` +- 给 `compat` 路径继续长新业务逻辑 +- 把已经进入 `deprecated` / `dead-candidate` / `dead` 的命令重新接回主链 +- 只改前端或只改 Rust,一侧通过就宣布完成 +- 用“先兼容一下”作为长期保留第二套入口的理由 + +## 当前主链示例 + +以下是仓库当前已经明确收敛的几个方向: + +- **Agent / Codex 主命令**:继续收敛到 `agent_runtime_*` +- **运行态摘要主链**:Aster `runtime_status` item -> timeline `turn_summary` +- **旧 `chat_*` 命令**:已停止注册,不应重新回到 `commands::mod` 或 `generate_handler!` +- **旧 `general_chat_*` 边界**:前端 compat 网关与 Rust 命令都已移除,不应重新接入 +- **记忆系统**:统一沉淀优先走 `unified_memory_*`,runtime / 上下文视图优先走 `memory_runtime_*` + +这些示例的意义不是列清单,而是提醒: + +**不要再造第三套入口,优先继续把能力收敛到已存在的主链。** + +## 相关检查脚本 + +```bash +# 命令契约检查 +npm run test:contracts + +# 旧边界与死链收口 +npm run governance:legacy-report + +# 本地统一校验 +npm run verify:local ``` ## 相关文档 -- [services.md](services.md) - 业务服务 -- [hooks.md](hooks.md) - 前端 Hooks +- `docs/aiprompts/governance.md` +- `docs/aiprompts/quality-workflow.md` +- `docs/aiprompts/credential-pool.md` diff --git a/docs/aiprompts/content-creator.md b/docs/aiprompts/content-creator.md index 567d615cc..472953762 100644 --- a/docs/aiprompts/content-creator.md +++ b/docs/aiprompts/content-creator.md @@ -140,12 +140,14 @@ const sendMessage = async (content: string, ...) => { messageToSend = `${systemPrompt}\n\n---\n\n用户请求:${content}`; } - await runtime.submitTurn({ - message: messageToSend, + await runtime.submitOp({ + type: "user_input", + text: messageToSend, sessionId, eventName, workspaceId, systemPrompt, + preferences: {}, }); }; ``` diff --git a/docs/aiprompts/credential-pool.md b/docs/aiprompts/credential-pool.md index d62c92c92..a44676110 100644 --- a/docs/aiprompts/credential-pool.md +++ b/docs/aiprompts/credential-pool.md @@ -59,11 +59,11 @@ impl RoundRobinPool { .iter() .filter(|c| c.is_healthy()) .collect(); - + if healthy.is_empty() { return None; } - + let index = self.current_index .fetch_add(1, Ordering::Relaxed) % healthy.len(); Some(healthy[index]) @@ -83,11 +83,11 @@ pub struct WeightedPool { ### 检查项目 -| 检查项 | 说明 | 频率 | -|--------|------|------| -| Token 过期 | 检查 expires_at | 每次请求前 | +| 检查项 | 说明 | 频率 | +| ---------- | ------------------ | ------------ | +| Token 过期 | 检查 expires_at | 每次请求前 | | Token 刷新 | 尝试刷新过期 Token | Token 过期时 | -| API 可用性 | 发送测试请求 | 定时 (5分钟) | +| API 可用性 | 发送测试请求 | 定时 (5分钟) | ### 健康状态 @@ -125,7 +125,7 @@ async fn health_check_task(pool: Arc) { _ => {} } } - + tokio::time::sleep(Duration::from_secs(300)).await; } } @@ -153,7 +153,7 @@ impl TokenCacheService { return Ok(cached.access_token.clone()); } } - + // 刷新并缓存 let new_token = self.refresh(credential_id).await?; self.cache.insert(credential_id.to_string(), new_token.clone()); @@ -220,3 +220,10 @@ async fn get_pool_status() -> Result; - [providers.md](providers.md) - Provider 系统 - [services.md](services.md) - 业务服务 - [database.md](database.md) - 数据库层 + +## 运行时路径与调试 + +- 凭证文件默认存放在应用数据目录下的 `lime/credentials/` +- `~/Library/Application Support/lime/credentials/` 只作为 macOS 示例,Windows 必须使用对应的应用数据目录 +- `request_logs`、日志目录等运行时路径也应通过统一 `app_paths` / 系统目录 API 获取,不要在实现里写死 +- 需要排查 Kiro 凭证加载时,可使用 `debug_kiro_credentials` 对应命令进行诊断;具体命令边界以 `docs/aiprompts/commands.md` 和 Rust 注册表为准 diff --git a/docs/aiprompts/governance.md b/docs/aiprompts/governance.md index 3a6997377..9520a628c 100644 --- a/docs/aiprompts/governance.md +++ b/docs/aiprompts/governance.md @@ -1,50 +1,55 @@ -# 治理第一原则 +# 治理判断手册 -## 核心规则 +## 这份文档回答什么 + +本文件定义 Lime 仓库的治理判断标准,主要回答: + +- 什么才算“统一事实源”,而不是“又补了一套更新版本” +- 哪些路径还能继续演进,哪些路径只能收口、下线或删除 +- 遇到新旧并存时,应该先做什么,而不是先补功能再说 +- 如何用仓库现有守卫阻止 compat / deprecated 路径继续膨胀 + +它是 **仓库治理规则**,不是某个 AI 工具、reviewer、sub-agent 或外部流程的说明书。 + +## 第一原则 **同一种能力,在同一时期只能存在一个继续演进的事实源。** -其余实现必须被明确归类为: +其余实现必须被明确归类。 + +## 分类语言 + +治理默认使用这四类: - `current`:当前唯一主路径,后续需求只允许继续向这里收敛 - `compat`:兼容层,只允许委托、适配、告警,不允许继续长新逻辑 - `deprecated`:废弃层,只允许迁移与下线,不允许新增依赖 -- `dead`:无入口或已停用,优先删除 +- `dead`:已停用或确认无入口,优先删除 -如果做不到这一点,系统就不会持续演进,只会持续膨胀。 +仓库脚本还可能给出一些辅助信号,例如: -## 文档定位 +- `dead-candidate` +- `unused-file` +- `unused-export` +- `zero-inbound` -本文件定义的是 **Lime 仓库的治理判断标准**,不是某一种具体工具或 Agent 框架的使用说明。 +这些信号 **不是正式分类本身**。例如 `dead-candidate` 代表“很可能可以删”,但不是自动等于 `dead`,仍需要人工确认。 -它解决的是: +## 什么时候先读 -- 什么才算“统一”,而不是“又写了一套更新版本” -- 什么路径还允许继续演进,什么路径只能收口或删除 -- 什么时候可以保留兼容层,什么时候必须补守卫或删除 - -它不直接规定: - -- 必须使用哪一种 harness、hook、sub-agent 或 reviewer -- 必须采用哪一个 AI 工具的工作流 - -如果未来引入额外 reviewer agent,它也只能 **执行** 本文件定义的治理规则,不能替代本文件成为新的事实源。 - -## 适用场景 - -当出现以下任一情况时,必须先读本文件,再决定是否改代码: +出现以下任一情况时,先读本文件,再决定是否改代码: - 新旧 Hook、新旧组件、新旧命令并存 -- 前端已经有新抽象,Rust 后端仍保留多套入口 -- 新服务已经落地,但旧数据表、旧 DAO、旧旁路查询仍在使用 -- 需求迭代后,AI 倾向继续沿用旧实现 -- 团队打算“先补功能,后面再统一” +- 前端已经切到新入口,Rust / 数据 / 旁路系统还在继续走旧路径 +- 新服务已落地,但旧表、旧 DAO、旧目录兼容仍被依赖 +- 需求迭代后,AI 倾向沿旧实现继续生成 +- 团队想“先补功能,后面再统一” -## 强制执行规则 +## 治理工作流 ### 1. 先盘点,再修改 -开始改动前,必须先盘点这项能力在 4 层中的实际分布: +开始改动前,先盘点这项能力在 4 层中的分布: - 入口层:页面、组件、Hook、前端 API - 服务层:Tauri 命令、Service、Workflow、事件入口 @@ -71,75 +76,61 @@ ### 3. 先分类,再动刀 -盘点完成后,必须把实际路径标成以下四类之一: +盘点完成后,把实际路径标成以下类型之一: - `current` - `compat` - `deprecated` - `dead` -分类不是文档装饰,而是后续动作的约束: +并为 `compat` / `deprecated` 写清退出条件: -- `current`:允许继续演进,但禁止再新增平级实现 -- `compat`:必须持续收口,不能承接新需求 -- `deprecated`:必须限制新增依赖,并明确退出条件 -- `dead`:优先删除,至少先建立自动守卫防止回流 +- 迁完哪些调用即可删 +- 哪个版本或阶段必须删 +- 删除前要看哪些扫描结果或指标 -### 4. compat 只能收口,不能增强 +没有退出条件的 compat,最终都会常驻。 -compat 层存在的唯一理由是迁移。 +### 4. 优先做减法 -compat 层允许: +默认优先执行这些动作,而不是再加一层抽象: -- 参数转换 -- 返回值适配 -- 委托到新实现 -- 迁移期埋点和告警 +- 把散落逻辑收回单一边界 +- 把 legacy 判断收回 `Repository` / `Database` / `app_paths` +- 让 compat 层只做委托与适配 +- 删除零引用入口 +- 把运行时 fallback 改成启动期迁移或边界短路 -compat 层禁止: +除非用户明确要求保留兼容,否则不要新增新的 compat 层。 -- 新增业务逻辑 -- 新增状态来源 -- 新增独立存储 -- 新增旁路能力 - -一旦 compat 层承载新需求,它就不再是 compat,而是新的分叉点。 - -### 5. 禁止回流,优先于“推荐新方案” +### 5. 先封旧路,再谈“推荐新路” 治理不能靠口头约定,必须靠守卫机制。 -至少建立以下一种或多种守卫: - -- ESLint / 静态规则禁止 import 旧入口 -- Rust 对旧命令输出 `warn` 与调用统计 -- CI 阻止新代码继续引用废弃路径 -- 脚本扫描旧表、旧 DAO、旧命令、旧 Hook 的新增使用点 - -当前仓库可直接运行: +当前仓库优先使用: ```bash npm run governance:legacy-report npm run test:contracts ``` -- `npm run governance:legacy-report` 用于扫描: - - 已被判定为 `deprecated` / `dead-candidate` 的前端入口 - - 旧 Tauri 命令是否仍然只收口在指定 API 网关 - - 哪些兼容壳层已经零引用,可以进入删除候选 -- `npm run test:contracts` 用于检查跨层命令契约: - - 前端 `safeInvoke(...)` / `invoke(...)` 的实际命令调用 - - Rust `tauri::generate_handler!` 的实际注册表 - - `agentCommandCatalog` 中的 `deprecated` 命令与 `runtime gateway` 命令边界 - - `mockPriorityCommands` 与 `defaultMocks` 是否仍然同步 +它们分别用于: -只看其中一侧都不够。只要能力仍然依赖命令边界,至少要同时看前端调用、Rust 注册、deprecated 目录、mock 集合这四个面。 +- `governance:legacy-report` + - 扫描已被判定为 `deprecated` / `dead-candidate` 的前端入口 + - 检查旧 Tauri 命令是否仍被限制在指定 API 网关 + - 找出已经零引用、可进入删除候选的兼容壳 +- `test:contracts` + - 检查前端 `safeInvoke(...)` / `invoke(...)` 的实际调用 + - 检查 Rust `tauri::generate_handler!` 的实际注册 + - 检查 `agentCommandCatalog` 中的治理口径 + - 检查 `mockPriorityCommands` 与 `defaultMocks` 是否同步 原则只有一句: -**不是鼓励走新路,而是封住老路。** +**不是鼓励走新路,而是先封住老路。** -### 6. 主链路和旁路必须一起治理 +### 6. 主链路和旁路一起治理 如果只迁: @@ -157,98 +148,36 @@ npm run test:contracts 那么旧表、旧命令、旧 DAO 最终都删不掉。 -治理完成的标准不是“页面能跑”,而是“系统生态已收口”。 +治理完成的标准不是“页面能跑”,而是“系统生态已经收口”。 -### 7. 删除必须有退出条件 +### 7. 验证后再删 -每一个 `compat` 或 `deprecated` 路径,都必须有明确退出条件: +只有当以下条件同时满足时,才允许删除旧路径: -- 哪些调用迁完即可删 -- 哪个版本或阶段必须删除 -- 删除前要验证哪些指标或扫描结果 +- 新增依赖已经被封住 +- 调用量或引用已清零 +- 旁路系统已经迁完 +- 边界检查与定向验证通过 -没有退出条件的兼容层,最终一定会常驻。 +## Lime 特别关注的三类边界 -## 推荐执行闭环 +### 1. 命令边界 -### 第一步:出迁移地图 +只要改动涉及 Tauri 命令、Bridge、mock、前端 API 网关,至少同时看这几处: -至少列清楚: +- 前端 `safeInvoke(...)` / `invoke(...)` +- Rust `tauri::generate_handler!` +- `src/lib/governance/agentCommandCatalog.json` +- `src/lib/dev-bridge/mockPriorityCommands.ts` +- `src/lib/tauri-mock/core.ts` -- 当前主路径 -- 兼容路径 -- 废弃路径 -- 无入口路径 +命令边界的详细规则,直接看: -### 第二步:写事实源声明 +- `docs/aiprompts/commands.md` -例如: +### 2. 运行时路径边界 -> 聊天能力后续统一收敛到 `useAgentChatUnified -> useAsterAgentChat -> agent_runtime_* + lime_core::database::agent_session_repository`。 - -## 文件级治理图谱配套规则 - -如果要维护文件 / 页面级治理图谱,仓库内默认配套: - -- 人工治理规则:`governance/surfaces.yml` -- 交互图谱命令:`npm run governance:graph` -- 既有边界扫描:`npm run governance:legacy-report` - -约束: - -- `current / compat / deprecated / dead` 只能来自人工治理规则或仓库内既有治理规则 -- 自动脚本只能补 `dead-candidate`、`unused-file`、`unused-export`、`zero-inbound`、`page-unreachable` 等信号 -- `dead` 不能由脚本自动判定,必须人工确认 -- `compat` 规则必须写清 `sourceOfTruth` -- `deprecated` 规则必须写清 `exitCriteria` -- 如遇构建期 alias、运行时桥接、特殊入口等静态分析盲区,可补 `ignoreSignals` 仅压制误报 signal,不改变事实源判断 - -### 第三步:优先做减法 - -默认优先做这些动作,而不是再加一层抽象: - -- 把上层散落逻辑收回单一边界 -- 把 legacy 判断收回 `Repository / Database / app_paths` -- 让 compat 层只做委托与适配 -- 删除零引用入口 -- 把运行时 fallback 改成启动期迁移或边界短路 - -除非用户明确要求保留兼容,否则不要新增新的 compat 层。 - -### 第四步:补守卫 - -至少加一条能自动失败的规则,阻止旧路径继续增长。 - -如果改动涉及 Tauri 命令、前端 API 网关、bridge 或 mock,优先补: - -- `npm run test:contracts` -- `npm run governance:legacy-report` - -### 第五步:迁旁路 - -确认统计、记忆、搜索、报表、审计、任务系统不再依赖旧实现。 - -### 第六步:验证并删除 - -只有当新增依赖已被封住、调用量清零、旁路迁完,才允许删除旧路径。 - -## Lime 中的典型判断方式 - -以聊天系统为例,遇到新旧并存时,必须同时问这几个问题: - -- 前端唯一入口是不是 `useAgentChatUnified -> useAsterAgentChat`,还是 `useChat` / `useAgentChat` / `useUnifiedChat` 还在继续长逻辑? -- Rust 唯一入口是不是 `agent_runtime_*`,还是 `chat_*` / `general_chat_*` / `agent_*` / `aster_agent_*` 还在平行演进? -- 前端 `safeInvoke(...)` / `invoke(...)`、Rust `tauri::generate_handler!`、`agentCommandCatalog`、`mockPriorityCommands` / `defaultMocks` 这四个命令边界是不是仍然一致,还是已经产生漂移? -- 数据事实源是不是同一组表 / 同一套 Repository,还是还在同时写 `agent_*` 与 `general_chat_*`? -- 统计、记忆等旁路是不是已经切到新路径,还是还在读旧表? - -只要其中任意一个答案是否定的,就说明治理还没完成。 - -## Lime 的治理偏好 - -### 路径与目录 - -涉及运行时目录、历史目录兼容、用户数据落盘时,优先收口到统一路径边界,例如 `app_paths` 或等价统一入口。 +涉及用户数据、日志、缓存、凭证、workspace、历史目录兼容时,优先收口到统一路径入口,例如 `app_paths` 或等价边界。 不要在上层继续手写: @@ -256,7 +185,9 @@ npm run test:contracts - `C:/Users/...` - `~/.lime/...` -### 数据迁移 +路径兼容是边界问题,不应该变成业务层到处散落的字符串问题。 + +### 3. 数据迁移与语义暴露 一旦历史数据迁移已接入启动流程: @@ -264,15 +195,13 @@ npm run test:contracts - 旧表只允许服务迁移、审计与回放 - 业务层优先消费 `pending_*` 等迁移态语义 -不要在多个 service 中重复写: +不要在多个 service 或 command 里重复写: - `is_migrated` - `legacy_*` - 手工分叉短路逻辑 -### 语义暴露 - -过渡期对外暴露的命名必须体现“迁移态”语义,例如: +过渡期对外暴露的命名,也应该体现“迁移态”语义,例如: - `pending_*` @@ -280,32 +209,51 @@ npm run test:contracts - `legacy_*` - `general_chat_*` -- 仅体现历史实现而不体现迁移语义的模块名 +- 只体现历史实现、不体现迁移语义的模块名 -## 与自动 reviewer / sub-agent 的关系 +## Lime 的典型判断方式 -如果未来为 Lime 增加治理 reviewer、hook 或额外 sub-agent,推荐把它们当成 **执行器**,而不是新的治理定义者。 +### 命令与会话主链 -它们至少应检查: +遇到 Agent / 聊天 / 会话相关新旧并存时,至少问这几个问题: + +- 前端唯一入口是不是已经收敛到现役 API 网关 +- Rust 唯一入口是不是已经收敛到 `agent_runtime_*` +- 旧 `chat_*`、`general_chat_*`、历史 helper 是否还在继续长逻辑 +- 命令契约五个事实源之间有没有漂移 + +只要其中任意一个答案是否定的,就说明治理还没完成。 + +### 记忆与旁路 + +遇到记忆系统治理时,至少同时看: + +- 统一沉淀能力是否继续收敛到 `unified_memory_*` +- runtime / 上下文视图是否继续收敛到 `memory_runtime_*` +- 统计、搜索、审计等旁路是否还在读旧路径 + +不要为了补一个功能,再造第三套记忆入口。 + +## 自动 reviewer / sub-agent 的角色 + +如果未来为 Lime 增加 reviewer、hook 或额外 sub-agent,它们只能做 **执行器**,不能成为新的治理事实源。 + +它们至少应该检查: - 是否新增了与 `current` 平级的第二套实现 - 是否让 `compat` 长了新业务逻辑 - 是否只迁主链路却漏掉旁路 -- 是否出现了新的旧入口引用或旧命令回流 +- 是否出现新的旧入口引用或旧命令回流 - 是否补了守卫与验证 -它们的输出应回到本文件的分类语言: +它们的输出,也必须回到本文件的分类语言: -- 本次改动涉及哪些 `current` -- 哪些路径仍是 `compat` -- 哪些路径已进入 `deprecated` -- 哪些路径已经可以判定为 `dead` +- 哪些是 `current` +- 哪些仍是 `compat` +- 哪些进入 `deprecated` +- 哪些只是 `dead-candidate`,哪些已经能确认是 `dead` -换句话说: - -**自动 reviewer 可以帮你发现问题和加速修复,但不能替你定义“什么叫收口完成”。** - -## 禁止事项 +## 明确禁止 出现以下行为,视为违反治理原则: @@ -316,26 +264,27 @@ npm run test:contracts - 主链路改到新表,旁路系统仍直接查旧表 - 看到“旧代码还能用”,就继续让 AI 沿旧上下文生成 -## AI 执行要求 - -未来 AI 在处理“新旧并存、迁移、重构、统一”类任务时,默认遵守以下要求: - -1. 不允许直接在旧路径上继续扩展新功能,除非用户明确要求做兼容补丁。 -2. 必须优先识别唯一事实源,并围绕事实源收口,而不是继续新增平级实现。 -3. 必须显式说明当前改动属于 `current`、`compat`、`deprecated`、`dead` 中哪一类。 -4. 如果发现主链路与旁路系统割裂,必须指出,不得假装治理已经完成。 -5. 如果无法在本次改动中完成收口,至少要建立守卫,阻止问题继续扩散。 -6. 如果已有自动 reviewer / hook / 子 agent,它们的结论必须服从本文件,而不是绕开本文件自行定义标准。 - -## 输出要求 +## 汇报要求 涉及治理类改动时,汇报结果至少应包含: 1. 本次收掉了哪些 surface 2. 当前改动分别属于 `current` / `compat` / `deprecated` / `dead` 中哪一类 3. 补了哪些守卫和验证 -4. 还剩哪条主链路或旁路最值得继续优化 +4. 剩余最值得继续优化的一刀是什么 + +如果仍保留 `dead-candidate`、延期白名单或临时例外,必须写明: + +- 具体对象 +- 当前原因 +- 退出条件 + +## 相关文档 + +- `docs/aiprompts/commands.md` +- `docs/aiprompts/quality-workflow.md` +- `docs/aiprompts/project-heatmap.md` ## 一句话总结 -**治理不是继续写一个“更新的版本”,而是让系统以后只能向一个版本收敛。** +**治理不是继续写一个“更新版本”,而是让系统以后只能向一个版本收敛。** diff --git a/docs/aiprompts/limecore-collaboration-entry.md b/docs/aiprompts/limecore-collaboration-entry.md new file mode 100644 index 000000000..dbc556423 --- /dev/null +++ b/docs/aiprompts/limecore-collaboration-entry.md @@ -0,0 +1,49 @@ +# LimeCore 联动入口说明 + +当任务同时涉及 `lime` 客户端与服务端能力时,不要只在本仓库里猜测。 + +跨仓库协作主文档放在: + +- 绝对路径:`/Users/coso/Documents/dev/ai/limecloud/limecore/docs/aiprompts/lime-limecore-collaboration.md` +- 在 `limecore` 仓库内的相对路径:`docs/aiprompts/lime-limecore-collaboration.md` + +## 什么时候先读主文档 + +遇到下面这些任务时,默认先读 `limecore` 主文档: + +- OEM 登录、Google 登录、desktop auth session +- 用户中心、个人资料、会话同步 +- AI 服务商页、云端 Provider、默认来源、模型目录 +- `client/bootstrap`、`client/session`、`client/profile` +- Gateway、Scene、Service Skill 云配置同步 +- 任何“客户端要不要本地维护一份服务端数据”的判断 + +## 为什么主文档放在 `limecore` + +因为跨仓库联动里的正式事实源更多在服务端: + +- 认证与会话 +- 客户端 bootstrap +- 用户资料与账户能力 +- Provider Offer / 服务目录 / Scene Catalog +- Gateway 与云端运行时策略 + +客户端仓库更适合作为实现消费方,而不是这些能力的唯一背景事实源。 + +## 在 `lime` 仓库里继续优先看这些 + +读完主文档后,如果确认主要是客户端实现,再回到本仓库重点看: + +- `src/hooks/useOemCloudAccess.ts` +- `src/lib/api/oemCloudControlPlane.ts` +- `src/lib/api/oemCloudRuntime.ts` +- `src/components/settings-v2/` +- `src/components/agent/` +- `src-tauri/` + +## 默认工作原则 + +- 服务端已有接口时,优先补客户端接线 +- 云事实源不要在客户端长期维护第二份 +- 能走运行时配置和 `bootstrap.features` 的,不要写死在前端 +- 用户界面不要直接暴露 “OEM” 技术概念 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index e82b42603..be3e6004e 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -1,30 +1,33 @@ -# Playwright MCP 续测与 E2E 指南 +# Playwright MCP 续测与 E2E -## 目的 +## 这份文档回答什么 -本文件用于指导 AI Agent 在 Lime 中继续进行浏览器端 E2E 测试,特别适用于以下场景: +本文件说明 AI Agent 在 Lime 中如何继续做 GUI 交互验证,主要回答: + +- 什么情况下应该进入 Playwright MCP,而不是只跑本地测试 +- 如何复用现有浏览器标签页和页面状态 +- GUI 续测前最少要做哪些准备 +- 出现 bridge 缺口、mock fallback、控制台报错时该怎么判断 + +它是 **GUI 续测手册**,不是新的本地 Playwright 测试文件模板。 + +## 什么时候先读 + +遇到以下任一情况时,先读本文件: - 用户说“继续测试”“继续复现”“继续用 Playwright MCP 验证” - 需要复用当前浏览器标签页和已有页面状态 - 需要排查浏览器模式下的 DevBridge、mock fallback、控制台报错 +- 已经跑过最小 GUI smoke,接下来要做真实页面交互验证 -## 适用边界 +## 使用边界 -- **优先使用 Playwright MCP** 做交互验证,不优先编写新的本地 Playwright 测试文件 -- **浏览器模式默认首页不变**,从 `http://127.0.0.1:1420/` 进入 -- **能走真实后端就走真实后端**;浏览器模式暂不支持或尚未桥接的能力,允许继续走 mock -- 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测,不要强行启动整条 E2E +- 优先使用 **Playwright MCP** 做交互验证,不优先编写新的本地 Playwright 测试文件 +- 浏览器模式默认首页从 `http://127.0.0.1:1420/` 进入 +- 能走真实后端就走真实后端;浏览器模式暂不支持或尚未桥接的能力,允许走 mock +- 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测或 `verify:local` -## AGENTS.md 最佳实践(本仓库落地版) - -为避免 `AGENTS.md` 膨胀,遵循以下组织方式: - -1. **根 `AGENTS.md` 只保留仓库级规则和入口索引** -2. **长流程文档放 `docs/aiprompts/`**,例如本文件 -3. **只有某个子目录存在长期稳定、强作用域规则时,才新增子目录 `AGENTS.md`** -4. **临时排障记录不要写进 `AGENTS.md`**,应写入普通文档或直接在任务对话里说明 - -## 启动方式 +## 进入前的最低准备 ### 推荐启动命令 @@ -33,21 +36,10 @@ npm run tauri:dev:headless ``` 用途: + - 启动前端 dev server - 启动 Tauri headless 调试环境 - 启动浏览器模式所需的 DevBridge -- 便于 Playwright MCP 访问 `http://127.0.0.1:1420/` - -### 针对性前端校验 - -```bash -npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts -``` - -适用时机: -- 修改了 `safeInvoke` -- 修改了 `src/lib/tauri-mock/` -- 修改了浏览器模式 bridge/mock 优先级 ### 桥接健康检查 @@ -56,82 +48,87 @@ npm run bridge:health -- --timeout-ms 120000 ``` 用途: + - 等待 `http://127.0.0.1:3030/health` 就绪 -- 避免 Playwright MCP 进入页面时,前端早于 DevBridge 启动而产生 `Failed to fetch` 噪音 -- 首次编译较慢时,比手工反复刷新页面更稳定 +- 避免 Playwright 进入页面时,前端早于 DevBridge 启动而产生 `Failed to fetch` 噪音 -### 已验证的最小冒烟路径(当前仓库) +### 命令 / bridge 相关定向测试 -1. 终端 A:`npm run tauri:dev:headless` -2. 终端 B:`npm run bridge:health -- --timeout-ms 120000` -3. Playwright MCP 打开 `http://127.0.0.1:1420/` -4. 等待首页从“正在加载...”进入默认首页 -5. 检查 `browser_console_messages(level=error)` 应为 `0` +```bash +npm run test:bridge +npm run test:contracts +``` -补充说明: -- 若首页已可用但仍有 warning,先区分是第三方库 warning 还是 bridge 缺口 -- 若 `bridge:health` 已通过,但页面仍报未知命令,优先检查 `dispatcher.rs` 是否缺少该命令分发 +适用时机: -## 继续测试的标准流程 +- 修改了 `safeInvoke` +- 修改了 `src/lib/tauri-mock/` +- 修改了浏览器模式 bridge/mock 优先级 +- 修改了 Tauri 命令边界 -### 1. 先确认当前 Playwright 会话是否可复用 +## 标准续测流程 + +### 1. 先确认当前浏览器会话是否可复用 优先顺序: -1. 调用 `browser_tabs` 查看当前标签页 -2. 如果已有 `Lime` 标签页,先查看当前 URL、标题、页面状态 +1. 调用标签页工具查看当前标签页 +2. 如果已有 `Lime` 标签页,先查看当前 URL、标题和页面状态 3. 如果页面已漂移到旧状态,直接重新导航到 `http://127.0.0.1:1420/` 建议: -- **继续测试优先复用当前标签页**,避免无意义重复建页 -- **如果控制台历史噪音太多,刷新页面重新计数** -### 2. 进入页面前先检查加载状态 +- 继续测试优先复用当前标签页,避免无意义重复建页 +- 如果控制台历史噪音太多,刷新页面重新计数 + +### 2. 进入页面后先验证加载状态 推荐动作: 1. 打开页面后等待“正在加载...”消失 -2. 使用 `browser_snapshot` 确认首页核心元素已出现 -3. 立刻检查一次 `browser_console_messages(level=error)` +2. 用页面快照确认首页核心元素已出现 +3. 立刻检查一次控制台 error 通过标准: + - 首页成功加载 - 默认首页可交互 -- 初始控制台 error 为 0;若非 0,需要先定位是否为 bridge 缺口 +- 初始控制台 error 为 0;如果不是 0,先定位是否为 bridge 缺口 -### 3. 交互时优先使用稳定定位方式 +### 3. 交互时优先使用稳定定位 遵循 Playwright 官方最佳实践: -- 优先使用 **角色、名称、可见文本** 定位 +- 优先用角色、名称、可见文本定位 - 优先使用 Playwright 自带等待与 web-first 断言 -- **不要依赖固定 sleep** 代替状态判断 +- 不要依赖固定 sleep 代替状态判断 - 点击前先确认元素可见、可交互 -本仓库中建议优先使用: +本仓库中优先使用: + - `button` + 中文名称 - 页面中明确可见的标题文本 -- `browser_snapshot` 返回的 ref 作为精确交互目标 +- 快照里的精确元素引用 -## Lime 推荐 E2E 主路径 +## Lime 推荐续测主路径 ### 首页基础验证 1. 打开 `http://127.0.0.1:1420/` 2. 等待默认首页加载完成 -3. 验证首页主导航可见:如“首页”“社媒内容”“设置” -4. 检查 `browser_console_messages(level=error)` 为 0 +3. 验证主导航可见,例如“首页”“社媒内容”“设置” +4. 检查控制台 error 是否为 0 -### 社媒内容工作流验证 +### 社媒内容工作流 1. 点击 `社媒内容` -2. 如果没有项目:点击 `新建项目` -3. 如果已有项目:直接选择目标项目 +2. 没有项目时点击 `新建项目` +3. 已有项目时直接选择目标项目 4. 点击 `新建文稿` 5. 选择 `新开帖子(创建新文稿)` 6. 点击 `确认生成` 7. 验证页面出现 `Theme Workbench` 或相关工作台内容 -8. 再次检查 `browser_console_messages(level=error)` +8. 再次检查控制台 error ### 素材页验证 @@ -140,7 +137,7 @@ npm run bridge:health -- --timeout-ms 120000 3. 验证素材计数、列表项或空状态正常显示 4. 检查控制台无新增 error -## 每一步都要记录什么 +## 每一步至少记录什么 执行 Playwright MCP 续测时,至少记录以下事实: @@ -149,7 +146,7 @@ npm run bridge:health -- --timeout-ms 120000 - 是否走到了真实 bridge - 是否触发了 mock fallback - 控制台 error 数量 -- 如失败,明确失败命令名(例如某个 invoke command) +- 如失败,明确失败命令名或失败交互点 推荐结论格式: @@ -157,17 +154,19 @@ npm run bridge:health -- --timeout-ms 120000 - 业务流是否走通 - 控制台是否归零 - 新暴露的命令缺口是什么 -- 该缺口适合补真实 bridge 还是补 mock +- 该缺口更适合补真实 bridge 还是补 mock -## 浏览器模式常见故障与处理 +## 常见故障与处理 ### 1. `Cannot read properties of undefined (reading 'invoke')` 通常表示: + - 浏览器里加载了真实 Tauri API 包 - 没有走 web mock / HTTP bridge 链路 优先排查: + - 是否使用了浏览器模式专用启动方式 - Vite 是否正确走了 web alias - 当前页面是否需要强制刷新以拿到最新前端代码 @@ -175,22 +174,26 @@ npm run bridge:health -- --timeout-ms 120000 ### 2. `[DevBridge] 未知命令` 说明: + - 前端已调用某命令 - 浏览器 bridge 分发器没有实现 处理顺序: + 1. 先判断该命令是否应走真实后端 2. 如果该能力在浏览器模式下不是关键阻塞项,可加入 mock 优先集合 -3. 如果该命令属于核心业务路径,优先补 `dispatcher.rs` +3. 如果该命令属于核心业务路径,优先补 bridge 分发 ### 3. `Failed to fetch` 常见原因: + - DevBridge 没启动 -- 3030 端口不可用 +- `3030` 端口不可用 - 前端先于 bridge 就绪开始调用 处理建议: + - 确认 `tauri:dev:headless` 已启动 - 检查 bridge 健康接口 - 刷新页面后复测,排除启动时序问题 @@ -198,18 +201,21 @@ npm run bridge:health -- --timeout-ms 120000 ### 4. UI 已可用但控制台仍报错 说明: + - 页面可能依赖 fallback mock 继续运行 - 但仍有命令先打到了 bridge 并报 unknown command 处理建议: -- 若该命令属于浏览器模式可接受的降级能力,加入 mock 优先列表 -- 若该命令属于当前主路径必须能力,补真实 bridge + +- 如果该命令属于浏览器模式可接受的降级能力,加入 mock 优先列表 +- 如果该命令属于当前主路径必须能力,补真实 bridge ## 何时补 mock,何时补真实 bridge ### 优先补真实 bridge 适用于: + - 当前主路径必须命令 - 明确已有后端实现 - 返回结构简单稳定 @@ -218,25 +224,32 @@ npm run bridge:health -- --timeout-ms 120000 ### 优先补 mock 适用于: + - 浏览器模式不支持的原生能力 - 非主路径功能 - 高频噪音命令,但不影响主流程完成 -- 流式/系统级能力,短期内 bridge 成本高于收益 +- 流式 / 系统级能力,短期内 bridge 成本高于收益 ## 结果判定标准 一次“继续测试”完成后,至少满足以下之一: -1. **主路径走通且控制台 error 归零** -2. **主路径走通,且剩余错误已被明确归类为非阻塞项** -3. **已定位新的 bridge 缺口,并给出下一步最小修复点** +1. 主路径走通且控制台 error 归零 +2. 主路径走通,且剩余错误已被明确归类为非阻塞项 +3. 已定位新的 bridge 缺口,并给出下一步最小修复点 -## 给后续 Agent 的交接要求 +## 交接要求 -如果本轮没有完全收口,请在结论中明确留下: +如果本轮没有完全收口,结论里必须留下: - 当前停留页面 - 已完成的业务步骤 - 最新暴露的命令缺口 - 推荐下一步先补 mock 还是先补 bridge - 下一轮建议的 Playwright 复测路径 + +## 相关文档 + +- `docs/aiprompts/quality-workflow.md` +- `docs/aiprompts/commands.md` +- `docs/aiprompts/governance.md` diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md new file mode 100644 index 000000000..c941eba34 --- /dev/null +++ b/docs/aiprompts/quality-workflow.md @@ -0,0 +1,242 @@ +# Lime 工程质量工作流 + +## 这份文档回答什么 + +本文件定义 Lime 仓库的工程质量入口,主要回答四个问题: + +- 不同改动,提交前默认该跑什么 +- 为什么 Lime 作为 GUI 桌面产品,不能只看 `lint` / `typecheck` / 单测 +- `verify:local`、`verify:gui-smoke`、`test:contracts`、Playwright E2E 分别覆盖什么风险 +- `.github/workflows/quality.yml` 与本地校验如何保持一条主线 + +它是 **工程入口文档**,不是某个模块的实现设计文档。 + +## 什么时候先读 + +遇到以下任一情况时,先读本文件: + +- 不确定本次改动最少该跑哪些校验 +- 修改了 GUI 壳、DevBridge、Workspace、Tauri 命令、前端主路径 +- 需要判断跑最小 smoke 还是交互型 E2E +- 需要理解 `quality.yml` 为什么触发某些 CI 任务 + +## 交付定义 + +对 Lime 来说,“代码通过检查” 不等于 “产品可以交付”。 + +一次可交付的改动,至少要满足: + +1. **静态与定向校验通过** - 对应范围的 lint、类型检查、单测、Rust 定向测试通过 +2. **边界变更已同步** - 命令、桥接、配置、版本等结构性改动完成成组更新 +3. **GUI 主路径可运行** - 涉及 GUI 壳、Bridge、Workspace、主页面路径时,最小冒烟通过 +4. **用户可见回归已补齐** - 用户可见 UI 改动有稳定断言或既有 snapshot 回归 +5. **文档与锁文件不掉队** - 相关文档、schema、锁文件与实际实现保持一致 + +## 执行硬规则 + +### 1. 不要继续扩展 compat / deprecated 路径 + +- 新 API、新 Tauri 命令、新前端入口默认落在当前 `current` 主路径 +- 不要继续给 legacy / compat 网关长新表面 +- 如果发现能力已经存在多条路径,先读 `docs/aiprompts/governance.md` + +### 2. 协议改动必须同步四侧 + +涉及命令或桥接协议时,至少检查: + +- 前端 `safeInvoke(...)` / `invoke(...)` 的实际调用 +- Rust `tauri::generate_handler!` 的实际注册 +- `src/lib/governance/agentCommandCatalog.json` 的治理口径 +- `mockPriorityCommands` 与 `defaultMocks` 的同步状态 + +只改其中一侧,不算完成。 + +### 3. 用户可见 UI 改动必须补稳定回归 + +- 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言 +- 如果目标区域已有 snapshot / 结构化快照机制,沿用现有机制 +- 不要因为“只是 UI”就跳过回归 + +### 4. 配置与依赖改动必须成组提交 + +- 改配置结构时,要同步更新 schema、校验器、消费者与文档 +- 改版本结构时,要执行 `npm run verify:app-version` +- 改依赖时,要同步提交对应锁文件,如 `package-lock.json`、`src-tauri/Cargo.lock` +- 本仓库没有 Bazel,不适用 Bazel lockfile 规则 + +### 5. Rust 校验先小后大 + +- 默认先跑受影响 crate、模块或定向测试 +- 再根据边界扩散决定是否执行全量 `cargo test` +- 目标是尽快暴露问题,而不是一上来把所有测试都跑满 + +## 质量分层 + +### Layer 0:快速提醒 + +入口: + +- `.husky/pre-commit` +- `npm run ai-verify` + +作用: + +- 做提交前的快速卫生检查 +- 暴露明显问题与风险 + +边界: + +- **不替代** 编译、测试、契约检查、GUI smoke + +### Layer 1:本地统一入口 + +入口: + +```bash +npm run verify:local +npm run verify:local:full +``` + +作用: + +- 根据改动范围自动选择前端、Rust、Bridge、GUI smoke 等检查 +- 让开发者在发起 PR 前有一个统一入口 + +适用建议: + +- 普通功能改动:默认执行 `npm run verify:local` +- 跨前后端、大范围重构、发布前自检:执行 `npm run verify:local:full` + +### Layer 2:GUI 最小冒烟 + +入口: + +```bash +npm run verify:gui-smoke +``` + +作用: + +- 启动或复用 `headless Tauri` +- 等待 `DevBridge` 健康检查通过 +- 验证默认 workspace 的准备态可用 + +它解决的是 GUI 产品特有风险: + +- 前端壳能不能真正起来 +- `DevBridge` 是否就绪 +- 默认 workspace / 本地工作目录能力是否可用 + +这类问题 **单靠** `lint`、`typecheck`、`vitest` 无法覆盖。 + +### Layer 3:契约与桥接边界 + +入口: + +```bash +npm run test:contracts +npm run test:bridge +npm run bridge:health -- --timeout-ms 120000 +``` + +作用: + +- 检查前端命令调用与 Rust 注册表是否一致 +- 检查浏览器桥接 / mock 优先路径是否同步 +- 检查 `DevBridge` 是否可用 + +高频场景: + +- 修改 `safeInvoke` / `invoke` +- 修改 `src/lib/dev-bridge/` +- 修改 `src/lib/tauri-mock/` +- 修改 `src-tauri/src/app/runner.rs` +- 修改 `src-tauri/src/dev_bridge/` + +### Layer 4:交互型 E2E + +入口: + +- `docs/aiprompts/playwright-e2e.md` + +作用: + +- 用 Playwright MCP 做真实页面交互验证 +- 检查控制台错误、主导航、关键业务工作流 + +注意: + +- 不要把所有页面默认都推进到重型 E2E +- 先跑最小 smoke,再决定是否需要完整交互验证 + +## 改动类型与最低门槛 + +| 改动类型 | 至少运行 | 额外要求 | +| ----------------------------------- | -------------------------------------------------- | ------------------------------------------- | +| 普通前端改动 | `npm run verify:local` | 如有用户可见变化,补稳定回归 | +| Tauri 命令 / Bridge / mock 改动 | `npm run verify:local`、`npm run test:contracts` | 必要时补 `npm run governance:legacy-report` | +| GUI 壳 / Workspace / 页面主路径改动 | `npm run verify:local`、`npm run verify:gui-smoke` | 必须补对应 UI 回归 | +| 配置结构改动 | `npm run verify:local` | 同步 schema、消费者、文档 | +| 版本相关改动 | `npm run verify:app-version` | 与发布配置一起核对 | +| Rust 模块改动 | 受影响 crate / 模块定向测试 | 再决定是否跑全量 `cargo test` | +| 真实页面交互验证 | 先跑 `npm run verify:gui-smoke` | 再进入 `playwright-e2e.md` | + +## CI 事实源 + +主工作流: + +- `.github/workflows/quality.yml` + +关键事实源: + +- `scripts/quality-task-planner.mjs` +- `scripts/quality-task-selector.mjs` +- `scripts/local-ci.mjs` + +要求: + +- 本地 `verify:local` 与 CI 使用同一套 changed-path 分类逻辑 +- 最终由 `results` job 聚合为统一质量信号 +- 对 GUI 产品来说,PR 门禁不能只覆盖静态检查,还必须覆盖 `Bridge & Contracts` 与 `GUI Smoke` + +## PR 前最小清单 + +发起 PR 前,至少自问这五件事: + +1. 这次改动属于普通逻辑、协议边界、GUI 主路径还是治理收口? +2. 我是不是已经走过对应的最低校验? +3. 如果改了命令、配置或版本,相关文档与锁文件是否同步? +4. 如果改了用户可见 UI,是否补了稳定回归? +5. 如果改了 GUI 壳、Bridge、Workspace,是否真的跑过最小 smoke? + +## 常用命令 + +```bash +# 本地统一校验 +npm run verify:local +npm run verify:local:full + +# GUI 最小冒烟 +npm run verify:gui-smoke + +# 前端 / 桥接 / 契约 +npm test +npm run test:bridge +npm run test:contracts +npm run bridge:health -- --timeout-ms 120000 + +# GUI / headless 调试 +npm run tauri:dev:headless +``` + +## 相关文档 + +- `docs/aiprompts/commands.md` +- `docs/aiprompts/governance.md` +- `docs/aiprompts/playwright-e2e.md` + +## 决策原则 + +只有一句话: + +**Lime 是 GUI 桌面产品,工程质量不能只验证“代码能编译”,还要验证“应用壳、桥接、工作区主路径能运行”。** diff --git a/docs/develop/ai-agent-development-guide.md b/docs/develop/ai-agent-development-guide.md index dc6499779..d20ae9210 100644 --- a/docs/develop/ai-agent-development-guide.md +++ b/docs/develop/ai-agent-development-guide.md @@ -385,7 +385,7 @@ pub struct TokenUsage { // 当前仓库的事实源是 Hook + runtime adapter,而不是根 store barrel。 // 推荐边界: // - 会话 / turn / action:`src/lib/api/agentRuntime.ts` -// - 流式协议:`src/lib/api/agentStream.ts` +// - 流式协议:`src/lib/api/agentProtocol.ts` // - 前端主链:`useAgentChatUnified -> useAsterAgentChat` // - 已删除旧入口:`useAgentChat`、`useAgentStore` diff --git a/docs/develop/lime-borrow-codex-engineering-practices.md b/docs/develop/lime-borrow-codex-engineering-practices.md new file mode 100644 index 000000000..2f03dac17 --- /dev/null +++ b/docs/develop/lime-borrow-codex-engineering-practices.md @@ -0,0 +1,541 @@ +# Lime 如何借鉴 Codex 的工程方法:从“已有基础”到“快而稳的迭代系统” + +> 目的不是照抄 Codex 的技术栈,而是借鉴它背后的工程方法,并结合 Lime 当前仓库的真实现状,收敛出一条适合我们的落地路径。 + +## 1. 背景 + +这份文档基于两部分事实: + +1. 对 `openai/codex` 仓库的结构、规则、CI、测试、发布流程做了实际阅读; +2. 对 Lime 当前仓库的工程入口、脚本、工作流、测试和发布链路做了核对。 + +结论先写在前面: + +**Lime 并不缺“零起点能力”,而是已经有不少关键积木。** + +我们现在更需要的,不是继续堆更多脚本,而是把已有能力收敛成一条清晰、分层、可执行的开发与交付路径。 + +Codex 值得借鉴的,不是 Bazel、不是纯 Rust、也不是它的体量; +真正值得借鉴的是: + +**他们把“如何开发、如何验证、如何发布”写进了仓库本身。** + +对 Lime 来说,这一点尤其重要。 + +因为 Lime 当前已经不是一个简单的单体项目,而是一个同时包含: + +- React + TypeScript 前端 +- Tauri 桌面壳 +- Rust workspace +- 多 Provider / 网关 / MCP / 插件 / 浏览器桥接 +- 文档站点 +- 多平台发布 + +的复合型仓库。 + +从 `src-tauri/Cargo.toml` 看,当前 Rust workspace 已经有 `20` 个 crate; +从 `package.json` 看,前端、桥接、契约、发布、本地验证、AI 验证、治理脚本都已经存在。 + +这意味着: + +**Lime 已经进入“需要系统化工程节奏”的阶段。** + +## 2. Lime 当前已经具备的基础 + +在讨论“借鉴 Codex”之前,先明确 Lime 不是从零开始。 + +### 2.1 仓库级规则已经存在 + +根 `AGENTS.md` 已经提供了不少高价值工程约束,例如: + +- 全部输出与文档统一使用中文 +- 涉及 UI 先看 `docs/aiprompts/design-language.md` +- 默认同时考虑 macOS / Windows +- 优先使用 `npm`、`cargo`、Tauri 命令与项目内封装 +- 明确给出了构建、测试、代码检查入口 + +这意味着 Lime 已经有“仓库级规则入口”,这是非常好的基础。 + +### 2.2 本地验证脚本已经不弱 + +当前 `package.json` 里已经有一整组工程命令: + +- `npm run lint` +- `npm run typecheck` +- `npm test` +- `npm run test:bridge` +- `npm run test:contracts` +- `npm run test:rust` +- `npm run verify:local` +- `npm run verify:local:full` +- `npm run ai-verify` + +其中 `scripts/local-ci.mjs` 已经实现了**按改动范围智能选择任务**: + +- 前端改动 -> `lint` + `typecheck` + `test` +- bridge 改动 -> `test:bridge` + `test:contracts` +- Rust 改动 -> `cargo test` +- `--full` 时再补 `cargo clippy` + +这其实已经很接近 Codex 仓库里“按 changed paths 决定 CI 跑什么”的思路了。 + +### 2.3 Git hook 也已经上线 + +`.husky/pre-commit` 当前会执行: + +`npx tsx scripts/ai-code-verify.ts` + +也就是说,Lime 已经不是“完全依赖人工自觉”的状态,而是开始把工程约束前移到提交流程。 + +### 2.4 契约与一致性检查已经有雏形 + +当前仓库里至少已经有两个很重要的“机器校验型”脚本: + +- `scripts/check-app-version-consistency.mjs` +- `scripts/check-command-contracts.mjs` + +前者会检查: + +- `package.json` +- `src-tauri/Cargo.toml` +- `src-tauri/tauri.conf.json` +- `src-tauri/tauri.conf.headless.json` + +的版本是否一致。 + +后者会检查: + +- 前端 `safeInvoke` / `invoke` 实际调用了哪些 Tauri 命令 +- `src-tauri/src/app/runner.rs` 里真正注册了哪些命令 +- mock priority 命令集合是否同步 + +这已经非常接近 Codex 那种“把接口契约变成可自动验证对象”的方向了。 + +### 2.5 发布链路也已具备完整骨架 + +当前 `.github/workflows/release.yml` 已经具备: + +- tag 驱动发布 +- 多平台构建(macOS arm64 / x64,Windows x64) +- 版本同步到 Tauri 配置 +- release notes 生成 +- GitHub Release 上传 + +再加上: + +- `deploy-docs.yml` +- `update-homebrew.yml` + +说明 Lime 已经不是没有交付能力,而是已经有了一个基础发布流水线。 + +## 3. Lime 当前最明显的缺口 + +和 Codex 对比后,我认为 Lime 当前最核心的缺口不是“工具不够多”,而是下面三件事还没有完全收敛。 + +### 3.1 缺少一个统一的 PR 质量主入口 + +当前 GitHub Actions 里: + +- `ci.yml` 主要覆盖 `src-tauri/**` 改动下的 **Windows OpenClaw regression** +- `deploy-docs.yml` 只负责文档站点 +- `release.yml` 只负责发布 + +这意味着,Lime 当前很多重要校验虽然**本地可跑**,但还没有成为 PR 上稳定、清晰、统一的 required status,例如: + +- 前端 `lint` +- 前端 `typecheck` +- Vitest +- `test:contracts` +- GUI headless smoke +- Rust 全量测试 / `clippy` +- bridge / smoke 校验 + +也就是说,我们已经有很多验证动作,但还没有把它们真正收束成“团队共同依赖的一条质量主链路”。 + +### 3.2 现有脚本之间还偏“并列”,没有形成明确分层 + +现在仓库里已经有: + +- pre-commit 的 `ai-verify` +- 本地智能校验 `verify:local` +- 前端和 Rust 单独测试命令 +- bridge / smoke / contracts 脚本 + +但还缺一件事: + +**每一层到底该挡什么问题,团队的预期还不够明确。** + +比如现在容易出现的疑问: + +- `ai-verify` 是补充提醒,还是硬门禁? +- `verify:local` 是建议执行,还是提交前标准动作? +- `test:contracts` 什么时候必须跑? +- `smoke:social-workbench` 该放本地还是 CI? + +Codex 很强的一点,就是每一层的角色很清楚。 + +Lime 现在更像是“积木已经有了,但还没拼成系统”。 + +### 3.3 契约检查已有基础,但还没有完全上升为“关键接口演进规范” + +`check-command-contracts.mjs` 已经很有价值,但它目前更像一个检查脚本,而不是一整套“接口演进流程”的中心。 + +对 Lime 这种项目来说,真正高风险的契约不止是 Tauri command 名称,还包括: + +- Tauri command 参数结构 +- 前后端共享数据结构 +- bridge 交互协议 +- OpenClaw / Gateway / Workspace 相关状态快照 +- 插件 / MCP 相关 manifest 或接口字段 + +Codex 的经验告诉我们: + +**接口文档、生成物、测试、实现,最好一起演进。** + +这正是 Lime 接下来最该补齐的一层。 + +## 4. Codex 最值得 Lime 借鉴的五个方法 + +下面只写“适合 Lime 的”,不写那些和我们技术栈不匹配的。 + +### 4.1 借鉴一:建立单一的“质量主入口” + +Codex 很重要的一个做法,是把很多检查统一收口到一个最终状态上。 + +对 Lime 来说,最值得借鉴的是: + +**新增一个主质量工作流,让 PR 的主要质量信号只看这一条。** + +建议名称可以是: + +- `.github/workflows/quality.yml` + +这个工作流不需要一开始就做得很重,但应该具备三个特征: + +1. **按改动范围决定执行内容** +2. **聚合前端 / Rust / bridge / docs 的核心信号** +3. **最后产出一个统一 results job 作为 required status** + +这件事最好优先复用已有逻辑,而不是重写。 + +最合适的方式是: + +- 继续保留 `scripts/local-ci.mjs` 作为本地入口 +- 抽一层共享的 changed-path 规则到独立脚本 +- 本地与 CI 共同复用这套判断逻辑 + +这样才能保证: + +- 本地怎么判断改动范围 +- CI 怎么判断改动范围 + +尽量一致。 + +### 4.2 借鉴二:把现有验证动作分层,而不是继续堆并列命令 + +建议把 Lime 的工程验证分成四层: + +#### Layer 0:提交前快速提醒 + +保留 `.husky/pre-commit` 的 `ai-verify`,但明确定位为: + +- 快速提醒 +- 风险提示 +- 代码卫生检查 + +**不要把它视为“提交前完整验证”的替代品。** + +#### Layer 1:开发者本地标准入口 + +继续以 `npm run verify:local` 为核心。 + +建议将它明确成: + +**所有功能型改动在发起 PR 前默认至少跑一次。** + +这样 Lime 就不需要额外引入 `justfile` 一类新工具,仍然符合我们仓库“优先使用 `npm` / `cargo` / Tauri 命令”的跨平台约束。 + +#### Layer 2:PR 级智能 CI + +这里建议接入: + +- 前端:`npm run lint`、`npm run typecheck`、`npm test` +- bridge:`npm run test:bridge`、`npm run test:contracts` +- Rust:`cargo test --manifest-path "src-tauri/Cargo.toml"` +- Rust 扩展:按路径触发的 `cargo clippy` + +#### Layer 3:高风险路径专项验证 + +这层不必每个 PR 都跑,但应该对特定路径生效,例如: + +- `src/lib/dev-bridge/**` +- `src/lib/tauri-mock/**` +- `src/components/openclaw/**` +- `src-tauri/src/services/openclaw_service/**` +- `src-tauri/src/app/**` +- Workspace / browser-runtime / MCP / plugin 高风险路径 + +可以复用现有命令: + +- `npm run bridge:health` +- `npm run verify:gui-smoke` +- `npm run bridge:e2e` +- `npm run smoke:workspace-ready` +- `npm run smoke:social-workbench` + +其中如果明确把 Lime 看作 **GUI 桌面产品**,我更建议把高风险路径的最小专项护栏优先收口成一个统一入口: + +- `npm run verify:gui-smoke` + +它适合作为: + +- headless Tauri 启动验证 +- DevBridge 健康检查 +- 默认 workspace 准备态 smoke + +这样既不会一开始就把所有页面都推进重型 E2E,也能确保桌面壳、前端入口、bridge 和 workspace 基本链路至少有一条稳定的自动回归。 + +#### Layer 4:发布前全量验证 + +发布时建议至少统一收口: + +- `npm run verify:app-version` +- `npm run test:frontend` +- `npm run test:contracts` +- `npm run test:rust` +- `npm run lint:rust` + +这样 release workflow 才不会只在“能构建”层面把关,而能同时覆盖“接口一致性”和“核心回归风险”。 + +### 4.3 借鉴三:把“命令契约”升级成 Lime 的正式工程边界 + +Codex 很强调契约一致性。 + +Lime 现在已经有 `check-command-contracts.mjs`,这是非常好的起点。 + +建议下一步不要只把它当脚本,而是把它升级成: + +**Lime 的正式工程边界规范之一。** + +具体建议: + +1. **PR CI 默认跑 `npm run test:contracts`** +2. **凡是修改 `src-tauri/src/app/runner.rs`、前端 `safeInvoke` 调用、bridge mock 命令集合的改动,都要求契约检查通过** +3. **后续逐步扩展到“命令名之外的参数契约”** + +这里不建议一上来做成很重的 schema 系统,而是先做最小闭环: + +- 命令名一致 +- 参数结构不出现静默漂移 +- mock / runtime / frontend 三方同步 + +这是 Lime 当前最值得优先工程化的一条“接口护栏”。 + +### 4.4 借鉴四:让治理脚本进入主流程,而不是停留在工具箱 + +Codex 的一个显著特点,是“治理不是旁路”。 + +Lime 当前已经有: + +- `npm run governance:legacy-report` +- `scripts/report-legacy-surfaces.mjs` + +同时我们也已经在 `docs/develop/` 下沉淀了不少退场与治理文档,例如: + +- `execution-tracker-deprecation-plan.md` +- `execution-tracker-p0-acceptance-report.md` + +这意味着 Lime 已经具备治理意识,但还可以更进一步: + +### 建议做法 + +1. 把 `governance:legacy-report` 纳入定期检查或专项 CI +2. 对明确已经进入退场窗口的旧路径,增加“新增引用即报警”的守卫 +3. 每个重要治理主题都要求同时有: + - 技术计划 + - 退场计划 + - 验收报告 + - 守卫脚本 + +也就是说,治理不要只写文档,最好同时配一个可执行的检测机制。 + +这正是 Codex 的典型思路。 + +### 4.5 借鉴五:把发布从“打 tag 构建”升级成“分层交付” + +Lime 当前的 `release.yml` 已经有不错的发布骨架。 + +但如果借鉴 Codex,我们可以把“发布”进一步拆成两个层级: + +#### A. 稳定版交付 + +继续保留当前正式 tag 驱动的 GitHub Release、Homebrew 更新、Tauri 构建发布。 + +#### B. 预发布交付 + +针对风险较高、改动较大的版本,建议引入: + +- `beta` / `rc` / `prerelease` +- 或内部 nightly / weekly 构建 + +原因很简单: + +Lime 现在涉及: + +- 桌面壳 +- bridge +- 浏览器 runtime +- Provider / Gateway +- OpenClaw 安装与运行态 +- 文档站点 + +这些链路任何一处变化,都可能需要在正式 tag 前先经过一次“真实用户环境暴露”。 + +Codex 的 alpha 节奏说明了一点: + +**快速发布不等于鲁莽发布,预发布本身就是风险管理工具。** + +## 5. 结合 Lime 实际,哪些不要照抄 + +借鉴 Codex,不代表要复制它的全部做法。 + +以下几件事我认为对 Lime 当前阶段不适合直接照搬。 + +### 5.1 不要为了“看起来更专业”引入额外构建系统 + +Codex 同时处理 Cargo / Bazel,是它自己的历史和规模决定的。 + +Lime 当前已经有: + +- `npm` +- `cargo` +- Tauri + +这套组合已经足够复杂。 + +现阶段不应该再额外引入一层类似 Bazel、Buck 或新的任务系统。 + +### 5.2 不要一下子把所有页面都做成重型 E2E + +Lime 当前已经有 bridge / smoke / headless 相关脚本,这很好。 + +但如果一下子把所有页面都推进到全量 E2E,只会显著拉高维护成本。 + +更适合的做法是: + +- 优先选高风险路径 +- 选用户感知强、状态复杂、跨端联动重的页面 +- 先做 smoke,再决定是否需要完整 E2E + +### 5.3 不要让 AI 验证替代编译与测试 + +`.husky/pre-commit` 里的 `ai-verify` 很有价值,但它更适合做: + +- 提醒 +- 自检 +- 风险暴露 + +不适合替代: + +- `typecheck` +- `cargo test` +- `test:contracts` +- `clippy` + +Codex 借鉴给我们的,不是“让 AI 代替工程”,而是: + +**把工程流程做好之后,再让 AI 增强它。** + +## 6. 建议的 Lime 落地顺序 + +下面给出一条尽量现实、成本可控的落地路径。 + +## Phase 0:收口现有入口(1 周内) + +目标:先让团队对“标准本地验证路径”达成一致。 + +建议动作: + +1. 在根 `AGENTS.md` 和开发文档中明确: + - 功能改动默认执行 `npm run verify:local` + - 大范围改动执行 `npm run verify:local:full` +2. 明确 `ai-verify` 是快速提醒层,不替代完整验证 +3. 为 `test:contracts` 和 `verify:app-version` 增加更明确的使用说明 + +产出结果: + +- 开发者知道“提交前至少跑什么” +- review 时可以默认假设这些动作已执行 + +## Phase 1:建立统一质量工作流(1~2 周) + +目标:把本地已有能力上升为 PR 门禁。 + +建议动作: + +1. 新增 `quality.yml` +2. 引入 changed-path detection +3. 按改动范围执行: + - frontend + - bridge/contracts + - rust + - docs build +4. 增加单一 `results` 汇总 job + +产出结果: + +- PR 有统一 required status +- 本地与 CI 质量路径更一致 + +## Phase 2:加强高风险边界(2~4 周) + +目标:让最容易出事故的路径拥有专项护栏。 + +优先建议覆盖: + +1. `safeInvoke` / Tauri command 契约 +2. OpenClaw 安装与运行态相关路径 +3. bridge / headless / smoke 相关路径 +4. Workspace 准备态相关 smoke + +可复用现有脚本: + +- `npm run test:contracts` +- `npm run bridge:health` +- `npm run bridge:e2e` +- `npm run smoke:workspace-ready` +- `npm run smoke:social-workbench` + +## Phase 3:把治理与预发布纳入节奏(1~2 个月) + +目标:让“旧路径收口”和“风险前移”进入长期机制。 + +建议动作: + +1. 给 `governance:legacy-report` 找到稳定的执行时机 +2. 对重要治理主题建立“技术计划 + 退场计划 + 验收报告 + 守卫脚本”闭环 +3. 为大改版本增加 prerelease / beta 节奏 + +## 7. 最终判断 + +如果只从表面看,Codex 和 Lime 的仓库差异很大。 + +但从工程方法看,Lime 其实已经有不少和 Codex 同方向的基础: + +- 有仓库级规则 +- 有本地智能校验 +- 有 AI 验证 hook +- 有命令契约检查 +- 有版本一致性检查 +- 有多平台 release +- 有治理脚本和退场文档 + +所以 Lime 现在最需要做的,不是“再发明一套全新的方法论”,而是: + +**把已有能力收口成一条清晰、稳定、分层的工程路径。** + +如果用一句话概括这份文档的核心建议,那就是: + +**对 Lime 来说,借鉴 Codex 的重点不是“学他们用了什么工具”,而是“把我们的规则、脚本、测试、治理和发布真正串成系统”。** + +一旦这件事完成,Lime 的迭代速度和稳定性都会一起上一个台阶。 diff --git a/docs/roadmap/artifacts/architecture-blueprint.md b/docs/roadmap/artifacts/architecture-blueprint.md new file mode 100644 index 000000000..75b609d9e --- /dev/null +++ b/docs/roadmap/artifacts/architecture-blueprint.md @@ -0,0 +1,744 @@ +# Lime Artifact Workbench 架构蓝图 + +> 状态:提案 +> 更新时间:2026-03-24 +> 运行时边界:凡涉及发送边界、runtime metadata、Team 委派、Op/Event 收口,均以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只细化 Artifact Workbench 架构 +> 依赖文档: +> - `docs/roadmap/artifacts/roadmap.md` +> - `docs/roadmap/artifacts/artifact-document-v1.md` +> - `docs/roadmap/artifacts/framework-boundary.md` +> - `docs/roadmap/artifacts/system-prompt-and-schema-contract.md` +> 目标:把 Lime 高配版 Artifact Workbench 的产品形态、运行链路、时序关系、system prompt 组装策略与实现边界收敛成一份可直接指导开发实施的总装图 + +## 1. 固定决策 + +本蓝图固定以下 v1 决策,不再留给后续实现阶段临时判断: + +1. **右侧主工作台** + Artifact Workbench 的主交互面是聊天右侧工作台,不以全屏页为默认入口。 + +2. **双阶段生成** + 高价值结构化交付物默认采用: + - Stage 1:Reasoning / Outline / Intent Resolution + - Stage 2:ArtifactDocument 结构化产出 + +3. **分层组装 prompt** + system prompt 由后端统一分层拼装,不让前端直接拼完整 prompt。 + +4. **产品层 canonical model** + - `ArtifactDocument JSON` 是长期事实源 + - `Tiptap / ProseMirror JSON` 仅是 `rich_text` block 的编辑器载荷 + - `Markdown / HTML / PDF` 是导出快照 + +5. **研究类 source 强约束** + - `report / analysis / comparison / research`:必须有 source + - `roadmap / prd / plan / brief`:source 可选,但建议保留 + +6. **消息区与交付区职责分离** + - 消息区负责解释、进度、追问、下一步 + - 交付区负责正式产物 + - 不允许整篇 Artifact 再重复贴回消息区 + +7. **框架层与产品层分离** + - Lime 持有 `ArtifactDocument` 与 Workbench 产品能力 + - Aster Runtime 持有 `thread / turn / item / event / output schema` + - `blueprint` 只是可选 planning module,不是主运行时 + +## 2. 文档依据 + +本文基于以下现役事实源: + +- `src/components/agent/chat/hooks/agentRuntimeAdapter.ts` +- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` +- `src-tauri/src/commands/aster_agent_cmd/prompt_context.rs` +- `src-tauri/src/services/memory_profile_prompt_service.rs` +- `src-tauri/src/services/web_search_prompt_service.rs` +- `src/components/agent/chat/workspace/workbenchPreview.tsx` +- `src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx` +- `src/components/artifact/ArtifactRenderer.tsx` +- `src/components/content-creator/canvas/document/editor/NotionEditor.tsx` +- `src-tauri/src/services/agent_timeline_service.rs` + +从这些事实源可以确认: + +1. 前端已支持 turn 级 `systemPrompt` 透传。 +2. 后端现有主链已按阶段合并 `runtime agents / memory / web search / request policy / elicitation / team preference / auto continue`。 +3. Lime 已具备右侧工作台、Artifact 渲染、Timeline 投影与富文本编辑器底座。 +4. 需要新增的是 Artifact 相关的产品层协议、编排层与 prompt 层,而不是另造第二套聊天系统。 + +## 3. 总体架构 + +## 3.0 跨层总装图 + +```mermaid +flowchart TB + User["用户"] + + subgraph FE["Lime 前端"] + Conversation["Conversation"] + Workbench["Artifact Workbench"] + Inspector["Timeline / Sources / Diff"] + end + + subgraph App["Lime 应用层"] + ArtifactDomain["ArtifactDocument Domain"] + WorkbenchService["Artifact Workbench Service"] + ProductPolicy["Artifact Product Policy"] + end + + subgraph Runtime["Aster Runtime 层"] + ThreadTurn["Thread / Turn / Item"] + PromptComposer["Prompt Composer"] + SchemaRuntime["Output Schema Runtime"] + EventBus["Runtime Event Bus"] + Approval["Approval / Elicitation / Interrupt"] + end + + subgraph Planning["Blueprint 模块"] + Blueprint["Blueprint / TaskTree / Worker"] + end + + subgraph Model["模型与工具"] + LLM["LLM"] + Tooling["Web / File / Exec / MCP"] + end + + User --> Conversation + Conversation --> WorkbenchService + Workbench --> ArtifactDomain + Inspector --> EventBus + WorkbenchService --> ProductPolicy + WorkbenchService --> ThreadTurn + ProductPolicy --> PromptComposer + ThreadTurn --> PromptComposer + PromptComposer --> SchemaRuntime + SchemaRuntime --> LLM + LLM --> Tooling + Tooling --> EventBus + EventBus --> ArtifactDomain + ThreadTurn -. optional planning handoff .-> Blueprint +``` + +## 3.1 总体架构图 + +```mermaid +flowchart TB + User["用户请求"] + + subgraph Frontend["前端交互层"] + Conversation["Conversation / Message List"] + Inputbar["Inputbar / Intent Metadata"] + Workbench["Artifact Workbench Shell"] + Inspector["Timeline / Inspector / Sources Drawer"] + end + + subgraph Runtime["Aster Runtime / Lime Runtime Adapter"] + TurnGateway["agent_runtime_submit_turn"] + IntentResolver["Artifact Intent Resolver"] + PromptComposer["Artifact Prompt Composer"] + Planner["Stage 1 Planner / Reasoner"] + Generator["Stage 2 Artifact Generator"] + Validator["Validator / Repair"] + Timeline["Agent Timeline Recorder"] + end + + subgraph Domain["Artifact Domain 层"] + ArtifactStore["ArtifactDocument Store"] + Versioning["Artifact Versioning"] + SourceRegistry["Artifact Source Registry"] + Exporter["Export Adapters"] + end + + subgraph Render["渲染与编辑层"] + BlockRenderer["Artifact Block Renderer Registry"] + RichTextAdapter["RichText Adapter / Tiptap"] + end + + User --> Inputbar --> TurnGateway + TurnGateway --> IntentResolver + IntentResolver --> PromptComposer + PromptComposer --> Planner + Planner --> Generator + Generator --> Validator + Validator --> ArtifactStore + Validator --> Timeline + ArtifactStore --> Versioning + ArtifactStore --> SourceRegistry + + Conversation --> Inspector + Timeline --> Inspector + ArtifactStore --> Workbench + SourceRegistry --> Inspector + Workbench --> BlockRenderer + BlockRenderer --> RichTextAdapter + Workbench --> Exporter + Exporter --> Versioning +``` + +## 3.2 分层职责 + +### 前端交互层 + +- 收集用户输入与 UI 场景元数据 +- 展示消息区、右侧 Artifact Workbench、Timeline / Source Inspector +- 不负责拼装完整 system prompt +- 不负责决定最终 block schema + +### Runtime 层 + +- 判断本次 turn 是否进入 Artifact 主链 +- 组装 prompt +- 绑定 turn 级 output schema +- 执行 Stage 1 / Stage 2 +- 做 validator / repair +- 产出 timeline 事件 +- 持有 item / delta / approval / interrupt 语义 + +### Artifact Domain 层 + +- 作为 `ArtifactDocument` 的持久化与版本事实源 +- 管理 sources、version、export records +- 连接 turn / item 与 artifact + +### 渲染与编辑层 + +- 把 block 渲染成统一视觉组件 +- 让 `rich_text` block 接入 Tiptap +- 承载局部编辑、局部 AI 改写、diff、导出 + +## 4. 生命周期流程 + +## 4.1 生命周期流程图 + +```mermaid +flowchart TD + A["用户发起请求"] --> B{"是否需要正式交付物?"} + B -- 否 --> C["普通消息链路"] + B -- 是 --> D["解析 artifact intent / kind / source policy"] + D --> E["组装 Stage 1 system prompt"] + E --> F["Stage 1 输出意图、骨架、source 需求、block 计划"] + F --> G["组装 Stage 2 system prompt"] + G --> H["Stage 2 输出 artifact_document_draft 或 artifact ops"] + H --> I["Validator / Repair"] + I --> J{"是否合法?"} + J -- 否 --> K["降级为 rich_text fallback + 记录 telemetry"] + J -- 是 --> L["写入 ArtifactDocument Store"] + K --> L + L --> M["生成版本记录 / source 绑定 / timeline 事件"] + M --> N["右侧 Artifact Workbench 渲染"] + N --> O{"用户继续编辑/改写?"} + O -- 否 --> P["导出 / 归档 / 保留为最终版本"] + O -- 是 --> Q["局部 rewrite / 局部编辑 / 新版本生成"] + Q --> I +``` + +## 4.2 状态机 + +| 状态 | 说明 | 可迁移到 | +|------|------|------| +| `draft` | 已建立 ArtifactDocument,尚未开始正式流式写入 | `streaming` / `failed` | +| `streaming` | 正在由 Stage 2 或局部 rewrite 写入 | `ready` / `failed` | +| `ready` | 当前版本可用 | `streaming` / `archived` | +| `failed` | 当前回合生成失败,但文档对象仍保留 | `streaming` / `archived` | +| `archived` | 已归档,只读 | - | + +触发原则: + +- `artifact.begin` 或创建 draft 时进入 `draft` +- 第一个有效 block 写入时进入 `streaming` +- validator / repair 完成并落盘进入 `ready` +- Stage 2 或 rewrite 失败进入 `failed` +- 归档操作进入 `archived` + +## 5. 核心时序图 + +以下时序图只表达产品层与运行时层的职责分工,不单独定义 Lime 当前仓库的 on-wire 字段名、命令名或 metadata 归一化细节。 + +这些当前实施细节统一以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准。 + +## 5.1 生成时序图 + +```mermaid +sequenceDiagram + participant User as 用户 + participant FE as 前端工作台 + participant RT as Runtime Turn + participant PC as Artifact Prompt Composer + participant S1 as Stage 1 Planner + participant S2 as Stage 2 Generator + participant VR as Validator/Repair + participant Store as ArtifactDocument Store + participant TL as Timeline + participant WB as Workbench + + User->>FE: 输入请求 + FE->>RT: submit_turn(message + artifact metadata) + RT->>PC: build prompts by layers + PC-->>RT: stage1_prompt + stage2_prompt context + RT->>S1: 执行 Stage 1 + S1-->>RT: artifact_intent + outline + source policy + RT->>TL: emit artifact_intent_resolved + RT->>S2: 执行 Stage 2 + S2-->>RT: artifact_document_draft / artifact ops + RT->>VR: validate and repair + VR-->>RT: repaired document + RT->>Store: persist document + version + sources + RT->>TL: emit artifact_version_persisted + Store-->>WB: latest ArtifactDocument + WB-->>User: 右侧显示正式交付物 +``` + +## 5.2 Prompt 组装时序图 + +```mermaid +sequenceDiagram + participant FE as 前端 + participant RT as runtime_turn + participant AP as artifact_prompt_service + participant Base as Base Prompt Layer + participant Mem as Memory Prompt Layer + participant Search as Web Search Layer + participant Artifact as Artifact Policy Layer + participant Source as Source Policy Layer + participant Stage as Stage Contract Layer + participant Schema as Output Schema Binder + + FE->>RT: submit_turn(system metadata only) + RT->>AP: compose(stage, kind, sourcePolicy, metadata) + AP->>Base: merge base runtime rules + AP->>Mem: merge memory prompt + AP->>Search: merge web search prompt + AP->>Artifact: merge artifact policy prompt + AP->>Source: merge source requirement prompt + AP->>Stage: merge stage-specific contract + AP->>Schema: attach turn-level output schema + Schema-->>AP: final system prompt + output schema + AP-->>RT: composed prompt + stage markers + schema +``` + +## 5.3 局部改写时序图 + +```mermaid +sequenceDiagram + participant User as 用户 + participant WB as Workbench + participant RT as Artifact Rewrite Runtime + participant PC as Prompt Composer + participant Gen as Rewrite Generator + participant VR as Validator/Repair + participant Store as Version Store + participant Diff as Diff Engine + + User->>WB: 选中 block 并点击 AI 改写 + WB->>RT: rewrite(blockId, instruction, current document) + RT->>PC: compose rewrite prompt + PC-->>RT: rewrite system prompt + RT->>Gen: 仅生成目标 block 新内容 + Gen-->>RT: updated block + RT->>VR: validate block + document + VR-->>RT: repaired document snapshot + RT->>Store: create new version + Store->>Diff: compare previous/current + Diff-->>WB: block diff + WB-->>User: 展示差异并切换到新版本 +``` + +## 5.4 导出时序图 + +```mermaid +sequenceDiagram + participant User as 用户 + participant WB as Workbench + participant Export as Export Service + participant Render as Render Adapter + participant Store as Artifact Store + + User->>WB: 点击导出 + WB->>Export: export(artifactId, format) + Export->>Store: load latest version + Store-->>Export: ArtifactDocument + editor payloads + Export->>Render: convert to md/html/pdf/json + Render-->>Export: exported content/file + Export-->>WB: download path / result + WB-->>User: 导出完成 +``` + +## 6. Prompt 架构 + +## 6.1 为什么必须由后端组装 + +当前现役事实源已经说明,system prompt 的主要规则是在后端主链按阶段合并: + +- `runtime agents` +- `memory` +- `web search` +- `request tool policy` +- `elicitation` +- `team preference` +- `auto continue` + +Artifact Workbench 不应绕开这条链,也不应让前端页面继续自行决定 prompt 顺序。 +因此: + +- 前端只传意图元数据 +- 后端统一决定 prompt 层级、去重 marker、冲突处理与日志记录 + +## 6.2 Prompt 分层 + +v1 固定采用 7 层 prompt: + +### 1) Base Runtime Layer + +职责: + +- 基础身份 +- 输出语言 +- 工具边界 +- 安全约束 +- “消息区 vs 交付区”职责分工 + +### 2) Runtime Agents Layer + +复用现有运行时能力提示与工作目录上下文。 + +### 3) Memory Layer + +复用现有记忆画像和记忆来源提示。 + +### 4) Search / Source Layer + +包含: + +- 现有 web search 偏好 +- 新增 Artifact source policy + +规则: + +- 研究类任务要求引用来源 +- 普通计划类任务仅鼓励来源 + +### 5) Artifact Policy Layer + +新增 Artifact 专属系统规则,至少包含: + +1. 何时必须生成 Artifact +2. 不要把完整文档重复发回聊天区 +3. 模型只输出语义结构,不输出视觉样式 +4. block 类型必须来自白名单 + +### 6) Stage Contract Layer + +根据 stage 动态切换: + +- `stage1`: 输出 artifact intent / outline / source policy / block plan +- `stage2`: 输出 `artifact_document_draft` 或 `artifact ops` +- `rewrite`: 只重写指定 block + +### 7) Turn Context Layer + +来自 turn metadata 的本次上下文: + +- theme +- artifact kind +- source policy +- selected block +- rewrite instruction +- workspace mode + +### 8) Output Schema Hint Layer + +职责: + +- 提醒模型当前 turn 存在严格 schema +- 明确优先满足结构合同,而不是自由排版 +- 让 stage1 / stage2 / rewrite 三条链分别受约束 + +## 6.3 Prompt marker 约定 + +为避免重复拼装,建议引入新 marker: + +- `【Artifact 交付策略】` +- `【Artifact 来源策略】` +- `【Artifact Stage 1 合同】` +- `【Artifact Stage 2 合同】` +- `【Artifact Rewrite 合同】` + +规则与现有服务一致: + +- 已包含 marker 时不重复追加 +- 空 prompt 不插入空段落 +- 每层都是可独立观测的 prompt section + +## 6.4 前端到后端的 Artifact 意图字段 + +前端不直接传完整 prompt,而是透传结构化 Artifact 意图。 + +这里的接口只描述 Artifact 领域希望表达的最小语义,不等于 Lime 当前请求载荷的最终 wire contract。 +当前仓库实际发送边界、`harness` 结构和 metadata 归一化,统一以执行效率路线图为准。 + +建议长期需要表达的 Artifact turn intent 包含: + +```ts +interface ArtifactTurnMetadata { + artifact_mode?: "none" | "draft" | "rewrite"; + artifact_kind?: + | "report" + | "roadmap" + | "prd" + | "brief" + | "analysis" + | "comparison" + | "plan"; + artifact_stage?: "stage1" | "stage2" | "rewrite"; + source_policy?: "required" | "preferred" | "none"; + workbench_surface?: "right_panel" | "fullscreen"; + artifact_request_id?: string; + artifact_target_block_id?: string; + artifact_rewrite_instruction?: string; +} +``` + +用途: + +- 驱动 prompt 组装 +- 写入 timeline +- 连接 ArtifactDocument 与本次 turn + +## 6.5 Stage 1 system prompt 目标 + +Stage 1 的职责不是写正文,而是锁定结构。 + +必须输出: + +- 是否需要 Artifact +- `kind` +- 建议标题 +- source policy +- block 计划 +- section 骨架 +- 风险或缺失信息 + +不得输出: + +- 完整长文 +- 任意 HTML +- 样式参数 + +## 6.6 Stage 2 system prompt 目标 + +Stage 2 的职责是生成正式结构化交付物。 + +必须输出: + +- `artifact_document_draft` +- 或增量 `artifact ops` + +必须遵守: + +- block 类型白名单 +- source 绑定要求 +- 不重复把全文写回消息区 + +## 6.7 Prompt 不是唯一控制点 + +本蓝图在此明确: + +- prompt 负责行为引导 +- turn 级 output schema 负责结构约束 +- validator / repair 负责最后兜底 + +如果只做 prompt,不做 schema 和 validator,Workbench 最终只会退化成“更漂亮的 Markdown 容器”。 + +## 7. 运行时服务设计 + +## 7.1 新增服务 + +建议新增: + +- `src-tauri/src/services/artifact_prompt_service.rs` +- `src-tauri/src/services/artifact_document_service.rs` +- `src-tauri/src/services/artifact_document_validator.rs` +- 可选 `src-tauri/src/services/artifact_generation_orchestrator.rs` + +如果同步建设 `aster-rust`,则建议把运行时通用能力下沉为独立 runtime 模块: + +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/runtime/thread.rs` +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/runtime/turn.rs` +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/runtime/item.rs` +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/runtime/event.rs` +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/runtime/prompt.rs` +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/runtime/schema.rs` + +这部分是框架层远期形态参考,不构成 Lime 当前仓库的直接实施清单。 + +职责: + +### `artifact_prompt_service` + +- 组装 Artifact 相关 prompt 层 +- 输出 stage1/stage2/rewrite 的最终 system prompt +- 记录 prompt stage 结果 + +### `artifact_document_service` + +- 持久化 `ArtifactDocument` +- 管理版本、sources、导出记录 +- 提供读模型给右侧 Workbench + +### `artifact_document_validator` + +- 做 schema 校验 +- 做 repair +- 输出 telemetry 与错误信息 + +### `artifact_generation_orchestrator` + +- 负责编排 Stage 1 / Stage 2 +- 连接 timeline 与 store +- 负责失败回退 + +## 7.2 建议新增事件 + +建议新增以下运行时事件,既供前端消费,也供 timeline 投影: + +- `artifact_intent_resolved` +- `artifact_stage_started` +- `artifact_stage_completed` +- `artifact_validation_repaired` +- `artifact_validation_failed` +- `artifact_version_persisted` +- `artifact_ready_for_render` + +## 8. 前端工作台设计 + +## 8.1 右侧主工作台壳层 + +建议新增 `ArtifactWorkbenchShell`,统一承载: + +- 阅读态 +- 编辑态 +- 版本条 +- source drawer +- diff 入口 +- 导出入口 + +### 阅读态 + +- 主渲染面 +- 使用 block renderer +- `rich_text` block 才接 Tiptap static renderer + +### 编辑态 + +- 仅对 `rich_text` block 进入 Tiptap 编辑 +- 结构型 block 走专用编辑表单或轻量编辑器 + +### 来源抽屉 + +- 展示 `sourceId -> locator` +- 可跳转到 timeline item / web result / file path + +## 8.2 与现有模块的关系 + +| 现有模块 | 蓝图定位 | +|------|------| +| `workbenchPreview.tsx` | 过渡期入口壳 | +| `WorkspaceCanvasContent.tsx` | 右侧容器事实源 | +| `ArtifactRenderer.tsx` | 兼容层入口,后续可下沉为 block renderer 包装器 | +| `NotionEditor.tsx` | `rich_text` block 编辑器 | +| `AgentThreadTimeline.tsx` | 过程和证据面 | + +## 9. 数据与导出 + +## 9.1 产品层事实源 + +唯一长期事实源: + +- `ArtifactDocument JSON` + +附属快照: + +- `editor_payload_snapshot` +- `markdown_snapshot` +- `render_manifest` + +规则: + +- 产品逻辑只依赖 `ArtifactDocument` +- 编辑器恢复依赖 `editor_payload_snapshot` +- 导出依赖 `markdown_snapshot` 或渲染适配 + +## 9.2 导出策略 + +v1 支持: + +- Markdown +- HTML +- PDF +- JSON + +其中: + +- Markdown 面向可复用文本 +- HTML/PDF 面向阅读和交付 +- JSON 面向版本、调试、二次处理 + +## 10. 失败与回退 + +## 10.1 Stage 1 失败 + +- 回退为普通消息链路 +- timeline 标记本次未进入 Artifact 主链 + +## 10.2 Stage 2 失败 + +- 仍创建 ArtifactDocument +- 状态记为 `failed` +- 保留最小 fallback 内容: + - 一个 `rich_text(markdown)` block + - 错误诊断进入 timeline / telemetry + +## 10.3 validator 失败 + +- 优先 repair +- repair 后仍失败则降级为 `rich_text(markdown)` +- 不允许把不合法结构直接渲染成 ready + +## 11. 验收标准 + +满足以下条件,才算架构蓝图落地正确: + +1. 右侧 Workbench 成为高价值交付物默认展示面。 +2. system prompt 的 Artifact 规则由后端统一组装,不由前端页面拼接。 +3. Stage 1 与 Stage 2 的输出边界清晰,timeline 能区分。 +4. `ArtifactDocument JSON` 与 `Tiptap/ProseMirror JSON` 的层级关系明确,不混淆。 +5. 研究类交付物缺 source 时不会直接进入 ready。 +6. 消息区不再重复整篇 Artifact。 +7. `blueprint` 不承担 Artifact 主链的根抽象。 +8. turn 级 output schema 已纳入主链,而不是只靠 prompt。 + +## 12. 本蓝图刻意不做 + +1. 不做无限开放的 block DSL。 +2. 不做任意页面搭建器。 +3. 不做完整协同编辑协议。 +4. 不做全屏页优先的主导航改造。 +5. 不做所有内容类型一次性统一迁移。 + +## 13. 实施顺序建议 + +建议严格按以下顺序推进: + +1. 实现 `artifact_prompt_service` 与 prompt marker +2. 实现 Stage 1 / Stage 2 编排 +3. 实现 validator / repair +4. 实现 `ArtifactWorkbenchShell` +5. 接入 3 个核心结构块: + - `hero_summary` + - `callout` + - `table` +6. 最后再接入 source drawer、diff、导出 + +原因: + +**没有 prompt 合同和 validator,WorkBench 只会成为更漂亮的 Markdown 容器。** diff --git a/docs/roadmap/artifacts/artifact-document-v1.md b/docs/roadmap/artifacts/artifact-document-v1.md new file mode 100644 index 000000000..318781322 --- /dev/null +++ b/docs/roadmap/artifacts/artifact-document-v1.md @@ -0,0 +1,777 @@ +# ArtifactDocument v1 协议草案 + +> 状态:提案 +> 更新时间:2026-03-24 +> 运行时边界:turn metadata、prompt 组装入口、runtime output schema 注入链以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义 `ArtifactDocument v1` 的产品层协议与校验映射 +> 依赖文档:`docs/roadmap/artifacts/roadmap.md` +> 架构蓝图:`docs/roadmap/artifacts/architecture-blueprint.md` +> 分层边界:`docs/roadmap/artifacts/framework-boundary.md` +> Prompt 合同:`docs/roadmap/artifacts/system-prompt-and-schema-contract.md` +> 目标:定义 Lime 高配版 Artifact Workbench 的第一版正式协议,包括产品层对象模型、模型输出契约、校验修复规则与渲染映射 + +## 1. 结论先行 + +本协议锁定以下架构决策: + +1. `ArtifactDocument JSON` 是产品层长期事实源。 +2. `Tiptap / ProseMirror JSON` 只作为编辑器层载荷,主要存在于 `rich_text` block 内。 +3. 模型不负责视觉样式,只负责语义结构。 +4. 前端不直接渲染“任意 Markdown 长文”为最终交付面,而是渲染语义 block。 +5. validator / repair 是主链路必备组件,不允许把模型原始输出直接当成可靠协议。 + +一句话概括: + +**模型输出结构,系统校验结构,渲染器呈现结构。** + +这里还要补一句边界声明: + +**`ArtifactDocument v1` 是产品层 persisted snapshot,不是 Aster runtime 的 thread/turn/item 协议。** + +## 2. 适用范围 + +`ArtifactDocument v1` 适用于以下高价值输出: + +- 报告 +- 研究总结 +- roadmap +- PRD +- 方案对比 +- 执行摘要 +- 调研表格 +- 多来源整合文档 + +不适用于: + +- 纯聊天短答 +- 普通代码片段 +- 单张图片生成结果 +- 浏览器实时会话帧流本身 + +这些内容仍可通过现有 `Artifact` 兼容层或其他工作台承载。 + +## 3. 设计原则 + +## 3.1 语义优先 + +协议描述“这是什么内容”,而不是“它长什么样”。 + +允许: + +- `hero_summary` +- `table` +- `callout` +- `citation_list` + +不允许: + +- 自定义颜色值 +- 自定义边距 +- 自定义字体大小 +- 自定义 CSS class + +## 3.2 产品对象优先于编辑器对象 + +产品层要能表达: + +- 版本 +- 来源 +- 执行绑定 +- block 语义 +- 导出 + +这些不应被编辑器内部树结构主导。 + +## 3.3 Flat But Typed + +`v1` 采用**扁平有序 block 列表**,不做复杂嵌套布局系统。 + +原因: + +1. 降低模型输出难度。 +2. 降低 validator / repair 复杂度。 +3. 降低 renderer 实现成本。 +4. 足够支持 80% 的报告类场景。 + +## 3.4 可降级 + +任何 block 在渲染失败、校验失败或组件不存在时,都必须可降级到: + +- `rich_text` +- 或纯文本 fallback + +## 4. 顶层对象模型 + +## 4.1 ArtifactDocumentV1 + +```ts +export type ArtifactKind = + | "report" + | "roadmap" + | "prd" + | "brief" + | "analysis" + | "comparison" + | "plan" + | "table_report"; + +export type ArtifactStatus = + | "draft" + | "streaming" + | "ready" + | "failed" + | "archived"; + +export interface ArtifactDocumentV1 { + schemaVersion: "artifact_document.v1"; + artifactId: string; + workspaceId?: string; + threadId?: string; + turnId?: string; + kind: ArtifactKind; + title: string; + status: ArtifactStatus; + language: "zh-CN"; + summary?: string; + blocks: ArtifactBlockV1[]; + sources: ArtifactSourceV1[]; + metadata: ArtifactDocumentMetaV1; +} + +export interface ArtifactDocumentMetaV1 { + theme?: + | "general" + | "document" + | "knowledge" + | "planning" + | "social-media"; + audience?: string; + intent?: string; + generatedBy?: "agent" | "user" | "automation"; + rendererHints?: { + density?: "comfortable" | "compact"; + defaultExpandedSections?: string[]; + }; + sourceRunBinding?: { + threadId?: string; + turnId?: string; + itemIds?: string[]; + }; + exportHints?: { + preferredFormats?: Array<"md" | "html" | "pdf" | "json">; + }; +} +``` + +## 4.2 顶层约束 + +1. `schemaVersion` 必须固定为 `artifact_document.v1`。 +2. `title` 必须为非空字符串。 +3. `blocks` 至少 1 个,建议不超过 40 个。 +4. `sources` 可以为空,但如果文档声称“基于搜索/网页/文件”,则不应为空。 +5. `language` 在 `v1` 固定为 `zh-CN`,避免多语言排版漂移。 + +## 5. Block 模型 + +## 5.1 通用字段 + +```ts +export interface ArtifactBlockBase { + id: string; + type: ArtifactBlockType; + sectionId?: string; + hidden?: boolean; + sourceIds?: string[]; +} + +export type ArtifactBlockType = + | "section_header" + | "hero_summary" + | "key_points" + | "rich_text" + | "callout" + | "table" + | "checklist" + | "metric_grid" + | "quote" + | "citation_list" + | "image" + | "code_block" + | "divider"; +``` + +规则: + +1. `id` 在同一文档内必须唯一。 +2. `sourceIds` 只能引用 `sources[]` 中已存在的 id。 +3. `sectionId` 只作归组标记,不引入嵌套 DOM 协议。 + +## 5.2 Block 定义 + +### A. `section_header` + +用于开始一个新章节。 + +```ts +export interface SectionHeaderBlock extends ArtifactBlockBase { + type: "section_header"; + title: string; + description?: string; +} +``` + +约束: + +1. `title` 必填。 +2. 文档首块不强制必须是 `section_header`。 + +### B. `hero_summary` + +用于顶部摘要卡。 + +```ts +export interface HeroSummaryBlock extends ArtifactBlockBase { + type: "hero_summary"; + eyebrow?: string; + title?: string; + summary: string; + highlights?: string[]; +} +``` + +约束: + +1. `summary` 必填,建议 60 到 220 字。 +2. `highlights` 建议 2 到 5 项。 + +### C. `key_points` + +用于快速结论列表。 + +```ts +export interface KeyPointsBlock extends ArtifactBlockBase { + type: "key_points"; + title?: string; + items: string[]; +} +``` + +约束: + +1. `items` 至少 2 项,建议不超过 7 项。 + +### D. `rich_text` + +通用正文块。 + +```ts +export interface RichTextBlock extends ArtifactBlockBase { + type: "rich_text"; + contentFormat: "prosemirror_json" | "markdown"; + content: unknown; +} +``` + +规则: + +1. 长期建议以 `prosemirror_json` 为主。 +2. `markdown` 只作为兼容输入与 repair fallback。 +3. `rich_text` 是唯一允许承载大段连续正文的 block。 + +### E. `callout` + +用于提醒、结论、风险、建议。 + +```ts +export interface CalloutBlock extends ArtifactBlockBase { + type: "callout"; + tone: "info" | "success" | "warning" | "danger" | "neutral"; + title?: string; + body: string; +} +``` + +### F. `table` + +```ts +export interface TableBlock extends ArtifactBlockBase { + type: "table"; + title?: string; + columns: string[]; + rows: string[][]; +} +``` + +约束: + +1. `columns` 至少 2 列。 +2. 每行单元格数量应与列数一致。 +3. 单元格内容必须是字符串,`v1` 不支持复杂嵌套对象。 + +### G. `checklist` + +```ts +export interface ChecklistBlock extends ArtifactBlockBase { + type: "checklist"; + title?: string; + items: Array<{ + id: string; + text: string; + state: "todo" | "doing" | "done"; + }>; +} +``` + +### H. `metric_grid` + +```ts +export interface MetricGridBlock extends ArtifactBlockBase { + type: "metric_grid"; + title?: string; + metrics: Array<{ + id: string; + label: string; + value: string; + note?: string; + tone?: "neutral" | "success" | "warning" | "danger"; + }>; +} +``` + +约束: + +1. 建议 2 到 8 个 metric。 +2. `value` 一律字符串化,避免渲染层处理 number/date 混乱。 + +### I. `quote` + +```ts +export interface QuoteBlock extends ArtifactBlockBase { + type: "quote"; + text: string; + attribution?: string; +} +``` + +### J. `citation_list` + +```ts +export interface CitationListBlock extends ArtifactBlockBase { + type: "citation_list"; + title?: string; + items: Array<{ + sourceId: string; + note?: string; + }>; +} +``` + +### K. `image` + +```ts +export interface ImageBlock extends ArtifactBlockBase { + type: "image"; + url: string; + alt?: string; + caption?: string; +} +``` + +### L. `code_block` + +```ts +export interface CodeBlock extends ArtifactBlockBase { + type: "code_block"; + language?: string; + title?: string; + code: string; +} +``` + +### M. `divider` + +```ts +export interface DividerBlock extends ArtifactBlockBase { + type: "divider"; +} +``` + +## 5.3 Block 联合类型 + +```ts +export type ArtifactBlockV1 = + | SectionHeaderBlock + | HeroSummaryBlock + | KeyPointsBlock + | RichTextBlock + | CalloutBlock + | TableBlock + | ChecklistBlock + | MetricGridBlock + | QuoteBlock + | CitationListBlock + | ImageBlock + | CodeBlock + | DividerBlock; +``` + +## 6. Source 模型 + +```ts +export type ArtifactSourceType = + | "web" + | "file" + | "tool" + | "message" + | "search_result"; + +export interface ArtifactSourceV1 { + id: string; + type: ArtifactSourceType; + label: string; + locator?: { + url?: string; + path?: string; + lineStart?: number; + lineEnd?: number; + toolCallId?: string; + messageId?: string; + }; + snippet?: string; + reliability?: "primary" | "secondary" | "derived"; +} +``` + +规则: + +1. `label` 必填。 +2. `snippet` 为可选摘录,不是完整内容镜像。 +3. `locator` 用于跳转,不要求所有字段齐全。 + +## 7. Version 模型 + +`v1` 不要求把版本协议塞进文档正文,但必须预留独立版本对象: + +```ts +export interface ArtifactVersionRecordV1 { + id: string; + artifactId: string; + versionNo: number; + documentSnapshot: ArtifactDocumentV1; + editorPayloads?: Record; + markdownSnapshot?: string; + summary?: string; + createdBy: "agent" | "user" | "automation"; + createdAt: string; +} +``` + +## 8. 模型输出契约 + +## 8.1 模型不应该直接输出什么 + +禁止作为正式协议输出: + +- 任意 CSS +- 任意 HTML 模板 +- 组件名 + 样式参数混合 +- 整篇只靠纯自然语言长文承载结构 + +## 8.2 模型应该输出什么 + +模型应输出以下两类之一: + +### 模式 A:一次性草稿 + +适用于: + +- 首次生成 +- 非流式离线生成 +- 简单交付物 + +输出对象: + +```ts +export interface ArtifactDraftEnvelope { + type: "artifact_document_draft"; + document: ArtifactDocumentV1; +} +``` + +### 模式 B:增量操作 + +适用于: + +- 流式生成 +- 多轮修订 +- 局部改写 + +输出对象: + +```ts +export type ArtifactOpEnvelope = + | { + type: "artifact.begin"; + artifactId: string; + kind: ArtifactKind; + title: string; + } + | { + type: "artifact.meta.patch"; + artifactId: string; + patch: Partial; + } + | { + type: "artifact.source.upsert"; + artifactId: string; + source: ArtifactSourceV1; + } + | { + type: "artifact.block.upsert"; + artifactId: string; + block: ArtifactBlockV1; + } + | { + type: "artifact.block.remove"; + artifactId: string; + blockId: string; + } + | { + type: "artifact.complete"; + artifactId: string; + summary?: string; + } + | { + type: "artifact.fail"; + artifactId: string; + reason: string; + }; +``` + +## 8.3 推荐生成流程 + +推荐两段式: + +1. `artifact_intent` + - 判断是否需要 Artifact + - 判断文档 kind + - 判断是否需要 sources +2. `artifact_document_draft` 或 `artifact ops` + - 正式生成结构化内容 + +这样比“一次自然语言长回复”更稳定。 + +## 8.4 模型约束规则 + +提示词应明确要求模型: + +1. 优先输出有限 block 集,不要发明新 block 类型。 +2. 每个 block 只承载单一职责。 +3. 所有来源型结论必须绑定 `sourceIds` 或 `citation_list`。 +4. 不要重复把整篇文档再输出到聊天消息区。 +5. block id 必须稳定且语义化,如 `hero`, `market-table`, `next-steps`。 +6. 需要大段正文时,使用 `rich_text` block,而不是拆成很多碎 paragraph block。 + +## 9. Validator 规则 + +## 9.1 文档级校验 + +必须校验: + +1. `schemaVersion` 是否匹配。 +2. `title` 是否存在。 +3. `kind` 是否在白名单中。 +4. `blocks` 是否非空。 +5. `block.id` 是否唯一。 +6. `sourceIds` 是否都可解析。 + +## 9.2 Block 级校验 + +### `hero_summary` + +- `summary` 必填 +- `highlights` 非字符串项直接丢弃 + +### `table` + +- `columns.length >= 2` +- 每行长度与列数对齐 + +### `checklist` + +- item `state` 只能是 `todo / doing / done` +- `text` 为空则删除该项 + +### `metric_grid` + +- `label` 与 `value` 必填 +- 超过 8 项时保留前 8 项 + +### `citation_list` + +- `sourceId` 必须存在于 `sources` + +### `rich_text` + +- `contentFormat` 只能是 `prosemirror_json` 或 `markdown` +- `content` 不能为空 + +## 9.3 Source 级校验 + +1. `id` 必须唯一。 +2. `label` 必填。 +3. `type` 必须在白名单中。 +4. `snippet` 超长时截断,不作为正文存档。 + +## 10. Repair 策略 + +validator 失败时,不应直接放弃整份文档。 +`v1` 采用保守修复策略: + +## 10.1 文档级 repair + +1. 缺 `title` + - 用首个 `section_header.title` + - 再不行用任务标题 + - 再不行用 `未命名交付物` + +2. 缺 `blocks` + - 将原始文本包成一个 `rich_text(markdown)` block + +3. 重复 block id + - 自动追加稳定后缀,如 `-2`、`-3` + +## 10.2 Block 级 repair + +1. 不支持的 block type + - 降级为 `rich_text(markdown)` + +2. `table` 行列不齐 + - 自动补空字符串到齐平 + +3. `citation_list` 引用了不存在的 source + - 删除非法项 + - 若最终为空,整个 block 删除 + +4. `metric_grid` 非法 value + - 强制转字符串 + +5. `callout.body` 为空 + - 降级为 `rich_text(markdown)` + +6. `rich_text.prosemirror_json` 无法解析 + - 降级为 `rich_text(markdown)` + +## 10.3 最终 fallback + +如果整份文档经过 repair 仍不合法: + +1. 保留 `artifactId / kind / title` +2. 将模型原始输出包成单个 `rich_text(markdown)` block +3. 标记 `metadata.rendererHints.density = "comfortable"` +4. 在 telemetry 中记录 repair failure + +## 11. Renderer 映射 + +| Block 类型 | 建议组件 | 失败回退 | +|------|------|------| +| `section_header` | `ArtifactSectionHeader` | `rich_text` | +| `hero_summary` | `ArtifactHeroSummaryCard` | `rich_text` | +| `key_points` | `ArtifactKeyPointsList` | `rich_text` | +| `rich_text` | `ArtifactRichTextRenderer` | 纯文本 | +| `callout` | `ArtifactCallout` | `rich_text` | +| `table` | `ArtifactStructuredTable` | `rich_text` | +| `checklist` | `ArtifactChecklist` | `rich_text` | +| `metric_grid` | `ArtifactMetricGrid` | `rich_text` | +| `quote` | `ArtifactQuote` | `rich_text` | +| `citation_list` | `ArtifactCitationList` | 删除 | +| `image` | `ArtifactImageBlock` | 占位图 | +| `code_block` | 复用现有 `CodeRenderer` | `rich_text` | +| `divider` | `ArtifactDivider` | 删除 | + +## 11.1 RichText Renderer 约束 + +`ArtifactRichTextRenderer` 只负责: + +- 解析 `rich_text` 内容 +- 渲染内联 mark +- 渲染段落、标题、列表、引用、代码 + +不负责: + +- 指标卡 +- 提示框 +- 表格型业务块 +- 来源列表 + +这些必须由业务 block 组件承载。 + +## 12. Prompt 模板约束 + +本节只定义 `ArtifactDocument v1` 需要的结构约束,不重新定义 runtime prompt 入口。 + +也就是说: + +- “哪些 turn 进入 Artifact 主链” +- “turn metadata 如何归一化” +- “output schema 在哪里注入” + +这些执行层问题仍以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准。 + +系统提示词应增加以下硬约束: + +1. 当用户请求的是报告、roadmap、PRD、比较、研究、整合总结时,优先输出 `ArtifactDocument v1`。 +2. 先给 `hero_summary` 或 `key_points`,再给主体 block。 +3. 对来源敏感内容必须附带 source。 +4. 不要输出 CSS、HTML class、视觉说明。 +5. 不要在消息正文里重复完整文档,只输出简短说明和下一步。 + +## 13. 仓库落地建议 + +建议新增: + +```text +src/lib/artifact-document/schema.ts +src/lib/artifact-document/validator.ts +src/lib/artifact-document/repair.ts +src/lib/artifact-document/adapters/tiptap.ts +src/lib/artifact-document/examples.ts +``` + +职责建议: + +- `schema.ts`:类型与 zod/schema 定义 +- `validator.ts`:协议合法性检查 +- `repair.ts`:保守修复逻辑 +- `adapters/tiptap.ts`:`rich_text` 与 Tiptap 互转 +- `examples.ts`:供 prompt / tests / storybook 复用的样例 + +后端建议新增: + +```text +src-tauri/src/services/artifact_document_service.rs +src-tauri/src/services/artifact_document_validator.rs +``` + +## 14. 本版刻意不做 + +1. 不做复杂栅格布局协议。 +2. 不做任意嵌套 section tree。 +3. 不做通用组件 DSL。 +4. 不做样式 token 下发。 +5. 不做完全开放的自定义 block 注册。 + +`v1` 的目标是稳定,不是无限灵活。 + +## 15. 最终建议 + +如果你们要把“漂亮回复”真正做成产品能力,实施顺序应该是: + +1. 先锁定 `ArtifactDocument v1` +2. 再做 validator / repair +3. 再做 renderer registry +4. 再做模型输出约束 +5. 最后才是视觉细化 + +原因很简单: + +**没有协议,渲染只是化妆。** diff --git a/docs/roadmap/artifacts/framework-boundary.md b/docs/roadmap/artifacts/framework-boundary.md new file mode 100644 index 000000000..04985e88c --- /dev/null +++ b/docs/roadmap/artifacts/framework-boundary.md @@ -0,0 +1,356 @@ +# Lime Artifact Workbench 与 Aster Runtime 分层边界 + +> 状态:提案 +> 更新时间:2026-03-24 +> 运行时边界:凡涉及发送边界、runtime metadata、Team 委派、Op/Event 收口、状态同步,均以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义长期框架分层原则与远期边界 +> 关联文档: +> - `docs/roadmap/artifacts/roadmap.md` +> - `docs/roadmap/artifacts/architecture-blueprint.md` +> - `docs/roadmap/artifacts/system-prompt-and-schema-contract.md` +> 目标:明确 Lime 产品层、Aster 框架层、Blueprint 规划模块三者的长期职责边界,避免后续把产品协议、任务规划、运行时编排混成一团 + +## 1. 结论先行 + +本文件锁定以下分层判断: + +1. **Lime 持有产品层交付物模型** + - `ArtifactDocument` + - Workbench UI + - block renderer / editor / source drawer / export + - `report / roadmap / prd / analysis / comparison` 这些产品语义 + +2. **Aster 应补齐通用 runtime substrate** + - `thread / turn / item / event` + - prompt 组装管线 + - turn 级 `output schema` + - approval / elicitation / interrupt / retry + - 运行时状态持久化与事件流 + +3. **Blueprint 不是 Artifact Workbench 的根抽象** + - Blueprint 应定位为长期规划与执行模块 + - 适合承接需求蓝图、任务树、TDD loop、worker coordination + - 不适合作为 Lime 文档交付协议或聊天主运行时 + +4. **Codex 值得参考的是运行时协议,不是它的 artifact 名字** + - `codex` 的强项在 `turn/start + outputSchema + item events + thread state` + - `codex-artifacts` 则是本地 JS runtime 的 presentation/spreadsheet 工具,不是报告文档协议 + +一句话总结: + +**Lime 负责“交付物产品”,Aster 负责“代理运行时”,Blueprint 负责“长周期规划”。** + +## 2. 事实依据 + +## 2.1 Lime 现役事实 + +来自以下文件: + +- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` +- `src-tauri/src/commands/aster_agent_cmd/prompt_context.rs` +- `src-tauri/src/services/memory_profile_prompt_service.rs` +- `src-tauri/src/services/web_search_prompt_service.rs` +- `src/components/agent/chat/hooks/agentRuntimeAdapter.ts` + +可确认: + +1. Lime 已经有以 turn 为单位的运行链路。 +2. system prompt 已经是后端分层组装,而不是纯前端拼接。 +3. 前端只需要透传结构化 metadata,就可以驱动不同运行策略。 +4. Lime 当前缺的是正式 Artifact 产品协议与工作台闭环,而不是重新发明 thread/turn 的概念。 + +## 2.2 Aster Blueprint 现役事实 + +来自以下文件: + +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/blueprint/README.md` +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/blueprint/types.rs` +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/blueprint/task_tree_manager.rs` +- `/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/blueprint/worker_executor.rs` + +可确认: + +1. Blueprint 的中心对象是 `Blueprint / TaskTree / TaskNode / Checkpoint / WorkerAgent`。 +2. 它的 `ArtifactType` 是 `file / patch / command`,明显偏代码执行产物。 +3. prompt 模板围绕 TDD 测试、写代码、重构,不围绕正式交付文档。 +4. Timeline 也是任务执行事件,不是聊天交付事件。 + +因此: + +**Blueprint 与 Artifact Workbench 存在概念相邻,但语义核心不同。** + +## 2.3 Codex 现役事实 + +来自以下文件: + +- `/Users/coso/Documents/dev/rust/codex/codex-rs/app-server/README.md` +- `/Users/coso/Documents/dev/rust/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts` +- `/Users/coso/Documents/dev/rust/codex/codex-rs/app-server/src/thread_state.rs` +- `/Users/coso/Documents/dev/rust/codex/codex-rs/codex-api/src/common.rs` +- `/Users/coso/Documents/dev/rust/codex/codex-rs/core/src/memories/phase1.rs` +- `/Users/coso/Documents/dev/rust/codex/codex-rs/artifacts/README.md` + +可确认: + +1. `turn/start` 支持 turn 级 `outputSchema`。 +2. app-server 明确定义了 `thread / turn / item / item delta / approval / interruption` 协议。 +3. 内部已经把结构化输出作为 runtime 能力,而不是业务页面私货。 +4. `codex-artifacts` 只是受控 JS artifact runtime,不是文档产品协议。 + +因此: + +**Codex 给我们的参考点,是 runtime substrate 的形状。** + +## 3. 分层总图 + +```mermaid +flowchart TB + User["用户"] + + subgraph Lime["Lime 产品层"] + UI["Conversation + Artifact Workbench + Inspector"] + Product["ArtifactDocument / Source UX / Export UX"] + Policy["Artifact Kind / Source Policy / UX Rules"] + end + + subgraph Aster["Aster Runtime 层"] + Thread["Thread / Turn / Item"] + Events["Runtime Event Bus"] + Prompt["Prompt Composer"] + Schema["Output Schema / Validator Contract"] + Control["Approval / Elicitation / Interrupt / Retry"] + Persist["Runtime State Store"] + end + + subgraph Planning["Blueprint 模块"] + BP["Blueprint"] + Tree["TaskTree / Checkpoint"] + Worker["Worker / TDD / Sandbox"] + end + + subgraph Model["模型与工具层"] + LLM["LLM"] + Tools["Tools / Web / File / Exec"] + end + + User --> UI + UI --> Product + UI --> Policy + Product --> Thread + Policy --> Prompt + Thread --> Events + Prompt --> Schema + Schema --> LLM + LLM --> Tools + Tools --> Events + Events --> Persist + Events --> Product + Thread -. optional planning handoff .-> BP + BP --> Tree + Tree --> Worker + Worker --> Events +``` + +## 4. 职责边界表 + +| 能力 | Lime 产品层 | Aster Runtime 层 | Blueprint 模块 | +|------|------|------|------| +| 线程 / 回合 / item 生命周期 | 不持有根定义 | 持有 | 可消费 | +| turn 级 `output schema` | 提供业务 schema | 持有执行通道 | 不负责 | +| prompt 分层组装 | 提供 Artifact 业务片段 | 持有总组装器 | 仅自有规划 prompt | +| 正式交付物对象 | 持有 `ArtifactDocument` | 不持有业务语义 | 不持有 | +| block renderer / editor | 持有 | 不持有 | 不持有 | +| source policy | 持有业务规则 | 负责执行与校验挂钩 | 不持有 | +| approvals / elicitation / interrupt | UI 展示与交互 | 持有协议与状态 | 可复用 | +| task tree / TDD loop | 仅作为某类 Artifact 来源 | 可桥接 | 持有 | +| file / patch / command 代码产物 | 只做引用展示 | 可流转 | 持有 | + +## 5. 为什么 Blueprint 不应直接接管 Artifact Workbench + +## 5.1 对象模型不匹配 + +Blueprint 的产物中心是: + +- 模块 +- 任务 +- 测试 +- patch +- command + +Lime Artifact Workbench 的产物中心是: + +- 文档 +- block +- source +- version +- reading/edit/export + +这不是同一类对象。 + +## 5.2 生命周期不匹配 + +Blueprint 关注: + +- 立项 +- 拆任务 +- 执行 +- 回滚 +- 验收 + +Artifact Workbench 关注: + +- 生成草稿 +- 结构校验 +- 工作台阅读 +- 局部改写 +- 版本 diff +- 导出分享 + +前者是执行治理,后者是交付体验。 + +## 5.3 prompt 不匹配 + +Blueprint 的 prompt 语言天然偏: + +- 写测试 +- 写实现 +- 修复错误 +- 重构代码 + +Artifact Workbench 要控制的是: + +- 交付物 kind +- source policy +- block plan +- report / roadmap / comparison 结构 + +直接混用会让 runtime 概念污染产品协议。 + +## 6. 应从 Codex 借鉴什么 + +## 6.1 必须借鉴 + +1. **turn 级输出 schema** + - 每次调用都能带一个明确的结构目标 + - 不把“结构化输出”写死成某个固定产品 + +2. **thread / turn / item / delta 协议** + - UI 能增量渲染 + - 持久化层能重建历史 + - 工具、计划、消息、文件改动可归一化 + +3. **独立的事件流** + - `turn_started` + - `item_started` + - `item_delta` + - `item_completed` + - `turn_completed` + +4. **approval / elicitation / interrupt 是 runtime 一等公民** + - 不能散落在某个单独产品页面中硬编码 + +## 6.2 不应照搬 + +1. 不照搬 `codex-artifacts` 的 JS runtime 工具模型。 +2. 不照搬终端/TUI 的 UI 心智。 +3. 不把 Lime 的文档协议退化成单一 `outputSchema` 结果对象。 + +因为 Lime 的核心仍然是: + +**多轮对话中的正式交付物工作台。** + +## 7. 建议中的 Aster Runtime 新边界 + +这里要明确: + +- 本节描述的是 **Aster 框架层的目标形状参考** +- 不是 Lime 当前仓库的直接实施主计划 +- 如果与 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 的当前迁移顺序、协议收口方式冲突,以后者为准 + +建议在 `aster-rust` 中,把通用 agent runtime 从 `blueprint` 旁边独立出来,而不是继续把所有能力堆进 `blueprint/`。 + +建议中的模块形态: + +```text +crates/aster/src/runtime/ + mod.rs + thread.rs + turn.rs + item.rs + event.rs + prompt.rs + schema.rs + approval.rs + persistence.rs + orchestration.rs +``` + +建议职责: + +- `thread.rs`:线程与上下文边界 +- `turn.rs`:turn 生命周期、输入、状态机 +- `item.rs`:消息、plan、tool、artifact stage 等 item 定义 +- `event.rs`:统一事件类型与 delta +- `prompt.rs`:分层 prompt composer +- `schema.rs`:turn 级输出 schema 注册与校验入口 +- `approval.rs`:approval / elicitation / interrupt / retry +- `persistence.rs`:runtime state 存储 +- `orchestration.rs`:Stage Runner、模型调用与失败恢复 + +## 8. Lime 与 Aster 的连接方式 + +本节时序图只表达长期职责连接,不单独定义 Lime 当前发送协议字段名。 + +建议 Lime 不直接自己实现第二套完整 runtime,而是成为 Aster runtime 的产品适配层: + +```mermaid +sequenceDiagram + participant FE as Lime Frontend + participant App as Lime App Service + participant RT as Aster Runtime + participant LLM as Model + participant WB as Artifact Workbench + + FE->>App: submit_turn(message + artifact metadata) + App->>RT: turn.start(input + prompt layers + output schema refs) + RT->>LLM: response request + LLM-->>RT: item deltas / final output + RT-->>App: turn events + item events + validated result + App-->>WB: ArtifactDocument / timeline / diff + WB-->>FE: render delivery view +``` + +这里的关键是: + +- Lime 提供业务语义和工作台 +- Aster 提供运行时协议和编排 +- 两者通过 turn metadata、schema id、event taxonomy 对接 +- 但 Lime 当前仓库里的实际发送边界和 runtime 收口步骤,仍按执行效率路线图推进 + +## 9. 实施顺序建议 + +本节属于框架层远期演进建议,不覆盖 Lime 当前仓库已确定的 P1 / P2 / P3 / P4 执行顺序。 + +## Phase A:先立 runtime 边界 + +- 在文档和接口层确认 Aster runtime 与 Blueprint 分家 +- 定义 `thread / turn / item / event / schema` + +## Phase B:再接 Artifact 主链 + +- Lime 将 Stage 1 / Stage 2 接到 Aster runtime turn +- 引入 Artifact 专用 output schema + +## Phase C:最后接 Blueprint + +- 仅在需要“长周期规划 / 多 worker 执行”时,把 Blueprint 作为特殊 item 或 planning capability 接入 +- 不让 Blueprint 接管普通 report / prd / roadmap 生成 + +## 10. 最终决策 + +长期最稳的架构不是: + +**Blueprint 不断膨胀,最后既当聊天 runtime,又当 Artifact 协议,又当任务执行器。** + +长期最稳的架构应该是: + +**Codex 式 runtime substrate + Blueprint 式 planning module + Lime 式 artifact product layer。** diff --git a/docs/roadmap/artifacts/roadmap.md b/docs/roadmap/artifacts/roadmap.md new file mode 100644 index 000000000..015ab099d --- /dev/null +++ b/docs/roadmap/artifacts/roadmap.md @@ -0,0 +1,903 @@ +# Lime 高配版 Artifacts 路线图 + +> 状态:进行中,P1 / P2 已落地,P3 已闭环,rewrite typed patch 合同已落地 +> 更新时间:2026-03-25 +> 运行时边界:发送边界、runtime metadata、Team 委派、协议瘦身以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义 Artifact 产品层与 Workbench 主线 +> 目标:把 Lime 从“能显示文件/画布的聊天工作台”升级为“交付物优先的 Artifact Workbench”,让回复不再只是普通 Markdown,而是可扫描、可编辑、可版本化、可复用的正式产物 +> +> 配套文档: +> - `docs/roadmap/artifacts/architecture-blueprint.md` +> - `docs/roadmap/artifacts/artifact-document-v1.md` +> - `docs/roadmap/artifacts/framework-boundary.md` +> - `docs/roadmap/artifacts/system-prompt-and-schema-contract.md` + +## 1. 文档依据 + +本文不是从抽象概念反推,而是基于当前仓库现役实现与设计约束编写。 + +关键事实源: + +- `docs/aiprompts/overview.md` +- `docs/aiprompts/design-language.md` +- `src/lib/artifact/types.ts` +- `src/lib/artifact/parser.ts` +- `src/components/artifact/ArtifactRenderer.tsx` +- `src/components/artifact/ArtifactToolbar.tsx` +- `src/components/agent/chat/components/MarkdownRenderer.tsx` +- `src/components/agent/chat/hooks/useArtifactDisplayState.ts` +- `src/components/agent/chat/components/AgentThreadTimeline.tsx` +- `src/components/agent/chat/workspace/workbenchPreview.tsx` +- `src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx` +- `src/components/content-creator/core/CanvasContainer/CanvasContainer.tsx` +- `src/components/content-creator/canvas/document/DocumentRenderer.tsx` +- `src/components/content-creator/canvas/document/editor/NotionEditor.tsx` +- `src-tauri/src/services/agent_timeline_service.rs` + +从这些事实源可以确认: + +1. Lime 已经是 `Artifact First` 方向,而不是纯聊天产品。 +2. 当前前端已经具备 `ArtifactRenderer + CanvasWorkbench + Timeline + Tiptap 编辑器` 这四块关键底座。 +3. 当前 Artifact 系统仍以“文件快照/Markdown 内容”作为主要载体,还不是“结构化交付物协议”。 +4. 当前回复的美观度问题,本质不是“模型不会写”,而是“产物协议、渲染层、交互层还没有真正收敛成一个系统”。 + +## 1.1 当前已落地能力 + +以下链路已经在当前仓库进入实现态: + +1. `runtime_turn` 已接入 Artifact 专属 prompt 组装服务。 +2. Artifact 回合已支持 turn-level `output_schema` 注入,不再只依赖 prompt hint。 +3. 后端已具备 `ArtifactDocument v1` 的 validator / repair / fallback / workspace 落盘能力。 +4. Timeline snapshot metadata 已可回灌 `artifactDocument`,前端在 `content` 为空时也能直接渲染结构化文档。 +5. 前端已落地最小 `artifact-protocol` 壳层,用于统一 runtime metadata 中的 `artifactDocument` 与 `artifact_path(s)` 读取合同。 +6. 后端已支持最小 `artifact_ops` 增量协议,可把 `artifact.upsert_block / attach_source / finalize_version` 等操作应用到已有 `ArtifactDocument` 并生成新版本。 +7. 右侧已接入最小 `ArtifactWorkbenchShell`,包含阅读面与 `概览 / 来源 / 版本 / 差异` inspector。 +8. 当前版本已支持最小 block diff 摘要,以及来源项 / 差异项到文档 block 的 Workbench 内跳转。 +9. `rewrite` 已把 `artifact_target_block_id` 贯通到 prompt / output schema / ops apply / persist 链路,非目标 block 的 op 会在运行时被忽略并记录 issue。 +10. `rewrite` 现已支持专用 `artifact_rewrite_patch` envelope,并保留 `artifact_ops` 兼容回退,用于逐步收紧模型输出合同。 + +这意味着当前主线已经从“只有路线图”推进到“结构合同 + 版本快照 + Workbench inspector 闭环”。当前仍然属于后续阶段的,主要是更细粒度的 typed rewrite patch 合同,以及编辑态 / 展示态 / 导出态的进一步同源。 + +## 2. 现状判断 + +## 2.1 Lime 已经具备的能力 + +- 有聊天主入口与工作台分栏能力,支持右侧预览区。 +- 有 Artifact 类型系统、解析器、统一渲染入口、工具栏与列表。 +- 有 Document Canvas 与 Tiptap 编辑器,可承载更高级的文档编辑体验。 +- 有 Agent Timeline,可记录 turn、item、artifact snapshot、warning、error。 +- 有 Workspace 概念,可作为 Artifact 的上下文边界与持久化边界。 + +这说明 Lime 不需要再造第二套“文档产品”,而是要把现有能力从“散件”收敛成“Artifact Workbench”。 + +## 2.2 当前短板 + +当前体验之所以还不够像 Ribbi / Manus / Claude Artifacts 的高配版本,主要有五个结构性问题: + +### 1) 回复仍以消息文本为主,Artifact 只是附属物 + +`MarkdownRenderer.tsx` 负责把 assistant 文本渲染成较好的 Markdown,但主心智仍然是“消息正文”。 +这会导致: + +- 模型把重要内容写在消息里,而不是交付区 +- 视觉上仍然像聊天气泡,而不是正式报告 +- 后续编辑、复用、导出、版本比较都不自然 + +### 2) Artifact 模型还是“文件/片段导向”,不是“结构化文档导向” + +当前 `Artifact` 的核心字段是: + +- `type` +- `title` +- `content` +- `meta` + +这对代码块、HTML、Mermaid 足够,但对高质量报告类交付物不够。 +缺的是: + +- section / block 层级 +- citations / references +- summary / scorecard / callout / table / checklist 等语义块 +- 版本差异与局部 patch + +### 3) Timeline、Canvas、Document Editor 还没有同源 + +当前: + +- Timeline 记录的是过程事件 +- Canvas 展示的是当前 artifact 预览 +- DocumentCanvas 编辑的是内容画布 + +三者都与 Artifact 相关,但还没有统一到同一个交付物主模型。 + +### 4) 当前 parser 仍以 fence/markdown 提取为主 + +`src/lib/artifact/parser.ts` 现在主要做: + +- ` ```artifact ... ` 提取 +- 普通代码块推断 +- plainText 与 artifact 分离 + +这适合兼容模式,不适合作为高配版的长期事实源。 +高配版必须从“解析文本里有什么 artifact”升级为“运行时明确产出什么 artifact block”。 + +### 5) 编辑态与展示态还不是一个产品闭环 + +当前有 Tiptap 编辑器,但它主要存在于 `document canvas` 内。 +高配版需要的是: + +- 先生成 +- 再预览 +- 再局部重写 +- 再比对版本 +- 再导出/复用 + +这些动作要围绕同一个 Artifact Document 完成,而不是在聊天、画布、文件之间来回切换。 + +## 3. 核心决策 + +## 3.1 产品主张 + +Lime 的高配版 Artifacts 应该明确采用: + +**Chat for reasoning, Artifact for delivery。** + +含义: + +- 聊天区负责目标澄清、任务推进、过程解释、追问与协作 +- Artifact Workbench 负责正式产物的创建、阅读、编辑、对比、导出与沉淀 +- Timeline 负责过程透明,而不是承担最终阅读面 + +换句话说: + +**消息区不是最终作品区。** + +## 3.2 长期事实源 + +长期统一到: + +**结构化 Artifact Document + 版本化 Block Tree + 可编辑 Workbench** + +而不是: + +- 普通 Markdown 长文 +- 单个大字符串 HTML +- 临时 artifact fence 解析结果 + +## 3.3 技术主栈决策 + +高配版建议采用: + +**Tiptap / ProseMirror 作为主编辑引擎,但不作为顶层长期 canonical model。** + +更准确地说: + +- `ArtifactDocument JSON` 才是产品层长期事实源 +- `Tiptap / ProseMirror JSON` 是编辑器层载荷与 rich_text block 的工作表示 +- `Markdown / HTML / PDF` 是导出与分发表达 + +理由: + +1. Tiptap / ProseMirror 非常适合富文本编辑、schema 约束、节点扩展、局部事务与协同增强。 +2. 但它的 JSON 结构本质上贴近编辑器内部 schema,不适合作为整个平台的顶层产品对象。 +3. Lime 的 Artifact 不只是富文本,还包括来源、评分块、表格块、执行绑定、浏览器会话引用、导出记录等领域对象。 +4. 如果把这些全部硬塞进 ProseMirror 节点树,未来的迁移、查询、导出、跨端实现和协议演进成本会被放大。 + +因此建议采用四层模型: + +- 兼容输入层:Markdown / artifact fence / 文件快照 +- 运行时协议层:Artifact Parts / Block Ops +- 产品持久化层:ArtifactDocument JSON +- 编辑器载荷层:RichText Block 内可使用 Tiptap / ProseMirror JSON + +## 3.4 框架层决策 + +在重新评估 `aster-rust blueprint` 与 `codex-rs` 之后,路线图增加一条硬决策: + +**不要把 Artifact Workbench 直接建立在 Blueprint 抽象之上。** + +更合适的长期分层是: + +- Lime:产品层交付物与工作台 +- Aster Runtime:通用 `thread / turn / item / event / output schema` +- Blueprint:可选的长周期 planning module + +判断依据见: + +- `docs/roadmap/artifacts/framework-boundary.md` + +这条决策很关键,因为一旦把文档交付协议和任务树执行框架混在一起,后面每扩一种 Artifact 类型,都会反向污染 runtime。 + +## 4. 目标与非目标 + +## 4.1 总目标 + +为 Lime 建立一套统一的 Artifact Workbench,让高价值回复默认沉淀为: + +`结构化交付物 -> 专用阅读面 -> 局部可编辑 -> 可版本比较 -> 可导出复用` + +## 4.2 子目标 + +1. 让“报告、方案、规划、研究、执行摘要、表格型结论”默认进入 Artifact Workbench,而不是只停留在消息正文。 +2. 让回复具备更强的视觉层级:摘要卡、提示框、表格、评分块、引用块、来源区、版本条。 +3. 让 Artifact 在会话中持续增量更新,而不是每轮都生成一个新的孤岛文件。 +4. 让编辑态、展示态、导出态围绕同一份 Artifact Document 运转。 +5. 让 Service Skill、Automation、Browser Assist、Theme Workbench 的产物最终都能落到同一套 Artifact Workbench。 + +## 4.3 非目标 + +1. 不做通用网页搭建器。 +2. 不在第一阶段做任意 React 组件执行沙箱。 +3. 不让模型直接输出大量不受控 HTML 作为主协议。 +4. 不重写现有全部 Canvas,只做收敛与增量替换。 +5. 不把所有回复都强制转成 Artifact,短问答仍可保持轻量聊天。 + +## 5. 目标产品形态 + +## 5.1 三栏心智 + +高配版建议把主界面稳定为三种职责: + +| 区域 | 主职责 | 说明 | +|------|------|------| +| Conversation | 对话、追问、任务推进 | 保留聊天心智,但弱化“大段正式正文” | +| Artifact Workbench | 阅读、编辑、比对、导出 | 正式交付面 | +| Timeline / Inspector | 过程、工具、来源、状态 | 可折叠的执行与证据面 | + +这与当前 Lime 的工作台分栏方向一致,不需要推翻现有 UI 模式。 + +## 5.2 Artifact Workbench 的核心视图 + +每个 Artifact Document 至少支持五种视图: + +1. `阅读视图` + - 报告式排版 + - 强层级和可扫描性 +2. `源码视图` + - Markdown / JSON / 原始块数据 +3. `编辑视图` + - Tiptap 可编辑文档 +4. `版本对比视图` + - 上一版本与当前版本差异 +5. `来源视图` + - citations、搜索结果、文件引用、工具产物引用 + +## 5.3 回复升级规则 + +不是所有回复都进入高配 Artifact。 + +建议由运行时按任务意图决定: + +| 场景 | 默认形态 | +|------|------| +| 简短问答 | 普通消息 | +| 研究、汇总、总结、方案、PRD、roadmap | Artifact Document | +| 表格、评分、对比、清单 | Artifact 内语义块 | +| 浏览器实时会话 | Browser Assist Artifact | +| 图片/海报/文档主题工作台 | Theme Canvas / Artifact Workbench | + +判断原则: + +- 有明确交付物时,Artifact 优先 +- 需要多轮持续改写时,Artifact 优先 +- 只是即时回答问题时,消息优先 + +## 6. 信息架构 + +## 6.1 核心对象模型 + +建议新增或收敛为以下产品对象: + +### ArtifactDocument + +正式交付物实体。 + +建议字段: + +| 字段 | 说明 | +|------|------| +| `id` | Artifact 文档 ID | +| `threadId` | 所属 thread | +| `workspaceId` | 所属 workspace | +| `theme` | 主题域,如 general / document / social-media | +| `kind` | `report / plan / brief / table / dashboard / canvas` | +| `title` | 标题 | +| `status` | `draft / streaming / ready / failed / archived` | +| `currentVersionId` | 当前版本 | +| `sourceRunId` | 来源 turn/run | +| `deliveryMode` | `inline / docked / fullscreen / exported` | + +### ArtifactVersion + +文档版本实体。 + +建议字段: + +| 字段 | 说明 | +|------|------| +| `id` | 版本 ID | +| `artifactId` | 所属文档 | +| `versionNo` | 递增版本号 | +| `documentSnapshot` | ArtifactDocument JSON 快照 | +| `editorPayloads` | 可选的编辑器层载荷快照,如 rich_text block 的 Tiptap JSON | +| `markdownSnapshot` | 兼容导出快照 | +| `summary` | 版本摘要 | +| `createdBy` | `agent / user / automation` | +| `createdAt` | 创建时间 | + +### ArtifactBlock + +文档内部结构块。 + +建议首批支持: + +- `heading` +- `paragraph` +- `summary_card` +- `key_points` +- `callout` +- `table` +- `checklist` +- `score_grid` +- `quote` +- `citation_list` +- `image` +- `code_block` +- `divider` + +### ArtifactSourceLink + +来源绑定。 + +建议字段: + +| 字段 | 说明 | +|------|------| +| `artifactId` | 文档 ID | +| `blockId` | 对应 block | +| `sourceType` | `web / file / tool / message / search_result` | +| `sourceRef` | 来源引用 | +| `label` | 显示名称 | +| `locator` | 行号、URL、toolCallId 等定位信息 | + +### ArtifactRunBinding + +交付物与执行过程的绑定关系。 + +建议字段: + +| 字段 | 说明 | +|------|------| +| `artifactId` | 文档 ID | +| `threadId` | thread | +| `turnId` | turn | +| `itemId` | timeline item | +| `bindingType` | `primary_output / intermediate / exported` | + +## 6.2 与现有 `Artifact` 的关系 + +当前 `src/lib/artifact/types.ts` 不应直接废弃,而应定位为: + +- 兼容层 Artifact +- 流式展示与轻量渲染容器 + +高配版建议新增一层更长期的 `ArtifactDocument` 模型。 +关系如下: + +- `Artifact`:运行时 UI 容器 +- `ArtifactDocument`:产品层正式交付物 +- `ArtifactVersion`:持久化版本 +- `ArtifactBlock`:结构化文档语义块 + +## 7. 协议设计 + +## 7.1 为什么要引入协议层 + +如果继续让模型只输出普通 Markdown,前端只能“尽量渲染好看”。 +高配版需要的是: + +- 模型明确声明自己在生成什么类型的交付物 +- 前端知道哪些内容属于摘要卡、表格、结论、提醒、引用 +- 后端能在流式过程中做版本记录与落盘 + +因此需要从“文本解析”升级为“结构化产物协议”。 + +但这里要注意: + +**结构化产物协议属于 Lime 产品层,不等于 runtime 协议。** + +runtime 协议更接近 `codex` 的做法: + +- turn 级 `outputSchema` +- item lifecycle +- approval / elicitation / interrupt +- event stream + +Artifact Workbench 应建立在这层稳定 runtime substrate 之上,而不是反过来把产品协议塞进框架层。 + +## 7.2 三层协议 + +### A. Message Parts 协议 + +用于聊天流中的即时显示。 + +建议 part 类型: + +- `text` +- `reasoning_summary` +- `tool_call` +- `tool_result` +- `artifact_intent` +- `artifact_progress` +- `artifact_block` +- `citation` + +这层用于: + +- 消息区轻量回显 +- Timeline 过程展示 +- Workbench 流式创建状态 + +### B. Artifact Ops 协议 + +用于构建正式交付物。 + +建议操作: + +- `artifact.create` +- `artifact.set_meta` +- `artifact.upsert_block` +- `artifact.reorder_blocks` +- `artifact.remove_block` +- `artifact.attach_source` +- `artifact.finalize_version` +- `artifact.fail` + +每个 block 必须有稳定 `blockId`,这样才支持: + +- 流式增量更新 +- 局部重写 +- 版本 diff +- 引用与块绑定 + +### C. Persisted Snapshot 协议 + +最终持久化为: + +- `artifact_document_json` +- `editor_payload_snapshot` +- `markdown_snapshot` +- `render_manifest` + +其中: + +- `artifact_document_json` 是长期事实源 +- `editor_payload_snapshot` 是编辑器层快照,不是产品层 canonical +- `markdown_snapshot` 负责兼容导出 +- `render_manifest` 负责阅读态性能与缓存 + +## 7.3 与当前 parser 的关系 + +`src/lib/artifact/parser.ts` 应保留,但角色需要降级为: + +### current + +- 兼容旧模型输出 +- 解析 fence/code block +- 在没有结构化协议时尽量抽出 artifact + +### future + +- 仅作为 fallback ingest +- 不再承担高配版主生成链路 + +## 8. 前端架构 + +## 8.1 Workbench Shell + +建议新增统一的 `ArtifactWorkbenchShell`,作为右侧或全屏交付物容器。 + +应复用: + +- `workbenchPreview.tsx` +- `WorkspaceCanvasContent.tsx` +- `ArtifactToolbar` +- `ArtifactRenderer` + +但职责要更清晰: + +- Shell 负责布局、视图切换、侧栏、版本条、来源抽屉 +- Renderer 负责块渲染 +- Editor 负责编辑 +- Timeline/Inspector 负责过程与证据 + +## 8.2 阅读态渲染器 + +阅读态不建议继续只靠通用 Markdown CSS。 +应改为: + +**Artifact Block Renderer Registry -> 自定义 React 组件** + +其中: + +- 语义块直接走业务组件渲染 +- rich_text block 可选使用 Tiptap Static Renderer + +每个语义块对应稳定组件: + +- 摘要卡 +- 指标卡 +- 对比表 +- 提示框 +- 评分矩阵 +- 来源列表 + +这样才能做到: + +- 风格稳定 +- 留白稳定 +- 层级稳定 +- 多次生成看起来像同一产品,而不是不同模型的随机输出 + +## 8.3 编辑态 + +编辑态建议直接复用并扩展现有 `NotionEditor.tsx`: + +- 支持块级选中 +- 支持局部 AI 改写 +- 支持引用插入 +- 支持固定模板块 +- 支持 slash command 插入语义块 + +不建议新起第二套富文本编辑器。 + +## 8.4 版本比较 + +高配版必须把“上一版/最新版”作为一等能力。 + +当前 `useArtifactDisplayState.ts` 已经有“上一版本占位”思路。 +下一步应该升级为真正的版本系统: + +- block diff +- 章节级变化高亮 +- 用户确认采纳/回退 + +## 8.5 来源与证据层 + +漂亮的回复如果没有证据层,会变成只是“看起来专业”。 + +因此 Workbench 需要固定的来源面: + +- 本地文件引用 +- 搜索结果引用 +- 网页来源 +- tool 输出来源 +- timeline item 引用 + +阅读态中可用上标或尾注形式呈现,点击后跳到右侧来源抽屉。 + +## 8.6 UI / UX 原则 + +遵守 `docs/aiprompts/design-language.md`,并针对 Artifact Workbench 补充以下原则: + +1. 主表面使用实体白底,不用半透明磨砂主容器。 +2. 正文排版优先中文阅读节奏,避免文档像英文博客模板。 +3. 强调色只用于: + - 状态 + - 关键结论 + - 引导操作 +4. 表格、提示框、指标卡必须来自统一组件,不允许模型自由拼样式。 +5. 右侧阅读面优先长时间可读,不做营销风大横幅。 + +## 9. 后端与持久化 + +## 9.1 数据库建议 + +建议新增以下表: + +### `artifact_documents` + +- 文档主表 +- 归属 workspace / thread / theme + +### `artifact_versions` + +- 版本表 +- 保存 `artifact_document_json`、可选 `editor_payload_snapshot`、`markdown_snapshot` 与版本摘要 + +### `artifact_sources` + +- block 到 source 的映射 + +### `artifact_exports` + +- 导出记录 +- 记录导出格式、路径、时间 + +### `artifact_run_bindings` + +- 连接 timeline turn/item 与 artifact + +## 9.2 文件系统策略 + +Lime 是本地优先产品,Artifact 应支持落盘,但不能写死平台路径。 + +要求: + +1. 落盘路径通过 Workspace 或应用目录 API 解析。 +2. 导出格式首期支持: + - Markdown + - HTML + - PDF + - JSON +3. 自动保存使用原子写入策略,避免写入中断造成损坏。 +4. Windows/macOS 都走统一目录解析,不写死 `~/Library/...`。 + +## 9.3 与 Timeline 的关系 + +`src-tauri/src/services/agent_timeline_service.rs` 当前已能投影 `ArtifactSnapshot`。 +高配版建议扩展为: + +- timeline 记录过程 +- artifact document 记录产物 +- 两者通过 `artifact_run_bindings` 连接 + +原则: + +- Timeline 不直接承担正式阅读面 +- Artifact 不丢失来源过程 + +## 10. Agent 与编排策略 + +## 10.1 产物生成策略 + +高配版不建议一开始就拆成很多 formatter 子 agent。 +首期先统一协议,再逐步增强编排。 + +建议顺序: + +1. 先让主 agent 明确输出 `artifact_intent` +2. 再通过 `artifact ops` 生成结构化块 +3. 最后可选地引入 `formatter/refiner` 子阶段 + +## 10.2 Prompt 约束 + +系统提示词需要明确: + +1. 当任务目标是报告、方案、roadmap、总结、研究时,优先生成 Artifact Document。 +2. 消息区只保留: + - 简短说明 + - 进度 + - 下一步 +3. 不把完整长文再次重复贴回聊天区。 +4. 优先使用 block 语义,而不是自由拼 HTML。 + +## 10.3 与 Service Skills 的关系 + +当前正在推进 `ServiceSkill`。 +高配版 Artifact Workbench 可以成为 ServiceSkill 的统一交付面: + +- `instant`:生成一份 Artifact Document +- `scheduled`:定期生成新版本 +- `managed`:持续维护同一文档或同一档案集 + +这会让 ServiceSkill 从“启动器”真正闭环到“交付物系统”。 + +## 11. 分阶段路线图 + +## Phase 0:协议与壳层对齐 + +目标: + +- 明确长期对象模型与协议边界 +- 不大改 UI,只先收口事实源 + +交付: + +1. 定义 `ArtifactDocument / ArtifactVersion / ArtifactSourceLink` 类型 +2. 明确 `Artifact` 兼容层与 `ArtifactDocument` 长期层的关系 +3. 定义 `artifact ops` 事件协议 +4. 新增 Workbench Shell 设计稿与组件边界 + +不做: + +- 大规模 UI 改版 +- 完整编辑器改造 + +## Phase 1:高质量阅读态 Workbench + +目标: + +- 先把“看起来高级”做出来 +- 回复从普通 Markdown 升级为报告式交付物 + +交付: + +1. 新增 `ArtifactWorkbenchShell` +2. 新增首批语义块: + - `summary_card` + - `callout` + - `table` + - `checklist` + - `score_grid` + - `citation_list` +3. 消息区与 Artifact Workbench 分工明确 +4. 高价值回复默认进入右侧交付面 + +验收: + +- 用户不打开源码,也能一眼扫读主要结论 +- 报告类回复在视觉上明显区别于普通消息 + +## Phase 2:可编辑 Artifact Document + +目标: + +- 让 Artifact 不只是预览面,而是正式编辑面 + +交付: + +1. 以 `ArtifactDocument JSON` 作为正式持久化模型 +2. 在 `rich_text` block 内引入 Tiptap / ProseMirror 编辑载荷 +3. 将现有 `NotionEditor` 融入 Artifact Workbench +4. 支持局部块编辑、局部 AI 改写、块插入 +5. 支持自动保存与版本生成 + +验收: + +- 用户能直接在 Workbench 上编辑,而不是跳回消息区重来 +- 编辑后的结果不会丢失结构与样式 + +## Phase 3:版本、差异与来源闭环 + +目标: + +- 让 Artifact 成为长期资产,而不是一次性结果 + +交付: + +1. 版本列表与版本摘要(已落地) +2. block diff(最小闭环已落地) +3. source drawer / citations(已落地,支持来源项 -> block 跳转) +4. timeline item 与 artifact block 双向跳转(已落地) + +验收: + +- 用户能知道“新版本改了什么” +- 用户能知道“这段内容从哪里来” + +## Phase 4:Artifact First 产品化 + +目标: + +- 让 Artifact Workbench 成为 Lime 的统一交付层 + +交付: + +1. ServiceSkill 默认输出 Artifact +2. Automation 定时生成 Artifact 版本 +3. Browser Assist / Search / File 结果可沉淀到同一文档 +4. 支持导出、分享、归档、项目复用 + +验收: + +- 用户可以把 Lime 当作持续生成与维护交付物的工作台 +- 交付物在会话结束后仍具备长期价值 + +## 12. 仓库落地建议 + +## 12.1 建议优先复用的现有模块 + +| 现有模块 | 建议角色 | +|------|------| +| `src/components/artifact/*` | 保留为渲染与工具栏底座 | +| `src/lib/artifact/*` | 保留为兼容层与基础状态层 | +| `workbenchPreview.tsx` | 升级为 Artifact Workbench 入口壳 | +| `WorkspaceCanvasContent.tsx` | 继续承载右侧主预览容器 | +| `NotionEditor.tsx` | 作为编辑态主内核 | +| `AgentThreadTimeline.tsx` | 作为过程层和来源层入口 | +| `CanvasContainer.tsx` | 继续承接主题类 Canvas | + +## 12.2 建议新增的目录 + +建议新增: + +```text +src/components/artifact-workbench/ +src/lib/artifact-document/ +src/lib/artifact-protocol/ +src-tauri/src/services/artifact_document_service.rs +``` + +职责建议: + +- `artifact-workbench/`:壳层、视图切换、侧栏、版本条、来源抽屉 +- `artifact-document/`:对象模型、版本管理、diff、序列化 +- `artifact-protocol/`:artifact ops、part 映射、兼容层;当前已先落地 metadata/path 读取壳层,后续继续向完整协议边界收敛 +- `artifact_document_service.rs`:持久化与查询 + +如果后续同步推进 `aster-rust`,则建议新增独立 runtime 模块,而不是继续堆进 `blueprint/`: + +```text +/Users/coso/Documents/dev/ai/astercloud/aster-rust/crates/aster/src/runtime/ +``` + +这部分是框架层远期方向,不覆盖 Lime 当前仓库已确定的运行时收口主计划。 + +建议职责: + +- `thread / turn / item` +- `event bus` +- `prompt composer` +- `output schema` +- `approval / elicitation / interrupt` +- `state persistence` + +## 12.3 迁移原则 + +1. 不直接删除旧 Artifact 系统,先把它降级成兼容层。 +2. 不直接替换所有 Canvas,只先把通用报告类产物接到新 Workbench。 +3. 优先打通 `general/document/planning/knowledge` 四类高价值文本产物。 +4. 在协议稳定前,不急着让所有模型都严格产出结构化块。 +5. 不让 `blueprint` 直接接管 Artifact 主链,Blueprint 只作为可选 planning capability 接入。 + +## 12.4 运行时迁移原则 + +本节只表达 Artifact 产品侧对 runtime 的依赖顺序,不替代 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 已锁定的 P1 / P2 / P3 / P4 执行顺序。 + +1. 先把 `system prompt + output schema + validator` 的控制链建立起来。 +2. 再把 Stage 1 / Stage 2 生成链升级为标准 turn。 +3. 再定义 item / delta / version / diff 事件。 +4. 最后才考虑把 Blueprint 接入某些“复杂规划型 Artifact”场景。 + +## 13. 成功指标 + +上线后建议重点观察: + +1. 报告类任务中,Artifact 打开率与停留时长。 +2. 用户对同一 Artifact 的二次编辑率。 +3. 版本比较的使用率。 +4. 导出率与复制率。 +5. 消息区长文占比是否下降。 +6. 用户是否更少要求“帮我重新整理得更清晰一点”。 + +## 14. 风险与约束 + +## 14.1 主要风险 + +1. 同时维护产品层 JSON、编辑器载荷和导出快照,容易漂移。 +2. 过早做成任意页面搭建器,会把范围做爆。 +3. 语义块过多、过复杂,会压垮 prompt 与 renderer。 +4. 如果没有来源层,最终只会变成“更好看的幻觉输出”。 + +## 14.2 控制原则 + +1. `ArtifactDocument JSON` 是正式事实源;Tiptap / ProseMirror JSON 只存在于编辑器层或 rich_text block 内。 +2. 首批只做有限 block 集,不追求无限扩展。 +3. 先把阅读态与编辑态打通,再做复杂自动排版。 +4. 任何导出与落盘都必须通过 workspace / 应用目录 API 解析路径。 +5. system prompt 不是唯一控制点,必须叠加 turn 级 schema 与 validator。 + +## 15. 最终结论 + +Lime 不缺“漂亮回复”的单点技巧,缺的是: + +**统一的 Artifact Product Model。** + +你们现有代码已经具备高配版所需的 70% 基础设施: + +- 有工作台 +- 有 artifact +- 有 timeline +- 有 canvas +- 有 Tiptap + +真正要补的是剩下这 30%: + +- 正式交付物对象 +- 结构化协议 +- 报告式阅读面 +- 版本与来源闭环 + +因此最优路径不是“继续调 Markdown 样式”,而是: + +**把 Artifact 从“聊天的附件”升级为“Lime 的正式交付层”。** diff --git a/docs/roadmap/artifacts/system-prompt-and-schema-contract.md b/docs/roadmap/artifacts/system-prompt-and-schema-contract.md new file mode 100644 index 000000000..0e9e75304 --- /dev/null +++ b/docs/roadmap/artifacts/system-prompt-and-schema-contract.md @@ -0,0 +1,402 @@ +# Artifact Workbench 的 System Prompt 与 Schema 合同 + +> 状态:进行中,核心合同已落地,P3 产品闭环已落地,rewrite typed patch 合同已落地 +> 更新时间:2026-03-25 +> 运行时边界:prompt 组装入口、turn metadata 主合同、runtime output schema 注入链以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只细化 Artifact 相关合同 +> 关联文档: +> - `docs/roadmap/artifacts/architecture-blueprint.md` +> - `docs/roadmap/artifacts/artifact-document-v1.md` +> - `docs/roadmap/artifacts/framework-boundary.md` +> 目标:定义 Artifact Workbench 在运行时如何通过 prompt、turn metadata、output schema、validator 四层协同控制结构化输出质量 + +## 当前落地状态 + +以下能力已经进入实现态: + +1. 后端已按 turn metadata 组装 Artifact 专属 prompt 段落。 +2. Artifact 回合已在 `runtime_turn` 中绑定 turn-level output schema。 +3. 结构化输出已经接入 validator / repair / fallback,并可落盘为 `ArtifactDocument v1`。 +4. `stage2` 已允许输出 `artifact_document_draft | artifact_ops`,`rewrite` 已收紧到增量 `artifact_ops`。 +5. 后端已支持最小 `artifact_ops` 应用链,可在已有 Artifact 上执行 block / source / version 级增量更新。 +6. 当前版本已回灌 `currentVersionDiff / artifactVersionDiff`,Workbench 也已接入来源抽屉、差异面以及来源项 / 差异项到 block 的定位。 +7. 当前版本已支持 `timeline item <-> artifact block` 双向跳转:timeline 可精确打开目标 block,Workbench 也可回跳对应过程项。 +8. `rewrite` 已把 `artifact_target_block_id` 下沉到 prompt hint、turn-level output schema、`artifact_ops` runtime apply 与 persist validator context;非目标 block 的改写 / 绑定 / 删除会被忽略并记录 issue。 +9. `rewrite` 已支持专用 `artifact_rewrite_patch` 输出 envelope,并在后端兼容转换为 `artifact_ops` 应用链,便于逐步把改写合同从“通用 ops”收紧到“目标 block patch”。 + +仍未完全落地的部分: + +1. rewrite 已具备 typed patch 主合同,但当前仍保留 `artifact_ops` 兼容分支;待模型稳定后可进一步收紧到单一 rewrite envelope + +## 1. 核心观点 + +用户之前的判断是对的: + +**渲染只是结果层。** + +如果模型上游没有被清晰约束,后面的漂亮渲染只是在给随机长文做包装。 + +Artifact Workbench 的真正控制链应是: + +1. prompt policy 决定任务规则 +2. stage contract 决定本轮该输出什么 +3. output schema 决定结果必须长什么结构 +4. validator / repair 决定是否可以进入 ready +5. renderer 决定最终视觉呈现 + +一句话: + +**结构质量先于视觉质量。** + +## 2. 总控制栈 + +```mermaid +flowchart TD + A["用户请求"] --> B["Turn Metadata"] + B --> C["System Prompt Composer"] + C --> D["Stage Contract"] + D --> E["Turn-level Output Schema"] + E --> F["LLM 输出"] + F --> G["Validator / Repair"] + G --> H["ArtifactDocument Store"] + H --> I["Renderer / Editor / Export"] +``` + +## 3. 为什么不能只靠 Prompt + +只靠 prompt,会稳定出现以下问题: + +1. **阶段串线** + - Stage 1 应该给结构计划,结果却开始写正文 +2. **来源丢失** + - 明明要求 source,模型仍然忘记挂引用 +3. **block 漂移** + - 一会儿输出表格,一会儿输出纯长文 +4. **协议脆弱** + - 一旦模型换版本,结果 shape 就可能漂 + +所以 prompt 只能解决“倾向”,不能单独保证“合同”。 + +## 4. 参考基准 + +本合同借鉴 `codex` 的一个关键思路: + +- turn 可以携带自己的 `outputSchema` +- schema 只约束当前 turn 的结构,不污染整个产品层协议 + +依据: + +- `/Users/coso/Documents/dev/rust/codex/codex-rs/app-server/README.md` +- `/Users/coso/Documents/dev/rust/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts` +- `/Users/coso/Documents/dev/rust/codex/codex-rs/codex-api/src/common.rs` + +这说明: + +**把结构化输出做成 runtime 能力,比把所有结构约束硬写死在前端页面或单个 prompt 模板里更稳。** + +## 5. 后端 Prompt 组装原则 + +## 5.1 前端不拼完整 prompt + +前端只提供: + +- 用户输入 +- artifact metadata +- 工作台上下文 +- 局部改写目标 + +后端负责: + +- 合并基础 system prompt +- 合并记忆/搜索/source 规则 +- 合并 Artifact 专属规则 +- 合并 stage 合同 +- 绑定 output schema + +## 5.2 Prompt 分层 + +建议长期固定为以下 8 层: + +1. `Base Runtime Layer` + - 身份、语言、安全边界、消息区与交付区职责分离 + +2. `Workspace / Team Layer` + - 团队偏好、项目约束、cwd、工作模式 + +3. `Memory Layer` + - 记忆画像、长期偏好、已知上下文 + +4. `Search / Source Layer` + - 搜索行为、来源要求、引文约束 + +5. `Artifact Policy Layer` + - 什么时候必须进入 Artifact + - 不要在消息区重复整篇文档 + - 只能输出白名单 block + +6. `Stage Contract Layer` + - 当前是 `stage1 / stage2 / rewrite` + +7. `Turn Context Layer` + - kind、source_policy、selected_block、rewrite_instruction + +8. `Schema Hint Layer` + - 明确提醒本轮有严格 output schema + - 模型应优先满足 schema 而不是自由发挥 + +## 5.3 Marker 约定 + +建议统一 marker: + +- `【Artifact 交付策略】` +- `【Artifact 来源策略】` +- `【Artifact Stage 1 合同】` +- `【Artifact Stage 2 合同】` +- `【Artifact Rewrite 合同】` +- `【Artifact 输出 Schema 提示】` + +作用: + +- 可观测 +- 可去重 +- 可调试 + +## 6. Turn Metadata 合同 + +这里的 `ArtifactTurnMetadata` 只表达 Artifact 领域的意图模型,不直接等于 Lime 当前仓库的请求 wire format。 + +当前实际发送边界、`harness` 结构、metadata 归一化与兼容收口,统一以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准。 + +建议长期保留以下 Artifact turn intent: + +```ts +interface ArtifactTurnMetadata { + artifactMode?: "none" | "draft" | "rewrite"; + artifactKind?: + | "report" + | "roadmap" + | "prd" + | "brief" + | "analysis" + | "comparison" + | "plan"; + artifactStage?: "stage1" | "stage2" | "rewrite"; + sourcePolicy?: "required" | "preferred" | "none"; + workbenchSurface?: "right_panel" | "fullscreen"; + artifactRequestId?: string; + artifactTargetBlockId?: string; + artifactRewriteInstruction?: string; +} +``` + +这里的设计原则是: + +- metadata 表达意图 +- prompt 表达规则 +- schema 表达结构 + +三者不要混写。 + +## 7. Stage 合同 + +## 7.1 Stage 1 + +职责: + +- 判断是否需要正式交付物 +- 锁定 `kind` +- 生成标题建议 +- 生成 source policy +- 生成 block plan / section outline +- 标记缺口与风险 + +禁止: + +- 直接写完整长文 +- 输出 HTML / CSS +- 输出最终排版说明 + +建议 schema 形态: + +```ts +interface ArtifactStage1Result { + needsArtifact: boolean; + kind: + | "report" + | "roadmap" + | "prd" + | "brief" + | "analysis" + | "comparison" + | "plan"; + title: string; + sourcePolicy: "required" | "preferred" | "none"; + outline: Array<{ + id: string; + title: string; + goal: string; + }>; + blockPlan: Array<{ + id: string; + type: + | "section_header" + | "hero_summary" + | "key_points" + | "rich_text" + | "callout" + | "table" + | "checklist" + | "metric_grid" + | "citation_list"; + sectionId?: string; + purpose: string; + }>; + gaps?: string[]; +} +``` + +## 7.2 Stage 2 + +职责: + +- 输出正式 `artifact_document_draft` +- 或输出增量 `artifact ops` + +必须: + +- 满足 `ArtifactDocument v1` +- 满足 source 约束 +- block 类型只能来自白名单 + +建议 schema 形态: + +```ts +type ArtifactStage2Result = + | { + type: "artifact_document_draft"; + document: ArtifactDocumentV1; + } + | { + type: "artifact_ops"; + artifactId: string; + ops: ArtifactOpEnvelope[]; + }; +``` + +## 7.3 Rewrite + +职责: + +- 只改目标 block +- 不允许顺手重写整篇文档 + +建议 schema 形态: + +```ts +interface ArtifactRewriteResult { + artifactId: string; + targetBlockId: string; + block: ArtifactBlockV1; +} +``` + +## 8. Output Schema 策略 + +## 8.1 Schema 绑定位置 + +建议 schema 不是前端硬编码,而是由后端在 turn 发起时绑定。 + +以下时序图只表达“绑定责任在后端”,不单独定义当前仓库的命令名、函数名或中间结构: + +```mermaid +sequenceDiagram + participant FE as 前端 + participant App as Lime App + participant RT as Aster Runtime + participant Model as LLM + + FE->>App: submit_turn(input + artifact metadata) + App->>RT: build_turn(stage, metadata) + RT->>RT: compose system prompt + RT->>RT: attach output schema + RT->>Model: request(instructions + schema) + Model-->>RT: structured output +``` + +## 8.2 Schema 颗粒度 + +建议采用三类 schema: + +1. **运行时 schema** + - 限制某一轮输出结构 +2. **产品层 schema** + - `ArtifactDocument v1` +3. **编辑器 payload schema** + - `rich_text` 内的 ProseMirror/Tiptap JSON + +不要把这三层混成一个巨大 schema。 + +## 8.3 Schema 与 Validator 的关系 + +schema 不是 validator 的替代品。 + +原因: + +1. 模型可能输出“表面符合 schema,但业务仍非法”的内容 +2. sourceId 引用、block 数量、fallback 等逻辑需要业务修复 +3. 编辑器 payload 可能需要额外兼容修正 + +因此主链仍然必须有: + +- schema check +- business validation +- repair +- fallback + +## 9. Validator / Repair 策略 + +validator 至少负责: + +1. 顶层字段校验 +2. block 类型白名单校验 +3. source 引用合法性校验 +4. `rich_text` payload 兼容性校验 +5. source policy 业务校验 + +repair 至少负责: + +1. 缺省 title 补全 +2. table 行列补齐 +3. citation 无效项删除 +4. block fallback 到 `rich_text(markdown)` +5. 整体失败时回退为单个 `rich_text` 文档 + +## 10. 推荐实现边界 + +## 10.1 Lime 产品层 + +建议新增或持有: + +- `artifact_document_schema.ts` +- `artifact_stage1_schema.ts` +- `artifact_stage2_schema.ts` +- `artifact_rewrite_schema.ts` +- `artifact_document_validator.ts` +- `artifact_document_repair.ts` + +## 10.2 Aster Runtime 层 + +建议持有: + +- prompt composer +- turn 级 schema registry +- model invocation wrapper +- item/turn events +- output parsing / validation hook + +## 11. 最终决策 + +Artifact Workbench 的输出质量,不应依赖“模型今天状态好不好”。 + +长期正确方案是: + +**Prompt 负责引导,Schema 负责约束,Validator 负责兜底,Renderer 负责呈现。** diff --git a/extensions/lime-chrome/background.js b/extensions/lime-chrome/background.js index ca8aa3b42..36ba90ee4 100644 --- a/extensions/lime-chrome/background.js +++ b/extensions/lime-chrome/background.js @@ -137,7 +137,9 @@ function disconnectObserver(manual = true) { function setConnectionState(connected) { isConnected = connected; chrome.action.setBadgeText({ text: connected ? "ON" : "OFF" }); - chrome.action.setBadgeBackgroundColor({ color: connected ? "#16a34a" : "#dc2626" }); + chrome.action.setBadgeBackgroundColor({ + color: connected ? "#16a34a" : "#dc2626", + }); } function startHeartbeat() { @@ -272,6 +274,7 @@ async function executeRemoteCommand(commandData) { sourceClientId, status: "success", message: response?.message || `${command} 执行成功`, + data: response?.data, }); if (waitForPageInfo || command === "get_page_info") { @@ -290,6 +293,7 @@ async function executeRemoteCommand(commandData) { async function handleOpenUrl(commandData, waitForPageInfo) { const requestId = commandData.requestId; const sourceClientId = commandData.sourceClientId; + const rawTarget = String(commandData.target || "").trim(); let targetUrl = String(commandData.url || "").trim(); if (!targetUrl) { sendCommandResult({ @@ -305,22 +309,45 @@ async function handleOpenUrl(commandData, waitForPageInfo) { } try { - const tab = await new Promise((resolve, reject) => { - chrome.tabs.create({ url: targetUrl, active: true }, (created) => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - return; - } - resolve(created); - }); - }); + const existingTabId = Number(rawTarget); + const tab = + Number.isInteger(existingTabId) && existingTabId > 0 + ? await new Promise((resolve, reject) => { + chrome.tabs.update( + existingTabId, + { url: targetUrl, active: true }, + (updated) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + resolve(updated); + }, + ); + }) + : await new Promise((resolve, reject) => { + chrome.tabs.create({ url: targetUrl, active: true }, (created) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + resolve(created); + }); + }); activeTabId = tab.id; sendCommandResult({ requestId, sourceClientId, status: "success", - message: `已打开 ${targetUrl}`, + message: + Number.isInteger(existingTabId) && existingTabId > 0 + ? `已在标签页 ${existingTabId} 打开 ${targetUrl}` + : `已打开 ${targetUrl}`, + data: { + tab_id: tab.id, + url: targetUrl, + }, }); if (waitForPageInfo) { @@ -387,6 +414,9 @@ async function handleSwitchTab(commandData, waitForPageInfo) { sourceClientId, status: "success", message: `已切换到标签页 ${targetTab.id}`, + data: { + tab_id: targetTab.id, + }, }); if (waitForPageInfo) { @@ -487,9 +517,12 @@ async function triggerPageCapture(reason, retry = 0) { }); } catch (error) { if (retry < PAGE_CAPTURE_RETRY_LIMIT) { - setTimeout(() => { - triggerPageCapture(reason, retry + 1); - }, 250 * (retry + 1)); + setTimeout( + () => { + triggerPageCapture(reason, retry + 1); + }, + 250 * (retry + 1), + ); } else { logWarn("页面抓取请求失败", error?.message || String(error)); } @@ -545,9 +578,18 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (type === "UPDATE_SETTINGS") { const patch = request?.data || {}; const next = { - serverUrl: typeof patch.serverUrl === "string" ? patch.serverUrl : lastSettings.serverUrl, - bridgeKey: typeof patch.bridgeKey === "string" ? patch.bridgeKey : lastSettings.bridgeKey, - profileKey: typeof patch.profileKey === "string" ? patch.profileKey : lastSettings.profileKey, + serverUrl: + typeof patch.serverUrl === "string" + ? patch.serverUrl + : lastSettings.serverUrl, + bridgeKey: + typeof patch.bridgeKey === "string" + ? patch.bridgeKey + : lastSettings.bridgeKey, + profileKey: + typeof patch.profileKey === "string" + ? patch.profileKey + : lastSettings.profileKey, monitoringEnabled: typeof patch.monitoringEnabled === "boolean" ? patch.monitoringEnabled diff --git a/extensions/lime-chrome/content_script.js b/extensions/lime-chrome/content_script.js index 756ad56b6..b9b85722d 100644 --- a/extensions/lime-chrome/content_script.js +++ b/extensions/lime-chrome/content_script.js @@ -10,295 +10,339 @@ window.__LIME_CONTENT_SCRIPT_LOADED__ = true; window.__PROXYCAST_CONTENT_SCRIPT_LOADED__ = true; -let refCounter = 0; -const REF_ATTR = "lime-id"; + let refCounter = 0; + const REF_ATTR = "lime-id"; -function nextRefId() { - refCounter += 1; - return `lime-${refCounter}`; -} - -function resetRefs() { - refCounter = 0; - document.querySelectorAll(`[${REF_ATTR}]`).forEach((el) => { - el.removeAttribute(REF_ATTR); - }); -} - -function normalizeText(value) { - return String(value || "").replace(/\s+/g, " ").trim(); -} - -function isElementVisible(element) { - if (!element) { - return false; - } - const style = window.getComputedStyle(element); - if ( - style.display === "none" || - style.visibility === "hidden" || - style.opacity === "0" - ) { - return false; - } - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; -} - -function isInteractiveElement(element) { - if (!element || !isElementVisible(element)) { - return false; + function nextRefId() { + refCounter += 1; + return `lime-${refCounter}`; } - const tag = element.tagName.toLowerCase(); - const role = element.getAttribute("role"); - if (["a", "button", "input", "textarea", "select", "option"].includes(tag)) { - return true; - } - if ( - role && - [ - "button", - "link", - "checkbox", - "radio", - "menuitem", - "tab", - "switch", - "option", - "searchbox", - "textbox", - "combobox", - ].includes(role) - ) { - return true; + function resetRefs() { + refCounter = 0; + document.querySelectorAll(`[${REF_ATTR}]`).forEach((el) => { + el.removeAttribute(REF_ATTR); + }); } - if (element.hasAttribute("onclick")) { - return true; + function normalizeText(value) { + return String(value || "") + .replace(/\s+/g, " ") + .trim(); } - const style = window.getComputedStyle(element); - return style.cursor === "pointer"; -} - -function interactiveLabel(element) { - const tag = element.tagName.toLowerCase(); - const role = element.getAttribute("role"); - const text = normalizeText( - element.innerText || - element.value || - element.placeholder || - element.getAttribute("aria-label") || - element.title || - element.name || - element.id, - ); - - if (tag === "a") { - return `链接: ${text || "无标题链接"}`; + function isElementVisible(element) { + if (!element) { + return false; + } + const style = window.getComputedStyle(element); + if ( + style.display === "none" || + style.visibility === "hidden" || + style.opacity === "0" + ) { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; } - if (tag === "button" || role === "button") { - return `按钮: ${text || "无标题按钮"}`; - } - if (tag === "input" || tag === "textarea") { - return `输入框: ${text || "未命名输入框"}`; - } - if (tag === "select") { - return `下拉框: ${text || "未命名下拉框"}`; - } - return `可交互元素: ${text || tag}`; -} -function buildMarkdown() { - resetRefs(); + function isInteractiveElement(element) { + if (!element || !isElementVisible(element)) { + return false; + } - const lines = []; - lines.push(`# ${document.title || "Untitled"}`); - lines.push(`URL: ${window.location.href}`); - lines.push(""); + const tag = element.tagName.toLowerCase(); + const role = element.getAttribute("role"); + if ( + ["a", "button", "input", "textarea", "select", "option"].includes(tag) + ) { + return true; + } + if ( + role && + [ + "button", + "link", + "checkbox", + "radio", + "menuitem", + "tab", + "switch", + "option", + "searchbox", + "textbox", + "combobox", + ].includes(role) + ) { + return true; + } - const bodyText = normalizeText(document.body ? document.body.innerText : ""); - if (bodyText) { - lines.push("## 页面文本"); - lines.push(bodyText.slice(0, 6000)); + if (element.hasAttribute("onclick")) { + return true; + } + + const style = window.getComputedStyle(element); + return style.cursor === "pointer"; + } + + function interactiveLabel(element) { + const tag = element.tagName.toLowerCase(); + const role = element.getAttribute("role"); + const text = normalizeText( + element.innerText || + element.value || + element.placeholder || + element.getAttribute("aria-label") || + element.title || + element.name || + element.id, + ); + + if (tag === "a") { + return `链接: ${text || "无标题链接"}`; + } + if (tag === "button" || role === "button") { + return `按钮: ${text || "无标题按钮"}`; + } + if (tag === "input" || tag === "textarea") { + return `输入框: ${text || "未命名输入框"}`; + } + if (tag === "select") { + return `下拉框: ${text || "未命名下拉框"}`; + } + return `可交互元素: ${text || tag}`; + } + + function buildMarkdown() { + resetRefs(); + + const lines = []; + lines.push(`# ${document.title || "Untitled"}`); + lines.push(`URL: ${window.location.href}`); lines.push(""); + + const bodyText = normalizeText( + document.body ? document.body.innerText : "", + ); + if (bodyText) { + lines.push("## 页面文本"); + lines.push(bodyText.slice(0, 6000)); + lines.push(""); + } + + lines.push("## 可交互元素"); + const allElements = Array.from(document.querySelectorAll("*")).filter( + isInteractiveElement, + ); + + for (const element of allElements.slice(0, 300)) { + const refId = nextRefId(); + element.setAttribute(REF_ATTR, refId); + lines.push(`- [${interactiveLabel(element)}](${refId})`); + } + + return lines.join("\n").trim(); } - lines.push("## 可交互元素"); - const allElements = Array.from(document.querySelectorAll("*")).filter(isInteractiveElement); + function findElement(target) { + if (!target || typeof target !== "string") { + return null; + } + const trimmed = target.trim(); + if (!trimmed) { + return null; + } - for (const element of allElements.slice(0, 300)) { - const refId = nextRefId(); - element.setAttribute(REF_ATTR, refId); - lines.push(`- [${interactiveLabel(element)}](${refId})`); - } - - return lines.join("\n").trim(); -} - -function findElement(target) { - if (!target || typeof target !== "string") { - return null; - } - const trimmed = target.trim(); - if (!trimmed) { - return null; - } - - let element = document.querySelector(`[${REF_ATTR}="${CSS.escape(trimmed)}"]`); - if (element) { - return element; - } - - try { - element = document.querySelector(trimmed); + let element = document.querySelector( + `[${REF_ATTR}="${CSS.escape(trimmed)}"]`, + ); if (element) { return element; } - } catch (_) {} - element = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button']")).find( - (candidate) => normalizeText(candidate.innerText || candidate.value || candidate.placeholder || "") === trimmed, - ); - if (element) { - return element; - } - - element = Array.from(document.querySelectorAll("[aria-label]")).find( - (candidate) => normalizeText(candidate.getAttribute("aria-label")) === trimmed, - ); - return element || null; -} - -function scrollPage(text) { - const input = normalizeText(text); - let direction = "down"; - let amount = 500; - - if (input.includes(":")) { - const parts = input.split(":"); - direction = normalizeText(parts[0]) || "down"; - const parsed = Number(parts[1]); - if (!Number.isNaN(parsed) && parsed > 0) { - amount = parsed; - } - } - - if (direction === "up") { - window.scrollBy(0, -amount); - } else if (direction === "left") { - window.scrollBy(-amount, 0); - } else if (direction === "right") { - window.scrollBy(amount, 0); - } else { - window.scrollBy(0, amount); - } -} - -async function executeCommand(commandData) { - const command = String(commandData.command || "").trim(); - const target = commandData.target; - const text = commandData.text; - - switch (command) { - case "click": { - const element = findElement(target); - if (!element) { - return { status: "error", error: `未找到点击目标: ${target}` }; + try { + element = document.querySelector(trimmed); + if (element) { + return element; } - element.click(); - return { status: "success", message: "click 执行成功" }; - } - case "type": { - const element = findElement(target); - if (!element) { - return { status: "error", error: `未找到输入目标: ${target}` }; - } - const value = text == null ? "" : String(text); - if ("value" in element) { - element.focus(); - element.value = value; - element.dispatchEvent(new Event("input", { bubbles: true })); - element.dispatchEvent(new Event("change", { bubbles: true })); - } else { - element.textContent = value; - } - return { status: "success", message: "type 执行成功" }; - } - case "scroll": - case "scroll_page": { - scrollPage(text); - return { status: "success", message: "scroll 执行成功" }; - } - case "get_page_info": { - await sendPageInfo("get_page_info"); - return { status: "success", message: "页面信息已回传" }; - } - case "refresh_page": { - window.location.reload(); - return { status: "success", message: "页面刷新中" }; - } - case "go_back": { - window.history.back(); - return { status: "success", message: "执行后退" }; - } - case "go_forward": { - window.history.forward(); - return { status: "success", message: "执行前进" }; - } - default: - return { status: "error", error: `不支持的命令: ${command}` }; - } -} + } catch (_) {} -async function sendPageInfo(reason) { - const markdown = buildMarkdown(); - const payload = { - type: "PAGE_INFO_UPDATE", - data: { - reason, - title: document.title || "", - url: window.location.href, - markdown, - }, - }; - await chrome.runtime.sendMessage(payload); -} + element = Array.from( + document.querySelectorAll( + "button,a,input,textarea,select,[role='button']", + ), + ).find( + (candidate) => + normalizeText( + candidate.innerText || candidate.value || candidate.placeholder || "", + ) === trimmed, + ); + if (element) { + return element; + } -chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { - const type = request?.type; - - if (type === "REQUEST_PAGE_CAPTURE") { - sendPageInfo(request?.data?.reason || "manual") - .then(() => sendResponse({ status: "success" })) - .catch((error) => - sendResponse({ status: "error", error: error?.message || String(error) }), - ); - return true; + element = Array.from(document.querySelectorAll("[aria-label]")).find( + (candidate) => + normalizeText(candidate.getAttribute("aria-label")) === trimmed, + ); + return element || null; } - if (type === "EXECUTE_COMMAND") { - executeCommand(request?.data || {}) - .then(async (result) => { - if (request?.data?.wait_for_page_info === true) { - setTimeout(() => { - sendPageInfo("wait_for_page_info"); - }, 400); + function scrollPage(text) { + const input = normalizeText(text); + let direction = "down"; + let amount = 500; + + if (input.includes(":")) { + const parts = input.split(":"); + direction = normalizeText(parts[0]) || "down"; + const parsed = Number(parts[1]); + if (!Number.isNaN(parsed) && parsed > 0) { + amount = parsed; + } + } + + if (direction === "up") { + window.scrollBy(0, -amount); + } else if (direction === "left") { + window.scrollBy(-amount, 0); + } else if (direction === "right") { + window.scrollBy(amount, 0); + } else { + window.scrollBy(0, amount); + } + } + + async function executeCommand(commandData) { + const command = String(commandData.command || "").trim(); + const target = commandData.target; + const text = commandData.text; + const payload = commandData.payload; + + switch (command) { + case "click": { + const element = findElement(target); + if (!element) { + return { status: "error", error: `未找到点击目标: ${target}` }; } - sendResponse(result); - }) - .catch((error) => - sendResponse({ status: "error", error: error?.message || String(error) }), - ); - return true; + element.click(); + return { status: "success", message: "click 执行成功" }; + } + case "type": { + const element = findElement(target); + if (!element) { + return { status: "error", error: `未找到输入目标: ${target}` }; + } + const value = text == null ? "" : String(text); + if ("value" in element) { + element.focus(); + element.value = value; + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + } else { + element.textContent = value; + } + return { status: "success", message: "type 执行成功" }; + } + case "scroll": + case "scroll_page": { + scrollPage(text); + return { status: "success", message: "scroll 执行成功" }; + } + case "get_page_info": { + await sendPageInfo("get_page_info"); + return { status: "success", message: "页面信息已回传" }; + } + case "refresh_page": { + window.location.reload(); + return { status: "success", message: "页面刷新中" }; + } + case "go_back": { + window.history.back(); + return { status: "success", message: "执行后退" }; + } + case "go_forward": { + window.history.forward(); + return { status: "success", message: "执行前进" }; + } + case "run_adapter": { + const script = String(payload?.script || "").trim(); + if (!script) { + return { status: "error", error: "run_adapter 缺少 script" }; + } + try { + const executor = new Function(`return ${script};`); + const result = await executor(); + return { + status: "success", + message: "run_adapter 执行成功", + data: result ?? null, + }; + } catch (error) { + return { + status: "error", + error: error?.message || String(error), + }; + } + } + default: + return { status: "error", error: `不支持的命令: ${command}` }; + } } - return true; -}); + async function sendPageInfo(reason) { + const markdown = buildMarkdown(); + const payload = { + type: "PAGE_INFO_UPDATE", + data: { + reason, + title: document.title || "", + url: window.location.href, + markdown, + }, + }; + await chrome.runtime.sendMessage(payload); + } -setTimeout(() => { - sendPageInfo("content_script_ready").catch(() => {}); -}, 800); + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + const type = request?.type; + if (type === "REQUEST_PAGE_CAPTURE") { + sendPageInfo(request?.data?.reason || "manual") + .then(() => sendResponse({ status: "success" })) + .catch((error) => + sendResponse({ + status: "error", + error: error?.message || String(error), + }), + ); + return true; + } + + if (type === "EXECUTE_COMMAND") { + executeCommand(request?.data || {}) + .then(async (result) => { + if (request?.data?.wait_for_page_info === true) { + setTimeout(() => { + sendPageInfo("wait_for_page_info"); + }, 400); + } + sendResponse(result); + }) + .catch((error) => + sendResponse({ + status: "error", + error: error?.message || String(error), + }), + ); + return true; + } + + return true; + }); + + setTimeout(() => { + sendPageInfo("content_script_ready").catch(() => {}); + }, 800); })(); // 结束 IIFE diff --git a/index.html b/index.html index 3e30c3cab..9986a2171 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@
+ diff --git a/package.json b/package.json index cd6205682..3c70a561b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.95.1", + "version": "0.96.0", "type": "module", "engines": { "node": ">=22.0.0" @@ -44,6 +44,8 @@ "detect-translations:fix": "tsx scripts/detect-missing-translations.ts --fix", "detect-translations:verbose": "tsx scripts/detect-missing-translations.ts --verbose", "verify:app-version": "node scripts/check-app-version-consistency.mjs", + "verify:tasks": "node scripts/quality-task-selector.mjs --format json", + "verify:gui-smoke": "node scripts/verify-gui-smoke.mjs", "verify:local": "node scripts/local-ci.mjs", "verify:local:full": "node scripts/local-ci.mjs --full", "ai-verify": "tsx scripts/ai-code-verify.ts", diff --git a/scripts/local-ci.mjs b/scripts/local-ci.mjs index 56d369c81..fde046293 100644 --- a/scripts/local-ci.mjs +++ b/scripts/local-ci.mjs @@ -1,34 +1,15 @@ #!/usr/bin/env node -import { execFileSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import process from "node:process"; +import { planQualityTasks } from "./quality-task-planner.mjs"; + const options = parseArgs(process.argv.slice(2)); const rootDir = process.cwd(); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const cargoCommand = process.platform === "win32" ? "cargo.exe" : "cargo"; -const gitCommand = process.platform === "win32" ? "git.exe" : "git"; - -const FRONTEND_ROOT_FILES = new Set([ - "package.json", - "package-lock.json", - "vite.config.ts", - "tsconfig.json", - "tsconfig.node.json", - "eslint.config.js", - "tailwind.config.js", - "postcss.config.js", - "index.html", -]); - -const BRIDGE_FILES = new Set([ - "vite.config.ts", - "scripts/check-dev-bridge-health.mjs", - "scripts/social-workbench-e2e-smoke.mjs", - "scripts/chrome-bridge-e2e.mjs", - "docs/aiprompts/playwright-e2e.md", -]); function parseArgs(argv) { const result = { @@ -96,143 +77,6 @@ function runCommand(command, args) { } } -function gitOutput(args) { - try { - return execFileSync(gitCommand, args, { - cwd: rootDir, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - return ""; - } -} - -function splitLines(value) { - if (!value) { - return []; - } - return value - .split("\n") - .map((item) => item.trim()) - .filter(Boolean); -} - -function resolveDiffBase() { - if (options.base) { - return options.base; - } - - const upstream = gitOutput(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]); - if (upstream) { - return upstream; - } - - for (const candidate of ["origin/main", "origin/master", "main", "master"]) { - const exists = gitOutput(["rev-parse", "--verify", candidate]); - if (exists) { - return candidate; - } - } - - return ""; -} - -function collectChangedFiles() { - if (options.full) { - return []; - } - - if (options.staged) { - return uniquePaths( - splitLines(gitOutput(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])), - ); - } - - const base = resolveDiffBase(); - const candidates = []; - - if (base) { - candidates.push( - ...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`])), - ); - } - - candidates.push( - ...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", "HEAD"])), - ); - candidates.push( - ...splitLines(gitOutput(["ls-files", "--others", "--exclude-standard"])), - ); - - return uniquePaths(candidates); -} - -function uniquePaths(paths) { - return Array.from(new Set(paths)); -} - -function isFrontendChange(file) { - return ( - file.startsWith("src/") || - FRONTEND_ROOT_FILES.has(file) - ); -} - -function isRustChange(file) { - return file.startsWith("src-tauri/"); -} - -function isBridgeChange(file) { - return ( - file.startsWith("src/lib/dev-bridge/") || - file.startsWith("src/lib/tauri-mock/") || - BRIDGE_FILES.has(file) - ); -} - -function isDocsOnlyChange(files) { - return files.length > 0 && files.every((file) => file.startsWith("docs/")); -} - -function detectTasks(changedFiles) { - if (options.full) { - return { - frontend: true, - rust: true, - bridge: true, - }; - } - - if (changedFiles.length === 0) { - return { - frontend: true, - rust: true, - bridge: true, - fallback: true, - }; - } - - if (isDocsOnlyChange(changedFiles)) { - return { - frontend: false, - rust: false, - bridge: false, - docsOnly: true, - }; - } - - const frontend = changedFiles.some(isFrontendChange); - const rust = changedFiles.some(isRustChange); - const bridge = changedFiles.some(isBridgeChange); - - return { - frontend, - rust, - bridge, - }; -} - function printSummary(changedFiles, tasks) { console.log("[local-ci] 模式:", options.full ? "full" : "smart"); if (!options.full) { @@ -243,7 +87,9 @@ function printSummary(changedFiles, tasks) { console.log(`[local-ci] - ${file}`); } if (changedFiles.length > preview.length) { - console.log(`[local-ci] ... 其余 ${changedFiles.length - preview.length} 个文件省略`); + console.log( + `[local-ci] ... 其余 ${changedFiles.length - preview.length} 个文件省略`, + ); } } } @@ -254,12 +100,18 @@ function printSummary(changedFiles, tasks) { } console.log("[local-ci] 计划执行:"); + if (tasks.integrity) { + console.log("[local-ci] - 一致性校验"); + } if (tasks.frontend) { console.log("[local-ci] - 前端校验"); } if (tasks.bridge) { console.log("[local-ci] - bridge 校验"); } + if (tasks.guiSmoke) { + console.log("[local-ci] - GUI 冒烟"); + } if (tasks.rust) { console.log("[local-ci] - Rust 校验"); } @@ -273,6 +125,10 @@ function runSelectedTasks(tasks) { return; } + if (tasks.integrity) { + runCommand(npmCommand, ["run", "verify:app-version"]); + } + if (tasks.frontend) { runCommand(npmCommand, ["run", "lint"]); runCommand(npmCommand, ["run", "typecheck"]); @@ -287,11 +143,23 @@ function runSelectedTasks(tasks) { } if (tasks.rust) { - runCommand(cargoCommand, ["test", "--manifest-path", "src-tauri/Cargo.toml"]); + runCommand(cargoCommand, [ + "test", + "--manifest-path", + "src-tauri/Cargo.toml", + ]); if (options.full) { - runCommand(cargoCommand, ["clippy", "--manifest-path", "src-tauri/Cargo.toml"]); + runCommand(cargoCommand, [ + "clippy", + "--manifest-path", + "src-tauri/Cargo.toml", + ]); } } + + if (tasks.guiSmoke) { + runCommand(npmCommand, ["run", "verify:gui-smoke"]); + } } function main() { @@ -300,8 +168,12 @@ function main() { return; } - const changedFiles = collectChangedFiles(); - const tasks = detectTasks(changedFiles); + const { changedFiles, tasks } = planQualityTasks({ + base: options.base, + cwd: rootDir, + full: options.full, + staged: options.staged, + }); printSummary(changedFiles, tasks); runSelectedTasks(tasks); console.log("\n[local-ci] 本地校验完成。"); diff --git a/scripts/quality-task-planner.mjs b/scripts/quality-task-planner.mjs new file mode 100644 index 000000000..515828996 --- /dev/null +++ b/scripts/quality-task-planner.mjs @@ -0,0 +1,362 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +const DEFAULT_GIT_COMMAND = process.platform === "win32" ? "git.exe" : "git"; + +const IGNORED_PREFIXES = [ + ".turbo/", + "coverage/", + "dist/", + "docs/.output/", + "node_modules/", + "target/", + "target-site-e2e/", +]; + +const IGNORED_FILES = new Set([".DS_Store"]); + +const FRONTEND_ROOT_FILES = new Set([ + "package.json", + "package-lock.json", + "vite.config.ts", + "tsconfig.json", + "tsconfig.node.json", + "eslint.config.js", + "tailwind.config.js", + "postcss.config.js", + "index.html", +]); + +const FRONTEND_TOOLING_FILES = new Set([ + "scripts/local-ci.mjs", + "scripts/ai-code-verify.ts", + "scripts/quality-task-planner.mjs", + "scripts/quality-task-selector.mjs", +]); + +const BRIDGE_FILES = new Set([ + "vite.config.ts", + "scripts/check-command-contracts.mjs", + "scripts/check-dev-bridge-health.mjs", + "scripts/social-workbench-e2e-smoke.mjs", + "scripts/chrome-bridge-e2e.mjs", + "scripts/verify-gui-smoke.mjs", + "docs/aiprompts/playwright-e2e.md", +]); + +const INTEGRITY_FILES = new Set([ + "package.json", + "src-tauri/Cargo.toml", + "src-tauri/tauri.conf.json", + "src-tauri/tauri.conf.headless.json", + "scripts/check-app-version-consistency.mjs", + "scripts/quality-task-planner.mjs", + "scripts/quality-task-selector.mjs", +]); + +const GUI_SMOKE_FILES = new Set([ + "src/App.tsx", + "src/main.tsx", + "src-tauri/tauri.conf.json", + "src-tauri/tauri.conf.headless.json", + "src-tauri/src/app/runner.rs", + "src-tauri/src/commands/workspace_cmd.rs", + "src-tauri/src/workspace_support.rs", + "scripts/check-dev-bridge-health.mjs", + "scripts/workspace-ready-smoke.mjs", + "scripts/verify-gui-smoke.mjs", +]); + +const GUI_SMOKE_PREFIXES = [ + "src/components/", + "src/contexts/", + "src/features/", + "src/hooks/", + "src/lib/dev-bridge/", + "src/lib/navigation/", + "src/lib/tauri/", + "src/lib/tauri-mock/", + "src/lib/workspace/", + "src/pages/", + "src/stores/", + "src-tauri/src/app/", + "src-tauri/src/dev_bridge/", +]; + +function gitOutput({ cwd, gitCommand, args }) { + try { + return execFileSync(gitCommand, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +function splitLines(value) { + if (!value) { + return []; + } + + return value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); +} + +function uniquePaths(paths) { + return Array.from(new Set(paths)).filter((file) => !isIgnoredPath(file)); +} + +function isIgnoredPath(file) { + if (IGNORED_FILES.has(file)) { + return true; + } + + return IGNORED_PREFIXES.some((prefix) => file.startsWith(prefix)); +} + +function resolveDiffBase({ base = "", cwd, gitCommand = DEFAULT_GIT_COMMAND }) { + if (base) { + return base; + } + + const upstream = gitOutput({ + cwd, + gitCommand, + args: ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + }); + if (upstream) { + return upstream; + } + + for (const candidate of ["origin/main", "origin/master", "main", "master"]) { + const exists = gitOutput({ + cwd, + gitCommand, + args: ["rev-parse", "--verify", candidate], + }); + if (exists) { + return candidate; + } + } + + return ""; +} + +function collectChangedFiles({ + full = false, + staged = false, + base = "", + cwd = process.cwd(), + gitCommand = DEFAULT_GIT_COMMAND, +} = {}) { + if (full) { + return []; + } + + if (staged) { + return uniquePaths( + splitLines( + gitOutput({ + cwd, + gitCommand, + args: ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], + }), + ), + ); + } + + const diffBase = resolveDiffBase({ base, cwd, gitCommand }); + const candidates = []; + + if (diffBase) { + candidates.push( + ...splitLines( + gitOutput({ + cwd, + gitCommand, + args: [ + "diff", + "--name-only", + "--diff-filter=ACMR", + `${diffBase}...HEAD`, + ], + }), + ), + ); + } + + candidates.push( + ...splitLines( + gitOutput({ + cwd, + gitCommand, + args: ["diff", "--name-only", "--diff-filter=ACMR", "HEAD"], + }), + ), + ); + candidates.push( + ...splitLines( + gitOutput({ + cwd, + gitCommand, + args: ["ls-files", "--others", "--exclude-standard"], + }), + ), + ); + + return uniquePaths(candidates); +} + +function isMarkdownLike(file) { + return /\.(md|mdx)$/i.test(file); +} + +function isWorkflowChange(file) { + return file.startsWith(".github/workflows/"); +} + +function isDocsChange(file) { + return file.startsWith("docs/") || isMarkdownLike(file); +} + +function isDocsOnlyChange(files) { + return files.length > 0 && files.every((file) => isDocsChange(file)); +} + +function isFrontendChange(file) { + return ( + file.startsWith("src/") || + FRONTEND_ROOT_FILES.has(file) || + FRONTEND_TOOLING_FILES.has(file) + ); +} + +function isRustChange(file) { + return file.startsWith("src-tauri/"); +} + +function isBridgeChange(file) { + return ( + file.startsWith("src/lib/dev-bridge/") || + file.startsWith("src/lib/tauri-mock/") || + BRIDGE_FILES.has(file) + ); +} + +function isGuiSmokeChange(file) { + return ( + GUI_SMOKE_FILES.has(file) || + GUI_SMOKE_PREFIXES.some((prefix) => file.startsWith(prefix)) + ); +} + +function isIntegrityChange(file) { + return ( + INTEGRITY_FILES.has(file) || + isWorkflowChange(file) || + isFrontendChange(file) || + isRustChange(file) + ); +} + +function detectTasks(changedFiles, { full = false } = {}) { + if (full) { + return { + integrity: true, + frontend: true, + rust: true, + bridge: true, + guiSmoke: true, + docs: true, + docsOnly: false, + fallback: false, + workflow: false, + }; + } + + if (changedFiles.length === 0) { + return { + integrity: true, + frontend: true, + rust: true, + bridge: true, + guiSmoke: true, + docs: true, + docsOnly: false, + fallback: true, + workflow: false, + }; + } + + const workflow = changedFiles.some(isWorkflowChange); + if (workflow) { + return { + integrity: true, + frontend: true, + rust: true, + bridge: true, + guiSmoke: true, + docs: true, + docsOnly: false, + fallback: false, + workflow: true, + }; + } + + if (isDocsOnlyChange(changedFiles)) { + return { + integrity: false, + frontend: false, + rust: false, + bridge: false, + guiSmoke: false, + docs: true, + docsOnly: true, + fallback: false, + workflow: false, + }; + } + + return { + integrity: changedFiles.some(isIntegrityChange), + frontend: changedFiles.some(isFrontendChange), + rust: changedFiles.some(isRustChange), + bridge: changedFiles.some(isBridgeChange), + guiSmoke: changedFiles.some(isGuiSmokeChange), + docs: changedFiles.some(isDocsChange), + docsOnly: false, + fallback: false, + workflow: false, + }; +} + +function planQualityTasks({ + full = false, + staged = false, + base = "", + cwd = process.cwd(), + gitCommand = DEFAULT_GIT_COMMAND, +} = {}) { + const changedFiles = collectChangedFiles({ + full, + staged, + base, + cwd, + gitCommand, + }); + + const tasks = detectTasks(changedFiles, { full }); + + return { + changedFiles, + tasks, + }; +} + +export { collectChangedFiles, detectTasks, planQualityTasks, resolveDiffBase }; diff --git a/scripts/quality-task-selector.mjs b/scripts/quality-task-selector.mjs new file mode 100644 index 000000000..11c962621 --- /dev/null +++ b/scripts/quality-task-selector.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +import process from "node:process"; + +import { planQualityTasks } from "./quality-task-planner.mjs"; + +function parseArgs(argv) { + const result = { + base: "", + format: "json", + full: false, + help: false, + staged: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--full") { + result.full = true; + continue; + } + if (arg === "--staged") { + result.staged = true; + continue; + } + if (arg === "--base" && argv[index + 1]) { + result.base = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime 质量任务规划器 + +用法: + node scripts/quality-task-selector.mjs + node scripts/quality-task-selector.mjs --staged + node scripts/quality-task-selector.mjs --base origin/main + node scripts/quality-task-selector.mjs --format github + +选项: + --full 忽略改动检测,返回全量任务 + --staged 仅基于已暂存文件判断 + --base REF 基于指定基线判断 + --format FMT 输出格式:json | github + -h, --help 显示帮助 +`); +} + +function printGithubFormat(result) { + const { changedFiles, tasks } = result; + const lines = [ + `changed_count=${changedFiles.length}`, + `integrity=${tasks.integrity}`, + `frontend=${tasks.frontend}`, + `rust=${tasks.rust}`, + `bridge=${tasks.bridge}`, + `gui_smoke=${tasks.guiSmoke}`, + `docs=${tasks.docs}`, + `docs_only=${tasks.docsOnly}`, + `fallback=${tasks.fallback}`, + `workflow=${tasks.workflow}`, + ]; + + process.stdout.write(`${lines.join("\n")}\n`); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const result = planQualityTasks(options); + + if (options.format === "github") { + printGithubFormat(result); + return; + } + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +main(); diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs new file mode 100644 index 000000000..2a50f4e28 --- /dev/null +++ b/scripts/verify-gui-smoke.mjs @@ -0,0 +1,375 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const DEFAULTS = { + appUrl: "http://127.0.0.1:1420/", + healthUrl: "http://127.0.0.1:3030/health", + timeoutMs: 120_000, + intervalMs: 1_000, + reuseRunning: false, + sampleProjectName: "Lime Smoke Workspace", +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, ".."); +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +const state = { + child: null, + cleanedUp: false, +}; + +function printHelp() { + console.log(` +Lime GUI 冒烟入口 + +用途: + 启动或复用 headless Tauri 环境,串联 GUI 壳、DevBridge 与默认 workspace 的最小回归校验。 + +用法: + npm run verify:gui-smoke + npm run verify:gui-smoke -- --reuse-running + npm run verify:gui-smoke -- --timeout-ms 180000 + +选项: + --app-url 前端地址,默认 http://127.0.0.1:1420/ + --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health + --timeout-ms 等待 headless / bridge / smoke 的超时,默认 120000 + --interval-ms 轮询间隔,默认 1000 + --sample-project-name workspace 路径校验使用的示例项目名 + --reuse-running 复用已启动的 headless Tauri,不主动拉起 + -h, --help 显示帮助 +`); +} + +function parseArgs(argv) { + const options = { ...DEFAULTS }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--app-url" && argv[index + 1]) { + options.appUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--health-url" && argv[index + 1]) { + options.healthUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--timeout-ms" && argv[index + 1]) { + options.timeoutMs = Number(argv[index + 1]); + index += 1; + continue; + } + + if (arg === "--interval-ms" && argv[index + 1]) { + options.intervalMs = Number(argv[index + 1]); + index += 1; + continue; + } + + if (arg === "--sample-project-name" && argv[index + 1]) { + options.sampleProjectName = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--reuse-running") { + options.reuseRunning = true; + continue; + } + + if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } + } + + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) { + throw new Error("--timeout-ms 必须是 >= 1000 的数字"); + } + + if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) { + throw new Error("--interval-ms 必须是 >= 100 的数字"); + } + + if (!options.appUrl) { + throw new Error("--app-url 不能为空"); + } + + if (!options.healthUrl) { + throw new Error("--health-url 不能为空"); + } + + if (!options.sampleProjectName) { + throw new Error("--sample-project-name 不能为空"); + } + + return options; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function formatCommand(command, args) { + return [command, ...args].join(" "); +} + +function runCommand(command, args, label) { + console.log(`\n[verify:gui-smoke] > ${formatCommand(command, args)}`); + const result = spawnSync(command, args, { + cwd: rootDir, + stdio: "inherit", + env: process.env, + }); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === "number" && result.status !== 0) { + const error = new Error(`[verify:gui-smoke] ${label} 失败`); + error.exitCode = result.status; + throw error; + } +} + +function startHeadlessTauri() { + console.log("[verify:gui-smoke] 启动 headless Tauri 环境..."); + state.child = spawn(npmCommand, ["run", "tauri:dev:headless"], { + cwd: rootDir, + stdio: "inherit", + env: process.env, + detached: process.platform !== "win32", + }); +} + +async function stopHeadlessTauri() { + const child = state.child; + if (!child || state.cleanedUp) { + return; + } + + state.cleanedUp = true; + console.log("[verify:gui-smoke] 停止 headless Tauri 环境..."); + + if (typeof child.pid !== "number") { + return; + } + + if (process.platform === "win32") { + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); + return; + } + + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + try { + child.kill("SIGTERM"); + } catch { + return; + } + } + + for (let attempt = 0; attempt < 25; attempt += 1) { + if (child.exitCode !== null || child.signalCode) { + return; + } + await sleep(200); + } + + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + } +} + +async function waitForAppShell(options) { + const startedAt = Date.now(); + let lastError = null; + + while (Date.now() - startedAt < options.timeoutMs) { + try { + const response = await fetch(options.appUrl, { method: "GET" }); + const html = await response.text(); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + assert( + html.includes("Lime") || + html.includes('
') || + html.includes('
{ + try { + await stopHeadlessTauri(); + } finally { + process.kill(process.pid, signal); + } + }; + + process.once("SIGINT", () => { + void handleSignal("SIGINT"); + }); + process.once("SIGTERM", () => { + void handleSignal("SIGTERM"); + }); + + try { + if (startedByScript) { + startHeadlessTauri(); + await sleep(1_500); + } else if (startupMode.reusedExisting) { + console.log("[verify:gui-smoke] 复用已运行的 headless Tauri 环境。"); + } + + runCommand( + npmCommand, + [ + "run", + "bridge:health", + "--", + "--url", + options.healthUrl, + "--timeout-ms", + String(options.timeoutMs), + "--interval-ms", + String(options.intervalMs), + ], + "bridge:health", + ); + + await waitForAppShell(options); + + runCommand( + npmCommand, + [ + "run", + "smoke:workspace-ready", + "--", + "--timeout-ms", + String(options.timeoutMs), + "--interval-ms", + String(options.intervalMs), + "--sample-project-name", + options.sampleProjectName, + ], + "smoke:workspace-ready", + ); + + console.log("\n[verify:gui-smoke] 通过"); + } finally { + if (startedByScript) { + await stopHeadlessTauri(); + } + } +} + +main().catch((error) => { + const exitCode = + typeof error?.exitCode === "number" && error.exitCode > 0 + ? error.exitCode + : 1; + console.error(error instanceof Error ? error.message : String(error)); + process.exit(exitCode); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a9481bee7..984ad9cfc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -378,7 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.21.0" +version = "0.22.0" dependencies = [ "ahash", "anyhow", @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.21.0" +version = "0.22.0" dependencies = [ "serde", "serde_json", @@ -2417,7 +2417,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -2433,7 +2433,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.3", "smallvec", ] @@ -4374,7 +4374,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.57.0", ] [[package]] @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "0.95.0" +version = "0.96.0" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "0.95.0" +version = "0.96.0" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "0.95.0" +version = "0.96.0" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "0.95.0" +version = "0.96.0" dependencies = [ "async-trait", "lime-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "0.95.0" +version = "0.96.0" dependencies = [ "aster-models", "async-trait", @@ -5307,7 +5307,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "0.95.0" +version = "0.96.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "0.95.0" +version = "0.96.0" dependencies = [ "aes", "axum 0.7.9", @@ -5372,7 +5372,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "0.95.0" +version = "0.96.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "0.95.0" +version = "0.96.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5424,7 +5424,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "0.95.0" +version = "0.96.0" dependencies = [ "async-trait", "lime-core", @@ -5443,7 +5443,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "0.95.0" +version = "0.96.0" dependencies = [ "anyhow", "async-stream", @@ -5498,7 +5498,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "0.95.0" +version = "0.96.0" dependencies = [ "aster-core", "async-stream", @@ -5543,7 +5543,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "0.95.0" +version = "0.96.0" dependencies = [ "axum 0.7.9", "futures", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "0.95.0" +version = "0.96.0" dependencies = [ "anyhow", "aster-core", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "0.95.0" +version = "0.96.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5618,7 +5618,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "0.95.0" +version = "0.96.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "0.95.0" +version = "0.96.0" dependencies = [ "axum 0.7.9", "chrono", @@ -6324,7 +6324,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.117", @@ -7077,9 +7077,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", - "proc-macro-hack", ] [[package]] @@ -7088,7 +7086,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ + "phf_macros 0.10.0", "phf_shared 0.10.0", + "proc-macro-hack", ] [[package]] @@ -7192,12 +7192,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -7609,7 +7609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -9108,7 +9108,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 4.0.0", + "dirs 6.0.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6629ad6d8..6678788f2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.95.1" +version = "0.96.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -127,8 +127,8 @@ enigo = "0.3" # 如需联调本地 aster-rust,请运行: # npm run setup:local-aster -- /path/to/aster-rust # 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。 -aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.21.0" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.21.0" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.22.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.22.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -192,7 +192,7 @@ version = "2.4" [package] name = "lime" -version = "0.95.1" +version = "0.96.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/artifact_protocol.rs b/src-tauri/crates/agent/src/artifact_protocol.rs new file mode 100644 index 000000000..be4e10af7 --- /dev/null +++ b/src-tauri/crates/agent/src/artifact_protocol.rs @@ -0,0 +1,218 @@ +use serde_json::{Map, Value}; +use std::collections::HashMap; + +pub const ARTIFACT_PROTOCOL_PATH_KEYS: &[&str] = &[ + "path", + "file_path", + "filePath", + "target_path", + "targetPath", + "output_path", + "outputPath", + "absolute_path", + "absolutePath", + "artifact_path", + "artifactPath", + "artifact_paths", + "artifactPaths", + "paths", + "source_file_name", + "sourceFileName", +]; + +pub fn normalize_artifact_protocol_path(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.replace('\\', "/")) + } +} + +pub fn push_unique_artifact_protocol_path(target: &mut Vec, raw: &str) { + let Some(normalized) = normalize_artifact_protocol_path(raw) else { + return; + }; + if !target.iter().any(|item| item == &normalized) { + target.push(normalized); + } +} + +pub fn extend_unique_artifact_protocol_paths(target: &mut Vec, incoming: &[String]) { + for path in incoming { + push_unique_artifact_protocol_path(target, path); + } +} + +pub fn extract_artifact_protocol_paths(record: &Map) -> Vec { + let mut paths = Vec::new(); + append_paths_from_object(&mut paths, record); + paths +} + +pub fn extract_artifact_protocol_paths_from_value(value: &Value) -> Vec { + let mut paths = Vec::new(); + collect_paths_from_value(&mut paths, value); + paths +} + +pub fn extract_artifact_protocol_paths_from_metadata( + metadata: &HashMap, +) -> Vec { + let mut paths = Vec::new(); + append_paths_from_metadata(&mut paths, metadata); + for value in metadata.values() { + if matches!(value, Value::Array(_) | Value::Object(_)) { + collect_paths_from_value(&mut paths, value); + } + } + paths +} + +fn append_unique_path(target: &mut Vec, raw: &str) { + push_unique_artifact_protocol_path(target, raw); +} + +fn append_paths_from_field(target: &mut Vec, value: &Value) { + match value { + Value::String(path) => append_unique_path(target, path), + Value::Array(items) => { + for item in items { + append_paths_from_field(target, item); + } + } + _ => {} + } +} + +fn append_paths_from_object(target: &mut Vec, object: &Map) { + for key in ARTIFACT_PROTOCOL_PATH_KEYS { + if let Some(value) = object.get(*key) { + append_paths_from_field(target, value); + } + } +} + +fn append_paths_from_metadata(target: &mut Vec, metadata: &HashMap) { + for key in ARTIFACT_PROTOCOL_PATH_KEYS { + if let Some(value) = metadata.get(*key) { + append_paths_from_field(target, value); + } + } +} + +fn collect_paths_from_value(target: &mut Vec, value: &Value) { + match value { + Value::Array(items) => { + for item in items { + collect_paths_from_value(target, item); + } + } + Value::Object(object) => { + append_paths_from_object(target, object); + for nested in object.values() { + if matches!(nested, Value::Array(_) | Value::Object(_)) { + collect_paths_from_value(target, nested); + } + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_artifact_protocol_paths_reads_canonical_keys() { + let value = serde_json::json!({ + "artifact_paths": [" .lime/artifacts/thread-1/report.artifact.json "], + "artifactPath": ".lime\\artifacts\\thread-1\\report.artifact.json", + "filePath": ".lime/artifacts/thread-1/outline.md", + "target_path": "workspace/result.md", + "sourceFileName": "workspace/final.md" + }); + + let paths = extract_artifact_protocol_paths( + value + .as_object() + .expect("artifact protocol record should be object"), + ); + + assert_eq!( + paths, + vec![ + ".lime/artifacts/thread-1/outline.md".to_string(), + "workspace/result.md".to_string(), + ".lime/artifacts/thread-1/report.artifact.json".to_string(), + "workspace/final.md".to_string(), + ] + ); + } + + #[test] + fn extract_artifact_protocol_paths_from_value_recurses_nested_records() { + let paths = extract_artifact_protocol_paths_from_value(&serde_json::json!({ + "payload": { + "absolute_path": " /tmp\\demo.md " + }, + "result": [ + { + "artifact_paths": ["workspace/final.md"] + } + ] + })); + + assert_eq!( + paths, + vec!["/tmp/demo.md".to_string(), "workspace/final.md".to_string()] + ); + } + + #[test] + fn extract_artifact_protocol_paths_from_metadata_recurses_nested_records() { + let metadata = HashMap::from([ + ( + "artifactPath".to_string(), + Value::String("workspace\\demo.cover.png".to_string()), + ), + ( + "payload".to_string(), + serde_json::json!({ + "artifact_paths": ["workspace/demo.md"] + }), + ), + ]); + + assert_eq!( + extract_artifact_protocol_paths_from_metadata(&metadata), + vec![ + "workspace/demo.cover.png".to_string(), + "workspace/demo.md".to_string(), + ] + ); + } + + #[test] + fn extend_unique_artifact_protocol_paths_should_normalize_and_dedupe() { + let mut target = vec!["workspace/demo.md".to_string()]; + extend_unique_artifact_protocol_paths( + &mut target, + &[ + " workspace\\demo.md ".to_string(), + "workspace/result.md".to_string(), + ], + ); + push_unique_artifact_protocol_path(&mut target, " workspace\\cover.png "); + + assert_eq!( + target, + vec![ + "workspace/demo.md".to_string(), + "workspace/result.md".to_string(), + "workspace/cover.png".to_string(), + ] + ); + } +} diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs index 396ff2a70..1f52ec7d1 100644 --- a/src-tauri/crates/agent/src/aster_state.rs +++ b/src-tauri/crates/agent/src/aster_state.rs @@ -38,7 +38,7 @@ use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use crate::credential_bridge::{create_aster_provider, AsterProviderConfig, CredentialBridge}; -use crate::event_converter::TauriActionRequiredScope; +use crate::protocol::AgentActionRequiredScope; use crate::provider_continuation_state::{ resolve_provider_continuation_capability, ProviderContinuationCapability, ProviderContinuationCapable, ProviderContinuationState, @@ -63,7 +63,7 @@ async fn configure_lime_native_file_tools(agent: &Agent) { } fn normalize_runtime_action_scope( - scope: Option, + scope: Option, ) -> Option { let scope = scope?; if scope.session_id.is_none() && scope.thread_id.is_none() && scope.turn_id.is_none() { @@ -575,7 +575,7 @@ impl AsterAgentState { session_id: &str, request_id: &str, user_data: serde_json::Value, - action_scope: Option, + action_scope: Option, ) -> Result<(), String> { let trimmed_session_id = session_id.trim(); if trimmed_session_id.is_empty() { diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 5cd4f1508..4c26aa4c8 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -12,10 +12,16 @@ use lime_core::database::dao::agent_timeline::{ AgentThreadItem, AgentThreadItemPayload, AgentThreadTurn, }; use regex::Regex; -use serde::{Deserialize, Serialize}; +pub use crate::protocol::{ + AgentActionRequiredScope as TauriActionRequiredScope, + AgentArtifactSignal as TauriArtifactSnapshot, AgentContextTraceStep as TauriContextTraceStep, + AgentEvent as TauriAgentEvent, AgentMessage as TauriMessage, + AgentMessageContent as TauriMessageContent, AgentRuntimeStatus as TauriRuntimeStatus, + AgentTokenUsage as TauriTokenUsage, AgentToolImage as TauriToolImage, + AgentToolResult as TauriToolResult, +}; use crate::tool_io_offload::{maybe_offload_tool_arguments, maybe_offload_tool_result_payload}; -use crate::QueuedTurnSnapshot; const JSON_RECURSION_LIMIT: usize = 50; const JSON_TRAVERSAL_NODE_LIMIT: usize = 4_096; @@ -524,303 +530,34 @@ fn extract_tool_result_metadata( .and_then(|value| find_metadata(&value, 0)) } -/// Tauri Agent 事件 -/// -/// 用于前端消费的事件格式,与现有的 StreamEvent 兼容 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum TauriAgentEvent { - /// 线程开始 - #[serde(rename = "thread_started")] - ThreadStarted { thread_id: String }, - - /// turn 开始 - #[serde(rename = "turn_started")] - TurnStarted { turn: AgentThreadTurn }, - - /// item 开始 - #[serde(rename = "item_started")] - ItemStarted { item: AgentThreadItem }, - - /// item 更新 - #[serde(rename = "item_updated")] - ItemUpdated { item: AgentThreadItem }, - - /// item 完成 - #[serde(rename = "item_completed")] - ItemCompleted { item: AgentThreadItem }, - - /// turn 完成 - #[serde(rename = "turn_completed")] - TurnCompleted { turn: AgentThreadTurn }, - - /// turn 失败 - #[serde(rename = "turn_failed")] - TurnFailed { turn: AgentThreadTurn }, - - /// 文本增量 - #[serde(rename = "text_delta")] - TextDelta { text: String }, - - /// 思考内容增量 - #[serde(rename = "thinking_delta")] - ThinkingDelta { text: String }, - - /// 工具调用开始 - #[serde(rename = "tool_start")] - ToolStart { - tool_name: String, - tool_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - arguments: Option, - }, - - /// 工具调用结束 - #[serde(rename = "tool_end")] - ToolEnd { - tool_id: String, - result: TauriToolResult, - }, - - /// 文件产物快照 - #[serde(rename = "artifact_snapshot")] - ArtifactSnapshot { artifact: TauriArtifactSnapshot }, - - /// 需要用户操作(权限确认、用户输入等) - #[serde(rename = "action_required")] - ActionRequired { - request_id: String, - action_type: String, - data: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option, - }, - - /// 模型变更 - #[serde(rename = "model_change")] - ModelChange { model: String, mode: String }, - - /// 上下文准备轨迹 - #[serde(rename = "context_trace")] - ContextTrace { steps: Vec }, - - /// 上下文压缩开始 - #[serde(rename = "context_compaction_started")] - ContextCompactionStarted { - item_id: String, - trigger: String, - #[serde(skip_serializing_if = "Option::is_none")] - detail: Option, - }, - - /// 上下文压缩完成 - #[serde(rename = "context_compaction_completed")] - ContextCompactionCompleted { - item_id: String, - trigger: String, - #[serde(skip_serializing_if = "Option::is_none")] - detail: Option, - }, - - /// 当前回合运行态摘要 - #[serde(rename = "runtime_status")] - RuntimeStatus { status: TauriRuntimeStatus }, - - /// 队列新增 - #[serde(rename = "queue_added")] - QueueAdded { - session_id: String, - queued_turn: QueuedTurnSnapshot, - }, - - /// 队列项移除 - #[serde(rename = "queue_removed")] - QueueRemoved { - session_id: String, - queued_turn_id: String, - }, - - /// 队列项开始执行 - #[serde(rename = "queue_started")] - QueueStarted { - session_id: String, - queued_turn_id: String, - }, - - /// 队列被清空 - #[serde(rename = "queue_cleared")] - QueueCleared { - session_id: String, - queued_turn_ids: Vec, - }, - - /// 完成(单次响应完成) - #[serde(rename = "done")] - Done { - #[serde(skip_serializing_if = "Option::is_none")] - usage: Option, - }, - - /// 最终完成(整个对话完成) - #[serde(rename = "final_done")] - FinalDone { - #[serde(skip_serializing_if = "Option::is_none")] - usage: Option, - }, - - /// 错误 - #[serde(rename = "error")] - Error { message: String }, - - /// 告警(不中断流程) - #[serde(rename = "warning")] - Warning { - #[serde(skip_serializing_if = "Option::is_none")] - code: Option, - message: String, - }, - - /// 完整消息(用于历史记录) - #[serde(rename = "message")] - Message { message: TauriMessage }, -} - -/// 工具执行结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TauriToolImage { - pub src: String, - #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")] - pub mime_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub origin: Option, -} - -/// 工具执行结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TauriToolResult { - pub success: bool, - pub output: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub images: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option>, -} - -/// 文件产物快照 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TauriArtifactSnapshot { - pub artifact_id: String, - pub file_path: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option>, -} - -/// Token 使用量 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TauriTokenUsage { - pub input_tokens: u32, - pub output_tokens: u32, -} - -/// 上下文准备轨迹步骤 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TauriContextTraceStep { - pub stage: String, - pub detail: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TauriRuntimeStatus { - pub phase: String, - pub title: String, - pub detail: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub checkpoints: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub metadata: Option>, -} - -/// 简化的消息结构 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TauriMessage { - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - pub role: String, - pub content: Vec, - pub timestamp: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TauriActionRequiredScope { - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub thread_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub turn_id: Option, -} - -/// 简化的消息内容 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum TauriMessageContent { - #[serde(rename = "text")] - Text { text: String }, - - #[serde(rename = "thinking")] - Thinking { text: String }, - - #[serde(rename = "tool_request")] - ToolRequest { - id: String, - tool_name: String, - arguments: serde_json::Value, - }, - - #[serde(rename = "tool_response")] - ToolResponse { - id: String, - success: bool, - output: String, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - images: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - metadata: Option>, - }, - - #[serde(rename = "action_required")] - ActionRequired { - id: String, - action_type: String, - data: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option, - }, - - #[serde(rename = "image")] - Image { mime_type: String, data: String }, -} - /// 将 Aster AgentEvent 转换为 TauriAgentEvent 列表 /// /// 一个 AgentEvent 可能产生多个 TauriAgentEvent pub fn convert_agent_event(event: AgentEvent) -> Vec { match event { AgentEvent::TurnStarted { turn } => { + let turn_context_event = + if turn.context_override.is_some() || turn.output_schema_runtime.is_some() { + Some(TauriAgentEvent::TurnContext { + session_id: turn.session_id.clone(), + thread_id: turn.thread_id.clone(), + turn_id: turn.id.clone(), + output_schema_runtime: turn.output_schema_runtime.clone(), + }) + } else { + None + }; let thread_id = turn.thread_id.clone(); - vec![ + let mut events = vec![ TauriAgentEvent::ThreadStarted { thread_id }, TauriAgentEvent::TurnStarted { turn: convert_turn_runtime(turn), }, - ] + ]; + if let Some(turn_context_event) = turn_context_event { + events.push(turn_context_event); + } + events } AgentEvent::ItemStarted { item } => vec![TauriAgentEvent::ItemStarted { item: convert_item_runtime(item), @@ -1566,6 +1303,48 @@ mod tests { } } + #[test] + fn test_convert_turn_started_with_output_schema_runtime_emits_turn_context() { + let turn = TurnRuntime::new( + "turn-2", + "session-2", + "thread-2", + Some("输出结构化结果".to_string()), + Some(aster::session::TurnContextOverride { + model: Some("gpt-5.4".to_string()), + ..aster::session::TurnContextOverride::default() + }), + ) + .with_output_schema_runtime(Some(aster::session::TurnOutputSchemaRuntime { + source: aster::session::TurnOutputSchemaSource::Turn, + strategy: aster::session::TurnOutputSchemaStrategy::Native, + provider_name: Some("openai".to_string()), + model_name: Some("gpt-5.4".to_string()), + })); + + let events = convert_agent_event(AgentEvent::TurnStarted { turn }); + + assert_eq!(events.len(), 3); + match &events[2] { + TauriAgentEvent::TurnContext { + session_id, + thread_id, + turn_id, + output_schema_runtime, + } => { + assert_eq!(session_id, "session-2"); + assert_eq!(thread_id, "thread-2"); + assert_eq!(turn_id, "turn-2"); + let runtime = output_schema_runtime + .as_ref() + .expect("expected output schema runtime"); + assert_eq!(runtime.provider_name.as_deref(), Some("openai")); + assert_eq!(runtime.model_name.as_deref(), Some("gpt-5.4")); + } + other => panic!("Expected TurnContext event, got {other:?}"), + } + } + #[test] fn test_convert_item_completed_tool_call() { let now = chrono::Utc::now(); diff --git a/src-tauri/crates/agent/src/filesystem_event_protocol.rs b/src-tauri/crates/agent/src/filesystem_event_protocol.rs new file mode 100644 index 000000000..692d407bb --- /dev/null +++ b/src-tauri/crates/agent/src/filesystem_event_protocol.rs @@ -0,0 +1,298 @@ +use serde_json::{Map, Value}; +use std::collections::HashMap; + +pub const FILESYSTEM_EVENT_PATH_KEYS: &[&str] = &[ + "path", + "file_path", + "filePath", + "file_name", + "fileName", + "filename", + "target_path", + "targetPath", + "output_path", + "outputPath", + "absolute_path", + "absolutePath", + "new_path", + "newPath", + "paths", + "files", +]; + +pub const FILESYSTEM_EVENT_LOCATION_HINT_KEYS: &[&str] = + &["directory", "cwd", "output_file", "offload_file"]; + +pub fn normalize_filesystem_event_path(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.replace('\\', "/")) + } +} + +pub fn extract_filesystem_event_paths(record: &Map) -> Vec { + extract_paths_from_object(record, FILESYSTEM_EVENT_PATH_KEYS) +} + +pub fn extract_filesystem_event_paths_from_value(value: &Value) -> Vec { + extract_paths_from_value(value, FILESYSTEM_EVENT_PATH_KEYS) +} + +pub fn extract_filesystem_event_paths_from_metadata( + metadata: &HashMap, +) -> Vec { + extract_paths_from_metadata(metadata, FILESYSTEM_EVENT_PATH_KEYS) +} + +pub fn extract_filesystem_event_location_hints(record: &Map) -> Vec { + extract_paths_from_object(record, FILESYSTEM_EVENT_LOCATION_HINT_KEYS) +} + +pub fn extract_filesystem_event_location_hints_from_value(value: &Value) -> Vec { + extract_paths_from_value(value, FILESYSTEM_EVENT_LOCATION_HINT_KEYS) +} + +pub fn extract_filesystem_event_location_hints_from_metadata( + metadata: &HashMap, +) -> Vec { + extract_paths_from_metadata(metadata, FILESYSTEM_EVENT_LOCATION_HINT_KEYS) +} + +fn extract_paths_from_object(record: &Map, keys: &[&str]) -> Vec { + let mut paths = Vec::new(); + append_paths_from_object(&mut paths, record, keys); + paths +} + +fn extract_paths_from_value(value: &Value, keys: &[&str]) -> Vec { + let mut paths = Vec::new(); + collect_paths_from_value(&mut paths, value, keys); + paths +} + +fn extract_paths_from_metadata(metadata: &HashMap, keys: &[&str]) -> Vec { + let mut paths = Vec::new(); + append_paths_from_metadata(&mut paths, metadata, keys); + for value in metadata.values() { + if matches!(value, Value::Array(_) | Value::Object(_)) { + collect_paths_from_value(&mut paths, value, keys); + } + } + paths +} + +fn append_unique_path(target: &mut Vec, raw: &str) { + let Some(normalized) = normalize_filesystem_event_path(raw) else { + return; + }; + if !target.iter().any(|item| item == &normalized) { + target.push(normalized); + } +} + +fn append_paths_from_field(target: &mut Vec, value: &Value) { + match value { + Value::String(path) => append_unique_path(target, path), + Value::Array(items) => { + for item in items { + append_paths_from_field(target, item); + } + } + _ => {} + } +} + +fn append_paths_from_object(target: &mut Vec, object: &Map, keys: &[&str]) { + for key in keys { + if let Some(value) = object.get(*key) { + append_paths_from_field(target, value); + } + } +} + +fn append_paths_from_metadata( + target: &mut Vec, + metadata: &HashMap, + keys: &[&str], +) { + for key in keys { + if let Some(value) = metadata.get(*key) { + append_paths_from_field(target, value); + } + } +} + +fn collect_paths_from_value(target: &mut Vec, value: &Value, keys: &[&str]) { + match value { + Value::Array(items) => { + for item in items { + collect_paths_from_value(target, item, keys); + } + } + Value::Object(object) => { + append_paths_from_object(target, object, keys); + for nested in object.values() { + if matches!(nested, Value::Array(_) | Value::Object(_)) { + collect_paths_from_value(target, nested, keys); + } + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_filesystem_event_paths_reads_canonical_keys() { + let value = serde_json::json!({ + "file_path": " workspace\\draft.md ", + "fileName": "workspace/final.md", + "new_path": "workspace/next.md", + "paths": ["workspace/archive.md"], + }); + + let paths = extract_filesystem_event_paths( + value + .as_object() + .expect("filesystem event record should be object"), + ); + + assert_eq!( + paths, + vec![ + "workspace/draft.md".to_string(), + "workspace/final.md".to_string(), + "workspace/next.md".to_string(), + "workspace/archive.md".to_string(), + ] + ); + } + + #[test] + fn extract_filesystem_event_paths_does_not_mix_location_hint_keys() { + let value = serde_json::json!({ + "output_file": "workspace/result.log", + "cwd": "/tmp/workspace", + }); + + let paths = extract_filesystem_event_paths( + value + .as_object() + .expect("filesystem event record should be object"), + ); + + assert!(paths.is_empty()); + } + + #[test] + fn extract_filesystem_event_paths_from_value_recurses_nested_records() { + let paths = extract_filesystem_event_paths_from_value(&serde_json::json!({ + "payload": { + "newPath": "workspace\\next.md", + }, + "result": [ + { + "absolute_path": "/tmp/workspace/final.md", + }, + ], + })); + + assert_eq!( + paths, + vec![ + "workspace/next.md".to_string(), + "/tmp/workspace/final.md".to_string(), + ] + ); + } + + #[test] + fn extract_filesystem_event_paths_from_metadata_recurses_nested_records() { + let metadata = HashMap::from([ + ( + "filePath".to_string(), + Value::String("workspace\\demo.md".to_string()), + ), + ( + "payload".to_string(), + serde_json::json!({ + "files": ["workspace/cover.png"] + }), + ), + ]); + + assert_eq!( + extract_filesystem_event_paths_from_metadata(&metadata), + vec![ + "workspace/demo.md".to_string(), + "workspace/cover.png".to_string(), + ] + ); + } + + #[test] + fn extract_filesystem_event_location_hints_reads_canonical_keys() { + let value = serde_json::json!({ + "directory": "workspace\\docs", + "cwd": "/tmp/workspace", + "output_file": "workspace/result.log", + "offload_file": "workspace/offload.txt", + }); + + let paths = extract_filesystem_event_location_hints( + value + .as_object() + .expect("filesystem hint record should be object"), + ); + + assert_eq!( + paths, + vec![ + "workspace/docs".to_string(), + "/tmp/workspace".to_string(), + "workspace/result.log".to_string(), + "workspace/offload.txt".to_string(), + ] + ); + } + + #[test] + fn extract_filesystem_event_location_hints_from_value_recurses_nested_records() { + let paths = extract_filesystem_event_location_hints_from_value(&serde_json::json!({ + "payload": { + "offload_file": "workspace\\full-output.txt", + }, + })); + + assert_eq!(paths, vec!["workspace/full-output.txt".to_string()]); + } + + #[test] + fn extract_filesystem_event_location_hints_from_metadata_recurses_nested_records() { + let metadata = HashMap::from([ + ( + "cwd".to_string(), + Value::String("workspace\\root".to_string()), + ), + ( + "payload".to_string(), + serde_json::json!({ + "output_file": "workspace/result.md" + }), + ), + ]); + + assert_eq!( + extract_filesystem_event_location_hints_from_metadata(&metadata), + vec![ + "workspace/root".to_string(), + "workspace/result.md".to_string() + ] + ); + } +} diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index 41d5fa605..179babf62 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -11,6 +11,7 @@ #![allow(clippy::borrowed_box)] pub mod agent_tools; +pub mod artifact_protocol; pub mod ask_bridge; pub mod aster_runtime_support; pub mod aster_state; @@ -18,11 +19,14 @@ pub mod aster_state_support; pub mod credential_bridge; pub mod durable_memory_fs; pub mod event_converter; +pub mod filesystem_event_protocol; pub mod hooks; mod kiro_provider_adapter; pub mod lsp_bridge; pub mod mcp_bridge; pub mod prompt; +pub mod protocol; +pub mod protocol_projection; pub mod provider_continuation_state; pub mod provider_runtime_governor; mod provider_safety; @@ -30,6 +34,7 @@ pub mod queued_turn; pub mod request_tool_policy; pub mod runtime_projection_snapshot; pub mod runtime_queue; +mod session_execution_runtime; mod session_query; pub mod session_state_snapshot; mod session_store; @@ -60,10 +65,6 @@ pub use durable_memory_fs::{ resolve_virtual_memory_path, to_virtual_memory_path, virtual_memory_relative_path, DURABLE_MEMORY_VIRTUAL_ROOT, LEGACY_DURABLE_MEMORY_ROOT_ENV, LIME_DURABLE_MEMORY_ROOT_ENV, }; -pub use event_converter::{ - convert_agent_event, convert_item_runtime, convert_to_tauri_message, convert_turn_runtime, - TauriAgentEvent, TauriArtifactSnapshot, TauriRuntimeStatus, -}; pub use lime_mcp as mcp; pub use lsp_bridge::create_lsp_callback; pub use prompt::SystemPromptBuilder; @@ -71,6 +72,12 @@ pub use prompt::{ build_runtime_agents_prompt, merge_system_prompt_with_runtime_agents, RUNTIME_AGENTS_PROMPT_MARKER, }; +pub use protocol::{ + AgentActionRequiredScope, AgentArtifactSignal, AgentEvent, AgentImageAttachment, AgentMessage, + AgentMessageContent, AgentOp, AgentRuntimeStatus, AgentTokenUsage, AgentToolImage, + AgentToolResult, AgentUserInputOp, AgentUserPreferences, +}; +pub use protocol_projection::{project_item_runtime, project_runtime_event, project_turn_runtime}; pub use provider_continuation_state::{ ProviderContinuationCapability, ProviderContinuationCapable, ProviderContinuationState, }; @@ -94,6 +101,9 @@ pub use runtime_queue::{ resume_runtime_queue_if_needed, submit_runtime_turn, RuntimeQueueEventEmitter, RuntimeQueueExecutor, }; +pub use session_execution_runtime::{ + build_session_execution_runtime, SessionExecutionRuntime, SessionExecutionRuntimeSource, +}; pub use session_query::{ collect_subagent_cascade_session_ids, list_child_subagent_sessions, list_subagent_cascade_session_ids, list_subagent_status_scope_session_ids, read_session, @@ -145,4 +155,4 @@ pub use turn_input_envelope::{ TurnRequestToolPolicySnapshot, TurnSystemPromptSource, }; pub use turn_state::TurnState; -pub use write_artifact_events::WriteArtifactEventEmitter; +pub use write_artifact_events::{build_write_tool_artifact_events, WriteArtifactEventEmitter}; diff --git a/src-tauri/crates/agent/src/protocol.rs b/src-tauri/crates/agent/src/protocol.rs new file mode 100644 index 000000000..9ebb302e6 --- /dev/null +++ b/src-tauri/crates/agent/src/protocol.rs @@ -0,0 +1,424 @@ +use aster::session::TurnOutputSchemaRuntime; +use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadTurn}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::HashMap; + +use crate::queued_turn::QueuedTurnSnapshot; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentToolImage { + pub src: String, + #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub origin: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentToolResult { + pub success: bool, + pub output: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentArtifactSignal { + pub artifact_id: String, + pub file_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentTokenUsage { + pub input_tokens: u32, + pub output_tokens: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentContextTraceStep { + pub stage: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeStatus { + pub phase: String, + pub title: String, + pub detail: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checkpoints: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentMessage { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub role: String, + pub content: Vec, + pub timestamp: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentActionRequiredScope { + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AgentMessageContent { + #[serde(rename = "text")] + Text { text: String }, + + #[serde(rename = "thinking")] + Thinking { text: String }, + + #[serde(rename = "tool_request")] + ToolRequest { + id: String, + tool_name: String, + arguments: Value, + }, + + #[serde(rename = "tool_response")] + ToolResponse { + id: String, + success: bool, + output: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + images: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option>, + }, + + #[serde(rename = "action_required")] + ActionRequired { + id: String, + action_type: String, + data: Value, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + }, + + #[serde(rename = "image")] + Image { mime_type: String, data: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AgentEvent { + #[serde(rename = "thread_started")] + ThreadStarted { thread_id: String }, + + #[serde(rename = "turn_started")] + TurnStarted { turn: AgentThreadTurn }, + + #[serde(rename = "item_started")] + ItemStarted { item: AgentThreadItem }, + + #[serde(rename = "item_updated")] + ItemUpdated { item: AgentThreadItem }, + + #[serde(rename = "item_completed")] + ItemCompleted { item: AgentThreadItem }, + + #[serde(rename = "turn_completed")] + TurnCompleted { turn: AgentThreadTurn }, + + #[serde(rename = "turn_failed")] + TurnFailed { turn: AgentThreadTurn }, + + #[serde(rename = "text_delta")] + TextDelta { text: String }, + + #[serde(rename = "thinking_delta")] + ThinkingDelta { text: String }, + + #[serde(rename = "tool_start")] + ToolStart { + tool_name: String, + tool_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + arguments: Option, + }, + + #[serde(rename = "tool_end")] + ToolEnd { + tool_id: String, + result: AgentToolResult, + }, + + #[serde(rename = "artifact_snapshot")] + ArtifactSnapshot { artifact: AgentArtifactSignal }, + + #[serde(rename = "action_required")] + ActionRequired { + request_id: String, + action_type: String, + data: Value, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + }, + + #[serde(rename = "turn_context")] + TurnContext { + session_id: String, + thread_id: String, + turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + output_schema_runtime: Option, + }, + + #[serde(rename = "model_change")] + ModelChange { model: String, mode: String }, + + #[serde(rename = "context_trace")] + ContextTrace { steps: Vec }, + + #[serde(rename = "context_compaction_started")] + ContextCompactionStarted { + item_id: String, + trigger: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, + + #[serde(rename = "context_compaction_completed")] + ContextCompactionCompleted { + item_id: String, + trigger: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, + + #[serde(rename = "runtime_status")] + RuntimeStatus { status: AgentRuntimeStatus }, + + #[serde(rename = "queue_added")] + QueueAdded { + session_id: String, + queued_turn: QueuedTurnSnapshot, + }, + + #[serde(rename = "queue_removed")] + QueueRemoved { + session_id: String, + queued_turn_id: String, + }, + + #[serde(rename = "queue_started")] + QueueStarted { + session_id: String, + queued_turn_id: String, + }, + + #[serde(rename = "queue_cleared")] + QueueCleared { + session_id: String, + queued_turn_ids: Vec, + }, + + #[serde(rename = "done")] + Done { + #[serde(skip_serializing_if = "Option::is_none")] + usage: Option, + }, + + #[serde(rename = "final_done")] + FinalDone { + #[serde(skip_serializing_if = "Option::is_none")] + usage: Option, + }, + + #[serde(rename = "error")] + Error { message: String }, + + #[serde(rename = "warning")] + Warning { + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + message: String, + }, + + #[serde(rename = "message")] + Message { message: AgentMessage }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentImageAttachment { + pub data: String, + pub media_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AgentUserPreferences { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_preference: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_preference: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_strategy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subagent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_team_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_continue: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AgentUserInputOp { + pub text: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec, + pub preferences: AgentUserPreferences, + pub session_id: String, + pub workspace_id: String, + pub event_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub queue_if_busy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub queued_turn_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentOp { + UserInput(AgentUserInputOp), + Interrupt { + session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + turn_id: Option, + }, + Retry { + session_id: String, + turn_id: String, + }, + ConfigUpdate { + session_id: String, + key: String, + value: Value, + }, + Shutdown { + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_op_user_input_serializes_with_protocol_tag() { + let value = serde_json::to_value(AgentOp::UserInput(AgentUserInputOp { + text: "继续处理当前任务".to_string(), + images: vec![AgentImageAttachment { + data: "aGVsbG8=".to_string(), + media_type: "image/png".to_string(), + }], + preferences: AgentUserPreferences { + provider_preference: Some("openai".to_string()), + model_preference: Some("gpt-5.4".to_string()), + thinking: Some(true), + web_search: Some(false), + search_mode: Some("disabled".to_string()), + execution_strategy: Some("react".to_string()), + task: Some(false), + subagent: Some(false), + theme: Some("general".to_string()), + selected_team_id: None, + auto_continue: Some(serde_json::json!({ + "enabled": true, + "continuation_length": 3 + })), + }, + session_id: "session-1".to_string(), + workspace_id: "workspace-1".to_string(), + event_name: "aster_stream_session-1".to_string(), + turn_id: Some("turn-1".to_string()), + system_prompt: Some("保持简洁".to_string()), + metadata: Some(Map::from_iter([( + "theme".to_string(), + Value::String("general".to_string()), + )])), + queue_if_busy: Some(true), + queued_turn_id: Some("queued-1".to_string()), + })) + .expect("serialize agent op"); + + assert_eq!(value["type"], "user_input"); + assert_eq!(value["preferences"]["provider_preference"], "openai"); + assert_eq!(value["session_id"], "session-1"); + assert_eq!(value["queue_if_busy"], true); + } + + #[test] + fn agent_op_interrupt_deserializes_from_snake_case_tag() { + let op: AgentOp = serde_json::from_value(serde_json::json!({ + "type": "interrupt", + "session_id": "session-2", + "turn_id": "turn-2" + })) + .expect("deserialize interrupt op"); + + assert_eq!( + op, + AgentOp::Interrupt { + session_id: "session-2".to_string(), + turn_id: Some("turn-2".to_string()), + } + ); + } + + #[test] + fn agent_event_runtime_status_serializes_with_protocol_tag() { + let value = serde_json::to_value(AgentEvent::RuntimeStatus { + status: AgentRuntimeStatus { + phase: "routing".to_string(), + title: "等待执行窗口".to_string(), + detail: "系统正在安排执行窗口".to_string(), + checkpoints: vec!["并发预算 1/2".to_string()], + metadata: None, + }, + }) + .expect("serialize runtime status"); + + assert_eq!(value["type"], "runtime_status"); + assert_eq!(value["status"]["phase"], "routing"); + } +} diff --git a/src-tauri/crates/agent/src/protocol_projection.rs b/src-tauri/crates/agent/src/protocol_projection.rs new file mode 100644 index 000000000..1fe8bed91 --- /dev/null +++ b/src-tauri/crates/agent/src/protocol_projection.rs @@ -0,0 +1,22 @@ +//! 运行时协议投影边界 +//! +//! 这里是业务层消费的 current 入口。 +//! 旧 `event_converter` 仅保留 compat converter 语义,不再作为生产调用入口暴露。 + +use aster::agents::AgentEvent as AsterAgentEvent; +use aster::session::{ItemRuntime, TurnRuntime}; +use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadTurn}; + +use crate::protocol::AgentEvent as RuntimeAgentEvent; + +pub fn project_runtime_event(event: AsterAgentEvent) -> Vec { + crate::event_converter::convert_agent_event(event) +} + +pub fn project_turn_runtime(turn: TurnRuntime) -> AgentThreadTurn { + crate::event_converter::convert_turn_runtime(turn) +} + +pub fn project_item_runtime(item: ItemRuntime) -> AgentThreadItem { + crate::event_converter::convert_item_runtime(item) +} diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index 9d78b1e84..8bf7f9666 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -3,11 +3,10 @@ //! 该模块沉淀“请求级工具策略(例如联网搜索)”与统一流式执行逻辑, //! 供 aster_agent_cmd、scheduler、gateway 等入口复用同一条执行主链。 -use crate::event_converter::{ - convert_agent_event, TauriAgentEvent, TauriRuntimeStatus, TauriToolResult, -}; +use crate::protocol::{AgentEvent as RuntimeAgentEvent, AgentRuntimeStatus, AgentToolResult}; +use crate::protocol_projection::project_runtime_event; use crate::write_artifact_events::WriteArtifactEventEmitter; -use aster::agents::{Agent, AgentEvent}; +use aster::agents::{Agent, AgentEvent as AsterAgentEvent}; use aster::conversation::message::Message; use aster::tools::ToolContext; use chrono::{Datelike, Local, NaiveDate}; @@ -232,7 +231,7 @@ impl WebSearchExecutionTracker { #[derive(Debug, Clone)] pub struct PreflightToolExecution { - pub events: Vec, + pub events: Vec, pub planned_queries: Vec, pub system_prompt_appendix: Option, pub coverage_summary: Option, @@ -289,10 +288,10 @@ struct StreamEventDiagnostics { fn update_stream_event_diagnostics( diagnostics: &mut StreamEventDiagnostics, - event: &TauriAgentEvent, + event: &RuntimeAgentEvent, ) { match event { - TauriAgentEvent::TextDelta { text } => { + RuntimeAgentEvent::TextDelta { text } => { diagnostics.text_delta_count += 1; let char_count = text.chars().count(); diagnostics.max_text_delta_chars = diagnostics.max_text_delta_chars.max(char_count); @@ -303,10 +302,10 @@ fn update_stream_event_diagnostics( ); } } - TauriAgentEvent::ToolStart { .. } => { + RuntimeAgentEvent::ToolStart { .. } => { diagnostics.tool_start_count += 1; } - TauriAgentEvent::ToolEnd { tool_id, result } => { + RuntimeAgentEvent::ToolEnd { tool_id, result } => { diagnostics.tool_end_count += 1; let output_chars = result.output.chars().count(); diagnostics.max_tool_output_chars = diagnostics.max_tool_output_chars.max(output_chars); @@ -319,7 +318,7 @@ fn update_stream_event_diagnostics( ); } } - TauriAgentEvent::ContextTrace { steps } => { + RuntimeAgentEvent::ContextTrace { steps } => { diagnostics.context_trace_events += 1; diagnostics.max_context_trace_steps = diagnostics.max_context_trace_steps.max(steps.len()); @@ -330,7 +329,7 @@ fn update_stream_event_diagnostics( ); } } - TauriAgentEvent::Error { .. } => { + RuntimeAgentEvent::Error { .. } => { diagnostics.error_count += 1; } _ => {} @@ -978,7 +977,7 @@ fn merge_system_prompt_with_web_search_synthesis_instruction( } } -fn build_web_search_synthesis_runtime_status(coverage_summary: Option<&str>) -> TauriRuntimeStatus { +fn build_web_search_synthesis_runtime_status(coverage_summary: Option<&str>) -> AgentRuntimeStatus { let mut checkpoints = vec![ "已完成 WebSearch 预检索".to_string(), "正在把检索结果整理为最终答复".to_string(), @@ -991,7 +990,7 @@ fn build_web_search_synthesis_runtime_status(coverage_summary: Option<&str>) -> checkpoints.push(summary.to_string()); } - TauriRuntimeStatus { + AgentRuntimeStatus { phase: "synthesizing".to_string(), title: "正在整理联网结果".to_string(), detail: "已完成前置扩搜,正在基于已有 WebSearch 结果输出最终总结,不再重复检索。" @@ -1018,10 +1017,10 @@ fn duplicate_session_config(config: &aster::agents::SessionConfig) -> aster::age async fn emit_runtime_status_with_projection( agent: &Agent, session_config: &aster::agents::SessionConfig, - status: TauriRuntimeStatus, + status: AgentRuntimeStatus, on_event: &mut F, ) where - F: FnMut(&TauriAgentEvent), + F: FnMut(&RuntimeAgentEvent), { match agent .upsert_runtime_status_item( @@ -1034,7 +1033,7 @@ async fn emit_runtime_status_with_projection( .await { Ok(agent_event) => { - for event in convert_agent_event(agent_event) { + for event in project_runtime_event(agent_event) { on_event(&event); } } @@ -1046,7 +1045,7 @@ async fn emit_runtime_status_with_projection( } } - let event = TauriAgentEvent::RuntimeStatus { status }; + let event = RuntimeAgentEvent::RuntimeStatus { status }; on_event(&event); } @@ -1080,7 +1079,7 @@ async fn stream_agent_reply_once( on_event: &mut F, ) -> Result<(), ReplyAttemptError> where - F: FnMut(&TauriAgentEvent), + F: FnMut(&RuntimeAgentEvent), { let mut stream = agent .reply(user_message, session_config, cancel_token) @@ -1095,36 +1094,38 @@ where Ok(agent_event) => { *emitted_any = true; let inline_provider_error = match &agent_event { - AgentEvent::Message(message) => extract_inline_agent_provider_error(message), + AsterAgentEvent::Message(message) => { + extract_inline_agent_provider_error(message) + } _ => None, }; - let tauri_events = convert_agent_event(agent_event); - for mut tauri_event in tauri_events { - let extra_events = write_artifact_emitter.process_event(&mut tauri_event); + let runtime_events = project_runtime_event(agent_event); + for mut runtime_event in runtime_events { + let extra_events = write_artifact_emitter.process_event(&mut runtime_event); for extra_event in &extra_events { update_stream_event_diagnostics(diagnostics, extra_event); on_event(extra_event); } - match &tauri_event { - TauriAgentEvent::TextDelta { text } => { + match &runtime_event { + RuntimeAgentEvent::TextDelta { text } => { if !text.is_empty() { text_chunks.push(text.clone()); } } - TauriAgentEvent::Error { message } => { + RuntimeAgentEvent::Error { message } => { if !message.trim().is_empty() { event_errors.push(message.clone()); } } - TauriAgentEvent::ToolStart { + RuntimeAgentEvent::ToolStart { tool_name, tool_id, .. } => web_search_tracker.record_tool_start( request_tool_policy, tool_id, tool_name, ), - TauriAgentEvent::ToolEnd { tool_id, result } => { + RuntimeAgentEvent::ToolEnd { tool_id, result } => { web_search_tracker.record_tool_end( request_tool_policy, tool_id, @@ -1134,8 +1135,8 @@ where } _ => {} } - update_stream_event_diagnostics(diagnostics, &tauri_event); - on_event(&tauri_event); + update_stream_event_diagnostics(diagnostics, &runtime_event); + on_event(&runtime_event); } if let Some(message) = inline_provider_error { return Err(ReplyAttemptError { @@ -1248,7 +1249,7 @@ pub async fn execute_web_search_preflight_if_needed( let mut events = Vec::new(); for planned in &planned_queries { tracker.record_tool_start(policy, &planned.tool_id, &preflight_tool_name); - events.push(TauriAgentEvent::ToolStart { + events.push(RuntimeAgentEvent::ToolStart { tool_name: preflight_tool_name.clone(), tool_id: planned.tool_id.clone(), arguments: planned.arguments.clone(), @@ -1307,9 +1308,9 @@ pub async fn execute_web_search_preflight_if_needed( outcome.success, outcome.error.as_deref(), ); - events.push(TauriAgentEvent::ToolEnd { + events.push(RuntimeAgentEvent::ToolEnd { tool_id: outcome.tool_id.clone(), - result: TauriToolResult { + result: AgentToolResult { success: outcome.success, output: outcome.output.clone(), error: outcome.error.clone(), @@ -1362,7 +1363,7 @@ pub async fn stream_reply_with_policy( on_event: F, ) -> Result where - F: FnMut(&TauriAgentEvent), + F: FnMut(&RuntimeAgentEvent), { stream_message_reply_with_policy( agent, @@ -1386,7 +1387,7 @@ pub async fn stream_message_reply_with_policy( mut on_event: F, ) -> Result where - F: FnMut(&TauriAgentEvent), + F: FnMut(&RuntimeAgentEvent), { let message_text = user_message.as_concat_text(); let mut web_search_tracker = WebSearchExecutionTracker::default(); diff --git a/src-tauri/crates/agent/src/runtime_projection_snapshot.rs b/src-tauri/crates/agent/src/runtime_projection_snapshot.rs index 7c8a971cd..25681228e 100644 --- a/src-tauri/crates/agent/src/runtime_projection_snapshot.rs +++ b/src-tauri/crates/agent/src/runtime_projection_snapshot.rs @@ -101,6 +101,7 @@ mod tests { input_text: Some("old".to_string()), error_message: None, context_override: None, + output_schema_runtime: None, created_at: now - Duration::minutes(2), started_at: Some(now - Duration::minutes(2)), completed_at: None, @@ -114,6 +115,7 @@ mod tests { input_text: Some("new".to_string()), error_message: None, context_override: None, + output_schema_runtime: None, created_at: now - Duration::seconds(30), started_at: Some(now - Duration::seconds(30)), completed_at: Some(now - Duration::seconds(10)), diff --git a/src-tauri/crates/agent/src/runtime_queue.rs b/src-tauri/crates/agent/src/runtime_queue.rs index f2b3cff64..f3b3a08a2 100644 --- a/src-tauri/crates/agent/src/runtime_queue.rs +++ b/src-tauri/crates/agent/src/runtime_queue.rs @@ -4,7 +4,8 @@ use crate::aster_runtime_support::{ queued_turn_runtime_from_task, queued_turn_snapshot_from_runtime, remove_aster_runtime_queued_turn, }; -use crate::{QueuedTurnSnapshot, QueuedTurnTask, TauriAgentEvent}; +use crate::protocol::AgentEvent as RuntimeAgentEvent; +use crate::{QueuedTurnSnapshot, QueuedTurnTask}; use aster::session::{ require_shared_session_runtime_queue_service, QueuedTurnRuntime, RuntimeQueueSubmitResult, }; @@ -15,12 +16,12 @@ use std::sync::Arc; pub type RuntimeQueueExecutor = Arc BoxFuture<'static, Result<(), String>> + Send + Sync>; -pub type RuntimeQueueEventEmitter = Arc; +pub type RuntimeQueueEventEmitter = Arc; fn emit_runtime_queue_event( emitter: &RuntimeQueueEventEmitter, event_name: &str, - event: TauriAgentEvent, + event: RuntimeAgentEvent, ) { emitter(event_name.to_string(), event); } @@ -69,7 +70,7 @@ where emit_runtime_queue_event( &emitter, &event_name, - TauriAgentEvent::QueueStarted { + RuntimeAgentEvent::QueueStarted { session_id: session_id.clone(), queued_turn_id: next_queued_turn.queued_turn_id.clone(), }, @@ -165,7 +166,7 @@ where emit_runtime_queue_event( &emitter, &queued_turn_event_name_from_runtime(&queued_turn), - TauriAgentEvent::QueueAdded { + RuntimeAgentEvent::QueueAdded { session_id, queued_turn: queued_turn_snapshot_from_runtime(&queued_turn, position), }, @@ -192,7 +193,7 @@ pub async fn clear_runtime_queue( emit_runtime_queue_event( &emitter, &queued_turn_event_name_from_runtime(queued_turn), - TauriAgentEvent::QueueCleared { + RuntimeAgentEvent::QueueCleared { session_id: session_id.to_string(), queued_turn_ids: queued_turn_ids.clone(), }, @@ -234,7 +235,7 @@ pub async fn remove_runtime_queued_turn( emit_runtime_queue_event( &emitter, &queued_turn_event_name_from_runtime(&existing), - TauriAgentEvent::QueueRemoved { + RuntimeAgentEvent::QueueRemoved { session_id: session_id.to_string(), queued_turn_id: queued_turn.queued_turn_id, }, diff --git a/src-tauri/crates/agent/src/session_execution_runtime.rs b/src-tauri/crates/agent/src/session_execution_runtime.rs new file mode 100644 index 000000000..6d1f4f2cb --- /dev/null +++ b/src-tauri/crates/agent/src/session_execution_runtime.rs @@ -0,0 +1,392 @@ +use aster::session::{Session, SessionRuntimeSnapshot, TurnOutputSchemaRuntime, TurnStatus}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionExecutionRuntimeSource { + Session, + RuntimeSnapshot, + TurnContext, + ModelChange, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionExecutionRuntimePreferences { + pub web_search: bool, + pub thinking: bool, + pub task: bool, + pub subagent: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SessionExecutionRuntime { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_selector: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema_runtime: Option, + pub source: SessionExecutionRuntimeSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recent_preferences: Option, +} + +fn resolve_session_model_name(session: &Session) -> Option { + session + .model_config + .as_ref() + .and_then(|config| normalize_optional_text(Some(config.model_name.clone()))) +} + +fn extract_bool_from_value(value: Option<&Value>) -> Option { + value.and_then(Value::as_bool) +} + +fn extract_bool_from_object( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .find_map(|key| extract_bool_from_value(object.get(*key))) +} + +fn extract_bool_from_metadata( + metadata: &std::collections::HashMap, + keys: &[&str], +) -> Option { + keys.iter() + .find_map(|key| extract_bool_from_value(metadata.get(*key))) +} + +fn extract_recent_preferences_from_metadata( + metadata: &std::collections::HashMap, +) -> Option { + let harness = metadata.get("harness").and_then(Value::as_object); + let nested_preferences = harness + .and_then(|value| value.get("preferences")) + .and_then(Value::as_object) + .or_else(|| metadata.get("preferences").and_then(Value::as_object)); + let resolve_nested = |keys: &[&str]| -> Option { + nested_preferences.and_then(|value| extract_bool_from_object(value, keys)) + }; + let resolve_flat = |keys: &[&str]| -> Option { + harness + .and_then(|value| extract_bool_from_object(value, keys)) + .or_else(|| extract_bool_from_metadata(metadata, keys)) + }; + + let web_search = resolve_nested(&["web_search", "webSearch"]) + .or_else(|| resolve_flat(&["web_search_enabled", "webSearchEnabled"])); + let thinking = resolve_nested(&["thinking", "thinking_enabled", "thinkingEnabled"]) + .or_else(|| resolve_flat(&["thinking_enabled", "thinkingEnabled"])); + let task = resolve_nested(&["task", "task_mode", "taskMode"]) + .or_else(|| resolve_flat(&["task_mode_enabled", "taskModeEnabled"])); + let subagent = resolve_nested(&["subagent", "subagent_mode", "subagentMode"]) + .or_else(|| resolve_flat(&["subagent_mode_enabled", "subagentModeEnabled"])); + + if web_search.is_none() && thinking.is_none() && task.is_none() && subagent.is_none() { + return None; + } + + Some(SessionExecutionRuntimePreferences { + web_search: web_search.unwrap_or(false), + thinking: thinking.unwrap_or(false), + task: task.unwrap_or(false), + subagent: subagent.unwrap_or(false), + }) +} + +fn resolve_latest_turn(snapshot: &SessionRuntimeSnapshot) -> Option<&aster::session::TurnRuntime> { + snapshot + .threads + .iter() + .flat_map(|thread| thread.turns.iter()) + .max_by(|left, right| { + left.updated_at + .cmp(&right.updated_at) + .then_with(|| left.created_at.cmp(&right.created_at)) + .then_with(|| left.id.cmp(&right.id)) + }) +} + +fn map_turn_status(status: TurnStatus) -> String { + match status { + TurnStatus::Queued => "queued".to_string(), + TurnStatus::Running => "running".to_string(), + TurnStatus::Completed => "completed".to_string(), + TurnStatus::Failed => "failed".to_string(), + TurnStatus::Aborted => "aborted".to_string(), + } +} + +pub fn build_session_execution_runtime( + session_id: &str, + session: Option<&Session>, + execution_strategy: Option, + snapshot: Option<&SessionRuntimeSnapshot>, + provider_selector: Option, +) -> Option { + let mut runtime = SessionExecutionRuntime { + session_id: session_id.to_string(), + provider_selector: normalize_optional_text(provider_selector), + provider_name: session + .and_then(|value| normalize_optional_text(value.provider_name.clone())), + model_name: session.and_then(resolve_session_model_name), + execution_strategy: normalize_optional_text(execution_strategy), + output_schema_runtime: None, + source: SessionExecutionRuntimeSource::Session, + mode: None, + latest_turn_id: None, + latest_turn_status: None, + recent_preferences: None, + }; + + if let Some(latest_turn) = snapshot.and_then(resolve_latest_turn) { + runtime.latest_turn_id = Some(latest_turn.id.clone()); + runtime.latest_turn_status = Some(map_turn_status(latest_turn.status)); + runtime.output_schema_runtime = latest_turn.output_schema_runtime.clone(); + runtime.model_name = latest_turn + .output_schema_runtime + .as_ref() + .and_then(|value| normalize_optional_text(value.model_name.clone())) + .or_else(|| { + latest_turn + .context_override + .as_ref() + .and_then(|value| normalize_optional_text(value.model.clone())) + }) + .or(runtime.model_name); + runtime.provider_name = latest_turn + .output_schema_runtime + .as_ref() + .and_then(|value| normalize_optional_text(value.provider_name.clone())) + .or(runtime.provider_name); + runtime.recent_preferences = latest_turn + .context_override + .as_ref() + .and_then(|value| extract_recent_preferences_from_metadata(&value.metadata)); + runtime.source = SessionExecutionRuntimeSource::RuntimeSnapshot; + } + + if runtime.provider_selector.is_none() + && runtime.provider_name.is_none() + && runtime.model_name.is_none() + && runtime.output_schema_runtime.is_none() + && runtime.recent_preferences.is_none() + { + return None; + } + + Some(runtime) +} + +#[cfg(test)] +mod tests { + use super::{ + build_session_execution_runtime, SessionExecutionRuntimePreferences, + SessionExecutionRuntimeSource, + }; + use aster::model::ModelConfig; + use aster::session::{ + Session, SessionRuntimeSnapshot, ThreadRuntime, ThreadRuntimeSnapshot, TurnContextOverride, + TurnOutputSchemaRuntime, TurnOutputSchemaSource, TurnOutputSchemaStrategy, TurnRuntime, + TurnStatus, + }; + use chrono::{Duration, Utc}; + use serde_json::json; + use std::path::PathBuf; + + #[test] + fn falls_back_to_session_when_runtime_snapshot_missing() { + let mut session = Session::default(); + session.id = "session-1".to_string(); + session.provider_name = Some("openai".to_string()); + session.model_config = Some(ModelConfig::new("gpt-5.1").expect("model config")); + + let runtime = build_session_execution_runtime( + "session-1", + Some(&session), + Some("react".to_string()), + None, + Some("openai".to_string()), + ) + .expect("runtime"); + + assert_eq!(runtime.source, SessionExecutionRuntimeSource::Session); + assert_eq!(runtime.provider_selector.as_deref(), Some("openai")); + assert_eq!(runtime.provider_name.as_deref(), Some("openai")); + assert_eq!(runtime.model_name.as_deref(), Some("gpt-5.1")); + assert_eq!(runtime.execution_strategy.as_deref(), Some("react")); + assert!(runtime.output_schema_runtime.is_none()); + assert!(runtime.recent_preferences.is_none()); + } + + #[test] + fn prefers_latest_runtime_snapshot_with_output_schema_runtime() { + let now = Utc::now(); + let mut session = Session::default(); + session.id = "session-2".to_string(); + session.provider_name = Some("openai".to_string()); + session.model_config = Some(ModelConfig::new("gpt-5.1").expect("model config")); + + let latest_turn = TurnRuntime { + id: "turn-new".to_string(), + session_id: "session-2".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Running, + input_text: Some("hello".to_string()), + error_message: None, + context_override: Some(TurnContextOverride { + model: Some("gpt-5.2".to_string()), + ..TurnContextOverride::default() + }), + output_schema_runtime: Some(TurnOutputSchemaRuntime { + source: TurnOutputSchemaSource::Turn, + strategy: TurnOutputSchemaStrategy::Native, + provider_name: Some("openai".to_string()), + model_name: Some("gpt-5.2".to_string()), + }), + created_at: now - Duration::seconds(30), + started_at: Some(now - Duration::seconds(30)), + completed_at: None, + updated_at: now, + }; + let snapshot = SessionRuntimeSnapshot { + session_id: "session-2".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-1", + "session-2", + PathBuf::from("/tmp/workspace"), + ), + turns: vec![ + TurnRuntime { + id: "turn-old".to_string(), + session_id: "session-2".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("old".to_string()), + error_message: None, + context_override: None, + output_schema_runtime: None, + created_at: now - Duration::minutes(2), + started_at: Some(now - Duration::minutes(2)), + completed_at: Some(now - Duration::minutes(1)), + updated_at: now - Duration::minutes(1), + }, + latest_turn.clone(), + ], + items: Vec::new(), + }], + }; + + let runtime = build_session_execution_runtime( + "session-2", + Some(&session), + Some("auto".to_string()), + Some(&snapshot), + Some("openai".to_string()), + ) + .expect("runtime"); + + assert_eq!( + runtime.source, + SessionExecutionRuntimeSource::RuntimeSnapshot + ); + assert_eq!(runtime.latest_turn_id.as_deref(), Some("turn-new")); + assert_eq!(runtime.latest_turn_status.as_deref(), Some("running")); + assert_eq!(runtime.model_name.as_deref(), Some("gpt-5.2")); + assert_eq!( + runtime + .output_schema_runtime + .as_ref() + .and_then(|value| value.model_name.as_deref()), + Some("gpt-5.2") + ); + assert!(runtime.recent_preferences.is_none()); + } + + #[test] + fn keeps_recent_preferences_from_latest_turn_metadata() { + let now = Utc::now(); + let latest_turn = TurnRuntime { + id: "turn-pref".to_string(), + session_id: "session-3".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("hello".to_string()), + error_message: None, + context_override: Some(TurnContextOverride { + metadata: std::collections::HashMap::from([( + "harness".to_string(), + json!({ + "preferences": { + "webSearch": true, + "thinking": true, + "task": false, + "subagent": true, + } + }), + )]), + ..TurnContextOverride::default() + }), + output_schema_runtime: None, + created_at: now - Duration::seconds(10), + started_at: Some(now - Duration::seconds(10)), + completed_at: Some(now - Duration::seconds(1)), + updated_at: now, + }; + let snapshot = SessionRuntimeSnapshot { + session_id: "session-3".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-1", + "session-3", + PathBuf::from("/tmp/workspace"), + ), + turns: vec![latest_turn], + items: Vec::new(), + }], + }; + + let runtime = + build_session_execution_runtime("session-3", None, None, Some(&snapshot), None) + .expect("runtime"); + + assert_eq!( + runtime.source, + SessionExecutionRuntimeSource::RuntimeSnapshot + ); + assert_eq!( + runtime.recent_preferences, + Some(SessionExecutionRuntimePreferences { + web_search: true, + thinking: true, + task: false, + subagent: true, + }) + ); + } +} diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index 91971f06a..8226046fb 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -3,7 +3,9 @@ //! 提供会话创建、列表查询、详情查询能力。 //! 数据事实源收敛到 lime_core::database::agent_session_repository + Lime 数据库。 -use aster::session::extension_data::{resolve_todo_list_state, TodoListItem, TodoListItemStatus}; +use aster::session::extension_data::{ + resolve_todo_list_state, ExtensionState, TodoListItem, TodoListItemStatus, +}; use aster::session::{ resolve_subagent_session_metadata, Session as AsterSession, SessionRuntimeSnapshot, }; @@ -24,9 +26,11 @@ use std::path::Path; use uuid::Uuid; use crate::aster_runtime_support::load_aster_runtime_snapshot; -use crate::event_converter::{ - convert_item_runtime, convert_turn_runtime, TauriMessage, TauriMessageContent, +use crate::protocol::{ + AgentMessage as RuntimeAgentMessage, AgentMessageContent as RuntimeAgentMessageContent, }; +use crate::protocol_projection::{project_item_runtime, project_turn_runtime}; +use crate::session_execution_runtime::{build_session_execution_runtime, SessionExecutionRuntime}; use crate::session_query::{list_child_subagent_sessions, read_session}; use crate::subagent_control::{load_subagent_runtime_status, SubagentRuntimeStatusKind}; use crate::subagent_profiles::{SubagentCustomizationState, SubagentSkillSummary}; @@ -63,8 +67,10 @@ pub struct SessionDetail { pub model: Option, pub working_dir: Option, pub workspace_id: Option, - pub messages: Vec, + pub messages: Vec, pub execution_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_runtime: Option, pub turns: Vec, pub items: Vec, #[serde(default)] @@ -307,6 +313,21 @@ fn normalize_optional_nonempty_body(value: Option) -> Option { } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct SessionProviderRoutingState { + provider_selector: String, +} + +impl ExtensionState for SessionProviderRoutingState { + const EXTENSION_NAME: &'static str = "lime_provider_routing"; + const VERSION: &'static str = "v0"; +} + +fn resolve_session_provider_selector(session: &AsterSession) -> Option { + SessionProviderRoutingState::from_extension_data(&session.extension_data) + .and_then(|state| normalize_optional_text(Some(state.provider_selector))) +} + fn map_session_todo_status(status: TodoListItemStatus) -> SessionTodoStatus { match status { TodoListItemStatus::Pending => SessionTodoStatus::Pending, @@ -726,7 +747,7 @@ fn apply_aster_runtime_snapshot(detail: &mut SessionDetail, snapshot: &SessionRu .collect::>(); for thread in &snapshot.threads { for turn in &thread.turns { - turns_by_id.insert(turn.id.clone(), convert_turn_runtime(turn.clone())); + turns_by_id.insert(turn.id.clone(), project_turn_runtime(turn.clone())); } } detail.turns = turns_by_id.into_values().collect(); @@ -745,7 +766,7 @@ fn apply_aster_runtime_snapshot(detail: &mut SessionDetail, snapshot: &SessionRu .collect::>(); for thread in &snapshot.threads { for item in &thread.items { - items_by_id.insert(item.id.clone(), convert_item_runtime(item.clone())); + items_by_id.insert(item.id.clone(), project_item_runtime(item.clone())); } } detail.items = items_by_id.into_values().collect(); @@ -959,6 +980,7 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result Result { let mut detail = get_session_sync(db, session_id)?; - - match load_aster_runtime_snapshot(session_id).await { - Ok(snapshot) => apply_aster_runtime_snapshot(&mut detail, &snapshot), + let session = match read_session(session_id, false, "读取运行态 session 失败").await { + Ok(session) => Some(session), + Err(error) => { + tracing::warn!( + "[SessionStore] 读取运行态 session 失败,execution runtime 已降级忽略: session_id={}, error={}", + session_id, + error + ); + None + } + }; + let runtime_snapshot = match load_aster_runtime_snapshot(session_id).await { + Ok(snapshot) => Some(snapshot), Err(error) => { tracing::warn!( "[SessionStore] 读取 Aster runtime snapshot 失败: session_id={}, error={}", session_id, error ); + None } + }; + + detail.execution_runtime = build_session_execution_runtime( + session_id, + session.as_ref(), + detail.execution_strategy.clone(), + runtime_snapshot.as_ref(), + session.as_ref().and_then(resolve_session_provider_selector), + ); + + if let Some(snapshot) = runtime_snapshot.as_ref() { + apply_aster_runtime_snapshot(&mut detail, snapshot); } match load_child_subagent_sessions(db, session_id).await { @@ -1099,32 +1144,32 @@ fn parse_data_url(url: &str) -> Option<(String, String)> { Some((normalized_mime, data.trim().to_string())) } -fn convert_image_part(image_url: &str) -> Option { +fn convert_image_part(image_url: &str) -> Option { let normalized = image_url.trim(); if normalized.is_empty() { return None; } if let Some((mime_type, data)) = parse_data_url(normalized) { - return Some(TauriMessageContent::Image { mime_type, data }); + return Some(RuntimeAgentMessageContent::Image { mime_type, data }); } if normalized.starts_with("data:") { - return Some(TauriMessageContent::Text { + return Some(RuntimeAgentMessageContent::Text { text: "[图片消息]".to_string(), }); } - Some(TauriMessageContent::Text { + Some(RuntimeAgentMessageContent::Text { text: format!("![image]({normalized})"), }) } -/// 将 AgentMessage 转换为 TauriMessage +/// 将 AgentMessage 转换为运行时协议消息 fn convert_agent_messages( messages: &[AgentMessage], model_name: Option<&str>, -) -> Vec { +) -> Vec { let eviction_plan = build_history_tool_io_eviction_plan_for_model(messages, model_name); messages .iter() @@ -1135,13 +1180,13 @@ fn convert_agent_messages( fn convert_agent_message( message: &AgentMessage, eviction_plan: &crate::tool_io_offload::HistoryToolIoEvictionPlan, -) -> TauriMessage { +) -> RuntimeAgentMessage { let mut content = match &message.content { MessageContent::Text(text) => { if text.trim().is_empty() { Vec::new() } else { - vec![TauriMessageContent::Text { text: text.clone() }] + vec![RuntimeAgentMessageContent::Text { text: text.clone() }] } } MessageContent::Parts(parts) => parts @@ -1151,7 +1196,7 @@ fn convert_agent_message( if text.trim().is_empty() { None } else { - Some(TauriMessageContent::Text { text: text.clone() }) + Some(RuntimeAgentMessageContent::Text { text: text.clone() }) } } ContentPart::ImageUrl { image_url } => convert_image_part(&image_url.url), @@ -1163,7 +1208,7 @@ fn convert_agent_message( if let Some(reasoning) = &message.reasoning_content { content.insert( 0, - TauriMessageContent::Thinking { + RuntimeAgentMessageContent::Thinking { text: reasoning.clone(), }, ); @@ -1177,7 +1222,7 @@ fn convert_agent_message( } else { maybe_offload_tool_arguments(&call.id, &parsed_arguments) }; - content.push(TauriMessageContent::ToolRequest { + content.push(RuntimeAgentMessageContent::ToolRequest { id: call.id.clone(), tool_name: call.function.name.clone(), arguments, @@ -1195,10 +1240,10 @@ fn convert_agent_message( // tool/user 的工具结果协议消息都不应作为普通文本重复渲染。 if message.role.eq_ignore_ascii_case("tool") || message.role.eq_ignore_ascii_case("user") { - content.retain(|part| !matches!(part, TauriMessageContent::Text { .. })); + content.retain(|part| !matches!(part, RuntimeAgentMessageContent::Text { .. })); } - content.push(TauriMessageContent::ToolResponse { + content.push(RuntimeAgentMessageContent::ToolResponse { id: tool_call_id.clone(), success: true, output: offloaded.output, @@ -1216,7 +1261,7 @@ fn convert_agent_message( .map(|dt| dt.timestamp()) .unwrap_or(0); - let result = TauriMessage { + let result = RuntimeAgentMessage { id: None, role: message.role.clone(), content, @@ -1618,6 +1663,7 @@ mod tests { input_text: Some("旧任务".to_string()), error_message: None, context_override: None, + output_schema_runtime: None, created_at: now - Duration::minutes(2), started_at: Some(now - Duration::minutes(2)), completed_at: None, @@ -1631,6 +1677,7 @@ mod tests { input_text: Some("新任务".to_string()), error_message: None, context_override: None, + output_schema_runtime: None, created_at: now - Duration::seconds(30), started_at: Some(now - Duration::seconds(30)), completed_at: Some(now - Duration::seconds(10)), @@ -1730,7 +1777,7 @@ mod tests { assert!(assistant_converted.content.iter().any(|part| { matches!( part, - TauriMessageContent::ToolRequest { id, tool_name, .. } + RuntimeAgentMessageContent::ToolRequest { id, tool_name, .. } if id == "call-1" && tool_name == "Write" ) })); @@ -1751,11 +1798,11 @@ mod tests { assert!(!tool_converted .content .iter() - .any(|part| matches!(part, TauriMessageContent::Text { .. }))); + .any(|part| matches!(part, RuntimeAgentMessageContent::Text { .. }))); assert!(tool_converted.content.iter().any(|part| { matches!( part, - TauriMessageContent::ToolResponse { id, output, .. } + RuntimeAgentMessageContent::ToolResponse { id, output, .. } if id == "call-1" && output == "写入成功" ) })); @@ -1789,14 +1836,13 @@ mod tests { assert!(converted.content.iter().any(|part| { matches!( part, - TauriMessageContent::Image { mime_type, data } + RuntimeAgentMessageContent::Image { mime_type, data } if mime_type == "image/png" && data == "aGVsbG8=" ) })); - assert!(converted - .content - .iter() - .any(|part| matches!(part, TauriMessageContent::Text { text } if text == "参考图"))); + assert!(converted.content.iter().any( + |part| matches!(part, RuntimeAgentMessageContent::Text { text } if text == "参考图") + )); } #[test] @@ -1817,11 +1863,11 @@ mod tests { assert!(!converted .content .iter() - .any(|part| matches!(part, TauriMessageContent::Text { .. }))); + .any(|part| matches!(part, RuntimeAgentMessageContent::Text { .. }))); assert!(converted.content.iter().any(|part| { matches!( part, - TauriMessageContent::ToolResponse { id, output, .. } + RuntimeAgentMessageContent::ToolResponse { id, output, .. } if id == "call-2" && output == "任务已完成" ) })); @@ -1893,7 +1939,7 @@ mod tests { .content .iter() .find_map(|part| match part { - TauriMessageContent::ToolRequest { arguments, .. } => Some(arguments), + RuntimeAgentMessageContent::ToolRequest { arguments, .. } => Some(arguments), _ => None, }) .expect("tool request"); diff --git a/src-tauri/crates/agent/src/skill_execution.rs b/src-tauri/crates/agent/src/skill_execution.rs index 1e2018358..5083095bf 100644 --- a/src-tauri/crates/agent/src/skill_execution.rs +++ b/src-tauri/crates/agent/src/skill_execution.rs @@ -1,6 +1,10 @@ +use crate::protocol::AgentEvent as RuntimeAgentEvent; use crate::{ - convert_agent_event, AsterAgentState, SessionConfigBuilder, TauriAgentEvent, - WriteArtifactEventEmitter, + artifact_protocol::{ + extend_unique_artifact_protocol_paths, push_unique_artifact_protocol_path, + }, + protocol_projection::project_runtime_event, + AsterAgentState, SessionConfigBuilder, WriteArtifactEventEmitter, }; use aster::agents::SessionConfig; use aster::conversation::message::Message; @@ -9,7 +13,7 @@ use lime_skills::{ExecutionCallback, LoadedSkillDefinition}; use serde::{Deserialize, Serialize}; use std::sync::Arc; -pub type SkillEventEmitter = Arc; +pub type SkillEventEmitter = Arc; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StepResult { @@ -25,6 +29,8 @@ pub struct SkillExecutionResult { pub success: bool, pub output: Option, pub error: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifact_paths: Vec, pub steps_completed: Vec, } @@ -57,12 +63,19 @@ impl std::error::Error for SkillExecutionError {} struct StreamedSkillReply { output: String, error: Option, + artifact_paths: Vec, } -fn emit_skill_event(emitter: &SkillEventEmitter, event_name: &str, event: TauriAgentEvent) { +fn emit_skill_event(emitter: &SkillEventEmitter, event_name: &str, event: RuntimeAgentEvent) { emitter(event_name.to_string(), event); } +fn collect_artifact_path_from_event(target: &mut Vec, event: &RuntimeAgentEvent) { + if let RuntimeAgentEvent::ArtifactSnapshot { artifact } = event { + push_unique_artifact_protocol_path(target, artifact.file_path.as_str()); + } +} + fn build_step_system_prompt( skill_markdown: &str, step_name: &str, @@ -118,6 +131,7 @@ async fn stream_skill_session( let mut output = String::new(); let mut error: Option = None; + let mut artifact_paths = Vec::new(); let mut write_artifact_emitter = WriteArtifactEventEmitter::new(session_id.to_string()); match stream_result { @@ -125,17 +139,19 @@ async fn stream_skill_session( while let Some(event_result) = stream.next().await { match event_result { Ok(agent_event) => { - let tauri_events = convert_agent_event(agent_event); - for mut tauri_event in tauri_events { + let runtime_events = project_runtime_event(agent_event); + for mut runtime_event in runtime_events { let extra_events = - write_artifact_emitter.process_event(&mut tauri_event); + write_artifact_emitter.process_event(&mut runtime_event); for extra_event in extra_events { + collect_artifact_path_from_event(&mut artifact_paths, &extra_event); emit_skill_event(emitter, event_name, extra_event); } - if let TauriAgentEvent::TextDelta { ref text } = tauri_event { + collect_artifact_path_from_event(&mut artifact_paths, &runtime_event); + if let RuntimeAgentEvent::TextDelta { ref text } = runtime_event { output.push_str(text); } - emit_skill_event(emitter, event_name, tauri_event); + emit_skill_event(emitter, event_name, runtime_event); } } Err(stream_error) => { @@ -152,7 +168,11 @@ async fn stream_skill_session( aster_state.remove_cancel_token(session_id).await; - Ok(StreamedSkillReply { output, error }) + Ok(StreamedSkillReply { + output, + error, + artifact_paths, + }) } pub async fn execute_skill_workflow( @@ -172,6 +192,7 @@ pub async fn execute_skill_workflow( let total_steps = steps.len(); let event_name = format!("skill-exec-{execution_id}"); let mut steps_completed = Vec::new(); + let mut artifact_paths = Vec::new(); let mut accumulated_context = user_input.to_string(); let mut final_output = String::new(); @@ -218,6 +239,7 @@ pub async fn execute_skill_workflow( &emitter, ) .await?; + extend_unique_artifact_protocol_paths(&mut artifact_paths, &reply.artifact_paths); if let Some(error) = &reply.error { callback.on_step_error(&step.id, error, false); @@ -234,13 +256,14 @@ pub async fn execute_skill_workflow( emit_skill_event( &emitter, &event_name, - TauriAgentEvent::FinalDone { usage: None }, + RuntimeAgentEvent::FinalDone { usage: None }, ); return Ok(SkillExecutionResult { success: false, output: None, error: Some(final_error), + artifact_paths, steps_completed, }); } @@ -261,7 +284,7 @@ pub async fn execute_skill_workflow( emit_skill_event( &emitter, &event_name, - TauriAgentEvent::FinalDone { usage: None }, + RuntimeAgentEvent::FinalDone { usage: None }, ); tracing::info!( @@ -274,6 +297,7 @@ pub async fn execute_skill_workflow( success: true, output: Some(final_output), error: None, + artifact_paths, steps_completed, }) } @@ -311,6 +335,7 @@ pub async fn execute_skill_prompt( success: false, output: None, error: Some(error.clone()), + artifact_paths: reply.artifact_paths.clone(), steps_completed: vec![StepResult { step_id: "main".to_string(), step_name: skill.display_name.clone(), @@ -325,6 +350,7 @@ pub async fn execute_skill_prompt( success: true, output: Some(reply.output.clone()), error: None, + artifact_paths: reply.artifact_paths, steps_completed: vec![StepResult { step_id: "main".to_string(), step_name: skill.display_name.clone(), diff --git a/src-tauri/crates/agent/src/turn_input_envelope.rs b/src-tauri/crates/agent/src/turn_input_envelope.rs index a51124699..6227795ac 100644 --- a/src-tauri/crates/agent/src/turn_input_envelope.rs +++ b/src-tauri/crates/agent/src/turn_input_envelope.rs @@ -65,6 +65,7 @@ pub enum TurnPromptAugmentationStageKind { Memory, WebSearch, RequestToolPolicy, + Artifact, Elicitation, TeamPreference, AutoContinue, @@ -171,6 +172,9 @@ pub struct TurnInputEnvelope { impl TurnInputEnvelope { fn merged_turn_context_metadata(&self) -> Option> { let mut metadata = self.turn_context_metadata.clone().unwrap_or_default(); + if let Some(project_id) = self.project_id.as_ref() { + metadata.insert("project_id".to_string(), Value::String(project_id.clone())); + } if let Some(provider_continuation) = build_provider_continuation_metadata(&self.provider_continuation) { @@ -480,12 +484,17 @@ mod tests { assert_eq!( diagnostics.turn_context_metadata_keys, vec![ + "project_id".to_string(), "provider_continuation".to_string(), "task_mode_enabled".to_string(), "theme".to_string() ] ); let turn_context = envelope.turn_context_override().expect("turn context"); + assert_eq!( + turn_context.metadata.get("project_id"), + Some(&json!("project-1")) + ); assert_eq!( turn_context.metadata.get("provider_continuation"), Some(&json!({ diff --git a/src-tauri/crates/agent/src/write_artifact_events.rs b/src-tauri/crates/agent/src/write_artifact_events.rs index 3c401b7d0..06ccd9eac 100644 --- a/src-tauri/crates/agent/src/write_artifact_events.rs +++ b/src-tauri/crates/agent/src/write_artifact_events.rs @@ -1,4 +1,8 @@ -use crate::event_converter::{TauriAgentEvent, TauriArtifactSnapshot, TauriToolResult}; +use crate::artifact_protocol::{ + extract_artifact_protocol_paths_from_metadata, extract_artifact_protocol_paths_from_value, + normalize_artifact_protocol_path, +}; +use crate::protocol::{AgentArtifactSignal, AgentEvent as RuntimeAgentEvent, AgentToolResult}; use regex::Regex; use serde_json::Value; use std::collections::HashMap; @@ -41,6 +45,53 @@ pub struct WriteArtifactEventEmitter { tracked_artifacts: HashMap, } +fn push_processed_event( + target: &mut Vec, + emitter: &mut WriteArtifactEventEmitter, + mut event: RuntimeAgentEvent, +) { + target.extend(emitter.process_event(&mut event)); + target.push(event); +} + +pub fn build_write_tool_artifact_events( + scope_id: &str, + tool_name: &str, + tool_id: &str, + file_path: &str, + file_content: &str, + result: AgentToolResult, +) -> Vec { + let arguments = serde_json::json!({ + "path": file_path, + "content": file_content, + }) + .to_string(); + let mut emitter = WriteArtifactEventEmitter::new(scope_id.to_string()); + let mut events = Vec::new(); + + push_processed_event( + &mut events, + &mut emitter, + RuntimeAgentEvent::ToolStart { + tool_name: tool_name.to_string(), + tool_id: tool_id.to_string(), + arguments: Some(arguments), + }, + ); + + push_processed_event( + &mut events, + &mut emitter, + RuntimeAgentEvent::ToolEnd { + tool_id: tool_id.to_string(), + result, + }, + ); + + events +} + impl WriteArtifactEventEmitter { pub fn new(scope_id: impl Into) -> Self { Self { @@ -49,15 +100,15 @@ impl WriteArtifactEventEmitter { } } - pub fn process_event(&mut self, event: &mut TauriAgentEvent) -> Vec { + pub fn process_event(&mut self, event: &mut RuntimeAgentEvent) -> Vec { match event { - TauriAgentEvent::ToolStart { + RuntimeAgentEvent::ToolStart { tool_name, tool_id, arguments, } => self.handle_tool_start(tool_name, tool_id, arguments.as_deref()), - TauriAgentEvent::TextDelta { text } => self.handle_text_delta(text), - TauriAgentEvent::ToolEnd { tool_id, result } => self.handle_tool_end(tool_id, result), + RuntimeAgentEvent::TextDelta { text } => self.handle_text_delta(text), + RuntimeAgentEvent::ToolEnd { tool_id, result } => self.handle_tool_end(tool_id, result), _ => Vec::new(), } } @@ -67,7 +118,7 @@ impl WriteArtifactEventEmitter { tool_name: &str, tool_id: &str, arguments: Option<&str>, - ) -> Vec { + ) -> Vec { let Some(arguments_value) = parse_json_str(arguments) else { return Vec::new(); }; @@ -116,7 +167,7 @@ impl WriteArtifactEventEmitter { events } - fn handle_text_delta(&mut self, text: &str) -> Vec { + fn handle_text_delta(&mut self, text: &str) -> Vec { if text.is_empty() { return Vec::new(); } @@ -176,8 +227,8 @@ impl WriteArtifactEventEmitter { fn handle_tool_end( &mut self, tool_id: &str, - result: &mut TauriToolResult, - ) -> Vec { + result: &mut AgentToolResult, + ) -> Vec { let artifacts = self.collect_tool_end_artifacts(tool_id, result.metadata.as_ref()); if artifacts.is_empty() { return Vec::new(); @@ -378,9 +429,9 @@ fn build_artifact_snapshot_event( file_path: &str, content: &str, metadata: HashMap, -) -> TauriAgentEvent { - TauriAgentEvent::ArtifactSnapshot { - artifact: TauriArtifactSnapshot { +) -> RuntimeAgentEvent { + RuntimeAgentEvent::ArtifactSnapshot { + artifact: AgentArtifactSignal { artifact_id: artifact_id.into(), file_path: file_path.to_string(), content: Some(content.to_string()), @@ -393,7 +444,7 @@ fn build_artifact_snapshot_event( } } -fn annotate_tool_result_metadata(result: &mut TauriToolResult, artifacts: &[ToolEndArtifact]) { +fn annotate_tool_result_metadata(result: &mut AgentToolResult, artifacts: &[ToolEndArtifact]) { let metadata = result.metadata.get_or_insert_with(HashMap::new); metadata.insert("artifact_streamed".to_string(), Value::Bool(true)); @@ -520,28 +571,7 @@ fn parse_json_str(raw: Option<&str>) -> Option { } fn extract_candidate_paths(value: &Value) -> Vec { - let Some(object) = value.as_object() else { - return Vec::new(); - }; - - let mut paths = Vec::new(); - for key in [ - "path", - "file_path", - "filePath", - "target_path", - "targetPath", - "output_path", - "outputPath", - "artifact_path", - "artifactPath", - "artifact_paths", - "artifactPaths", - ] { - if let Some(candidate) = object.get(key) { - push_paths_from_value(&mut paths, candidate); - } - } + let mut paths = extract_artifact_protocol_paths_from_value(value); if paths.is_empty() { if let Some(patch_text) = extract_candidate_patch_text(value) { @@ -616,7 +646,7 @@ fn push_paths_from_patch_text(target: &mut Vec, patch_text: &str) { "*** Move to:", ] { if let Some(path) = trimmed.strip_prefix(prefix) { - if let Some(normalized) = normalize_path(path.trim()) { + if let Some(normalized) = normalize_artifact_protocol_path(path.trim()) { if !target.iter().any(|item| item == &normalized) { target.push(normalized); } @@ -642,10 +672,10 @@ fn extract_embedded_metadata(value: &Value) -> Option> { None } -fn push_paths_from_value(target: &mut Vec, value: &Value) { +fn push_compat_paths_from_value(target: &mut Vec, value: &Value) { match value { Value::String(path) => { - if let Some(normalized) = normalize_path(path) { + if let Some(normalized) = normalize_artifact_protocol_path(path) { if !target.iter().any(|item| item == &normalized) { target.push(normalized); } @@ -653,22 +683,13 @@ fn push_paths_from_value(target: &mut Vec, value: &Value) { } Value::Array(values) => { for nested in values { - push_paths_from_value(target, nested); + push_compat_paths_from_value(target, nested); } } _ => {} } } -fn normalize_path(raw: &str) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.replace('\\', "/")) - } -} - fn extract_artifacts_from_metadata( metadata: Option<&HashMap>, ) -> Vec { @@ -676,23 +697,10 @@ fn extract_artifacts_from_metadata( return Vec::new(); }; - let mut paths = Vec::new(); - for key in [ - "artifact_paths", - "artifact_path", - "path", - "absolute_path", - "output_file", - "file_path", - "output_path", - "filePath", - "outputPath", - "article_path", - "cover_meta_path", - "publish_path", - ] { + let mut paths = extract_artifact_protocol_paths_from_metadata(metadata); + for key in ["article_path", "cover_meta_path", "publish_path"] { if let Some(value) = metadata.get(key) { - push_paths_from_value(&mut paths, value); + push_compat_paths_from_value(&mut paths, value); } } @@ -755,7 +763,7 @@ fn parse_write_file_blocks(text: &str) -> Vec { let open_start = search_offset + full_match.start(); let open_end = search_offset + full_match.end(); - let Some(path) = normalize_path(path_match.as_str()) else { + let Some(path) = normalize_artifact_protocol_path(path_match.as_str()) else { search_offset = open_end; continue; }; @@ -801,13 +809,13 @@ mod tests { use super::*; fn assert_snapshot( - event: &TauriAgentEvent, + event: &RuntimeAgentEvent, expected_path: &str, expected_content: &str, expected_complete: bool, ) -> String { match event { - TauriAgentEvent::ArtifactSnapshot { artifact } => { + RuntimeAgentEvent::ArtifactSnapshot { artifact } => { assert_eq!(artifact.file_path, expected_path); assert_eq!(artifact.content.as_deref(), Some(expected_content)); assert_eq!( @@ -827,7 +835,7 @@ mod tests { #[test] fn tool_start_with_path_only_emits_preparing_snapshot() { let mut emitter = WriteArtifactEventEmitter::new("session-1"); - let mut event = TauriAgentEvent::ToolStart { + let mut event = RuntimeAgentEvent::ToolStart { tool_name: "write_file".to_string(), tool_id: "tool-1".to_string(), arguments: Some(r#"{"path":"drafts/demo.md"}"#.to_string()), @@ -838,7 +846,7 @@ mod tests { let artifact_id = assert_snapshot(&extras[0], "drafts/demo.md", "", false); match &extras[0] { - TauriAgentEvent::ArtifactSnapshot { artifact } => { + RuntimeAgentEvent::ArtifactSnapshot { artifact } => { assert_eq!( artifact .metadata @@ -856,7 +864,7 @@ mod tests { #[test] fn tool_start_apply_patch_emits_preparing_snapshot_for_target_file() { let mut emitter = WriteArtifactEventEmitter::new("session-1"); - let mut event = TauriAgentEvent::ToolStart { + let mut event = RuntimeAgentEvent::ToolStart { tool_name: "apply_patch".to_string(), tool_id: "tool-patch-1".to_string(), arguments: Some( @@ -874,7 +882,7 @@ mod tests { #[test] fn shell_apply_patch_command_emits_preparing_snapshot_for_target_file() { let mut emitter = WriteArtifactEventEmitter::new("session-1"); - let mut event = TauriAgentEvent::ToolStart { + let mut event = RuntimeAgentEvent::ToolStart { tool_name: "bash".to_string(), tool_id: "tool-shell-patch-1".to_string(), arguments: Some( @@ -892,10 +900,10 @@ mod tests { #[test] fn text_delta_write_file_stream_emits_incremental_snapshots() { let mut emitter = WriteArtifactEventEmitter::new("session-1"); - let mut first = TauriAgentEvent::TextDelta { + let mut first = RuntimeAgentEvent::TextDelta { text: "开始 Hello".to_string(), }; - let mut second = TauriAgentEvent::TextDelta { + let mut second = RuntimeAgentEvent::TextDelta { text: " world 完成".to_string(), }; @@ -912,16 +920,16 @@ mod tests { #[test] fn tool_end_emits_completed_snapshot_and_backfills_metadata() { let mut emitter = WriteArtifactEventEmitter::new("session-1"); - let mut tool_start = TauriAgentEvent::ToolStart { + let mut tool_start = RuntimeAgentEvent::ToolStart { tool_name: "write_file".to_string(), tool_id: "tool-1".to_string(), arguments: Some(r##"{"path":"drafts/demo.md","content":"# 标题"}"##.to_string()), }; emitter.process_event(&mut tool_start); - let mut tool_end = TauriAgentEvent::ToolEnd { + let mut tool_end = RuntimeAgentEvent::ToolEnd { tool_id: "tool-1".to_string(), - result: TauriToolResult { + result: AgentToolResult { success: true, output: "写入完成".to_string(), error: None, @@ -935,7 +943,7 @@ mod tests { let artifact_id = assert_snapshot(&extras[0], "drafts/demo.md", "# 标题", true); match &tool_end { - TauriAgentEvent::ToolEnd { result, .. } => { + RuntimeAgentEvent::ToolEnd { result, .. } => { let metadata = result.metadata.as_ref().expect("tool_end metadata"); assert_eq!( metadata.get("artifact_id").and_then(Value::as_str), @@ -953,4 +961,99 @@ mod tests { _ => panic!("expected tool_end"), } } + + #[test] + fn tool_end_reads_nested_artifact_protocol_metadata_paths() { + let mut emitter = WriteArtifactEventEmitter::new("session-1"); + let mut tool_end = RuntimeAgentEvent::ToolEnd { + tool_id: "tool-2".to_string(), + result: AgentToolResult { + success: true, + output: "写入完成".to_string(), + error: None, + images: None, + metadata: Some(HashMap::from([ + ( + "artifact_id".to_string(), + Value::String("artifact-social-1".to_string()), + ), + ( + "payload".to_string(), + serde_json::json!({ + "artifact_paths": ["social-posts\\final.md"] + }), + ), + ])), + }, + }; + + let extras = emitter.process_event(&mut tool_end); + + assert_eq!(extras.len(), 1); + assert_snapshot(&extras[0], "social-posts/final.md", "", true); + + match &tool_end { + RuntimeAgentEvent::ToolEnd { result, .. } => { + let metadata = result.metadata.as_ref().expect("tool_end metadata"); + assert_eq!( + metadata.get("path").and_then(Value::as_str), + Some("social-posts/final.md") + ); + assert_eq!( + metadata.get("artifact_id").and_then(Value::as_str), + Some("artifact-social-1") + ); + } + _ => panic!("expected tool_end"), + } + } + + #[test] + fn build_write_tool_artifact_events_emits_protocol_order() { + let events = build_write_tool_artifact_events( + "session-1", + "write_file", + "tool-3", + "drafts/demo.md", + "# 标题", + AgentToolResult { + success: true, + output: "写入完成".to_string(), + error: None, + images: None, + metadata: None, + }, + ); + + assert_eq!(events.len(), 4); + assert_snapshot(&events[0], "drafts/demo.md", "# 标题", false); + + match &events[1] { + RuntimeAgentEvent::ToolStart { + tool_name, tool_id, .. + } => { + assert_eq!(tool_name, "write_file"); + assert_eq!(tool_id, "tool-3"); + } + _ => panic!("expected tool_start"), + } + + assert_snapshot(&events[2], "drafts/demo.md", "# 标题", true); + + match &events[3] { + RuntimeAgentEvent::ToolEnd { tool_id, result } => { + assert_eq!(tool_id, "tool-3"); + let metadata = result.metadata.as_ref().expect("tool_end metadata"); + assert_eq!( + metadata.get("artifact_streamed").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + metadata.get("file_path").and_then(Value::as_str), + Some("drafts/demo.md") + ); + } + _ => panic!("expected tool_end"), + } + } } diff --git a/src-tauri/crates/agent/tests/protocol_fact_source_guard.rs b/src-tauri/crates/agent/tests/protocol_fact_source_guard.rs new file mode 100644 index 000000000..4220d4ca3 --- /dev/null +++ b/src-tauri/crates/agent/tests/protocol_fact_source_guard.rs @@ -0,0 +1,78 @@ +use std::fs::{read_dir, read_to_string}; +use std::path::{Path, PathBuf}; + +const ALLOWED_DIRECT_EVENT_CONVERTER_FILES: &[&str] = &[ + "crates/agent/src/event_converter.rs", + "crates/agent/src/protocol_projection.rs", +]; + +fn collect_rust_files(dir: &Path, files: &mut Vec) { + let Ok(entries) = read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + + if file_type.is_dir() { + if path.file_name().and_then(|name| name.to_str()) == Some("tests") { + continue; + } + collect_rust_files(&path, files); + continue; + } + + if path.extension().and_then(|value| value.to_str()) != Some("rs") { + continue; + } + + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".test.rs")) + { + continue; + } + + files.push(path); + } +} + +#[test] +fn production_rust_code_should_not_bypass_protocol_projection() { + let src_tauri_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut files = Vec::new(); + + collect_rust_files(&src_tauri_root.join("src"), &mut files); + collect_rust_files(&src_tauri_root.join("crates"), &mut files); + + let mut offenders = Vec::new(); + for file_path in files { + let relative_path = file_path + .strip_prefix(&src_tauri_root) + .expect("相对路径转换失败"); + let relative_path_string = relative_path.to_string_lossy().replace('\\', "/"); + if ALLOWED_DIRECT_EVENT_CONVERTER_FILES + .iter() + .any(|allowed| *allowed == relative_path_string) + { + continue; + } + + let Ok(content) = read_to_string(&file_path) else { + continue; + }; + if content.contains("event_converter::") + || content.contains("convert_agent_event(") + || content.contains("convert_turn_runtime(") + || content.contains("convert_item_runtime(") + { + offenders.push(relative_path_string); + } + } + + assert_eq!(offenders, Vec::::new()); +} diff --git a/src-tauri/crates/agent/tests/real_codex_tool_events.rs b/src-tauri/crates/agent/tests/real_codex_tool_events.rs index 3e1d54a74..891ebfcf4 100644 --- a/src-tauri/crates/agent/tests/real_codex_tool_events.rs +++ b/src-tauri/crates/agent/tests/real_codex_tool_events.rs @@ -1,5 +1,6 @@ use futures::StreamExt; -use lime_agent::{convert_agent_event, AsterAgentState, SessionConfigBuilder, TauriAgentEvent}; +use lime_agent::event_converter::convert_agent_event; +use lime_agent::{AgentEvent, AsterAgentState, SessionConfigBuilder}; use lime_core::database::dao::api_key_provider::ApiProviderType; use lime_core::database::init_database; use lime_services::api_key_provider_service::ApiKeyProviderService; @@ -138,10 +139,10 @@ async fn test_real_codex_stream_emits_tool_events() { Ok(agent_event) => { for event in convert_agent_event(agent_event) { match event { - TauriAgentEvent::ToolStart { .. } => tool_start_count += 1, - TauriAgentEvent::ToolEnd { .. } => tool_end_count += 1, - TauriAgentEvent::TextDelta { text } => text_buffer.push_str(&text), - TauriAgentEvent::Error { message } => error_messages.push(message), + AgentEvent::ToolStart { .. } => tool_start_count += 1, + AgentEvent::ToolEnd { .. } => tool_end_count += 1, + AgentEvent::TextDelta { text } => text_buffer.push_str(&text), + AgentEvent::Error { message } => error_messages.push(message), _ => {} } } diff --git a/src-tauri/crates/browser-runtime/src/action.rs b/src-tauri/crates/browser-runtime/src/action.rs index 462b0c26e..5f61fc6cf 100644 --- a/src-tauri/crates/browser-runtime/src/action.rs +++ b/src-tauri/crates/browser-runtime/src/action.rs @@ -1,10 +1,12 @@ use crate::manager::CdpSessionHandle; -use crate::types::BrowserPageInfo; +use crate::types::{BrowserEvent, BrowserEventPayload, BrowserPageInfo}; use chrono::Utc; use serde_json::{json, Value}; -use std::time::Duration; +use std::time::{Duration, Instant}; +use tokio::sync::broadcast; const DEFAULT_ACTION_TIMEOUT_MS: u64 = 15_000; +const NAVIGATION_POLL_INTERVAL_MS: u64 = 250; pub async fn execute_action( session: &CdpSessionHandle, @@ -52,23 +54,44 @@ async fn navigate(session: &CdpSessionHandle, args: &Value) -> Result Result Result { + let previous_url = session + .state() + .await + .last_page_info + .as_ref() + .map(|page| page.url.clone()) + .filter(|value| !value.trim().is_empty()); + let mut event_rx = session.subscribe(); session .runtime_evaluate(script.to_string(), false, DEFAULT_ACTION_TIMEOUT_MS) .await?; - tokio::time::sleep(Duration::from_millis(800)).await; - let page = session.capture_page_info().await?; + let page = if let Some(page_info) = wait_for_navigation_page( + session, + None, + previous_url.as_deref(), + DEFAULT_ACTION_TIMEOUT_MS, + &mut event_rx, + ) + .await + { + page_info + } else if let Some(page_info) = session.state().await.last_page_info.clone() { + page_info + } else { + capture_and_sync_page_info(session) + .await + .ok_or_else(|| "读取页面信息失败".to_string())? + }; Ok(json!({ "page_info": page })) } +async fn wait_for_navigation_page( + session: &CdpSessionHandle, + expected_url: Option<&str>, + previous_url: Option<&str>, + timeout_ms: u64, + event_rx: &mut broadcast::Receiver, +) -> Option { + let started_at = Instant::now(); + let timeout = Duration::from_millis(timeout_ms.max(500)); + + while started_at.elapsed() < timeout { + let remaining = timeout.saturating_sub(started_at.elapsed()); + let wait_window = remaining.min(Duration::from_millis(NAVIGATION_POLL_INTERVAL_MS)); + match tokio::time::timeout(wait_window, event_rx.recv()).await { + Ok(Ok(event)) => { + if let Some(page_info) = page_info_from_event(&event) { + if should_accept_navigation_page( + Some(page_info.url.as_str()), + expected_url, + previous_url, + ) { + return session + .state() + .await + .last_page_info + .clone() + .or(Some(page_info)); + } + } + } + Ok(Err(broadcast::error::RecvError::Lagged(_))) | Err(_) => { + if let Some(page_info) = capture_and_sync_page_info(session).await { + if should_accept_navigation_page( + Some(page_info.url.as_str()), + expected_url, + previous_url, + ) { + return Some(page_info); + } + } + } + Ok(Err(broadcast::error::RecvError::Closed)) => break, + } + } + + capture_and_sync_page_info(session).await +} + +fn page_info_from_event(event: &BrowserEvent) -> Option { + match &event.payload { + BrowserEventPayload::PageInfoChanged { + title, + url, + markdown, + } => Some(BrowserPageInfo { + title: title.clone(), + url: url.clone(), + markdown: markdown.clone(), + updated_at: Utc::now().to_rfc3339(), + }), + _ => None, + } +} + +async fn capture_and_sync_page_info(session: &CdpSessionHandle) -> Option { + let page_info = session.capture_page_info().await.ok()?; + let current_page_info = session.state().await.last_page_info; + let should_update = current_page_info.as_ref().map_or(true, |current| { + current.url != page_info.url + || current.title != page_info.title + || current.markdown != page_info.markdown + }); + if should_update { + session.update_page_info(page_info.clone()).await; + } + Some(page_info) +} + +fn should_accept_navigation_page( + current_url: Option<&str>, + expected_url: Option<&str>, + previous_url: Option<&str>, +) -> bool { + let Some(current_url) = current_url.map(str::trim).filter(|value| !value.is_empty()) else { + return false; + }; + + if expected_url + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some_and(|expected_url| urls_equivalent(current_url, expected_url)) + { + return true; + } + + match previous_url + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(previous_url) => !urls_equivalent(current_url, previous_url), + None => true, + } +} + +fn urls_equivalent(left: &str, right: &str) -> bool { + normalize_url_for_navigation(left) == normalize_url_for_navigation(right) +} + +fn normalize_url_for_navigation(url: &str) -> String { + let trimmed = url.trim(); + if trimmed.len() > "https://".len() { + trimmed.trim_end_matches('/').to_string() + } else { + trimmed.to_string() + } +} + fn get_string_arg(args: &Value, keys: &[&str]) -> Option { keys.iter().find_map(|key| { args.get(*key) @@ -247,3 +410,40 @@ fn get_string_arg(args: &Value, keys: &[&str]) -> Option { .map(ToString::to_string) }) } + +fn get_u64_arg(args: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| args.get(*key).and_then(Value::as_u64)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_accept_exact_expected_url_even_when_previous_matches() { + assert!(should_accept_navigation_page( + Some("https://github.com/search?q=mcp&type=repositories"), + Some("https://github.com/search?q=mcp&type=repositories"), + Some("https://github.com/search?q=mcp&type=repositories"), + )); + } + + #[test] + fn should_accept_url_change_when_expected_differs_only_by_query_order() { + assert!(should_accept_navigation_page( + Some("https://github.com/search?type=repositories&q=model%20context%20protocol"), + Some("https://github.com/search?q=model%20context%20protocol&type=repositories"), + Some("https://www.36kr.com/newsflashes"), + )); + } + + #[test] + fn should_reject_stale_previous_url() { + assert!(!should_accept_navigation_page( + Some("https://www.36kr.com/newsflashes"), + Some("https://search.bilibili.com/all?keyword=AI%20Agent"), + Some("https://www.36kr.com/newsflashes"), + )); + } +} diff --git a/src-tauri/crates/gateway/src/wechat/runtime.rs b/src-tauri/crates/gateway/src/wechat/runtime.rs index fc307b115..4866845b2 100644 --- a/src-tauri/crates/gateway/src/wechat/runtime.rs +++ b/src-tauri/crates/gateway/src/wechat/runtime.rs @@ -7,8 +7,7 @@ use super::types::{ TypingStatus, WechatMessage, DEFAULT_BASE_URL, DEFAULT_CDN_BASE_URL, SESSION_EXPIRED_ERRCODE, }; use chrono::Utc; -use lime_agent::event_converter::TauriActionRequiredScope; -use lime_agent::AsterAgentState; +use lime_agent::{AgentActionRequiredScope, AsterAgentState}; use lime_core::config::{ Config, ConfigManager, WechatAccountConfig, WechatBotConfig, WechatGroupConfig, }; @@ -144,7 +143,7 @@ struct PendingWechatAction { action_type: PendingWechatActionType, prompt: String, options: Vec, - scope: Option, + scope: Option, } enum WechatAgentOutcome { diff --git a/src-tauri/crates/scheduler/src/executor.rs b/src-tauri/crates/scheduler/src/executor.rs index 42c0d303e..7205e8225 100644 --- a/src-tauri/crates/scheduler/src/executor.rs +++ b/src-tauri/crates/scheduler/src/executor.rs @@ -446,7 +446,7 @@ impl AgentExecutor { None, &request_tool_policy, |event| match event { - lime_agent::TauriAgentEvent::ToolStart { + lime_agent::AgentEvent::ToolStart { tool_name, tool_id, .. } => { tracing::info!( @@ -455,7 +455,7 @@ impl AgentExecutor { tool_id ); } - lime_agent::TauriAgentEvent::ToolEnd { tool_id, result } => { + lime_agent::AgentEvent::ToolEnd { tool_id, result } => { tracing::info!( "[AgentExecutor] 工具调用结束: tool_id={} success={}", tool_id, diff --git a/src-tauri/crates/server/src/chrome_bridge.rs b/src-tauri/crates/server/src/chrome_bridge.rs index 192073d63..fab05ac5b 100644 --- a/src-tauri/crates/server/src/chrome_bridge.rs +++ b/src-tauri/crates/server/src/chrome_bridge.rs @@ -33,6 +33,7 @@ const COMMAND_WHITELIST: &[&str] = &[ "go_forward", "switch_tab", "list_tabs", + "run_adapter", ]; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -93,6 +94,8 @@ pub struct ChromeBridgeCommandRequest { #[serde(default)] pub url: Option, #[serde(default)] + pub payload: Option, + #[serde(default)] pub wait_for_page_info: bool, #[serde(default)] pub timeout_ms: Option, @@ -140,6 +143,8 @@ pub struct ControlCommandPayload { #[serde(default)] pub url: Option, #[serde(default)] + pub payload: Option, + #[serde(default)] pub wait_for_page_info: bool, } @@ -284,7 +289,7 @@ impl ChromeBridgeHub { request: ChromeBridgeCommandRequest, ) -> Result { self.sweep_expired_pending().await; - validate_command(&request.command, &request.url)?; + validate_command(&request.command, &request.url, &request.payload)?; let timeout = normalize_timeout_ms(request.timeout_ms); let source_client_id = format!("lime-api-{}", Uuid::new_v4()); @@ -304,6 +309,7 @@ impl ChromeBridgeHub { request.target.clone(), request.text.clone(), request.url.clone(), + request.payload.clone(), request.wait_for_page_info, ); @@ -387,7 +393,7 @@ impl ChromeBridgeHub { ) { self.sweep_expired_pending().await; - if let Err(error) = validate_command(&payload.command, &payload.url) { + if let Err(error) = validate_command(&payload.command, &payload.url, &payload.payload) { self.send_control_error(control_client_id, &payload.request_id, &error) .await; return; @@ -417,6 +423,7 @@ impl ChromeBridgeHub { payload.target, payload.text, payload.url, + payload.payload, payload.wait_for_page_info, ) .to_string(); @@ -844,7 +851,11 @@ impl Default for ChromeBridgeHub { } } -fn validate_command(command: &str, url: &Option) -> Result<(), String> { +fn validate_command( + command: &str, + url: &Option, + payload: &Option, +) -> Result<(), String> { let normalized = command.trim().to_ascii_lowercase(); if !COMMAND_WHITELIST.contains(&normalized.as_str()) { return Err(format!( @@ -863,6 +874,18 @@ fn validate_command(command: &str, url: &Option) -> Result<(), String> { } } + if normalized == "run_adapter" { + let script = payload + .as_ref() + .and_then(|value| value.get("script")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if script.is_none() { + return Err("run_adapter 命令需要提供 payload.script。".to_string()); + } + } + Ok(()) } @@ -947,6 +970,7 @@ fn build_command_payload( target: Option, text: Option, url: Option, + payload: Option, wait_for_page_info: bool, ) -> Value { json!({ @@ -958,6 +982,7 @@ fn build_command_payload( "target": target, "text": text, "url": url, + "payload": payload, "wait_for_page_info": wait_for_page_info, } }) @@ -1030,10 +1055,21 @@ mod tests { #[test] 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()); + assert!( + validate_command("open_url", &Some("https://example.com".to_string()), &None,).is_ok() + ); + assert!(validate_command("click", &None, &None).is_ok()); + assert!(validate_command("list_tabs", &None, &None).is_ok()); + assert!(validate_command( + "run_adapter", + &None, + &Some(json!({ + "script": "(async () => ({ ok: true }))()", + })), + ) + .is_ok()); + assert!(validate_command("run_adapter", &None, &None).is_err()); + assert!(validate_command("eval_js", &None, &None).is_err()); } #[test] @@ -1064,6 +1100,7 @@ mod tests { target: Some("#btn".to_string()), text: None, url: None, + payload: None, wait_for_page_info: false, timeout_ms: Some(10), }) @@ -1223,4 +1260,27 @@ mod tests { })), ); } + + #[test] + fn build_command_payload_should_include_payload() { + let payload = build_command_payload( + "req-1", + "lime-api-1", + "run_adapter", + Some("202".to_string()), + None, + None, + Some(json!({ + "script": "(async () => ({ ok: true }))()", + })), + false, + ); + + assert_eq!(payload["data"]["command"], json!("run_adapter")); + assert_eq!(payload["data"]["target"], json!("202")); + assert_eq!( + payload["data"]["payload"]["script"], + json!("(async () => ({ ok: true }))()") + ); + } } diff --git a/src-tauri/resources/site-adapters/bundled/index.json b/src-tauri/resources/site-adapters/bundled/index.json new file mode 100644 index 000000000..69d81c79f --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/index.json @@ -0,0 +1,189 @@ +{ + "registry_version": 1, + "adapters": [ + { + "name": "36kr/newsflash", + "domain": "www.36kr.com", + "description": "采集 36Kr 快讯列表。", + "read_only": true, + "capabilities": ["newsflash", "research"], + "args": [ + { + "name": "limit", + "description": "返回条目数量上限", + "required": false, + "arg_type": "integer", + "example": 8 + } + ], + "example": "36kr/newsflash {\"limit\":8}", + "entry": { + "kind": "fixed_url", + "url": "https://www.36kr.com/newsflashes" + }, + "script_file": "scripts/36kr-newsflash.js", + "source_version": "2026-03-25" + }, + { + "name": "bilibili/search", + "domain": "search.bilibili.com", + "description": "按关键词采集 B 站视频搜索结果。", + "read_only": true, + "capabilities": ["search", "video", "research"], + "args": [ + { + "name": "query", + "description": "搜索关键词", + "required": true, + "arg_type": "string", + "example": "AI Agent" + }, + { + "name": "limit", + "description": "返回条目数量上限", + "required": false, + "arg_type": "integer", + "example": 5 + } + ], + "example": "bilibili/search {\"query\":\"AI Agent\",\"limit\":5}", + "entry": { + "kind": "url_template", + "template": "https://search.bilibili.com/all?keyword={{query|urlencode}}" + }, + "script_file": "scripts/bilibili-search.js", + "source_version": "2026-03-25" + }, + { + "name": "github/issues", + "domain": "github.com", + "description": "采集指定 GitHub 仓库的 issue 列表。", + "read_only": true, + "capabilities": ["issues", "repository", "research"], + "args": [ + { + "name": "repo", + "description": "GitHub 仓库,格式 owner/name", + "required": true, + "arg_type": "string", + "example": "rust-lang/rust" + }, + { + "name": "query", + "description": "Issue 搜索关键词,可选", + "required": false, + "arg_type": "string", + "example": "borrow checker" + }, + { + "name": "state", + "description": "Issue 状态过滤,可选 open/closed/all", + "required": false, + "arg_type": "string", + "example": "open" + }, + { + "name": "limit", + "description": "返回条目数量上限", + "required": false, + "arg_type": "integer", + "example": 5 + } + ], + "example": "github/issues {\"repo\":\"rust-lang/rust\",\"query\":\"borrow checker\",\"limit\":5}", + "auth_hint": "私有仓库或受限 issue 需要先在浏览器中登录 GitHub。", + "entry": { + "kind": "builder", + "id": "github_issues" + }, + "script_file": "scripts/github-issues.js", + "source_version": "2026-03-25" + }, + { + "name": "github/search", + "domain": "github.com", + "description": "按关键词采集 GitHub 仓库搜索结果。", + "read_only": true, + "capabilities": ["search", "repository", "research"], + "args": [ + { + "name": "query", + "description": "搜索关键词", + "required": true, + "arg_type": "string", + "example": "AI Agent" + }, + { + "name": "limit", + "description": "返回条目数量上限", + "required": false, + "arg_type": "integer", + "example": 5 + } + ], + "example": "github/search {\"query\":\"model context protocol\",\"limit\":5}", + "auth_hint": "若需要访问更完整的搜索结果,请先在浏览器中登录 GitHub。", + "entry": { + "kind": "url_template", + "template": "https://github.com/search?q={{query|urlencode}}&type=repositories" + }, + "script_file": "scripts/github-search.js", + "source_version": "2026-03-25" + }, + { + "name": "zhihu/hot", + "domain": "www.zhihu.com", + "description": "采集知乎热榜问题列表。", + "read_only": true, + "capabilities": ["hot", "feed", "research"], + "args": [ + { + "name": "limit", + "description": "返回条目数量上限", + "required": false, + "arg_type": "integer", + "example": 5 + } + ], + "example": "zhihu/hot {\"limit\":5}", + "auth_hint": "请先在浏览器中登录知乎,再重试该命令。", + "entry": { + "kind": "fixed_url", + "url": "https://www.zhihu.com/hot" + }, + "script_file": "scripts/zhihu-hot.js", + "source_version": "2026-03-25" + }, + { + "name": "zhihu/search", + "domain": "www.zhihu.com", + "description": "按关键词采集知乎搜索结果。", + "read_only": true, + "capabilities": ["search", "research"], + "args": [ + { + "name": "query", + "description": "搜索关键词", + "required": true, + "arg_type": "string", + "example": "AI Agent" + }, + { + "name": "limit", + "description": "返回条目数量上限", + "required": false, + "arg_type": "integer", + "example": 5 + } + ], + "example": "zhihu/search {\"query\":\"AI Agent\",\"limit\":5}", + "auth_hint": "请先在浏览器中登录知乎,再重试该命令。", + "entry": { + "kind": "url_template", + "template": "https://www.zhihu.com/search?type=content&q={{query|urlencode}}" + }, + "script_file": "scripts/zhihu-search.js", + "source_version": "2026-03-25" + } + ] +} diff --git a/src-tauri/resources/site-adapters/bundled/scripts/36kr-newsflash.js b/src-tauri/resources/site-adapters/bundled/scripts/36kr-newsflash.js new file mode 100644 index 000000000..8ee0757f6 --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/36kr-newsflash.js @@ -0,0 +1,34 @@ +async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => document.querySelectorAll('a[href*="/p/"]').length > 0, + 12000, + 300, + ); + const links = Array.from(document.querySelectorAll('a[href*="/p/"]')); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const card = anchor.closest("div, article"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + return { + ok: true, + data: { + items, + count: items.length, + }, + source_url: location.href, + }; +}; diff --git a/src-tauri/resources/site-adapters/bundled/scripts/bilibili-search.js b/src-tauri/resources/site-adapters/bundled/scripts/bilibili-search.js new file mode 100644 index 000000000..7c2241a0f --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/bilibili-search.js @@ -0,0 +1,52 @@ +async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => + document.querySelectorAll( + 'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]', + ).length > 0, + 12000, + 300, + ); + const links = Array.from( + document.querySelectorAll( + 'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]', + ), + ); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const card = anchor.closest("div"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + author: helpers.text( + card?.querySelector( + ".bili-video-card__info--author, .up-name, .bili-video-card__info--bottom .bili-video-card__info--author", + ), + ), + summary: helpers.text( + card?.querySelector( + ".bili-video-card__info--desc, .des, .bili-video-card__info--title", + ), + ), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; +}; diff --git a/src-tauri/resources/site-adapters/bundled/scripts/github-issues.js b/src-tauri/resources/site-adapters/bundled/scripts/github-issues.js new file mode 100644 index 000000000..eb893768f --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/github-issues.js @@ -0,0 +1,58 @@ +async (args, helpers) => { + const repo = String(args.repo || "").trim(); + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => + document.querySelectorAll( + 'a[data-hovercard-type="issue"], a[id^="issue_"]', + ).length > 0, + 12000, + 300, + ); + const links = Array.from( + document.querySelectorAll( + 'a[data-hovercard-type="issue"], a[id^="issue_"]', + ), + ); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const row = anchor.closest("div, li"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + status: helpers.text( + row?.querySelector( + '[aria-label*="Open"], [aria-label*="Closed"], .State', + ), + ), + summary: helpers.text( + row?.querySelector("span.color-fg-muted, .markdown-title + div"), + ), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: `GitHub 仓库 ${repo} 的 issue 列表为空,可能需要登录后访问。`, + }; + } + + return { + ok: true, + data: { + repo, + items, + count: items.length, + }, + source_url: location.href, + }; +}; diff --git a/src-tauri/resources/site-adapters/bundled/scripts/github-search.js b/src-tauri/resources/site-adapters/bundled/scripts/github-search.js new file mode 100644 index 000000000..093288d6d --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/github-search.js @@ -0,0 +1,44 @@ +async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => document.querySelectorAll("a.v-align-middle").length > 0, + 12000, + 300, + ); + const links = Array.from(document.querySelectorAll("a.v-align-middle")); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const card = anchor.closest("li, div.search-title, div.Box-row"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: "GitHub 搜索结果为空,可能需要先登录后再访问。", + }; + } + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; +}; diff --git a/src-tauri/resources/site-adapters/bundled/scripts/zhihu-hot.js b/src-tauri/resources/site-adapters/bundled/scripts/zhihu-hot.js new file mode 100644 index 000000000..97b8e90de --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/zhihu-hot.js @@ -0,0 +1,62 @@ +async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + const hrefTokens = ["/question/"]; + const maxCandidates = Math.max(limit * 6, 30); + const main = document.querySelector("main") || document.body; + const collectCandidates = (maxCount) => { + if (!main) { + return []; + } + const results = []; + const seen = new Set(); + for (const anchor of main.getElementsByTagName("a")) { + const href = anchor.getAttribute("href") || ""; + if (!hrefTokens.some((token) => href.includes(token))) { + continue; + } + const title = helpers.text(anchor); + const url = helpers.absoluteUrl(href); + if (!title || !url || seen.has(url)) { + continue; + } + seen.add(url); + results.push({ anchor, title, url }); + if (results.length >= maxCount) { + break; + } + } + return results; + }; + await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300); + const items = helpers.take( + collectCandidates(maxCandidates) + .map(({ anchor, title, url }, index) => { + const card = anchor.closest("section, div"); + return { + rank: index + 1, + title, + url, + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: "当前页面没有返回知乎热榜内容,可能需要先登录知乎。", + }; + } + + return { + ok: true, + data: { + items, + count: items.length, + }, + source_url: location.href, + }; +}; diff --git a/src-tauri/resources/site-adapters/bundled/scripts/zhihu-search.js b/src-tauri/resources/site-adapters/bundled/scripts/zhihu-search.js new file mode 100644 index 000000000..dee32c03a --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/zhihu-search.js @@ -0,0 +1,63 @@ +async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + const hrefTokens = ["/question/", "/answer/", "/zvideo/"]; + const maxCandidates = Math.max(limit * 8, 40); + const main = document.querySelector("main") || document.body; + const collectCandidates = (maxCount) => { + if (!main) { + return []; + } + const results = []; + const seen = new Set(); + for (const anchor of main.getElementsByTagName("a")) { + const href = anchor.getAttribute("href") || ""; + if (!hrefTokens.some((token) => href.includes(token))) { + continue; + } + const title = helpers.text(anchor); + const url = helpers.absoluteUrl(href); + if (!title || !url || seen.has(url)) { + continue; + } + seen.add(url); + results.push({ anchor, title, url }); + if (results.length >= maxCount) { + break; + } + } + return results; + }; + await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300); + const items = helpers.take( + collectCandidates(maxCandidates) + .map(({ anchor, title, url }) => { + const card = anchor.closest("div"); + return { + title, + url, + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: `知乎没有返回 "${query}" 的搜索结果,可能需要先登录。`, + }; + } + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; +}; diff --git a/src-tauri/src/agent/aster_agent.rs b/src-tauri/src/agent/aster_agent.rs index 88330fee7..3bd818fe5 100644 --- a/src-tauri/src/agent/aster_agent.rs +++ b/src-tauri/src/agent/aster_agent.rs @@ -12,8 +12,8 @@ use crate::services::memory_profile_prompt_service::{ use aster::conversation::message::Message; use futures::StreamExt; use lime_agent::{ - convert_agent_event, get_persisted_session_metadata_sync, - merge_system_prompt_with_runtime_agents, TauriAgentEvent, WriteArtifactEventEmitter, + get_persisted_session_metadata_sync, merge_system_prompt_with_runtime_agents, + project_runtime_event, AgentEvent as RuntimeAgentEvent, WriteArtifactEventEmitter, }; use std::path::Path; use tauri::{AppHandle, Emitter, Manager}; @@ -103,10 +103,10 @@ impl AsterAgentWrapper { while let Some(event_result) = stream.next().await { match event_result { Ok(agent_event) => { - let tauri_events = convert_agent_event(agent_event); - for mut tauri_event in tauri_events { + let runtime_events = project_runtime_event(agent_event); + for mut runtime_event in runtime_events { let extra_events = - write_artifact_emitter.process_event(&mut tauri_event); + write_artifact_emitter.process_event(&mut runtime_event); for extra_event in &extra_events { if let Err(error) = app.emit(&event_name, extra_event) { tracing::error!( @@ -115,13 +115,13 @@ impl AsterAgentWrapper { ); } } - if let Err(error) = app.emit(&event_name, &tauri_event) { + if let Err(error) = app.emit(&event_name, &runtime_event) { tracing::error!("[AsterAgentWrapper] 发送事件失败: {}", error); } } } Err(error) => { - let error_event = TauriAgentEvent::Error { + let error_event = RuntimeAgentEvent::Error { message: format!("Stream error: {error}"), }; let _ = app.emit(&event_name, &error_event); @@ -129,11 +129,11 @@ impl AsterAgentWrapper { } } - let done_event = TauriAgentEvent::FinalDone { usage: None }; + let done_event = RuntimeAgentEvent::FinalDone { usage: None }; let _ = app.emit(&event_name, &done_event); } Err(error) => { - let error_event = TauriAgentEvent::Error { + let error_event = RuntimeAgentEvent::Error { message: format!("Agent error: {error}"), }; let _ = app.emit(&event_name, &error_event); diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index 3bb7367fc..70b7b8efa 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -24,10 +24,9 @@ pub use credential_bridge::{ create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError, }; pub use lime_agent::{ - convert_agent_event, convert_to_tauri_message, initialize_aster_runtime, - ChildSubagentRuntimeStatus, ChildSubagentSession, QueuedTurnSnapshot, QueuedTurnTask, - SubagentControlState, SubagentParentContext, SubagentRuntimeStatus, SubagentRuntimeStatusKind, - TauriAgentEvent, + initialize_aster_runtime, AgentEvent, ChildSubagentRuntimeStatus, ChildSubagentSession, + QueuedTurnSnapshot, QueuedTurnTask, SubagentControlState, SubagentParentContext, + SubagentRuntimeStatus, SubagentRuntimeStatusKind, }; pub use subagent_scheduler::{ LimeScheduler, LimeSubAgentExecutor, SubAgentProgressEvent, SubAgentRole, diff --git a/src-tauri/src/agent/runtime_queue_service.rs b/src-tauri/src/agent/runtime_queue_service.rs index efbb51e62..c6811c074 100644 --- a/src-tauri/src/agent/runtime_queue_service.rs +++ b/src-tauri/src/agent/runtime_queue_service.rs @@ -18,8 +18,9 @@ use lime_agent::{ remove_runtime_queued_turn as remove_runtime_queued_turn_impl, resume_persisted_runtime_queues_on_startup as resume_persisted_runtime_queues_on_startup_impl, resume_runtime_queue_if_needed as resume_runtime_queue_if_needed_impl, - submit_runtime_turn as submit_runtime_turn_impl, QueuedTurnSnapshot, QueuedTurnTask, - RuntimeQueueEventEmitter, RuntimeQueueExecutor as SharedRuntimeQueueExecutor, TauriAgentEvent, + submit_runtime_turn as submit_runtime_turn_impl, AgentEvent as RuntimeAgentEvent, + QueuedTurnSnapshot, QueuedTurnTask, RuntimeQueueEventEmitter, + RuntimeQueueExecutor as SharedRuntimeQueueExecutor, }; use serde_json::Value; use tauri::{AppHandle, Emitter}; @@ -78,7 +79,7 @@ fn build_runtime_queue_context( fn build_runtime_queue_event_emitter(app: &AppHandle) -> RuntimeQueueEventEmitter { let app = app.clone(); - std::sync::Arc::new(move |event_name: String, event: TauriAgentEvent| { + std::sync::Arc::new(move |event_name: String, event: RuntimeAgentEvent| { if let Err(error) = app.emit(&event_name, &event) { tracing::warn!( "[AsterAgent][Queue] 发送队列事件失败: event_name={}, error={}", diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs index b650329b9..589785f6b 100644 --- a/src-tauri/src/agent_tools/catalog.rs +++ b/src-tauri/src/agent_tools/catalog.rs @@ -11,6 +11,10 @@ pub const LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME: &str = pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation_task"; pub const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task"; pub const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task"; +pub const LIME_SITE_LIST_TOOL_NAME: &str = "lime_site_list"; +pub const LIME_SITE_SEARCH_TOOL_NAME: &str = "lime_site_search"; +pub const LIME_SITE_INFO_TOOL_NAME: &str = "lime_site_info"; +pub const LIME_SITE_RUN_TOOL_NAME: &str = "lime_site_run"; pub const BROWSER_RUNTIME_TOOL_PREFIX: &str = "mcp__lime-browser__"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -126,6 +130,7 @@ const SEARCH_CAP: &[ToolCapability] = &[ToolCapability::WebSearch]; const SKILL_CAP: &[ToolCapability] = &[ToolCapability::SkillExecution]; const CONTENT_CAP: &[ToolCapability] = &[ToolCapability::ContentCreation]; const BROWSER_CAP: &[ToolCapability] = &[ToolCapability::BrowserRuntime]; +const SITE_CAP: &[ToolCapability] = &[ToolCapability::BrowserRuntime, ToolCapability::WebSearch]; const WORKSPACE_IO_CAP: &[ToolCapability] = &[ToolCapability::WorkspaceIo]; const EXECUTION_CAP: &[ToolCapability] = &[ToolCapability::Execution]; const VISION_CAP: &[ToolCapability] = &[ToolCapability::Vision]; @@ -437,6 +442,42 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, + ToolCatalogEntry { + name: LIME_SITE_LIST_TOOL_NAME, + profiles: BROWSER_PROFILES, + capabilities: SITE_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_SITE_SEARCH_TOOL_NAME, + profiles: BROWSER_PROFILES, + capabilities: SITE_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_SITE_INFO_TOOL_NAME, + profiles: BROWSER_PROFILES, + capabilities: SITE_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_SITE_RUN_TOOL_NAME, + profiles: BROWSER_PROFILES, + capabilities: SITE_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, ToolCatalogEntry { name: BROWSER_RUNTIME_TOOL_PREFIX, profiles: BROWSER_PROFILES, @@ -673,14 +714,14 @@ mod tests { .any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX)); let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist()); - assert_eq!(browser.len(), 27); + assert_eq!(browser.len(), 31); assert!(browser .iter() .any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX)); let combined = tool_catalog_entries_for_surface(WorkspaceToolSurface::creator_with_browser_assist()); - assert_eq!(combined.len(), 35); + assert_eq!(combined.len(), 39); } #[test] @@ -699,9 +740,10 @@ mod tests { let names = workspace_default_allowed_tool_names( WorkspaceToolSurface::creator_with_browser_assist(), ); - assert_eq!(names.len(), 22); + assert_eq!(names.len(), 26); assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME)); assert!(names.contains(&"tool_search")); + assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME)); assert!(!names .iter() .any(|name| name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX))); diff --git a/src-tauri/src/agent_tools/inventory.rs b/src-tauri/src/agent_tools/inventory.rs index 438b5f8e3..cc17d3298 100644 --- a/src-tauri/src/agent_tools/inventory.rs +++ b/src-tauri/src/agent_tools/inventory.rs @@ -910,8 +910,8 @@ mod tests { .map(ToString::to_string) .collect::>(); - assert_eq!(inventory.counts.catalog_total, 35); - assert_eq!(inventory.counts.catalog_current_total, 34); + assert_eq!(inventory.counts.catalog_total, 39); + assert_eq!(inventory.counts.catalog_current_total, 38); assert_eq!(inventory.counts.catalog_compat_total, 1); assert_eq!(inventory.default_allowed_tools, expected_default_allowed); assert_eq!( diff --git a/src-tauri/src/app/commands/logs.rs b/src-tauri/src/app/commands/logs.rs index 10694a8eb..19a6a3562 100644 --- a/src-tauri/src/app/commands/logs.rs +++ b/src-tauri/src/app/commands/logs.rs @@ -1051,14 +1051,18 @@ mod tests { use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; + static LOG_FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0); + fn unique_log_path() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("系统时间异常") .as_nanos(); - let log_dir = std::env::temp_dir().join(format!("lime-log-tail-test-{nanos}")); + let counter = LOG_FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed); + let log_dir = std::env::temp_dir().join(format!("lime-log-tail-test-{nanos}-{counter}")); fs::create_dir_all(&log_dir).expect("创建测试日志目录失败"); log_dir.join("lime.log") } diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 36374a740..17b90f596 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -3,10 +3,10 @@ //! 包含 Tauri 应用的主入口函数和命令注册。 use std::sync::Arc; -use tauri::Manager; +use tauri::{Emitter, Manager}; -#[cfg(target_os = "macos")] -use tauri::{Emitter, Listener}; +#[cfg(desktop)] +use tauri::Listener; use crate::commands; use crate::tray::{TrayIconStatus, TrayManager, TrayStateSnapshot}; @@ -148,9 +148,9 @@ pub fn run() { Some(vec!["--minimized"]), )); - // 在 macOS 上注册 Deep Link 插件 + // 在桌面端注册 Deep Link 插件 // _Requirements: 1.4_ - #[cfg(target_os = "macos")] + #[cfg(desktop)] { builder = builder.plugin(tauri_plugin_deep_link::init()); } @@ -164,6 +164,25 @@ pub fn run() { if let Some(window) = app.get_webview_window("main") { reveal_main_window(&window); } + + let deep_link_urls: Vec = args + .iter() + .filter_map(|arg| { + let value = arg.trim(); + if value.starts_with("lime://") { + Some(value.to_string()) + } else { + None + } + }) + .collect(); + + if !deep_link_urls.is_empty() { + tracing::info!("[单实例] 转发 Deep Link URL: {:?}", deep_link_urls); + if let Err(error) = app.emit("deep-link://new-url", &deep_link_urls) { + tracing::error!("[单实例] 转发 Deep Link URL 失败: {}", error); + } + } })); builder @@ -568,9 +587,9 @@ pub fn run() { } } - // 注册 Deep Link 事件处理器(仅 macOS) + // 注册 Deep Link 事件处理器(桌面端) // _Requirements: 1.4_ - #[cfg(target_os = "macos")] + #[cfg(desktop)] { let app_handle = app.handle().clone(); app.listen("deep-link://new-url", move |event| { @@ -1267,6 +1286,15 @@ pub fn run() { commands::browser_runtime_cmd::close_browser_runtime_debugger_window, commands::browser_runtime_cmd::launch_browser_session, commands::browser_runtime_cmd::launch_browser_runtime_assist, + commands::site_capability_cmd::site_list_adapters, + commands::site_capability_cmd::site_search_adapters, + commands::site_capability_cmd::site_get_adapter_info, + commands::site_capability_cmd::site_get_adapter_catalog_status, + commands::site_capability_cmd::site_apply_adapter_catalog_bootstrap, + commands::site_capability_cmd::site_clear_adapter_catalog_cache, + commands::site_capability_cmd::site_run_adapter, + commands::site_capability_cmd::site_debug_run_adapter, + commands::site_capability_cmd::site_save_adapter_result, // API Key Provider commands commands::api_key_provider_cmd::get_system_provider_catalog, commands::api_key_provider_cmd::get_api_key_providers, @@ -1579,6 +1607,7 @@ pub fn run() { // Screenshot Chat commands // _Requirements: 1.1, 1.4, 1.5, 2.2, 2.4, 3.1, 5.1_ commands::screenshot_cmd::get_experimental_config, + commands::screenshot_cmd::get_screenshot_shortcut_runtime_status, commands::screenshot_cmd::save_experimental_config, commands::screenshot_cmd::start_screenshot, commands::screenshot_cmd::validate_shortcut, @@ -1798,6 +1827,7 @@ pub fn run() { commands::external_tools_cmd::get_external_tools, // Voice Input commands crate::voice::commands::get_voice_input_config, + crate::voice::commands::get_voice_shortcut_runtime_status, crate::voice::commands::save_voice_input_config, crate::voice::commands::get_voice_instructions, crate::voice::commands::save_voice_instruction, diff --git a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs index d876e05aa..3295f473e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs @@ -1,4 +1,5 @@ use super::*; +use lime_agent::AgentEvent as RuntimeAgentEvent; /// 统一运行时:删除会话。 #[tauri::command] @@ -79,8 +80,8 @@ pub(crate) fn validate_elicitation_submission( Ok(trimmed_session_id) } -pub(crate) fn build_action_resume_runtime_status() -> TauriRuntimeStatus { - TauriRuntimeStatus { +pub(crate) fn build_action_resume_runtime_status() -> AgentRuntimeStatus { + AgentRuntimeStatus { phase: "routing".to_string(), title: "已提交补充信息,继续执行中".to_string(), detail: "补充信息已回填到当前执行链路,正在恢复后续步骤。".to_string(), @@ -98,7 +99,7 @@ fn emit_action_resume_runtime_status(app: &AppHandle, event_name: &str) { return; } - let event = TauriAgentEvent::RuntimeStatus { + let event = RuntimeAgentEvent::RuntimeStatus { status: build_action_resume_runtime_status(), }; if let Err(error) = app.emit(event_name, &event) { diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index 3073943dc..be68b9264 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -62,6 +62,15 @@ pub struct AsterChatRequest { /// Provider 配置(可选,如果未配置则使用当前配置) #[serde(default, alias = "providerConfig")] pub provider_config: Option, + /// Provider 偏好(后端会基于该偏好解析最终 provider_config) + #[serde(default, alias = "providerPreference")] + pub provider_preference: Option, + /// 模型偏好(后端会基于该偏好解析最终 model_name) + #[serde(default, alias = "modelPreference")] + pub model_preference: Option, + /// 是否偏好 reasoning 变体 + #[serde(default, alias = "thinkingEnabled")] + pub thinking_enabled: Option, /// 项目 ID(可选,用于注入项目上下文到 System Prompt) #[serde(default, alias = "projectId")] pub project_id: Option, @@ -101,6 +110,12 @@ pub struct AsterChatRequest { pub struct AgentTurnConfigSnapshot { #[serde(default, alias = "providerConfig")] pub provider_config: Option, + #[serde(default, alias = "providerPreference")] + pub provider_preference: Option, + #[serde(default, alias = "modelPreference")] + pub model_preference: Option, + #[serde(default, alias = "thinkingEnabled")] + pub thinking_enabled: Option, #[serde(default, alias = "executionStrategy")] pub execution_strategy: Option, #[serde(default, alias = "webSearch")] @@ -148,6 +163,15 @@ impl From for AsterChatRequest { provider_config: turn_config .as_ref() .and_then(|config| config.provider_config.clone()), + provider_preference: turn_config + .as_ref() + .and_then(|config| config.provider_preference.clone()), + model_preference: turn_config + .as_ref() + .and_then(|config| config.model_preference.clone()), + thinking_enabled: turn_config + .as_ref() + .and_then(|config| config.thinking_enabled), project_id: None, workspace_id: request.workspace_id, web_search: turn_config.as_ref().and_then(|config| config.web_search), @@ -223,8 +247,10 @@ pub struct AgentRuntimeSessionDetail { pub created_at: i64, pub updated_at: i64, pub thread_id: String, - pub messages: Vec, + pub messages: Vec, pub execution_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_runtime: Option, pub turns: Vec, pub items: Vec, #[serde(default)] @@ -463,6 +489,7 @@ impl AgentRuntimeSessionDetail { thread_id: detail.thread_id, messages: detail.messages, execution_strategy: detail.execution_strategy, + execution_runtime: detail.execution_runtime, turns: detail.turns, items: detail.items, todo_items: detail.todo_items, @@ -955,7 +982,12 @@ fn build_thread_diagnostics( }) } else if latest_turn_aborted { last_outcome - .and_then(|outcome| outcome.summary.clone().or_else(|| outcome.primary_cause.clone())) + .and_then(|outcome| { + outcome + .summary + .clone() + .or_else(|| outcome.primary_cause.clone()) + }) .or_else(|| Some("最近一次回合已被中断".to_string())) } else if let Some(incident) = incidents.first() { incident @@ -1634,6 +1666,7 @@ mod tests { workspace_id: None, messages: Vec::new(), execution_strategy: None, + execution_runtime: None, turns, items, todo_items: Vec::new(), diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index f07885824..758057f33 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -16,14 +16,16 @@ use crate::agent::runtime_queue_service::{ }; use crate::agent::{ AsterAgentState, AsterAgentWrapper, QueuedTurnSnapshot, QueuedTurnTask, SessionDetail, - SessionInfo, SubAgentRole, TauriAgentEvent, + SessionInfo, SubAgentRole, }; use crate::agent_tools::catalog::{ browser_runtime_tool_prefix, build_mcp_extension_surface, creator_tool_names, WorkspaceToolSurface, LIME_CREATE_BROADCAST_TASK_TOOL_NAME, LIME_CREATE_COVER_TASK_TOOL_NAME, LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME, LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME, - LIME_CREATE_VIDEO_TASK_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME, + LIME_CREATE_VIDEO_TASK_TOOL_NAME, LIME_SITE_INFO_TOOL_NAME, LIME_SITE_LIST_TOOL_NAME, + LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME, + TOOL_SEARCH_TOOL_NAME, }; #[cfg(test)] use crate::agent_tools::execution::build_workspace_shell_allow_pattern; @@ -44,6 +46,7 @@ use crate::config::{GlobalConfigManager, GlobalConfigManagerState}; use crate::database::DbConnection; use crate::mcp::{McpManagerState, McpServerConfig}; use crate::services::agent_timeline_service::AgentTimelineRecorder; +use crate::services::artifact_prompt_service::merge_system_prompt_with_artifact_context; use crate::services::automation_service::AutomationServiceState; use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource}; use crate::services::memory_profile_prompt_service::{ @@ -79,13 +82,13 @@ use aster::tools::{ }; use async_trait::async_trait; use futures::{FutureExt, StreamExt}; -use lime_agent::event_converter::{TauriMessage, TauriMessageContent}; use lime_agent::mcp_bridge::McpBridgeClient; #[cfg(test)] use lime_agent::request_tool_policy::REQUEST_TOOL_POLICY_MARKER; use lime_agent::request_tool_policy::{ merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy_with_mode, stream_message_reply_with_policy, ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, + StreamReplyExecution, }; use lime_agent::{ acquire_provider_runtime_permit, acquire_team_runtime_permit, @@ -100,14 +103,14 @@ use lime_agent::{ release_provider_runtime_permit, release_team_runtime_permit, replace_session_conversation, resolve_provider_runtime_parallel_budget, resolve_virtual_memory_path, snapshot_provider_runtime_lease, snapshot_team_runtime_session, summarize_builtin_skill, - virtual_memory_relative_path, write_subagent_control_state, CompactionSessionMetricsUpdate, - ProviderContinuationCapability, ProviderContinuationCapable, ProviderContinuationState, - ProviderRuntimeGovernorSnapshot, RuntimeProjectionSnapshot, SessionStateSnapshot, - SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus, - SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus, - TeamRuntimeGovernorSnapshot, TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, - TurnProviderRoutingSnapshot, TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, - DURABLE_MEMORY_VIRTUAL_ROOT, + virtual_memory_relative_path, write_subagent_control_state, AgentMessage, AgentMessageContent, + AgentRuntimeStatus, CompactionSessionMetricsUpdate, ProviderContinuationCapability, + ProviderContinuationCapable, ProviderContinuationState, ProviderRuntimeGovernorSnapshot, + RuntimeProjectionSnapshot, SessionStateSnapshot, SubagentControlState, + SubagentCustomizationState, SubagentRuntimeStatus, SubagentRuntimeStatusKind, + SubagentSkillPromptBlock, SubagentSkillSummary, TeamRuntimeGovernorSnapshot, + TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot, + TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, DURABLE_MEMORY_VIRTUAL_ROOT, }; use lime_services::api_key_provider_service::ApiKeyProviderService; use lime_services::mcp_service::McpService; @@ -258,6 +261,7 @@ mod dto; mod mcp_bridge; mod prompt_context; mod reply_runtime; +mod request_model_resolution; mod run_metadata; mod runtime_turn; mod session_runtime; @@ -335,6 +339,7 @@ use reply_runtime::{ emit_runtime_status_with_projection, ensure_code_execution_extension_enabled, should_fallback_to_react_from_code_orchestrated, stream_reply_once, }; +use request_model_resolution::resolve_runtime_request_provider_config; use run_metadata::{ build_chat_run_finish_metadata, build_chat_run_metadata_base, extract_harness_array, extract_harness_bool, extract_harness_nested_object, extract_harness_string, @@ -346,12 +351,6 @@ use run_metadata::{ resolve_social_run_artifact_descriptor, }; pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor}; -#[cfg(test)] -pub(crate) use runtime_turn::{ - build_runtime_prepared_team_spawn_message, parse_runtime_prepared_team_roles, - plan_runtime_prepared_team_actions, RuntimePreparedTeamAction, RuntimePreparedTeamRole, - RuntimePreparedTeamSessionCandidate, -}; pub(crate) use session_runtime::{ delete_runtime_session_internal, persist_session_provider_routing, resolve_session_provider_selector, diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index d08148d83..8a6589842 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -232,17 +232,6 @@ fn render_team_roles(role_items: &[serde_json::Value]) -> Vec { .collect() } -fn describe_turn_team_reason(reason: &str) -> &'static str { - match reason { - "runtime_team_prepared" => "GUI 已提前准备这次 Team 分工", - "runtime_team_generation_failed" => "GUI 尝试准备 Team 失败,当前任务改由主助手直接推进", - "subagent_disabled" => "当前任务未开启 Team 模式", - "turn_purpose_override" => "当前任务属于特定目的流程,这次不走 Team 分工", - "single_agent_direct" => "GUI 判断当前任务由主助手直接处理更合适", - _ => "GUI 已记录这次 Team 判定", - } -} - pub(crate) fn build_team_preference_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -271,16 +260,6 @@ pub(crate) fn build_team_preference_system_prompt( request_metadata, &["selected_team_roles", "selectedTeamRoles"], ); - let turn_team_decision = extract_harness_string( - request_metadata, - &["turn_team_decision", "turnTeamDecision"], - ); - let turn_team_reason = - extract_harness_string(request_metadata, &["turn_team_reason", "turnTeamReason"]); - let turn_team_blueprint = extract_harness_nested_object( - request_metadata, - &["turn_team_blueprint", "turnTeamBlueprint"], - ); if !subagent_mode_enabled { return None; @@ -332,78 +311,6 @@ pub(crate) fn build_team_preference_system_prompt( } } - match turn_team_decision.as_deref() { - Some("team_prepared") => { - lines.push( - "- 当前任务在 GUI 发送前已经准备好协作分工;请把这份安排当成执行参考,而不是事后建议。" - .to_string(), - ); - if let Some(reason) = turn_team_reason.as_deref() { - lines.push(format!( - "- GUI 判定:{}。", - describe_turn_team_reason(reason) - )); - } - - if let Some(blueprint) = turn_team_blueprint { - let blueprint_label = blueprint - .get("label") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()); - let blueprint_description = blueprint - .get("description") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()); - let rendered_roles = blueprint - .get("roles") - .and_then(serde_json::Value::as_array) - .map(|items| render_team_roles(items)) - .unwrap_or_default(); - - if let Some(label) = blueprint_label { - lines.push(format!("- 当前协作蓝图:{label}。")); - } - if let Some(description) = blueprint_description { - lines.push(format!("- 蓝图说明:{description}")); - } - if !rendered_roles.is_empty() { - lines.push("- 当前协作分工:".to_string()); - lines.extend(rendered_roles); - } - } - - lines.push( - "- 回复用户时,先说明为什么要拆分协作、谁会先处理哪一部分、主对话会在什么节点带着结果回来同步。不要只播报“已进入 Team 协作”。" - .to_string(), - ); - lines.push( - "- 主对话要像项目助理一样汇总目标、分工、关键进展和下一步,而不是只抛出简短状态。" - .to_string(), - ); - lines.push( - "- 如果你决定调用 spawn_agent / send_input,应尽早按上述蓝图启动角色,并把蓝图里的 id / label 映射到 blueprintRoleId / blueprintRoleLabel,让各角色承担自己的输出,不要等主 agent 完整处理结束后再补做 team。" - .to_string(), - ); - } - Some("single_agent") => { - lines - .push("- 当前任务没有在 GUI 中提前准备 Team,默认先由主助手直接推进。".to_string()); - if let Some(reason) = turn_team_reason.as_deref() { - lines.push(format!( - "- GUI 判定:{}。", - describe_turn_team_reason(reason) - )); - } - lines.push( - "- 除非执行中出现明确的拆分必要性,否则不要为了形式化 team 而推迟主任务。" - .to_string(), - ); - } - _ => {} - } - lines.push( "- spawn_agent 支持这些结构化字段:blueprintRoleId、blueprintRoleLabel、teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。" .to_string(), @@ -412,6 +319,10 @@ pub(crate) fn build_team_preference_system_prompt( "- 如果任务简单、强依赖当前上下文或下一步立即阻塞在结果上,不要为了套用 preset 而滥用 team。" .to_string(), ); + lines.push( + "- 主对话需要承担协调职责:说明为什么要拆分、谁先处理哪一部分,并在拿到子 agent 结果后主动汇总关键进展、风险和下一步。" + .to_string(), + ); Some(lines.join("\n")) } diff --git a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs index 4d512357f..471f9f085 100644 --- a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs @@ -1,4 +1,5 @@ use super::*; +use lime_agent::{project_runtime_event, AgentEvent as RuntimeAgentEvent}; fn execution_strategy_label(strategy: AsterExecutionStrategy) -> &'static str { match strategy { @@ -115,16 +116,45 @@ fn message_suggests_subagent(message: &str) -> bool { .any(|keyword| normalized.contains(keyword)) } +fn message_suggests_content_generation(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + [ + "生成", + "写一", + "帮我写", + "起草", + "草拟", + "撰写", + "提纲", + "大纲", + "报告", + "方案", + "文档", + "演示", + "ppt", + "slide", + "brief", + "draft", + "outline", + "generate", + "write", + "create a", + ] + .iter() + .any(|keyword| normalized.contains(keyword)) +} + pub(super) fn build_turn_runtime_statuses( request: &AsterChatRequest, effective_strategy: AsterExecutionStrategy, request_tool_policy: &RequestToolPolicy, model_name: Option<&str>, -) -> (TauriRuntimeStatus, TauriRuntimeStatus) { +) -> (AgentRuntimeStatus, AgentRuntimeStatus) { let thinking_enabled = extract_harness_bool( request.metadata.as_ref(), &["thinking_enabled", "thinkingEnabled"], ) + .or(request.thinking_enabled) .unwrap_or(false); let task_enabled = extract_harness_bool( request.metadata.as_ref(), @@ -256,6 +286,15 @@ pub(super) fn build_turn_runtime_statuses( "优先输出结构化行动路径".to_string(), ], ) + } else if message_suggests_content_generation(&request.message) { + ( + "已决定:先生成草稿".to_string(), + "当前请求属于内容生成类,优先基于已有上下文生成一版草稿,信息不足时附带假设说明,而非反复追问。".to_string(), + vec![ + "检测到内容生成需求".to_string(), + "先产出可用草稿,再根据反馈迭代".to_string(), + ], + ) } else { ( "已决定:直接回答优先".to_string(), @@ -268,7 +307,7 @@ pub(super) fn build_turn_runtime_statuses( }; ( - TauriRuntimeStatus { + AgentRuntimeStatus { phase: "preparing".to_string(), title: "正在理解意图".to_string(), detail: "正在判断当前任务应该直接回答、深度思考、规划、联网核实,还是升级为任务协作。" @@ -276,7 +315,7 @@ pub(super) fn build_turn_runtime_statuses( checkpoints: initial_checkpoints, metadata: None, }, - TauriRuntimeStatus { + AgentRuntimeStatus { phase: "routing".to_string(), title: decided.0, detail: decided.1, @@ -291,7 +330,7 @@ fn emit_projected_runtime_item_event( event_name: &str, timeline_recorder: &Arc>, workspace_root: &str, - event: TauriAgentEvent, + event: RuntimeAgentEvent, ) { if let Err(error) = app.emit(event_name, &event) { tracing::warn!("[AsterAgent] 发送 runtime item 投影事件失败: {}", error); @@ -316,7 +355,7 @@ pub(super) async fn emit_runtime_status_with_projection( timeline_recorder: &Arc>, workspace_root: &str, session_config: &aster::agents::SessionConfig, - status: TauriRuntimeStatus, + status: AgentRuntimeStatus, ) { match agent .upsert_runtime_status_item( @@ -329,7 +368,7 @@ pub(super) async fn emit_runtime_status_with_projection( .await { Ok(agent_event) => { - for event in lime_agent::convert_agent_event(agent_event) { + for event in project_runtime_event(agent_event) { emit_projected_runtime_item_event( app, event_name, @@ -347,7 +386,7 @@ pub(super) async fn emit_runtime_status_with_projection( } } - let runtime_event = TauriAgentEvent::RuntimeStatus { status }; + let runtime_event = RuntimeAgentEvent::RuntimeStatus { status }; if let Err(error) = app.emit(event_name, &runtime_event) { tracing::warn!("[AsterAgent] 发送 runtime_status 失败: {}", error); } @@ -363,7 +402,7 @@ pub(super) async fn complete_runtime_status_projection( ) { match agent.complete_runtime_status_item(session_config).await { Ok(Some(agent_event)) => { - for event in lime_agent::convert_agent_event(agent_event) { + for event in project_runtime_event(agent_event) { emit_projected_runtime_item_event( app, event_name, @@ -428,9 +467,9 @@ pub(super) async fn stream_reply_once( cancel_token: CancellationToken, request_tool_policy: &RequestToolPolicy, mut on_event: F, -) -> Result<(), ReplyAttemptError> +) -> Result where - F: FnMut(&TauriAgentEvent), + F: FnMut(&RuntimeAgentEvent), { stream_message_reply_with_policy( agent, @@ -453,7 +492,6 @@ where }, ) .await - .map(|_| ()) } pub(super) fn build_runtime_user_message( diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs new file mode 100644 index 000000000..636b5fc4d --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs @@ -0,0 +1,1011 @@ +use super::*; +use crate::commands::model_registry_cmd::ModelRegistryState; +use lime_core::models::model_registry::{ + EnhancedModelMetadata, ModelCapabilities, ModelSource, ModelTier, ProviderAliasConfig, +}; +use std::collections::HashSet; +use tauri::Manager; + +#[derive(Debug, Clone)] +struct ProviderResolutionContext { + provider_selector: String, + compatibility_provider_key: String, + registry_provider_ids: Vec, + alias_key: String, + custom_models: Vec, +} + +fn normalize_identifier(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn provider_alias_config_key(provider_key: &str) -> String { + match normalize_identifier(provider_key).as_str() { + "gemini_api_key" => "gemini".to_string(), + normalized => normalized.to_string(), + } +} + +fn provider_registry_id_from_key(provider_key: &str) -> String { + match normalize_identifier(provider_key).as_str() { + "openai" => "openai".to_string(), + "anthropic" | "anthropic-compatible" | "claude" | "claude_oauth" => "anthropic".to_string(), + "gemini" | "gemini_api_key" => "gemini".to_string(), + "azure-openai" => "openai".to_string(), + "vertexai" => "google".to_string(), + "ollama" => "ollama".to_string(), + "fal" => "fal".to_string(), + "kiro" => "kiro".to_string(), + "qwen" => "alibaba".to_string(), + "codex" => "codex".to_string(), + "antigravity" => "antigravity".to_string(), + "iflow" => "openai".to_string(), + normalized => normalized.to_string(), + } +} + +fn infer_reasoning_capability(model_id: &str) -> bool { + let normalized = normalize_identifier(model_id); + normalized.contains("thinking") || normalized.contains("reasoning") +} + +fn text_contains_any(text: &str, keywords: &[&str]) -> bool { + keywords.iter().any(|keyword| text.contains(keyword)) +} + +fn infer_vision_capability( + model_id: &str, + provider_id: Option<&str>, + family: Option<&str>, + description: Option<&str>, +) -> bool { + let text = [ + normalize_identifier(model_id), + family.map(normalize_identifier).unwrap_or_default(), + description.map(normalize_identifier).unwrap_or_default(), + ] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join(" "); + if text.is_empty() { + return false; + } + + let provider = provider_id.map(normalize_identifier).unwrap_or_default(); + + if text_contains_any( + &text, + &[ + "embedding", + "embed", + "rerank", + "tts", + "stt", + "transcribe", + "transcription", + "speech", + "audio", + "moderation", + "imagen", + "dall-e", + "dalle", + "stable diffusion", + "stable-diffusion", + "sdxl", + "sd3", + "midjourney", + "image generation", + "image-generation", + "image-gen", + "image-preview", + "flux", + ], + ) { + return false; + } + + if text_contains_any( + &text, + &[ + "vision", + "multimodal", + "multi-modal", + "omni", + "image-input", + "image understanding", + ], + ) { + return true; + } + + let openai_like = text.contains("gpt-5") + || text.contains("gpt-4o") + || text.contains("gpt-4.1") + || text.contains("gpt-4.5") + || text.contains("codex"); + if provider == "openai" || provider == "codex" { + return openai_like; + } + + if provider == "gemini" { + return text.contains("gemini"); + } + + if provider == "anthropic" || provider == "claude" { + return text.contains("claude"); + } + + if provider == "qwen" || provider == "alibaba" { + return (text.contains("qwen") && (text.contains("vl") || text.contains("vision"))) + || text.contains("qvq"); + } + + if provider == "zhipuai" { + return text.contains("glm-") && text.contains('v'); + } + + openai_like + || text.contains("gemini") + || text.contains("claude") + || text.contains("qvq") + || (text.contains("qwen") && (text.contains("vl") || text.contains("vision"))) + || (text.contains("glm-") && text.contains('v')) +} + +fn infer_model_capabilities( + model_id: &str, + provider_id: Option<&str>, + family: Option<&str>, + description: Option<&str>, +) -> ModelCapabilities { + ModelCapabilities { + vision: infer_vision_capability(model_id, provider_id, family, description), + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: infer_reasoning_capability(model_id), + } +} + +fn build_inferred_model_metadata( + model_id: &str, + provider_id: &str, + family: Option, + description: Option, +) -> EnhancedModelMetadata { + let now = chrono::Utc::now().timestamp(); + EnhancedModelMetadata { + id: model_id.to_string(), + display_name: model_id.to_string(), + provider_id: provider_id.to_string(), + provider_name: provider_id.to_string(), + family: family.clone(), + tier: ModelTier::Pro, + capabilities: infer_model_capabilities( + model_id, + Some(provider_id), + family.as_deref(), + description.as_deref(), + ), + pricing: None, + limits: Default::default(), + status: Default::default(), + release_date: None, + is_latest: false, + description, + source: ModelSource::Custom, + created_at: now, + updated_at: now, + } +} + +fn merge_model_catalog( + target: &mut Vec, + incoming: impl IntoIterator, +) { + for candidate in incoming { + let normalized_id = normalize_identifier(&candidate.id); + if let Some(existing_index) = target + .iter() + .position(|model| normalize_identifier(&model.id) == normalized_id) + { + target[existing_index] = candidate; + } else { + target.push(candidate); + } + } +} + +fn build_provider_resolution_context( + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + provider_selector: &str, +) -> Result { + let provider_selector = normalize_identifier(provider_selector); + let mut compatibility_provider_key = provider_selector.clone(); + let mut registry_provider_ids = vec![ + provider_selector.clone(), + provider_registry_id_from_key(&provider_selector), + ]; + let mut custom_models = Vec::new(); + + if lime_core::models::provider_type::is_custom_provider_id(&provider_selector) { + if let Some(provider_with_keys) = api_key_provider_service + .0 + .get_provider(db, &provider_selector)? + { + compatibility_provider_key = provider_with_keys.provider.provider_type.to_string(); + registry_provider_ids.push(provider_registry_id_from_key(&compatibility_provider_key)); + custom_models = provider_with_keys.provider.custom_models; + } + } + + let mut seen = HashSet::new(); + registry_provider_ids.retain(|provider_id| { + !provider_id.trim().is_empty() && seen.insert(normalize_identifier(provider_id)) + }); + + Ok(ProviderResolutionContext { + alias_key: provider_alias_config_key(&provider_selector), + compatibility_provider_key, + custom_models, + provider_selector, + registry_provider_ids, + }) +} + +async fn load_model_registry_catalog( + app: &AppHandle, + context: &ProviderResolutionContext, +) -> (Vec, Option) { + let mut catalog = context + .custom_models + .iter() + .map(|model_id| { + build_inferred_model_metadata(model_id, &context.provider_selector, None, None) + }) + .collect::>(); + + let model_registry_state = app.state::(); + let guard = model_registry_state.read().await; + let Some(service) = guard.as_ref() else { + return (catalog, None); + }; + + let all_models = service.get_all_models().await; + let alias_config = service.get_provider_alias_config(&context.alias_key).await; + drop(guard); + + if let Some(config) = alias_config.as_ref() { + merge_model_catalog( + &mut catalog, + config.models.iter().map(|model_id| { + let alias = config.aliases.get(model_id); + build_inferred_model_metadata( + model_id, + &context.provider_selector, + alias.and_then(|item| item.provider.clone()), + alias.and_then(|item| item.description.clone()), + ) + }), + ); + } + + let registry_models = all_models.into_iter().filter(|model| { + context + .registry_provider_ids + .iter() + .any(|provider_id| provider_id == &normalize_identifier(&model.provider_id)) + }); + merge_model_catalog(&mut catalog, registry_models); + + (catalog, alias_config) +} + +fn normalize_base_model_key(model_id: &str) -> String { + let normalized_model_id = normalize_identifier(model_id); + let tokens = normalized_model_id + .split(|ch| ['.', '_', '-', '/'].contains(&ch)) + .filter(|token| !token.is_empty() && *token != "thinking" && *token != "reasoning") + .collect::>(); + tokens.join("-") +} + +fn find_model_meta<'a>( + model_id: &str, + models: &'a [EnhancedModelMetadata], +) -> Option<&'a EnhancedModelMetadata> { + let normalized = normalize_identifier(model_id); + models + .iter() + .find(|model| normalize_identifier(&model.id) == normalized) +} + +fn model_has_reasoning_capability( + model: Option<&EnhancedModelMetadata>, + fallback_model_id: &str, +) -> bool { + model + .map(|item| item.capabilities.reasoning) + .unwrap_or(false) + || infer_reasoning_capability(fallback_model_id) +} + +fn compare_release_date_desc(left: &EnhancedModelMetadata, right: &EnhancedModelMetadata) -> i32 { + match (left.release_date.as_deref(), right.release_date.as_deref()) { + (Some(left_date), Some(right_date)) => right_date.cmp(left_date) as i32, + (Some(_), None) => -1, + (None, Some(_)) => 1, + (None, None) => 0, + } +} + +fn sort_reasoning_candidates<'a>( + mut candidates: Vec<&'a EnhancedModelMetadata>, + current_model_id: &str, +) -> Vec<&'a EnhancedModelMetadata> { + let normalized_current_id = normalize_identifier(current_model_id); + let exact_preferred_ids = [ + format!("{normalized_current_id}-thinking"), + format!("{normalized_current_id}_thinking"), + format!("{normalized_current_id}-reasoning"), + format!("{normalized_current_id}_reasoning"), + ]; + + candidates.sort_by(|left, right| { + let left_exact = exact_preferred_ids + .iter() + .any(|candidate| candidate == &normalize_identifier(&left.id)); + let right_exact = exact_preferred_ids + .iter() + .any(|candidate| candidate == &normalize_identifier(&right.id)); + left_exact + .cmp(&right_exact) + .reverse() + .then(left.is_latest.cmp(&right.is_latest).reverse()) + .then(compare_release_date_desc(left, right).cmp(&0)) + .then(left.id.cmp(&right.id)) + }); + + candidates +} + +fn resolve_thinking_model_id(current_model_id: &str, models: &[EnhancedModelMetadata]) -> String { + let current_model = find_model_meta(current_model_id, models); + if model_has_reasoning_capability(current_model, current_model_id) { + return current_model + .map(|model| model.id.clone()) + .unwrap_or_else(|| current_model_id.to_string()); + } + + let current_base_key = normalize_base_model_key(current_model_id); + let candidates = models + .iter() + .filter(|candidate| { + model_has_reasoning_capability(Some(candidate), &candidate.id) + && normalize_base_model_key(&candidate.id) == current_base_key + }) + .collect::>(); + sort_reasoning_candidates(candidates, current_model_id) + .into_iter() + .next() + .map(|model| model.id.clone()) + .unwrap_or_else(|| current_model_id.to_string()) +} + +fn resolve_base_model_on_thinking_off( + current_model_id: &str, + models: &[EnhancedModelMetadata], +) -> String { + let current_model = find_model_meta(current_model_id, models); + if !model_has_reasoning_capability(current_model, current_model_id) { + return current_model + .map(|model| model.id.clone()) + .unwrap_or_else(|| current_model_id.to_string()); + } + + let current_base_key = normalize_base_model_key(current_model_id); + let candidates = models + .iter() + .filter(|candidate| { + !model_has_reasoning_capability(Some(candidate), &candidate.id) + && normalize_base_model_key(&candidate.id) == current_base_key + }) + .collect::>(); + + sort_reasoning_candidates(candidates, current_model_id) + .into_iter() + .next() + .map(|model| model.id.clone()) + .unwrap_or_else(|| current_model_id.to_string()) +} + +fn is_likely_image_generation_model(model: &EnhancedModelMetadata) -> bool { + let text = [ + normalize_identifier(&model.id), + normalize_identifier(&model.display_name), + model + .family + .as_deref() + .map(normalize_identifier) + .unwrap_or_default(), + model + .description + .as_deref() + .map(normalize_identifier) + .unwrap_or_default(), + ] + .join(" "); + + text_contains_any( + &text, + &[ + "imagen", + "dall-e", + "dalle", + "stable-diffusion", + "stable diffusion", + "sdxl", + "sd3", + "midjourney", + "image generation", + "image-generation", + "image-gen", + "image-preview", + "flux", + ], + ) && !model.capabilities.tools + && !model.capabilities.function_calling + && !model.capabilities.json_mode +} + +fn supports_vision(model: Option<&EnhancedModelMetadata>, fallback_model_id: &str) -> bool { + if let Some(item) = model { + return item.capabilities.vision; + } + + infer_vision_capability(fallback_model_id, None, None, None) +} + +fn capability_score(model: &EnhancedModelMetadata) -> u8 { + let mut score = 0; + if model.capabilities.tools { + score += 5; + } + if model.capabilities.function_calling { + score += 4; + } + if model.capabilities.json_mode { + score += 3; + } + if model.capabilities.reasoning { + score += 2; + } + if model.capabilities.streaming { + score += 1; + } + score +} + +fn tier_weight(tier: &ModelTier) -> u8 { + match tier { + ModelTier::Mini => 1, + ModelTier::Pro => 2, + ModelTier::Max => 3, + } +} + +fn resolve_vision_model_id( + current_model_id: &str, + models: &[EnhancedModelMetadata], +) -> Result { + let current_model = find_model_meta(current_model_id, models); + if supports_vision(current_model, current_model_id) { + return Ok(current_model + .map(|model| model.id.clone()) + .unwrap_or_else(|| current_model_id.to_string())); + } + + let current_family = current_model + .and_then(|model| model.family.as_deref()) + .map(normalize_identifier) + .unwrap_or_default(); + let mut candidates = models + .iter() + .filter(|candidate| { + candidate.capabilities.vision && !is_likely_image_generation_model(candidate) + }) + .collect::>(); + + candidates.sort_by(|left, right| { + let left_same_family = !current_family.is_empty() + && left.family.as_deref().map(normalize_identifier) == Some(current_family.clone()); + let right_same_family = !current_family.is_empty() + && right.family.as_deref().map(normalize_identifier) == Some(current_family.clone()); + + left_same_family + .cmp(&right_same_family) + .reverse() + .then( + capability_score(left) + .cmp(&capability_score(right)) + .reverse(), + ) + .then(left.is_latest.cmp(&right.is_latest).reverse()) + .then( + tier_weight(&left.tier) + .cmp(&tier_weight(&right.tier)) + .reverse(), + ) + .then(compare_release_date_desc(left, right).cmp(&0)) + .then(left.id.cmp(&right.id)) + }); + + candidates + .into_iter() + .next() + .map(|model| model.id.clone()) + .ok_or_else(|| { + "当前 Provider 没有可用的多模态模型,请切换到支持多模态的 Provider 或模型后再发送图片" + .to_string() + }) +} + +fn resolve_provider_model_compatibility(provider_key: &str, model_id: &str) -> String { + let normalized_provider = normalize_identifier(provider_key); + let normalized_model = normalize_identifier(model_id); + + if normalized_provider == "codex" && normalized_model == "gpt-5.3-codex" { + return "gpt-5.2-codex".to_string(); + } + + model_id.to_string() +} + +fn extract_request_thinking_enabled(request: &AsterChatRequest) -> bool { + request.thinking_enabled.unwrap_or_else(|| { + extract_harness_bool( + request.metadata.as_ref(), + &["thinking_enabled", "thinkingEnabled"], + ) + .unwrap_or(false) + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RequestPreferenceSource { + Request, + Session, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct SessionProviderModelContext { + provider_selector: Option, + provider_name: Option, + model_name: Option, +} + +impl SessionProviderModelContext { + fn from_session(session: &aster::session::Session) -> Self { + Self { + provider_selector: resolve_session_provider_selector(session), + provider_name: normalize_optional_text(session.provider_name.clone()), + model_name: session + .model_config + .as_ref() + .and_then(|config| normalize_optional_text(Some(config.model_name.clone()))), + } + } +} + +fn resolve_provider_preference_with_session_fallback( + requested_provider_preference: Option, + session_context: Option<&SessionProviderModelContext>, +) -> Option<(String, RequestPreferenceSource)> { + if let Some(provider_preference) = normalize_optional_text(requested_provider_preference) { + return Some((provider_preference, RequestPreferenceSource::Request)); + } + + session_context + .and_then(|context| { + context + .provider_selector + .clone() + .or_else(|| context.provider_name.clone()) + }) + .map(|provider_selector| (provider_selector, RequestPreferenceSource::Session)) +} + +fn resolve_model_preference_with_session_fallback( + requested_model_preference: Option, + requested_provider_selector: &str, + session_context: Option<&SessionProviderModelContext>, +) -> Result<(String, RequestPreferenceSource), String> { + if let Some(model_preference) = normalize_optional_text(requested_model_preference) { + return Ok((model_preference, RequestPreferenceSource::Request)); + } + + let normalized_requested_provider = normalize_identifier(requested_provider_selector); + let session_provider_matches = session_context + .into_iter() + .flat_map(|context| { + [ + context.provider_selector.as_deref(), + context.provider_name.as_deref(), + ] + }) + .flatten() + .any(|candidate| normalize_identifier(candidate) == normalized_requested_provider); + if !session_provider_matches { + return Err("model_preference 不能为空;切换 provider 时必须显式提供模型".to_string()); + } + + let Some(session_model_name) = session_context + .and_then(|context| context.model_name.clone()) + .and_then(|value| normalize_optional_text(Some(value))) + else { + return Err("model_preference 不能为空;当前会话尚未持久化模型".to_string()); + }; + + Ok((session_model_name, RequestPreferenceSource::Session)) +} + +async fn load_session_provider_model_context( + request: &AsterChatRequest, +) -> Result { + let session = read_session( + &request.session_id, + false, + "读取会话 provider/model 上下文失败", + ) + .await?; + Ok(SessionProviderModelContext::from_session(&session)) +} + +pub(super) async fn resolve_runtime_request_provider_config( + app: &AppHandle, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + request: &AsterChatRequest, +) -> Result, String> { + if request.provider_config.is_some() { + return Ok(None); + } + + let session_context = + if request.provider_preference.is_some() && request.model_preference.is_some() { + None + } else { + Some(load_session_provider_model_context(request).await?) + }; + + let Some((provider_selector, provider_preference_source)) = + resolve_provider_preference_with_session_fallback( + request.provider_preference.clone(), + session_context.as_ref(), + ) + else { + return Ok(None); + }; + let (model_preference, model_preference_source) = + resolve_model_preference_with_session_fallback( + request.model_preference.clone(), + &provider_selector, + session_context.as_ref(), + )?; + + let context = + build_provider_resolution_context(db, api_key_provider_service, &provider_selector)?; + let (catalog, _alias_config) = load_model_registry_catalog(app, &context).await; + let thinking_enabled = extract_request_thinking_enabled(request); + let has_images = request + .images + .as_ref() + .map(|images| !images.is_empty()) + .unwrap_or(false); + + if matches!(provider_preference_source, RequestPreferenceSource::Session) { + tracing::info!( + "[AsterAgent] 后端从会话恢复 provider 偏好: session={}, provider={}", + request.session_id, + provider_selector + ); + } + + if matches!(model_preference_source, RequestPreferenceSource::Session) { + tracing::info!( + "[AsterAgent] 后端从会话恢复模型偏好: session={}, provider={}, model={}", + request.session_id, + provider_selector, + model_preference + ); + } + + let mut resolved_model = if thinking_enabled { + resolve_thinking_model_id(&model_preference, &catalog) + } else { + resolve_base_model_on_thinking_off(&model_preference, &catalog) + }; + resolved_model = + resolve_provider_model_compatibility(&context.compatibility_provider_key, &resolved_model); + if has_images { + resolved_model = resolve_vision_model_id(&resolved_model, &catalog)?; + } + + if resolved_model != model_preference { + tracing::info!( + "[AsterAgent] 后端已解析请求模型: provider={}, requested_model={}, resolved_model={}, thinking_enabled={}, has_images={}", + context.provider_selector, + model_preference, + resolved_model, + thinking_enabled, + has_images + ); + } + + Ok(Some(ConfigureProviderRequest { + provider_id: Some(context.provider_selector.clone()), + provider_name: context.provider_selector, + model_name: resolved_model, + api_key: None, + base_url: None, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_model( + id: &str, + family: Option<&str>, + reasoning: bool, + vision: bool, + is_latest: bool, + tier: ModelTier, + release_date: Option<&str>, + ) -> EnhancedModelMetadata { + EnhancedModelMetadata { + id: id.to_string(), + display_name: id.to_string(), + provider_id: "openai".to_string(), + provider_name: "openai".to_string(), + family: family.map(ToString::to_string), + tier, + capabilities: ModelCapabilities { + vision, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning, + }, + pricing: None, + limits: Default::default(), + status: Default::default(), + release_date: release_date.map(ToString::to_string), + is_latest, + description: None, + source: ModelSource::Embedded, + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn thinking_on_prefers_reasoning_variant() { + let models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-01"), + ), + build_model( + "gpt-5.4-mini-thinking", + Some("gpt-5.4"), + true, + false, + true, + ModelTier::Mini, + Some("2026-01-02"), + ), + ]; + + assert_eq!( + resolve_thinking_model_id("gpt-5.4-mini", &models), + "gpt-5.4-mini-thinking" + ); + } + + #[test] + fn thinking_off_restores_base_variant() { + let models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-01"), + ), + build_model( + "gpt-5.4-mini-thinking", + Some("gpt-5.4"), + true, + false, + true, + ModelTier::Mini, + Some("2026-01-02"), + ), + ]; + + assert_eq!( + resolve_base_model_on_thinking_off("gpt-5.4-mini-thinking", &models), + "gpt-5.4-mini" + ); + } + + #[test] + fn vision_resolution_prefers_same_family_candidate() { + let models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-01"), + ), + build_model( + "gpt-5.4", + Some("gpt-5.4"), + true, + true, + true, + ModelTier::Pro, + Some("2026-01-03"), + ), + build_model( + "gemini-2.5-pro", + Some("gemini-2.5"), + true, + true, + true, + ModelTier::Pro, + Some("2026-01-02"), + ), + ]; + + assert_eq!( + resolve_vision_model_id("gpt-5.4-mini", &models).unwrap(), + "gpt-5.4" + ); + } + + #[test] + fn vision_resolution_keeps_unknown_model_when_name_implies_vision() { + let models = vec![build_model( + "gpt-5.4", + Some("gpt-5.4"), + true, + true, + true, + ModelTier::Pro, + Some("2026-01-03"), + )]; + + assert_eq!( + resolve_vision_model_id("gpt-5.4-mini", &models).unwrap(), + "gpt-5.4-mini" + ); + } + + #[test] + fn codex_compatibility_falls_back_to_supported_model() { + assert_eq!( + resolve_provider_model_compatibility("codex", "gpt-5.3-codex"), + "gpt-5.2-codex" + ); + } + + #[test] + fn model_preference_falls_back_to_session_model_when_provider_matches() { + let resolved = resolve_model_preference_with_session_fallback( + None, + "openai", + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap(); + + assert_eq!( + resolved, + ("gpt-5.4-mini".to_string(), RequestPreferenceSource::Session) + ); + } + + #[test] + fn model_preference_requires_explicit_value_when_provider_changes() { + let error = resolve_model_preference_with_session_fallback( + None, + "gemini", + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap_err(); + + assert!( + error.contains("切换 provider"), + "unexpected error message: {error}" + ); + } + + #[test] + fn explicit_model_preference_wins_over_session_fallback() { + let resolved = resolve_model_preference_with_session_fallback( + Some("gpt-5.4".to_string()), + "openai", + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap(); + + assert_eq!( + resolved, + ("gpt-5.4".to_string(), RequestPreferenceSource::Request) + ); + } + + #[test] + fn provider_preference_falls_back_to_session_provider_when_request_missing() { + let resolved = resolve_provider_preference_with_session_fallback( + None, + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap(); + + assert_eq!( + resolved, + ("openai".to_string(), RequestPreferenceSource::Session) + ); + } + + #[test] + fn explicit_provider_preference_wins_over_session_fallback() { + let resolved = resolve_provider_preference_with_session_fallback( + Some("gemini".to_string()), + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap(); + + assert_eq!( + resolved, + ("gemini".to_string(), RequestPreferenceSource::Request) + ); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata/provider_continuation.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata/provider_continuation.rs index d6782631f..cccb0cad5 100644 --- a/src-tauri/src/commands/aster_agent_cmd/run_metadata/provider_continuation.rs +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata/provider_continuation.rs @@ -47,11 +47,11 @@ pub(super) fn extract_provider_continuation_from_metadata( } pub(super) fn extract_provider_continuation_from_message( - message: &TauriMessage, + message: &AgentMessage, capability: ProviderContinuationCapability, ) -> Option { for content in &message.content { - if let TauriMessageContent::ToolResponse { + if let AgentMessageContent::ToolResponse { metadata: Some(metadata), .. } = content diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs index 13b3a4b0a..5c634daf0 100644 --- a/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs @@ -11,6 +11,28 @@ fn extract_harness_object( Some(object) } +fn extract_harness_preferences_object( + request_metadata: Option<&serde_json::Value>, +) -> Option<&serde_json::Map> { + extract_harness_object(request_metadata)? + .get("preferences") + .and_then(serde_json::Value::as_object) +} + +fn legacy_harness_bool_preference_keys(key: &str) -> Option<&'static [&'static str]> { + match key { + "web_search_enabled" | "webSearchEnabled" => Some(&["web_search", "webSearch"]), + "thinking_enabled" | "thinkingEnabled" => { + Some(&["thinking", "thinking_enabled", "thinkingEnabled"]) + } + "task_mode_enabled" | "taskModeEnabled" => Some(&["task", "task_mode", "taskMode"]), + "subagent_mode_enabled" | "subagentModeEnabled" => { + Some(&["subagent", "subagent_mode", "subagentMode"]) + } + _ => None, + } +} + pub(in crate::commands::aster_agent_cmd) fn extract_harness_string( request_metadata: Option<&serde_json::Value>, keys: &[&str], @@ -29,8 +51,19 @@ pub(in crate::commands::aster_agent_cmd) fn extract_harness_bool( keys: &[&str], ) -> Option { let harness = extract_harness_object(request_metadata)?; - keys.iter() + let explicit = keys + .iter() .filter_map(|key| harness.get(*key)) + .find_map(serde_json::Value::as_bool); + if explicit.is_some() { + return explicit; + } + + let preferences = extract_harness_preferences_object(request_metadata)?; + keys.iter() + .filter_map(|key| legacy_harness_bool_preference_keys(key)) + .flat_map(|nested_keys| nested_keys.iter().copied()) + .filter_map(|key| preferences.get(key)) .find_map(serde_json::Value::as_bool) } @@ -73,6 +106,9 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields( ("creationMode", "creation_mode"), ("chat_mode", "chat_mode"), ("chatMode", "chat_mode"), + ("turn_purpose", "turn_purpose"), + ("turnPurpose", "turn_purpose"), + ("purpose", "turn_purpose"), ("session_mode", "session_mode"), ("sessionMode", "session_mode"), ("gate_key", "gate_key"), @@ -93,12 +129,6 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields( ("selectedTeamSummary", "selected_team_summary"), ("selected_team_roles", "selected_team_roles"), ("selectedTeamRoles", "selected_team_roles"), - ("turn_team_decision", "turn_team_decision"), - ("turnTeamDecision", "turn_team_decision"), - ("turn_team_reason", "turn_team_reason"), - ("turnTeamReason", "turn_team_reason"), - ("turn_team_blueprint", "turn_team_blueprint"), - ("turnTeamBlueprint", "turn_team_blueprint"), ("browser_requirement", "browser_requirement"), ("browserRequirement", "browser_requirement"), ("browser_requirement_reason", "browser_requirement_reason"), @@ -113,6 +143,32 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields( target.insert(target_key.to_string(), value.clone()); } } + + for (target_key, preference_keys) in [ + ("web_search_enabled", &["web_search", "webSearch"][..]), + ( + "thinking_enabled", + &["thinking", "thinking_enabled", "thinkingEnabled"][..], + ), + ("task_mode_enabled", &["task", "task_mode", "taskMode"][..]), + ( + "subagent_mode_enabled", + &["subagent", "subagent_mode", "subagentMode"][..], + ), + ] { + if target.contains_key(target_key) { + continue; + } + let value = extract_harness_preferences_object(request_metadata).and_then(|preferences| { + preference_keys + .iter() + .filter_map(|key| preferences.get(*key)) + .find_map(serde_json::Value::as_bool) + }); + if let Some(value) = value { + target.insert(target_key.to_string(), serde_json::json!(value)); + } + } } pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base( diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata/social_artifacts.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata/social_artifacts.rs index 587dd6a5f..80882307b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/run_metadata/social_artifacts.rs +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata/social_artifacts.rs @@ -1,4 +1,10 @@ use super::*; +use lime_agent::artifact_protocol::{ + extract_artifact_protocol_paths_from_metadata, extract_artifact_protocol_paths_from_value, + normalize_artifact_protocol_path, +}; +use lime_agent::filesystem_event_protocol::extract_filesystem_event_location_hints_from_metadata; +use lime_agent::AgentEvent as RuntimeAgentEvent; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(in crate::commands::aster_agent_cmd) struct SocialRunArtifactDescriptor { @@ -25,13 +31,13 @@ pub(in crate::commands::aster_agent_cmd) struct ChatRunObservation { impl ChatRunObservation { pub(in crate::commands::aster_agent_cmd) fn record_event( &mut self, - event: &TauriAgentEvent, + event: &RuntimeAgentEvent, workspace_root: &str, request_metadata: Option<&serde_json::Value>, provider_continuation_capability: ProviderContinuationCapability, ) { match event { - TauriAgentEvent::ToolStart { + RuntimeAgentEvent::ToolStart { tool_name, arguments, .. @@ -44,7 +50,7 @@ impl ChatRunObservation { self.record_artifact_path(path, request_metadata); } } - TauriAgentEvent::ToolEnd { result, .. } => { + RuntimeAgentEvent::ToolEnd { result, .. } => { if let Some(metadata) = &result.metadata { if let Some(provider_continuation) = extract_provider_continuation_from_metadata( metadata, @@ -59,7 +65,7 @@ impl ChatRunObservation { } } } - TauriAgentEvent::Message { message } => { + RuntimeAgentEvent::Message { message } => { if let Some(provider_continuation) = extract_provider_continuation_from_message( message, provider_continuation_capability, @@ -67,7 +73,7 @@ impl ChatRunObservation { self.record_provider_continuation(provider_continuation); } } - TauriAgentEvent::ArtifactSnapshot { artifact } => { + RuntimeAgentEvent::ArtifactSnapshot { artifact } => { if let Some(path) = normalize_metadata_path(artifact.file_path.as_str(), workspace_root) { @@ -166,62 +172,86 @@ pub(in crate::commands::aster_agent_cmd) fn extract_artifact_path_from_tool_star } let args = parse_tool_arguments(arguments)?; - let object = args.as_object()?; - - for key in ["path", "file_path", "filePath", "output_path", "outputPath"] { - let Some(raw_path) = object.get(key).and_then(serde_json::Value::as_str) else { - continue; - }; - if normalized_tool_name.contains("write") - || normalized_tool_name.contains("create") - || normalized_tool_name.contains("output") - { - return normalize_metadata_path(raw_path, workspace_root); - } + if normalized_tool_name.contains("write") + || normalized_tool_name.contains("create") + || normalized_tool_name.contains("output") + { + return extract_artifact_protocol_paths_from_value(&args) + .into_iter() + .find_map(|path| normalize_metadata_path(path.as_str(), workspace_root)); } None } -fn push_metadata_path(target: &mut Vec, value: &serde_json::Value, workspace_root: &str) { +fn push_normalized_metadata_path(target: &mut Vec, path: &str, workspace_root: &str) { + if let Some(normalized) = normalize_metadata_path(path, workspace_root) { + if !target.iter().any(|item| item == &normalized) { + target.push(normalized); + } + } +} + +fn push_compat_metadata_paths( + target: &mut Vec, + value: &serde_json::Value, + workspace_root: &str, +) { match value { serde_json::Value::String(path) => { - if let Some(normalized) = normalize_metadata_path(path, workspace_root) { - if !target.iter().any(|item| item == &normalized) { - target.push(normalized); - } - } + let Some(normalized_path) = normalize_artifact_protocol_path(path) else { + return; + }; + push_normalized_metadata_path(target, normalized_path.as_str(), workspace_root); } serde_json::Value::Array(items) => { for item in items { - push_metadata_path(target, item, workspace_root); + push_compat_metadata_paths(target, item, workspace_root); } } _ => {} } } +fn is_probable_artifact_location_hint(path: &str) -> bool { + let normalized = path.trim().replace('\\', "/").to_lowercase(); + if normalized.is_empty() || normalized.ends_with('/') { + return false; + } + + let file_name = normalized.rsplit('/').next().unwrap_or(normalized.as_str()); + if !file_name.contains('.') { + return false; + } + + !file_name.ends_with(".log") && !file_name.ends_with(".txt") && !file_name.ends_with(".jsonl") +} + fn extract_artifact_paths_from_tool_result_metadata( metadata: &HashMap, workspace_root: &str, ) -> Vec { - let mut paths = Vec::new(); - for key in [ - "artifact_paths", - "artifact_path", - "path", - "absolute_path", - "output_file", - "file_path", - "output_path", - "article_path", - "cover_meta_path", - "publish_path", - ] { + let mut paths = extract_artifact_protocol_paths_from_metadata(metadata) + .into_iter() + .filter_map(|path| normalize_metadata_path(path.as_str(), workspace_root)) + .collect::>(); + + for key in ["article_path", "cover_meta_path", "publish_path"] { if let Some(value) = metadata.get(key) { - push_metadata_path(&mut paths, value, workspace_root); + push_compat_metadata_paths(&mut paths, value, workspace_root); } } + + if paths.is_empty() { + // `output_file` / `cwd` 这类字段只是文件事件位置线索,不是 artifact 事实源。 + // 只有完全没有显式 artifact 路径时,才允许做一次保守兜底。 + for hint in extract_filesystem_event_location_hints_from_metadata(metadata) { + if is_probable_artifact_location_hint(hint.as_str()) { + push_normalized_metadata_path(&mut paths, hint.as_str(), workspace_root); + } + } + } + paths } diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index ceb160b84..9cf81a9fb 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -1,355 +1,180 @@ use super::*; +use aster::session::TurnContextOverride; +use lime_agent::AgentEvent as RuntimeAgentEvent; -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RuntimePreparedTeamRole { - pub(crate) id: String, - pub(crate) label: String, - pub(crate) summary: Option, - pub(crate) profile_id: Option, - pub(crate) role_key: Option, - pub(crate) skill_ids: Vec, -} +const ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE: &str = "artifact_document_repaired"; +const ARTIFACT_DOCUMENT_FAILED_WARNING_CODE: &str = "artifact_document_failed"; +const ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE: &str = "artifact_document_persist_failed"; -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RuntimePreparedTeamSessionCandidate { - pub(crate) blueprint_role_id: String, - pub(crate) session_id: String, - pub(crate) status_kind: SubagentRuntimeStatusKind, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum RuntimePreparedTeamAction { - Spawn(RuntimePreparedTeamRole), - Resume { - role: RuntimePreparedTeamRole, - session_id: String, - }, -} - -fn normalize_runtime_team_role_string(value: Option<&serde_json::Value>) -> Option { - normalize_optional_text( - value - .and_then(serde_json::Value::as_str) - .map(ToString::to_string), - ) -} - -fn normalize_runtime_team_role_id_fragment(value: &str) -> Option { - let normalized = value.split_whitespace().collect::>().join("-"); - let normalized = normalized.trim_matches('-').to_string(); - if normalized.is_empty() { - None - } else { - Some(normalized) - } -} - -fn normalize_runtime_team_skill_ids(value: Option<&serde_json::Value>) -> Vec { - let Some(items) = value.and_then(serde_json::Value::as_array) else { - return Vec::new(); - }; - - let mut seen = HashSet::new(); - let mut skill_ids = Vec::new(); - for item in items { - let Some(skill_id) = normalize_runtime_team_role_string(Some(item)) else { - continue; - }; - if seen.insert(skill_id.clone()) { - skill_ids.push(skill_id); - } - } - - skill_ids -} - -fn parse_runtime_prepared_team_role( - role_value: &serde_json::Value, - index: usize, -) -> Option { - let role = role_value.as_object()?; - let profile_id = normalize_runtime_team_role_string(role.get("profile_id")) - .or_else(|| normalize_runtime_team_role_string(role.get("profileId"))); - let role_key = normalize_runtime_team_role_string(role.get("role_key")) - .or_else(|| normalize_runtime_team_role_string(role.get("roleKey"))); - let label = normalize_runtime_team_role_string(role.get("label")) - .or_else(|| profile_id.clone()) - .or_else(|| role_key.clone()) - .unwrap_or_else(|| format!("角色 {}", index + 1)); - let id = normalize_runtime_team_role_string(role.get("id")) - .or_else(|| { - profile_id - .as_deref() - .and_then(normalize_runtime_team_role_id_fragment) - .map(|fragment| format!("profile-{fragment}")) - }) - .or_else(|| { - role_key - .as_deref() - .and_then(normalize_runtime_team_role_id_fragment) - .map(|fragment| format!("role-{fragment}")) - }) - .or_else(|| { - normalize_runtime_team_role_id_fragment(&label) - .map(|fragment| format!("lane-{fragment}")) - }) - .unwrap_or_else(|| format!("runtime-team-role-{}", index + 1)); - - Some(RuntimePreparedTeamRole { - id, - label, - summary: normalize_runtime_team_role_string(role.get("summary")), - profile_id, - role_key, - skill_ids: normalize_runtime_team_skill_ids( - role.get("skill_ids").or_else(|| role.get("skillIds")), - ), - }) -} - -pub(crate) fn parse_runtime_prepared_team_roles( - request_metadata: Option<&serde_json::Value>, -) -> Vec { - if extract_harness_string( - request_metadata, - &["turn_team_decision", "turnTeamDecision"], - ) - .as_deref() - != Some("team_prepared") - { - return Vec::new(); - } - - let Some(blueprint) = extract_harness_nested_object( - request_metadata, - &["turn_team_blueprint", "turnTeamBlueprint"], - ) else { - return Vec::new(); - }; - let Some(role_values) = blueprint.get("roles").and_then(serde_json::Value::as_array) else { - return Vec::new(); - }; - - let mut seen = HashSet::new(); - let mut roles = Vec::new(); - for (index, role_value) in role_values.iter().enumerate() { - let Some(role) = parse_runtime_prepared_team_role(role_value, index) else { - continue; - }; - if seen.insert(role.id.clone()) { - roles.push(role); - } - } - - roles -} - -fn runtime_prepared_team_session_rank(status_kind: SubagentRuntimeStatusKind) -> u8 { - match status_kind { - SubagentRuntimeStatusKind::Running => 7, - SubagentRuntimeStatusKind::Queued => 6, - SubagentRuntimeStatusKind::Idle => 5, - SubagentRuntimeStatusKind::Completed => 4, - SubagentRuntimeStatusKind::Failed => 3, - SubagentRuntimeStatusKind::Aborted => 2, - SubagentRuntimeStatusKind::Closed => 1, - SubagentRuntimeStatusKind::NotFound => 0, - } -} - -pub(crate) fn plan_runtime_prepared_team_actions( - roles: &[RuntimePreparedTeamRole], - existing_candidates: &[RuntimePreparedTeamSessionCandidate], -) -> Vec { - let mut existing_by_role_id: HashMap<&str, &RuntimePreparedTeamSessionCandidate> = - HashMap::new(); - - for candidate in existing_candidates { - let replace = existing_by_role_id - .get(candidate.blueprint_role_id.as_str()) - .map(|current| { - runtime_prepared_team_session_rank(candidate.status_kind) - > runtime_prepared_team_session_rank(current.status_kind) - }) - .unwrap_or(true); - if replace { - existing_by_role_id.insert(candidate.blueprint_role_id.as_str(), candidate); - } - } - - roles - .iter() - .filter_map(|role| match existing_by_role_id.get(role.id.as_str()) { - Some(candidate) if candidate.status_kind == SubagentRuntimeStatusKind::Closed => { - Some(RuntimePreparedTeamAction::Resume { - role: role.clone(), - session_id: candidate.session_id.clone(), - }) - } - Some(candidate) if candidate.status_kind != SubagentRuntimeStatusKind::NotFound => None, - _ => Some(RuntimePreparedTeamAction::Spawn(role.clone())), - }) - .collect() -} - -pub(crate) fn build_runtime_prepared_team_spawn_message( - role: &RuntimePreparedTeamRole, - user_message: &str, -) -> String { - let mut sections = vec![format!("你是当前协作团队中的「{}」角色。", role.label)]; - if let Some(summary) = role.summary.as_deref() { - sections.push(format!("你负责:{summary}")); - } - sections - .push("先用 1-2 句说明你会接手哪一部分,再开始处理,不要只回一句笼统状态。".to_string()); - sections.push( - "请直接在当前子会话输出可交付的过程与结果,优先给事实、结论、风险和下一步,不要把具体产出留给父会话代写。".to_string(), - ); - sections.push(format!("当前用户任务:\n{}", user_message.trim())); - sections.push( - "只处理当前角色范围内的工作;如果依赖其他角色,请明确写出需要谁补充什么,再继续推进。" - .to_string(), - ); - sections.join("\n\n") -} - -async fn collect_runtime_prepared_team_candidates( - parent_session_id: &str, -) -> Result, String> { - let child_sessions = - list_child_subagent_sessions(parent_session_id, "读取 runtime team child sessions 失败") - .await?; - let mut candidates = Vec::new(); - - for child_session in child_sessions { - let Some(customization) = SubagentCustomizationState::from_session(&child_session) else { - continue; - }; - let Some(blueprint_role_id) = normalize_optional_text(customization.blueprint_role_id) - else { - continue; - }; - let status_kind = load_subagent_runtime_status(&child_session.id) - .await - .map(|status| status.kind) - .unwrap_or(SubagentRuntimeStatusKind::NotFound); - candidates.push(RuntimePreparedTeamSessionCandidate { - blueprint_role_id, - session_id: child_session.id, - status_kind, - }); - } - - Ok(candidates) -} - -async fn maybe_prepare_runtime_team_sessions( +fn emit_runtime_side_event( app: &AppHandle, - state: &AsterAgentState, - db: &DbConnection, - api_key_provider_service: &ApiKeyProviderServiceState, - logs: &LogState, - config_manager: &GlobalConfigManagerState, - mcp_manager: &McpManagerState, - automation_state: &AutomationServiceState, - request: &AsterChatRequest, -) -> Result<(), String> { - let roles = parse_runtime_prepared_team_roles(request.metadata.as_ref()); - if roles.is_empty() { - return Ok(()); - } - - let existing_candidates = collect_runtime_prepared_team_candidates(&request.session_id).await?; - let actions = plan_runtime_prepared_team_actions(&roles, &existing_candidates); - if actions.is_empty() { - tracing::info!( - "[AsterAgent][RuntimeTeam] session={} 所有 blueprint 角色已就绪,无需新增预拉起", - request.session_id - ); - return Ok(()); - } - - tracing::info!( - "[AsterAgent][RuntimeTeam] session={} 开始按 blueprint 预拉起角色: total_roles={}, pending_actions={}", - request.session_id, - roles.len(), - actions.len() - ); - - let runtime = SubagentControlRuntime::new( - app.clone(), - state, - db, - api_key_provider_service, - logs, - config_manager, - mcp_manager, - automation_state, - ); - - for action in actions { - match action { - RuntimePreparedTeamAction::Spawn(role) => { - tracing::info!( - "[AsterAgent][RuntimeTeam] session={} 自动预拉起角色: role_id={}, label={}", - request.session_id, - role.id, - role.label - ); - agent_runtime_spawn_subagent_internal( - &runtime, - AgentRuntimeSpawnSubagentRequest { - parent_session_id: request.session_id.clone(), - message: build_runtime_prepared_team_spawn_message(&role, &request.message), - agent_type: Some(role.label.clone()), - model: None, - reasoning_effort: None, - fork_context: false, - blueprint_role_id: Some(role.id.clone()), - blueprint_role_label: Some(role.label.clone()), - profile_id: role.profile_id.clone(), - profile_name: None, - role_key: role.role_key.clone(), - skill_ids: role.skill_ids.clone(), - skill_directories: Vec::new(), - team_preset_id: None, - theme: None, - system_overlay: None, - output_contract: None, - }, - ) - .await - .map_err(|error| { - format!( - "自动预拉起 team 角色失败: role_id={}, label={}, error={error}", - role.id, role.label - ) - })?; - } - RuntimePreparedTeamAction::Resume { role, session_id } => { - tracing::info!( - "[AsterAgent][RuntimeTeam] session={} 恢复已关闭角色 lane: role_id={}, label={}, child_session={}", - request.session_id, - role.id, - role.label, - session_id - ); - agent_runtime_resume_subagent_internal( - &runtime, - AgentRuntimeResumeSubagentRequest { id: session_id.clone() }, - ) - .await - .map_err(|error| { - format!( - "恢复已关闭 team 角色失败: role_id={}, label={}, child_session={}, error={error}", - role.id, role.label, session_id - ) - })?; - } + event_name: &str, + timeline_recorder: &Arc>, + workspace_root: &str, + event: RuntimeAgentEvent, +) { + { + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(error) = recorder.record_runtime_event(app, event_name, &event, workspace_root) { + tracing::warn!( + "[AsterAgent] 记录 Artifact 运行时事件失败(已降级继续): {}", + error + ); } } - Ok(()) + if let Err(error) = app.emit(event_name, &event) { + tracing::warn!("[AsterAgent] 发送 Artifact 运行时事件失败: {}", error); + } +} + +fn summarize_artifact_document_issues(issues: &[String]) -> String { + let parts = issues + .iter() + .map(|issue| issue.trim()) + .filter(|issue| !issue.is_empty()) + .take(3) + .collect::>(); + + if parts.is_empty() { + "结构已按最小可用方案落盘。".to_string() + } else { + parts.join(";") + } +} + +fn merge_turn_context_with_artifact_output_schema( + turn_context: Option, + request_metadata: Option<&serde_json::Value>, +) -> Option { + crate::services::artifact_output_schema_service::merge_turn_context_with_artifact_output_schema( + turn_context, + request_metadata, + ) +} + +fn should_skip_artifact_document_autopersist( + run_observation: &Arc>, + final_text_output: &str, +) -> bool { + if final_text_output.trim().is_empty() { + return true; + } + + let observation = match run_observation.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + // 只允许根据运行期 artifact observation 决定是否跳过 autopersist, + // 不再从最终文本中的 `` 片段反推 artifact 状态。 + !observation.artifact_paths.is_empty() +} + +fn maybe_persist_artifact_document_after_stream( + app: &AppHandle, + event_name: &str, + timeline_recorder: &Arc>, + run_observation: &Arc>, + workspace_root: &str, + workspace_id: &str, + thread_id: &str, + turn_id: &str, + request_metadata: Option<&serde_json::Value>, + final_text_output: &str, +) { + if !crate::services::artifact_document_service::should_attempt_artifact_document_autopersist( + request_metadata, + ) { + return; + } + if should_skip_artifact_document_autopersist(run_observation, final_text_output) { + return; + } + + let persist_params = + crate::services::artifact_document_service::ArtifactDocumentPersistParams { + workspace_root: PathBuf::from(workspace_root), + workspace_id: Some(workspace_id.to_string()), + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + request_metadata: request_metadata.cloned(), + }; + + match crate::services::artifact_document_service::persist_artifact_document_from_text( + final_text_output, + &persist_params, + ) { + Ok(persisted) => { + { + let mut observation = match run_observation.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + observation.record_artifact_path(persisted.relative_path.clone(), request_metadata); + } + + emit_runtime_side_event( + app, + event_name, + timeline_recorder, + workspace_root, + RuntimeAgentEvent::ArtifactSnapshot { + artifact: lime_agent::AgentArtifactSignal { + artifact_id: persisted.artifact_id.clone(), + file_path: persisted.relative_path.clone(), + content: Some(persisted.serialized_document.clone()), + metadata: Some( + persisted + .snapshot_metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + ), + }, + }, + ); + + if persisted.repaired || persisted.status == "failed" { + let (code, prefix) = if persisted.status == "failed" { + ( + ARTIFACT_DOCUMENT_FAILED_WARNING_CODE, + "ArtifactDocument 未通过完整校验,已以失败态文档落盘", + ) + } else { + ( + ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE, + "ArtifactDocument 已自动修复后落盘", + ) + }; + let detail = summarize_artifact_document_issues(&persisted.issues); + emit_runtime_side_event( + app, + event_name, + timeline_recorder, + workspace_root, + RuntimeAgentEvent::Warning { + code: Some(code.to_string()), + message: format!("{prefix}: {detail}"), + }, + ); + } + } + Err(error) => { + emit_runtime_side_event( + app, + event_name, + timeline_recorder, + workspace_root, + RuntimeAgentEvent::Warning { + code: Some(ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE.to_string()), + message: format!("ArtifactDocument 自动落盘失败,已保留消息区结果:{error}"), + }, + ); + } + } } async fn execute_aster_chat_request( @@ -361,7 +186,7 @@ async fn execute_aster_chat_request( config_manager: &GlobalConfigManagerState, mcp_manager: &McpManagerState, automation_state: &AutomationServiceState, - request: AsterChatRequest, + mut request: AsterChatRequest, ) -> Result<(), String> { tracing::info!( "[AsterAgent] 发送流式消息: session={}, event={}", @@ -388,6 +213,12 @@ async fn execute_aster_chat_request( } ensure_tool_search_tool_registered(state).await?; + if let Some(resolved_provider_config) = + resolve_runtime_request_provider_config(app, db, api_key_provider_service, &request).await? + { + request.provider_config = Some(resolved_provider_config); + } + // 直接使用前端传递的 session_id // LimeSessionStore 会在 add_message 时自动创建不存在的 session // 同时 get_session 也会自动创建不存在的 session @@ -463,7 +294,7 @@ async fn execute_aster_chat_request( logs.write() .await .add("warn", &format!("[AsterAgent] {}", warning_message)); - let warning_event = TauriAgentEvent::Warning { + let warning_event = RuntimeAgentEvent::Warning { code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()), message: warning_message, }; @@ -645,10 +476,19 @@ async fn execute_aster_chat_request( prompt_with_request_policy.clone(), ); - let prompt_with_elicitation = merge_system_prompt_with_elicitation_context( + let prompt_with_artifact = merge_system_prompt_with_artifact_context( prompt_with_request_policy, request.metadata.as_ref(), ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::Artifact, + prompt_with_artifact.clone(), + ); + + let prompt_with_elicitation = merge_system_prompt_with_elicitation_context( + prompt_with_artifact, + request.metadata.as_ref(), + ); turn_input_builder.apply_prompt_stage( TurnPromptAugmentationStageKind::Elicitation, prompt_with_elicitation.clone(), @@ -844,7 +684,7 @@ async fn execute_aster_chat_request( warning_message ); if notify_user { - let warning_event = TauriAgentEvent::Warning { + let warning_event = RuntimeAgentEvent::Warning { code: Some(WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE.to_string()), message: warning_message, }; @@ -858,6 +698,8 @@ async fn execute_aster_chat_request( let tracker = ExecutionTracker::new(db.clone()); let cancel_token = state.create_cancel_token(session_id).await; let auto_continue_metadata = auto_continue_config.clone(); + request.metadata = crate::services::artifact_request_metadata_service:: + normalize_request_metadata_with_artifact_defaults(request.metadata.take()); let request_metadata = request.metadata.clone(); sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await; let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref()); @@ -950,51 +792,30 @@ async fn execute_aster_chat_request( let mut session_config_builder = SessionConfigBuilder::new(session_id) .thread_id(turn_state.thread_id.clone()) .turn_id(turn_state.turn_id.clone()); - if let Some(turn_context) = turn_input_envelope.turn_context_override() { + if let Some(turn_context) = merge_turn_context_with_artifact_output_schema( + turn_input_envelope.turn_context_override(), + request_metadata.as_ref(), + ) { session_config_builder = session_config_builder.turn_context(turn_context); } session_config_builder.build() }; - let runtime_turn_initialized = { + { let guard = agent_arc.read().await; let agent = guard.as_ref().ok_or("Agent not initialized")?; - match agent + if let Err(error) = agent .ensure_runtime_turn_initialized( &runtime_status_session_config, Some(request.message.clone()), ) .await { - Ok(_) => true, - Err(error) => { - tracing::warn!( - "[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}", - error - ); - false - } + tracing::warn!( + "[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}", + error + ); } - }; - - if runtime_turn_initialized { - maybe_prepare_runtime_team_sessions( - app, - state, - db, - api_key_provider_service, - logs, - config_manager, - mcp_manager, - automation_state, - &request, - ) - .await?; - } else { - tracing::warn!( - "[AsterAgent][RuntimeTeam] 跳过当前 task 的 team 预拉起,因为父会话 runtime turn 尚未就绪: session={}", - session_id - ); } // 获取 Agent Arc 并保持 guard 在整个流处理期间存活 @@ -1025,6 +846,7 @@ async fn execute_aster_chat_request( let resolved_thread_id_for_session = turn_state.thread_id.clone(); let resolved_turn_id_for_session = turn_state.turn_id.clone(); let turn_input_envelope_for_session = turn_input_envelope.clone(); + let request_metadata_for_session = request_metadata.clone(); let build_session_config = || { let mut session_config_builder = SessionConfigBuilder::new(session_id) @@ -1033,7 +855,10 @@ async fn execute_aster_chat_request( if let Some(prompt) = turn_input_envelope_for_session.system_prompt() { session_config_builder = session_config_builder.system_prompt(prompt.to_string()); } - if let Some(turn_context) = turn_input_envelope_for_session.turn_context_override() { + if let Some(turn_context) = merge_turn_context_with_artifact_output_schema( + turn_input_envelope_for_session.turn_context_override(), + request_metadata_for_session.as_ref(), + ) { session_config_builder = session_config_builder.turn_context(turn_context); } session_config_builder = session_config_builder @@ -1108,7 +933,21 @@ async fn execute_aster_chat_request( .await; let run_result: Result<(), String> = match primary_result { - Ok(()) => Ok(()), + Ok(execution) => { + maybe_persist_artifact_document_after_stream( + &app, + &request.event_name, + &timeline_recorder, + &run_observation, + workspace_root.as_str(), + workspace_id.as_str(), + turn_state.thread_id.as_str(), + turn_state.turn_id.as_str(), + request_metadata.as_ref(), + execution.text_output.as_str(), + ); + Ok(()) + } Err(primary_error) if effective_strategy == AsterExecutionStrategy::CodeOrchestrated && should_fallback_to_react_from_code_orchestrated(&primary_error) => @@ -1184,6 +1023,20 @@ async fn execute_aster_chat_request( }, ) .await + .map(|execution| { + maybe_persist_artifact_document_after_stream( + &app, + &request.event_name, + &timeline_recorder, + &run_observation, + workspace_root.as_str(), + workspace_id.as_str(), + turn_state.thread_id.as_str(), + turn_state.turn_id.as_str(), + request_metadata.as_ref(), + execution.text_output.as_str(), + ); + }) .map_err(|fallback_err| fallback_err.message) } Err(primary_error) => Err(primary_error.message), @@ -1252,7 +1105,7 @@ async fn execute_aster_chat_request( tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error); } } - let done_event = TauriAgentEvent::FinalDone { usage: None }; + let done_event = RuntimeAgentEvent::FinalDone { usage: None }; if let Err(e) = app.emit(&request.event_name, &done_event) { tracing::error!("[AsterAgent] 发送完成事件失败: {}", e); } @@ -1280,7 +1133,7 @@ async fn execute_aster_chat_request( ); } } - let error_event = TauriAgentEvent::Error { message: e.clone() }; + let error_event = RuntimeAgentEvent::Error { message: e.clone() }; if let Err(emit_err) = app.emit(&request.event_name, &error_event) { tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_err); } @@ -1406,9 +1259,7 @@ pub(crate) async fn compact_runtime_session_internal( .ensure_runtime_turn_initialized(&session_config, Some("压缩上下文".to_string())) .await .map_err(|error| format!("初始化压缩 turn 失败: {error}"))?; - for event in - lime_agent::event_converter::convert_agent_event(AgentEvent::TurnStarted { turn }) - { + for event in lime_agent::project_runtime_event(AgentEvent::TurnStarted { turn }) { { let mut recorder = match timeline_recorder.lock() { Ok(guard) => guard, @@ -1431,7 +1282,7 @@ pub(crate) async fn compact_runtime_session_internal( .clone() .unwrap_or_else(|| session_id.clone()); let compaction_item_id = format!("context_compaction:{compaction_turn_id}"); - let start_event = TauriAgentEvent::ContextCompactionStarted { + let start_event = RuntimeAgentEvent::ContextCompactionStarted { item_id: compaction_item_id.clone(), trigger: "manual".to_string(), detail: Some("系统正在将较早消息整理为摘要,以释放上下文窗口。".to_string()), @@ -1468,7 +1319,7 @@ pub(crate) async fn compact_runtime_session_internal( .await?; update_compaction_session_metrics(&session_config, &usage).await?; - let completed_event = TauriAgentEvent::ContextCompactionCompleted { + let completed_event = RuntimeAgentEvent::ContextCompactionCompleted { item_id: compaction_item_id, trigger: "manual".to_string(), detail: Some("较早消息已替换为摘要,后续回复会基于压缩后的上下文继续。".to_string()), @@ -1506,7 +1357,7 @@ pub(crate) async fn compact_runtime_session_internal( error ); } - let done_event = TauriAgentEvent::FinalDone { usage: None }; + let done_event = RuntimeAgentEvent::FinalDone { usage: None }; if let Err(error) = app.emit(&event_name, &done_event) { tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error); } @@ -1523,7 +1374,7 @@ pub(crate) async fn compact_runtime_session_internal( timeline_error ); } - let error_event = TauriAgentEvent::Error { + let error_event = RuntimeAgentEvent::Error { message: error.clone(), }; if let Err(emit_error) = app.emit(&event_name, &error_event) { @@ -1635,7 +1486,7 @@ fn build_provider_runtime_status_metadata( fn build_provider_waiting_runtime_status( snapshot: &ProviderRuntimeGovernorSnapshot, is_team_member: bool, -) -> TauriRuntimeStatus { +) -> AgentRuntimeStatus { let target_label = if is_team_member { "这位协作成员" } else { @@ -1658,7 +1509,7 @@ fn build_provider_waiting_runtime_status( )); } - TauriRuntimeStatus { + AgentRuntimeStatus { phase: "routing".to_string(), title: "当前服务较忙,稍后开始处理".to_string(), detail: snapshot @@ -1673,14 +1524,14 @@ fn build_provider_waiting_runtime_status( fn build_provider_running_runtime_status( snapshot: &ProviderRuntimeGovernorSnapshot, is_team_member: bool, -) -> TauriRuntimeStatus { +) -> AgentRuntimeStatus { let detail = if is_team_member { "已轮到这位协作成员,系统会按更稳妥的节奏继续处理。".to_string() } else { "已轮到这条请求,系统会按更稳妥的节奏开始处理。".to_string() }; - TauriRuntimeStatus { + AgentRuntimeStatus { phase: "routing".to_string(), title: if is_team_member { "协作成员开始处理".to_string() @@ -1737,7 +1588,7 @@ fn build_team_runtime_status_metadata( metadata } -fn build_team_waiting_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> TauriRuntimeStatus { +fn build_team_waiting_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> AgentRuntimeStatus { let mut checkpoints = vec![format!( "当前已有 {}/{} 位协作成员在处理", snapshot.team_active_count, snapshot.team_parallel_budget @@ -1752,7 +1603,7 @@ fn build_team_waiting_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> checkpoints.push("当前服务较忙,已切换为更稳妥的顺序处理".to_string()); } - TauriRuntimeStatus { + AgentRuntimeStatus { phase: "routing".to_string(), title: "等待执行窗口".to_string(), detail: snapshot @@ -1764,7 +1615,7 @@ fn build_team_waiting_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> } } -fn build_team_running_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> TauriRuntimeStatus { +fn build_team_running_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> AgentRuntimeStatus { let mut checkpoints = vec![format!( "当前并发预算 {}/{}", snapshot.team_active_count, snapshot.team_parallel_budget @@ -1773,7 +1624,7 @@ fn build_team_running_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> checkpoints.push("当前服务使用稳妥处理模式".to_string()); } - TauriRuntimeStatus { + AgentRuntimeStatus { phase: "routing".to_string(), title: "开始处理".to_string(), detail: "已获得可用执行窗口,这位协作成员正在接手当前任务。".to_string(), @@ -1782,11 +1633,11 @@ fn build_team_running_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> } } -fn emit_transient_runtime_status(app: &AppHandle, event_name: &str, status: TauriRuntimeStatus) { +fn emit_transient_runtime_status(app: &AppHandle, event_name: &str, status: AgentRuntimeStatus) { if event_name.trim().is_empty() { return; } - let event = TauriAgentEvent::RuntimeStatus { status }; + let event = RuntimeAgentEvent::RuntimeStatus { status }; if let Err(error) = app.emit(event_name, &event) { tracing::warn!( "[AsterAgent] 发送 team runtime 状态失败: event_name={}, error={}", @@ -2143,4 +1994,38 @@ mod tests { .await .expect("清理测试会话失败"); } + + #[test] + fn should_skip_artifact_document_autopersist_when_output_is_empty() { + let observation = Arc::new(Mutex::new(ChatRunObservation::default())); + + assert!(should_skip_artifact_document_autopersist( + &observation, + " \n " + )); + } + + #[test] + fn should_skip_artifact_document_autopersist_when_runtime_observation_has_artifacts() { + let observation = Arc::new(Mutex::new(ChatRunObservation::default())); + observation + .lock() + .expect("lock observation") + .record_artifact_path("social-posts/demo.md".to_string(), None); + + assert!(should_skip_artifact_document_autopersist( + &observation, + "普通正文输出" + )); + } + + #[test] + fn should_not_skip_artifact_document_autopersist_based_on_write_file_text_only() { + let observation = Arc::new(Mutex::new(ChatRunObservation::default())); + + assert!(!should_skip_artifact_document_autopersist( + &observation, + "内容" + )); + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs index 0952363da..9ca2f10ed 100644 --- a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -1,5 +1,6 @@ use super::*; use lime_agent::restore_aster_runtime_queued_turns; +use lime_agent::AgentEvent as RuntimeAgentEvent; const SUBAGENT_RUNTIME_EVENT_PREFIX: &str = "agent_subagent_stream"; const SUBAGENT_STATUS_EVENT_PREFIX: &str = "agent_subagent_status"; @@ -325,17 +326,17 @@ fn parse_subagent_runtime_event_session_id(event_name: &str) -> Option<&str> { .and_then(|rest| rest.strip_prefix(':')) } -fn should_emit_subagent_status_for_runtime_event(event: &TauriAgentEvent) -> bool { +fn should_emit_subagent_status_for_runtime_event(event: &RuntimeAgentEvent) -> bool { matches!( event, - TauriAgentEvent::ThreadStarted { .. } - | TauriAgentEvent::TurnStarted { .. } - | TauriAgentEvent::TurnCompleted { .. } - | TauriAgentEvent::TurnFailed { .. } - | TauriAgentEvent::QueueAdded { .. } - | TauriAgentEvent::QueueRemoved { .. } - | TauriAgentEvent::QueueStarted { .. } - | TauriAgentEvent::QueueCleared { .. } + RuntimeAgentEvent::ThreadStarted { .. } + | RuntimeAgentEvent::TurnStarted { .. } + | RuntimeAgentEvent::TurnCompleted { .. } + | RuntimeAgentEvent::TurnFailed { .. } + | RuntimeAgentEvent::QueueAdded { .. } + | RuntimeAgentEvent::QueueRemoved { .. } + | RuntimeAgentEvent::QueueStarted { .. } + | RuntimeAgentEvent::QueueCleared { .. } ) } @@ -379,7 +380,7 @@ pub(crate) async fn emit_subagent_status_changed_events(app: &AppHandle, session pub(crate) async fn maybe_emit_subagent_status_for_runtime_event( app: &AppHandle, event_name: &str, - event: &TauriAgentEvent, + event: &RuntimeAgentEvent, ) { let Some(session_id) = parse_subagent_runtime_event_session_id(event_name) else { return; @@ -678,6 +679,9 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal( event_name: build_subagent_runtime_event_name(&child_session_id), images: None, provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, project_id: None, workspace_id, web_search: None, @@ -751,6 +755,9 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( event_name: build_subagent_runtime_event_name(&session_id), images: None, provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, project_id: None, workspace_id, web_search: None, diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 1292b0d6c..aacc6171a 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -1,9 +1,10 @@ #[cfg(test)] mod tests { use super::*; - use async_trait::async_trait; use crate::commands::aster_agent_cmd::action_runtime::build_runtime_action_scope; use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope; + use async_trait::async_trait; + use lime_agent::AgentEvent as RuntimeAgentEvent; use lime_agent::request_tool_policy::resolve_request_tool_policy; use regex::Regex; use std::ffi::OsString; @@ -530,6 +531,9 @@ mod tests { "turn_config": { "execution_strategy": "auto", "web_search": true, + "provider_preference": "custom-provider", + "model_preference": "gpt-5.3-codex", + "thinking_enabled": true, "system_prompt": "runtime prompt", "provider_config": { "provider_id": "custom-provider", @@ -554,6 +558,12 @@ mod tests { Some(AsterExecutionStrategy::Auto) ); assert_eq!(mapped.web_search, Some(true)); + assert_eq!( + mapped.provider_preference.as_deref(), + Some("custom-provider") + ); + assert_eq!(mapped.model_preference.as_deref(), Some("gpt-5.3-codex")); + assert_eq!(mapped.thinking_enabled, Some(true)); assert_eq!(mapped.system_prompt.as_deref(), Some("runtime prompt")); assert_eq!( mapped @@ -750,6 +760,17 @@ mod tests { assert_eq!(path.as_deref(), Some("social-posts/demo.md")); } + #[test] + fn test_extract_artifact_path_from_tool_start_reads_nested_artifact_protocol_path() { + let path = extract_artifact_path_from_tool_start( + "write_file", + Some(r##"{"payload":{"artifact_paths":["social-posts\\nested.md"]}}"##), + "/tmp/workspace", + ); + + assert_eq!(path.as_deref(), Some("social-posts/nested.md")); + } + #[test] fn test_resolve_social_run_artifact_descriptor_matches_social_draft() { let descriptor = resolve_social_run_artifact_descriptor( @@ -773,6 +794,9 @@ mod tests { event_name: "event-1".to_string(), images: None, provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, project_id: Some("project-1".to_string()), workspace_id: "workspace-1".to_string(), web_search: Some(false), @@ -839,15 +863,228 @@ mod tests { ); } + #[test] + fn test_extract_harness_bool_reads_nested_preferences() { + let metadata = serde_json::json!({ + "harness": { + "preferences": { + "web_search": true, + "thinking": true, + "task": false, + "subagent": true + } + } + }); + + assert_eq!( + extract_harness_bool(Some(&metadata), &["web_search_enabled", "webSearchEnabled"]), + Some(true) + ); + assert_eq!( + extract_harness_bool(Some(&metadata), &["thinking_enabled", "thinkingEnabled"]), + Some(true) + ); + assert_eq!( + extract_harness_bool(Some(&metadata), &["task_mode_enabled", "taskModeEnabled"]), + Some(false) + ); + assert_eq!( + extract_harness_bool( + Some(&metadata), + &["subagent_mode_enabled", "subagentModeEnabled"] + ), + Some(true) + ); + } + + #[test] + fn test_build_chat_run_metadata_base_flattens_nested_preferences() { + let metadata = build_chat_run_metadata_base( + &AsterChatRequest { + message: "hello".to_string(), + session_id: "session-1".to_string(), + event_name: "event-1".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + project_id: Some("project-1".to_string()), + workspace_id: "workspace-1".to_string(), + web_search: Some(false), + search_mode: None, + execution_strategy: Some(AsterExecutionStrategy::React), + auto_continue: None, + system_prompt: None, + metadata: Some(serde_json::json!({ + "harness": { + "theme": "general", + "preferences": { + "thinking": true, + "task": false, + "subagent": true + } + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }, + "workspace-1", + AsterExecutionStrategy::React, + &RequestToolPolicy { + search_mode: RequestToolPolicyMode::Disabled, + effective_web_search: false, + required_tools: vec![], + allowed_tools: vec![], + disallowed_tools: vec![], + }, + false, + None, + ); + + assert_eq!( + metadata + .get("thinking_enabled") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + metadata + .get("task_mode_enabled") + .and_then(serde_json::Value::as_bool), + Some(false) + ); + assert_eq!( + metadata + .get("subagent_mode_enabled") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + } + + #[test] + fn test_chat_run_observation_records_nested_artifact_protocol_paths_from_tool_result() { + let mut observation = ChatRunObservation::default(); + observation.record_event( + &RuntimeAgentEvent::ToolEnd { + tool_id: "tool-1".to_string(), + result: lime_agent::AgentToolResult { + success: true, + output: "done".to_string(), + error: None, + images: None, + metadata: Some(HashMap::from([( + "payload".to_string(), + serde_json::json!({ + "artifact_paths": [" /tmp/workspace/social-posts\\final.md "] + }), + )])), + }, + }, + "/tmp/workspace", + Some(&serde_json::json!({ + "harness": { + "theme": "social-media", + "gate_key": "write_mode" + } + })), + ProviderContinuationCapability::HistoryReplayOnly, + ); + + assert_eq!( + observation.artifact_paths, + vec!["social-posts/final.md".to_string()] + ); + assert_eq!( + observation + .primary_social_artifact + .as_ref() + .map(|artifact| artifact.source_file_name.as_str()), + Some("social-posts/final.md") + ); + } + + #[test] + fn test_chat_run_observation_ignores_output_file_log_hint_without_explicit_artifact_path() { + let mut observation = ChatRunObservation::default(); + observation.record_event( + &RuntimeAgentEvent::ToolEnd { + tool_id: "tool-1".to_string(), + result: lime_agent::AgentToolResult { + success: true, + output: "done".to_string(), + error: None, + images: None, + metadata: Some(HashMap::from([( + "output_file".to_string(), + serde_json::json!("/tmp/workspace/tasks/task.log"), + )])), + }, + }, + "/tmp/workspace", + Some(&serde_json::json!({ + "harness": { + "theme": "social-media", + "gate_key": "write_mode" + } + })), + ProviderContinuationCapability::HistoryReplayOnly, + ); + + assert!(observation.artifact_paths.is_empty()); + assert!(observation.primary_social_artifact.is_none()); + } + + #[test] + fn test_chat_run_observation_falls_back_to_probable_output_file_artifact_hint() { + let mut observation = ChatRunObservation::default(); + observation.record_event( + &RuntimeAgentEvent::ToolEnd { + tool_id: "tool-1".to_string(), + result: lime_agent::AgentToolResult { + success: true, + output: "done".to_string(), + error: None, + images: None, + metadata: Some(HashMap::from([( + "output_file".to_string(), + serde_json::json!("/tmp/workspace/social-posts/final.md"), + )])), + }, + }, + "/tmp/workspace", + Some(&serde_json::json!({ + "harness": { + "theme": "social-media", + "gate_key": "write_mode" + } + })), + ProviderContinuationCapability::HistoryReplayOnly, + ); + + assert_eq!( + observation.artifact_paths, + vec!["social-posts/final.md".to_string()] + ); + assert_eq!( + observation + .primary_social_artifact + .as_ref() + .map(|artifact| artifact.source_file_name.as_str()), + Some("social-posts/final.md") + ); + } + #[test] fn test_chat_run_observation_records_previous_response_id_from_message_event() { let mut observation = ChatRunObservation::default(); observation.record_event( - &TauriAgentEvent::Message { - message: TauriMessage { + &RuntimeAgentEvent::Message { + message: lime_agent::AgentMessage { id: Some("resp-1".to_string()), role: "assistant".to_string(), - content: vec![TauriMessageContent::Text { + content: vec![lime_agent::AgentMessageContent::Text { text: "hello".to_string(), }], timestamp: 0, @@ -868,11 +1105,11 @@ mod tests { fn test_chat_run_observation_records_provider_session_token_from_message_event() { let mut observation = ChatRunObservation::default(); observation.record_event( - &TauriAgentEvent::Message { - message: TauriMessage { + &RuntimeAgentEvent::Message { + message: lime_agent::AgentMessage { id: Some("conv-1".to_string()), role: "assistant".to_string(), - content: vec![TauriMessageContent::Text { + content: vec![lime_agent::AgentMessageContent::Text { text: "hello".to_string(), }], timestamp: 0, @@ -1308,58 +1545,30 @@ mod tests { } #[test] - fn test_build_team_preference_system_prompt_renders_turn_team_contract() { + fn test_build_team_preference_system_prompt_emphasizes_parent_coordination() { let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({ "harness": { "subagent_mode_enabled": true, - "turn_team_decision": "team_prepared", - "turn_team_reason": "runtime_team_prepared", - "turn_team_blueprint": { - "label": "当前调试 Team", - "description": "先分析,再实现,最后验证。", - "roles": [ - { - "id": "runtime-explorer", - "label": "分析", - "summary": "负责定位问题。", - "profile_id": "code-explorer", - "role_key": "explorer", - "skill_ids": ["repo-exploration"] - }, - { - "label": "执行", - "summary": "负责提交修复。" - } - ] - } + "selected_team_label": "当前调试 Team", + "selected_team_roles": [ + { + "id": "runtime-explorer", + "label": "分析", + "summary": "负责定位问题。", + "profile_id": "code-explorer", + "role_key": "explorer", + "skill_ids": ["repo-exploration"] + } + ] } }))) .expect("team prompt should exist"); - assert!(prompt.contains("发送前已经准备好协作分工")); assert!(prompt.contains("当前调试 Team")); - assert!(prompt.contains("先分析,再实现,最后验证。")); assert!(prompt.contains("分析:负责定位问题。")); - assert!(prompt.contains("id: runtime-explorer")); assert!(prompt.contains("blueprintRoleId")); - assert!(prompt.contains("主对话要像项目助理一样汇总目标")); - assert!(prompt.contains("不要等主 agent 完整处理结束后再补做 team")); - } - - #[test] - fn test_build_team_preference_system_prompt_renders_single_agent_turn_decision() { - let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({ - "harness": { - "subagent_mode_enabled": true, - "turn_team_decision": "single_agent", - "turn_team_reason": "single_agent_direct", - } - }))) - .expect("team prompt should exist"); - - assert!(prompt.contains("当前任务没有在 GUI 中提前准备 Team")); - assert!(prompt.contains("主助手直接处理更合适")); - assert!(prompt.contains("不要为了形式化 team 而推迟主任务")); + assert!(prompt.contains("主对话需要承担协调职责")); + assert!(prompt.contains("主动汇总关键进展、风险和下一步")); } #[test] @@ -1442,126 +1651,6 @@ mod tests { assert!(prompt.contains("输出问题定位、证据与影响面。")); } - #[test] - fn test_parse_runtime_prepared_team_roles_from_metadata() { - let roles = parse_runtime_prepared_team_roles(Some(&serde_json::json!({ - "harness": { - "turn_team_decision": "team_prepared", - "turn_team_blueprint": { - "roles": [ - { - "id": "runtime-explorer", - "label": "分析", - "summary": "负责定位问题。", - "profile_id": "code-explorer", - "role_key": "explorer", - "skill_ids": ["repo-exploration", "repo-exploration", " "] - }, - { - "label": "执行 角色", - "summary": "负责提交修复。" - }, - { - "id": "runtime-explorer", - "label": "重复分析" - } - ] - } - } - }))); - - assert_eq!(roles.len(), 2); - assert_eq!(roles[0].id, "runtime-explorer"); - assert_eq!(roles[0].label, "分析"); - assert_eq!(roles[0].profile_id.as_deref(), Some("code-explorer")); - assert_eq!(roles[0].role_key.as_deref(), Some("explorer")); - assert_eq!(roles[0].skill_ids, vec!["repo-exploration".to_string()]); - - assert_eq!(roles[1].id, "lane-执行-角色"); - assert_eq!(roles[1].label, "执行 角色"); - assert_eq!(roles[1].summary.as_deref(), Some("负责提交修复。")); - } - - #[test] - fn test_plan_runtime_prepared_team_actions_skips_existing_and_resumes_closed_lane() { - let roles = vec![ - RuntimePreparedTeamRole { - id: "runtime-explorer".to_string(), - label: "分析".to_string(), - summary: Some("负责定位问题。".to_string()), - profile_id: Some("code-explorer".to_string()), - role_key: Some("explorer".to_string()), - skill_ids: vec!["repo-exploration".to_string()], - }, - RuntimePreparedTeamRole { - id: "runtime-executor".to_string(), - label: "执行".to_string(), - summary: Some("负责提交修复。".to_string()), - profile_id: Some("code-executor".to_string()), - role_key: Some("executor".to_string()), - skill_ids: vec![], - }, - RuntimePreparedTeamRole { - id: "runtime-verifier".to_string(), - label: "验证".to_string(), - summary: Some("负责回归验证。".to_string()), - profile_id: Some("code-verifier".to_string()), - role_key: Some("verifier".to_string()), - skill_ids: vec![], - }, - ]; - let existing_candidates = vec![ - RuntimePreparedTeamSessionCandidate { - blueprint_role_id: "runtime-explorer".to_string(), - session_id: "child-explorer".to_string(), - status_kind: SubagentRuntimeStatusKind::Running, - }, - RuntimePreparedTeamSessionCandidate { - blueprint_role_id: "runtime-executor".to_string(), - session_id: "child-executor-old".to_string(), - status_kind: SubagentRuntimeStatusKind::Closed, - }, - RuntimePreparedTeamSessionCandidate { - blueprint_role_id: "runtime-executor".to_string(), - session_id: "child-executor-stale".to_string(), - status_kind: SubagentRuntimeStatusKind::NotFound, - }, - ]; - - let actions = plan_runtime_prepared_team_actions(&roles, &existing_candidates); - assert_eq!(actions.len(), 2); - - assert!(matches!( - &actions[0], - RuntimePreparedTeamAction::Resume { role, session_id } - if role.id == "runtime-executor" && session_id == "child-executor-old" - )); - assert!(matches!( - &actions[1], - RuntimePreparedTeamAction::Spawn(role) if role.id == "runtime-verifier" - )); - } - - #[test] - fn test_build_runtime_prepared_team_spawn_message_emphasizes_lane_output() { - let message = build_runtime_prepared_team_spawn_message( - &RuntimePreparedTeamRole { - id: "runtime-explorer".to_string(), - label: "分析".to_string(), - summary: Some("负责定位问题。".to_string()), - profile_id: Some("code-explorer".to_string()), - role_key: Some("explorer".to_string()), - skill_ids: vec!["repo-exploration".to_string()], - }, - "修正 team runtime 的执行顺序", - ); - - assert!(message.contains("当前子会话输出")); - assert!(message.contains("修正 team runtime 的执行顺序")); - assert!(message.contains("负责定位问题。")); - assert!(message.contains("不要把具体产出留给父会话代写")); - } - #[test] fn test_normalize_shell_command_params_accepts_cmd_alias() { let input = serde_json::json!({ @@ -1867,15 +1956,16 @@ mod tests { model: None, working_dir: None, workspace_id: None, - messages: vec![TauriMessage { + messages: vec![lime_agent::AgentMessage { id: None, role: "assistant".to_string(), - content: vec![TauriMessageContent::Text { + content: vec![lime_agent::AgentMessageContent::Text { text: "子代理最终结论".to_string(), }], timestamp: 0, }], execution_strategy: None, + execution_runtime: None, turns: vec![], items: vec![], todo_items: vec![], @@ -1902,6 +1992,7 @@ mod tests { workspace_id: None, messages: vec![], execution_strategy: None, + execution_runtime: None, turns: vec![lime_core::database::dao::agent_timeline::AgentThreadTurn { id: "turn-1".to_string(), thread_id: "thread-2".to_string(), diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs index 360205872..ea98223f1 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -6,6 +6,8 @@ mod browser_tools; mod creation_tools; #[path = "tool_runtime/search_bridge.rs"] mod search_bridge; +#[path = "tool_runtime/site_tools.rs"] +mod site_tools; #[path = "tool_runtime/social_tools.rs"] pub(crate) mod social_tools; #[path = "tool_runtime/subagent_tools.rs"] @@ -73,8 +75,10 @@ fn sync_workspace_mode_native_tool_surface( ) { if surface.browser_assist { browser_tools::register_browser_mcp_tools_to_registry(registry); + site_tools::register_site_tools_to_registry(registry, db.clone()); } else { browser_tools::unregister_browser_mcp_tools_from_registry(registry); + site_tools::unregister_site_tools_from_registry(registry); } if surface.creator { diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs new file mode 100644 index 000000000..54619cf1b --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs @@ -0,0 +1,1020 @@ +use super::*; +#[cfg(test)] +use crate::services::site_capability_service::{ + build_site_result_document_body, save_site_result_to_project, +}; +use crate::services::site_capability_service::{ + get_site_adapter, list_site_adapters, run_site_adapter_with_optional_save, + search_site_adapters, RunSiteAdapterRequest, +}; +#[cfg(test)] +use crate::services::site_capability_service::{SiteAdapterDefinition, SiteAdapterRunResult}; +use aster::session::{load_shared_session_runtime_snapshot, SessionRuntimeSnapshot}; +use serde_json::Value; + +const PROJECT_ID_ENV_KEYS: &[&str] = &["LIME_PROJECT_ID", "PROXYCAST_PROJECT_ID"]; + +#[derive(Debug, Clone, Copy)] +enum LimeSiteToolKind { + List, + Search, + Info, + Run, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LimeSiteProjectSource { + ExplicitProject, + ContextProject, +} + +impl LimeSiteProjectSource { + fn as_str(self) -> &'static str { + match self { + LimeSiteProjectSource::ExplicitProject => "explicit_project", + LimeSiteProjectSource::ContextProject => "context_project", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LimeSiteProjectTarget { + project_id: String, + source: LimeSiteProjectSource, +} + +#[derive(Debug, Clone)] +pub(crate) struct LimeSiteTool { + tool_name: String, + description: String, + input_schema: serde_json::Value, + kind: LimeSiteToolKind, + db: DbConnection, +} + +impl LimeSiteTool { + fn new( + tool_name: String, + description: impl Into, + input_schema: serde_json::Value, + kind: LimeSiteToolKind, + db: DbConnection, + ) -> Self { + Self { + tool_name, + description: description.into(), + input_schema, + kind, + db, + } + } + + fn extract_required_string( + params: &serde_json::Value, + keys: &[&str], + field_name: &str, + ) -> Result { + keys.iter() + .find_map(|key| { + params + .get(*key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .ok_or_else(|| ToolError::invalid_params(format!("{field_name} 不能为空"))) + } + + fn extract_optional_string(params: &serde_json::Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + params + .get(*key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + } + + fn extract_profile_key( + params: &serde_json::Value, + context: &ToolContext, + session_hint: Option<&BrowserAssistRuntimeHint>, + ) -> Option { + Self::extract_optional_string(params, &["profile_key"]) + .or_else(|| session_hint.map(|hint| hint.profile_key.clone())) + .or_else(|| { + context + .environment + .get(BROWSER_PROFILE_KEY_ENV_KEYS[0]) + .cloned() + }) + .or_else(|| { + context + .environment + .get(BROWSER_PROFILE_KEY_ENV_KEYS[1]) + .cloned() + }) + } + + fn build_list_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }) + } + + fn build_search_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "搜索适配器的关键词,可按站点名、域名或能力搜索" + } + }, + "required": ["query"], + "additionalProperties": false, + }) + } + + fn build_info_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "adapter_name": { + "type": "string", + "description": "适配器名称,例如 github/search" + } + }, + "required": ["adapter_name"], + "additionalProperties": false, + }) + } + + fn build_run_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "adapter_name": { + "type": "string", + "description": "适配器名称,例如 zhihu/search" + }, + "args": { + "type": "object", + "description": "适配器参数对象" + }, + "profile_key": { + "type": "string", + "description": "浏览器资料 Key,可选;未传时优先复用当前 browser assist 会话" + }, + "target_id": { + "type": "string", + "description": "可选的指定标签页 target_id" + }, + "timeout_ms": { + "type": "integer", + "description": "脚本执行超时时间,毫秒" + }, + "project_id": { + "type": "string", + "description": "可选项目 ID;未传时优先复用当前项目上下文,成功后会保存为资源文档" + }, + "save_title": { + "type": "string", + "description": "可选保存标题;仅在存在保存目标时生效" + } + }, + "required": ["adapter_name"], + "additionalProperties": false, + }) + } + + fn extract_project_id_from_value(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + } + + fn extract_project_id_from_metadata_map( + metadata: &HashMap, + ) -> Option { + ["project_id", "projectId"] + .iter() + .find_map(|key| Self::extract_project_id_from_value(metadata.get(*key))) + } + + fn extract_project_id_from_runtime_snapshot( + snapshot: &SessionRuntimeSnapshot, + ) -> Option { + snapshot + .threads + .iter() + .flat_map(|thread| thread.turns.iter()) + .filter_map(|turn| { + let project_id = turn.context_override.as_ref().and_then(|context| { + Self::extract_project_id_from_metadata_map(&context.metadata) + })?; + Some((turn.updated_at, project_id)) + }) + .max_by_key(|(updated_at, _)| *updated_at) + .map(|(_, project_id)| project_id) + .or_else(|| { + snapshot + .threads + .iter() + .filter_map(|thread| { + let project_id = + Self::extract_project_id_from_metadata_map(&thread.thread.metadata)?; + Some((thread.thread.updated_at, project_id)) + }) + .max_by_key(|(updated_at, _)| *updated_at) + .map(|(_, project_id)| project_id) + }) + } + + fn extract_project_id_from_context_environment(context: &ToolContext) -> Option { + PROJECT_ID_ENV_KEYS.iter().find_map(|key| { + context + .environment + .get(*key) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + } + + async fn resolve_context_project_id(context: &ToolContext) -> Option { + let session_id = context.session_id.trim(); + if !session_id.is_empty() { + match load_shared_session_runtime_snapshot(session_id).await { + Ok(snapshot) => { + if let Some(project_id) = + Self::extract_project_id_from_runtime_snapshot(&snapshot) + { + return Some(project_id); + } + } + Err(error) => { + tracing::debug!( + "[AsterAgent][SiteTool] 读取 runtime snapshot 失败,跳过上下文项目解析: session_id={}, error={}", + session_id, + error + ); + } + } + } + + Self::extract_project_id_from_context_environment(context) + } + + async fn resolve_project_target( + params: &serde_json::Value, + context: &ToolContext, + ) -> Option { + if let Some(project_id) = Self::extract_optional_string(params, &["project_id"]) { + return Some(LimeSiteProjectTarget { + project_id, + source: LimeSiteProjectSource::ExplicitProject, + }); + } + + Self::resolve_context_project_id(context) + .await + .map(|project_id| LimeSiteProjectTarget { + project_id, + source: LimeSiteProjectSource::ContextProject, + }) + } + + fn apply_project_target_to_run_result( + mut result: crate::services::site_capability_service::SiteAdapterRunResult, + project_target: Option<&LimeSiteProjectTarget>, + ) -> crate::services::site_capability_service::SiteAdapterRunResult { + let Some(project_target) = project_target else { + return result; + }; + + let normalized_source = project_target.source.as_str().to_string(); + if result.saved_project_id.is_some() { + result.saved_by = Some(normalized_source.clone()); + } + if result.save_skipped_project_id.is_some() { + result.save_skipped_by = Some(normalized_source); + } + + result + } + + fn attach_run_result_metadata( + mut tool_result: ToolResult, + result: &crate::services::site_capability_service::SiteAdapterRunResult, + adapter_definition: Option< + &crate::services::site_capability_service::SiteAdapterDefinition, + >, + browser_session: Option, + ) -> ToolResult { + tool_result = tool_result + .with_metadata("tool_family", serde_json::json!("site")) + .with_metadata("adapter_name", serde_json::json!(result.adapter.clone())) + .with_metadata("result", serde_json::json!(result.clone())); + + if let Some(saved_content) = result.saved_content.clone() { + tool_result = + tool_result.with_metadata("saved_content", serde_json::json!(saved_content)); + } + if let Some(saved_project_id) = result.saved_project_id.as_ref() { + tool_result = + tool_result.with_metadata("saved_project_id", serde_json::json!(saved_project_id)); + } + if let Some(saved_by) = result.saved_by.as_ref() { + tool_result = tool_result.with_metadata("saved_by", serde_json::json!(saved_by)); + } + if let Some(save_skipped_project_id) = result.save_skipped_project_id.as_ref() { + tool_result = tool_result.with_metadata( + "save_skipped_project_id", + serde_json::json!(save_skipped_project_id), + ); + } + if let Some(save_skipped_by) = result.save_skipped_by.as_ref() { + tool_result = + tool_result.with_metadata("save_skipped_by", serde_json::json!(save_skipped_by)); + } + if let Some(save_error_message) = result.save_error_message.as_ref() { + tool_result = tool_result + .with_metadata("save_error_message", serde_json::json!(save_error_message)); + } + if let Some(adapter) = adapter_definition { + if let Some(source_kind) = adapter.source_kind.as_ref() { + tool_result = tool_result + .with_metadata("adapter_source_kind", serde_json::json!(source_kind)); + } + if let Some(source_version) = adapter.source_version.as_ref() { + tool_result = tool_result + .with_metadata("adapter_source_version", serde_json::json!(source_version)); + } + } + if let Some(browser_session) = browser_session { + tool_result = tool_result.with_metadata("browser_session", browser_session); + } + + tool_result + } +} + +#[async_trait] +impl Tool for LimeSiteTool { + fn name(&self) -> &str { + &self.tool_name + } + + fn description(&self) -> &str { + &self.description + } + + fn input_schema(&self) -> serde_json::Value { + self.input_schema.clone() + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(1) + .with_base_timeout(Duration::from_secs(90)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let session_hint = get_browser_assist_runtime_hint(&context.session_id).await; + match self.kind { + LimeSiteToolKind::List => { + let result = list_site_adapters(); + let payload = serde_json::to_string_pretty(&result).map_err(|error| { + ToolError::execution_failed(format!("序列化适配器列表失败: {error}")) + })?; + Ok(ToolResult::success(payload) + .with_metadata("tool_family", serde_json::json!("site")) + .with_metadata("result", serde_json::json!(result))) + } + LimeSiteToolKind::Search => { + let query = Self::extract_required_string(¶ms, &["query"], "query")?; + let result = search_site_adapters(&query); + let payload = serde_json::to_string_pretty(&result).map_err(|error| { + ToolError::execution_failed(format!("序列化适配器搜索结果失败: {error}")) + })?; + Ok(ToolResult::success(payload) + .with_metadata("tool_family", serde_json::json!("site")) + .with_metadata("query", serde_json::json!(query)) + .with_metadata("result", serde_json::json!(result))) + } + LimeSiteToolKind::Info => { + let adapter_name = Self::extract_required_string( + ¶ms, + &["adapter_name", "name"], + "adapter_name", + )?; + let result = get_site_adapter(&adapter_name).ok_or_else(|| { + ToolError::invalid_params("未找到对应的站点适配器".to_string()) + })?; + let payload = serde_json::to_string_pretty(&result).map_err(|error| { + ToolError::execution_failed(format!("序列化适配器详情失败: {error}")) + })?; + Ok(ToolResult::success(payload) + .with_metadata("tool_family", serde_json::json!("site")) + .with_metadata("adapter_name", serde_json::json!(adapter_name)) + .with_metadata("result", serde_json::json!(result))) + } + LimeSiteToolKind::Run => { + let adapter_name = Self::extract_required_string( + ¶ms, + &["adapter_name", "name"], + "adapter_name", + )?; + let adapter_definition = get_site_adapter(&adapter_name); + let profile_key = + Self::extract_profile_key(¶ms, context, session_hint.as_ref()); + let args = params + .get("args") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let target_id = Self::extract_optional_string(¶ms, &["target_id"]); + let timeout_ms = params.get("timeout_ms").and_then(serde_json::Value::as_u64); + let project_target = Self::resolve_project_target(¶ms, context).await; + let save_title = Self::extract_optional_string(¶ms, &["save_title"]); + let run_request = RunSiteAdapterRequest { + adapter_name: adapter_name.clone(), + args, + profile_key, + target_id, + timeout_ms, + project_id: project_target + .as_ref() + .map(|target| target.project_id.clone()), + save_title, + }; + + let result = Self::apply_project_target_to_run_result( + run_site_adapter_with_optional_save(&self.db, run_request.clone()).await, + project_target.as_ref(), + ); + let browser_session = match (&result.session_id, &result.target_id) { + (Some(session_id), Some(target_id)) => Some(serde_json::json!({ + "session_id": session_id, + "target_id": target_id, + "profile_key": result.profile_key, + })), + _ => None, + }; + let payload = serde_json::to_string_pretty(&result).map_err(|error| { + ToolError::execution_failed(format!("序列化站点执行结果失败: {error}")) + })?; + + if result.ok { + Ok(Self::attach_run_result_metadata( + ToolResult::success(payload), + &result, + adapter_definition.as_ref(), + browser_session, + )) + } else { + Ok(Self::attach_run_result_metadata( + ToolResult::error( + result + .error_message + .clone() + .unwrap_or_else(|| "站点命令执行失败".to_string()), + ), + &result, + adapter_definition.as_ref(), + browser_session, + )) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{ContentManager, ContentType}; + use crate::database::schema::create_tables; + use crate::workspace::{WorkspaceManager, WorkspaceType}; + use aster::session::{ + SessionRuntimeSnapshot, ThreadRuntime, ThreadRuntimeSnapshot, TurnContextOverride, + TurnRuntime, + }; + use chrono::{Duration as ChronoDuration, Utc}; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + use tempfile::tempdir; + + fn setup_test_db() -> DbConnection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + Arc::new(Mutex::new(conn)) + } + + #[test] + fn should_include_project_save_fields_in_run_schema() { + let schema = LimeSiteTool::build_run_schema(); + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("properties should exist"); + + assert!(properties.contains_key("project_id")); + assert!(properties.contains_key("save_title")); + } + + #[test] + fn should_build_site_result_document_body_with_sections() { + let adapter = SiteAdapterDefinition { + name: "github/search".to_string(), + domain: "github.com".to_string(), + description: "按关键词采集 GitHub 仓库搜索结果。".to_string(), + read_only: true, + capabilities: vec!["search".to_string()], + input_schema: serde_json::json!({}), + example_args: serde_json::json!({"query":"mcp","limit":5}), + example: "github/search {\"query\":\"mcp\"}".to_string(), + auth_hint: Some("请先登录 GitHub。".to_string()), + source_kind: Some("bundled".to_string()), + source_version: Some("2026-03-25".to_string()), + }; + let request = RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({"query":"mcp","limit":5}), + profile_key: Some("general_browser_assist".to_string()), + target_id: None, + timeout_ms: Some(20_000), + project_id: None, + save_title: None, + }; + let result = SiteAdapterRunResult { + ok: true, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: Some("session-1".to_string()), + target_id: Some("target-1".to_string()), + entry_url: "https://github.com/search?q=mcp&type=repositories".to_string(), + source_url: Some("https://github.com/search?q=mcp&type=repositories".to_string()), + data: Some(serde_json::json!({ + "items": [ + {"title": "modelcontextprotocol/servers"} + ] + })), + error_code: None, + error_message: None, + auth_hint: Some("请先登录 GitHub。".to_string()), + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }; + + let body = build_site_result_document_body(&adapter, &request, &result); + + assert!(body.contains("# 站点采集结果")); + assert!(body.contains("## 执行参数")); + assert!(body.contains("\"query\": \"mcp\"")); + assert!(body.contains("## 结构化结果")); + assert!(body.contains("modelcontextprotocol/servers")); + assert!(body.contains("## 登录提示")); + } + + #[test] + fn should_save_site_result_to_project_as_document_content() { + let db = setup_test_db(); + let workspace_root = tempdir().expect("创建临时目录失败"); + let workspace = WorkspaceManager::new(db.clone()) + .create_with_type( + "站点采集项目".to_string(), + workspace_root.path().join("site-adapter-project"), + WorkspaceType::Document, + ) + .expect("创建测试项目失败"); + let adapter = SiteAdapterDefinition { + name: "github/search".to_string(), + domain: "github.com".to_string(), + description: "按关键词采集 GitHub 仓库搜索结果。".to_string(), + read_only: true, + capabilities: vec!["search".to_string()], + input_schema: serde_json::json!({}), + example_args: serde_json::json!({"query":"mcp","limit":5}), + example: "github/search {\"query\":\"mcp\"}".to_string(), + auth_hint: Some("请先登录 GitHub。".to_string()), + source_kind: Some("bundled".to_string()), + source_version: Some("2026-03-25".to_string()), + }; + let request = RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({"query":"mcp","limit":5}), + profile_key: Some("general_browser_assist".to_string()), + target_id: Some("target-1".to_string()), + timeout_ms: Some(20_000), + project_id: None, + save_title: None, + }; + let result = SiteAdapterRunResult { + ok: true, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: Some("session-1".to_string()), + target_id: Some("target-1".to_string()), + entry_url: "https://github.com/search?q=mcp&type=repositories".to_string(), + source_url: Some("https://github.com/search?q=mcp&type=repositories".to_string()), + data: Some(serde_json::json!({ + "items": [ + {"title": "modelcontextprotocol/servers"} + ] + })), + error_code: None, + error_message: None, + auth_hint: Some("请先登录 GitHub。".to_string()), + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }; + + let saved_content = save_site_result_to_project( + &db, + &workspace.id, + Some("GitHub MCP 搜索结果"), + &adapter, + &request, + &result, + ) + .expect("保存站点结果到项目失败"); + let manager = ContentManager::new(db); + let contents = manager + .list_by_project(&workspace.id, None) + .expect("读取项目内容失败"); + + assert_eq!(contents.len(), 1); + + let content = &contents[0]; + assert_eq!(content.id, saved_content.content_id); + assert_eq!(content.project_id, workspace.id); + assert_eq!(content.title, "GitHub MCP 搜索结果"); + assert_eq!(content.content_type, ContentType::Document); + assert!(content.body.contains("# 站点采集结果")); + assert!(content.body.contains("\"query\": \"mcp\"")); + assert!(content.body.contains("modelcontextprotocol/servers")); + + let metadata = content.metadata.as_ref().expect("应写入 metadata"); + assert_eq!( + metadata.get("resourceKind"), + Some(&serde_json::json!("document")) + ); + assert_eq!( + metadata.get("siteAdapterName"), + Some(&serde_json::json!("github/search")) + ); + assert_eq!( + metadata.get("siteAdapterDomain"), + Some(&serde_json::json!("github.com")) + ); + assert_eq!( + metadata.get("siteAdapterProfileKey"), + Some(&serde_json::json!("general_browser_assist")) + ); + assert_eq!( + metadata.get("siteAdapterEntryUrl"), + Some(&serde_json::json!( + "https://github.com/search?q=mcp&type=repositories" + )) + ); + assert_eq!( + metadata.get("siteAdapterSourceUrl"), + Some(&serde_json::json!( + "https://github.com/search?q=mcp&type=repositories" + )) + ); + } + + #[test] + fn should_extract_latest_project_id_from_runtime_snapshot() { + let now = Utc::now(); + let mut older_turn = TurnRuntime::new( + "turn-older", + "session-1", + "thread-1", + Some("旧 turn".to_string()), + Some(TurnContextOverride { + metadata: HashMap::from([( + "project_id".to_string(), + serde_json::json!("project-older"), + )]), + ..TurnContextOverride::default() + }), + ); + older_turn.updated_at = now; + + let mut latest_turn = TurnRuntime::new( + "turn-latest", + "session-1", + "thread-1", + Some("新 turn".to_string()), + Some(TurnContextOverride { + metadata: HashMap::from([( + "project_id".to_string(), + serde_json::json!("project-current"), + )]), + ..TurnContextOverride::default() + }), + ); + latest_turn.updated_at = now + ChronoDuration::seconds(5); + + let mut thread = + ThreadRuntime::new("thread-1", "session-1", PathBuf::from("/tmp/site-runtime")); + thread.updated_at = latest_turn.updated_at; + + let snapshot = SessionRuntimeSnapshot { + session_id: "session-1".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread, + turns: vec![older_turn, latest_turn], + items: Vec::new(), + }], + }; + + let project_id = LimeSiteTool::extract_project_id_from_runtime_snapshot(&snapshot); + + assert_eq!(project_id.as_deref(), Some("project-current")); + } + + #[test] + fn should_extract_project_id_from_thread_metadata_when_turn_metadata_missing() { + let now = Utc::now(); + let mut thread = + ThreadRuntime::new("thread-1", "session-1", PathBuf::from("/tmp/site-runtime")); + thread.updated_at = now; + thread.metadata.insert( + "project_id".to_string(), + serde_json::json!("project-thread"), + ); + + let snapshot = SessionRuntimeSnapshot { + session_id: "session-1".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread, + turns: vec![TurnRuntime::new( + "turn-1", + "session-1", + "thread-1", + Some("无项目上下文".to_string()), + None, + )], + items: Vec::new(), + }], + }; + + let project_id = LimeSiteTool::extract_project_id_from_runtime_snapshot(&snapshot); + + assert_eq!(project_id.as_deref(), Some("project-thread")); + } + + #[test] + fn should_resolve_project_target_as_explicit_when_param_exists() { + let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败"); + let context = + ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1"); + + let target = runtime.block_on(LimeSiteTool::resolve_project_target( + &serde_json::json!({ + "project_id": "project-explicit" + }), + &context, + )); + + assert_eq!( + target, + Some(LimeSiteProjectTarget { + project_id: "project-explicit".to_string(), + source: LimeSiteProjectSource::ExplicitProject, + }) + ); + } + + #[test] + fn should_resolve_project_target_from_context_environment_when_runtime_missing() { + let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败"); + let mut context = + ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("missing"); + context.environment.insert( + "LIME_PROJECT_ID".to_string(), + "project-from-env".to_string(), + ); + + let target = runtime.block_on(LimeSiteTool::resolve_project_target( + &serde_json::json!({}), + &context, + )); + + assert_eq!( + target, + Some(LimeSiteProjectTarget { + project_id: "project-from-env".to_string(), + source: LimeSiteProjectSource::ContextProject, + }) + ); + } + + #[test] + fn should_rewrite_saved_source_as_context_project_when_result_comes_from_context() { + let result = SiteAdapterRunResult { + ok: true, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: Some("session-1".to_string()), + target_id: Some("target-1".to_string()), + entry_url: "https://github.com/search?q=mcp&type=repositories".to_string(), + source_url: Some("https://github.com/search?q=mcp&type=repositories".to_string()), + data: Some(serde_json::json!({ "items": [] })), + error_code: None, + error_message: None, + auth_hint: None, + saved_content: Some( + crate::services::site_capability_service::SavedSiteAdapterContent { + content_id: "content-1".to_string(), + project_id: "project-context".to_string(), + title: "GitHub MCP 搜索结果".to_string(), + }, + ), + saved_project_id: Some("project-context".to_string()), + saved_by: Some("explicit_project".to_string()), + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }; + + let normalized = LimeSiteTool::apply_project_target_to_run_result( + result, + Some(&LimeSiteProjectTarget { + project_id: "project-context".to_string(), + source: LimeSiteProjectSource::ContextProject, + }), + ); + + assert_eq!(normalized.saved_by.as_deref(), Some("context_project")); + } + + #[test] + fn should_rewrite_skipped_source_as_context_project_when_run_fails_in_context() { + let result = SiteAdapterRunResult { + ok: false, + adapter: "missing/adapter".to_string(), + domain: String::new(), + profile_key: "general_browser_assist".to_string(), + session_id: None, + target_id: None, + entry_url: String::new(), + source_url: None, + data: None, + error_code: Some("adapter_not_found".to_string()), + error_message: Some("未找到对应的站点适配器".to_string()), + auth_hint: None, + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: Some("project-context".to_string()), + save_skipped_by: Some("explicit_project".to_string()), + save_error_message: None, + }; + + let normalized = LimeSiteTool::apply_project_target_to_run_result( + result, + Some(&LimeSiteProjectTarget { + project_id: "project-context".to_string(), + source: LimeSiteProjectSource::ContextProject, + }), + ); + + assert_eq!( + normalized.save_skipped_by.as_deref(), + Some("context_project") + ); + } + + #[test] + fn should_expose_context_project_save_skip_metadata_when_site_run_fails() { + let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败"); + let tool = LimeSiteTool::new( + LIME_SITE_RUN_TOOL_NAME.to_string(), + "执行站点适配器", + LimeSiteTool::build_run_schema(), + LimeSiteToolKind::Run, + setup_test_db(), + ); + let mut context = + ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("missing"); + context.environment.insert( + "LIME_PROJECT_ID".to_string(), + "project-from-env".to_string(), + ); + + let result = runtime + .block_on(tool.execute( + serde_json::json!({ + "adapter_name": "missing/adapter" + }), + &context, + )) + .expect("工具执行应返回 ToolResult"); + + assert!(!result.success); + assert_eq!( + result.metadata.get("save_skipped_project_id"), + Some(&serde_json::json!("project-from-env")) + ); + assert_eq!( + result.metadata.get("save_skipped_by"), + Some(&serde_json::json!("context_project")) + ); + assert_eq!( + result + .metadata + .get("result") + .and_then(serde_json::Value::as_object) + .and_then(|value| value.get("save_skipped_by")), + Some(&serde_json::json!("context_project")) + ); + } +} + +pub(super) fn site_tool_names() -> Vec<&'static str> { + vec![ + LIME_SITE_LIST_TOOL_NAME, + LIME_SITE_SEARCH_TOOL_NAME, + LIME_SITE_INFO_TOOL_NAME, + LIME_SITE_RUN_TOOL_NAME, + ] +} + +pub(super) fn register_site_tools_to_registry( + registry: &mut aster::tools::ToolRegistry, + db: DbConnection, +) { + let definitions = [ + ( + LIME_SITE_LIST_TOOL_NAME, + "列出 Lime 内置站点适配器目录。", + LimeSiteTool::build_list_schema(), + LimeSiteToolKind::List, + ), + ( + LIME_SITE_SEARCH_TOOL_NAME, + "按关键词搜索 Lime 内置站点适配器。", + LimeSiteTool::build_search_schema(), + LimeSiteToolKind::Search, + ), + ( + LIME_SITE_INFO_TOOL_NAME, + "查看指定 Lime 站点适配器的参数和说明。", + LimeSiteTool::build_info_schema(), + LimeSiteToolKind::Info, + ), + ( + LIME_SITE_RUN_TOOL_NAME, + "在真实浏览器登录态中执行 Lime 站点适配器,返回结构化只读结果;未传 project_id 时会优先复用当前项目上下文自动保存到项目文档。", + LimeSiteTool::build_run_schema(), + LimeSiteToolKind::Run, + ), + ]; + + for (name, description, input_schema, kind) in definitions { + if registry.contains(name) { + continue; + } + registry.register(Box::new(LimeSiteTool::new( + name.to_string(), + description, + input_schema, + kind, + db.clone(), + ))); + } +} + +pub(super) fn unregister_site_tools_from_registry(registry: &mut aster::tools::ToolRegistry) { + for tool_name in site_tool_names() { + registry.unregister(tool_name); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs index d0aee4102..5b891dde6 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs @@ -199,16 +199,16 @@ pub(crate) fn collect_subagent_task_compat_warnings(input: &SubAgentTaskToolInpu warnings } -fn extract_tauri_message_text(message: &TauriMessage) -> Option { +fn extract_runtime_message_text(message: &AgentMessage) -> Option { let parts = message .content .iter() .filter_map(|content| match content { - TauriMessageContent::Text { text } => { + AgentMessageContent::Text { text } => { let trimmed = text.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) } - TauriMessageContent::ToolResponse { + AgentMessageContent::ToolResponse { output, success, .. } if *success => { let trimmed = output.trim(); @@ -231,7 +231,7 @@ pub(crate) fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Op .iter() .rev() .find(|message| message.role == "assistant") - .and_then(extract_tauri_message_text) + .and_then(extract_runtime_message_text) .or_else(|| { detail.items.iter().rev().find_map(|item| { match &item.payload { diff --git a/src-tauri/src/commands/execution_run_cmd.rs b/src-tauri/src/commands/execution_run_cmd.rs index 0487bb900..fd8b84016 100644 --- a/src-tauri/src/commands/execution_run_cmd.rs +++ b/src-tauri/src/commands/execution_run_cmd.rs @@ -6,6 +6,7 @@ use crate::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; use crate::database::DbConnection; use crate::services::execution_tracker_service::ExecutionTracker; use chrono::Utc; +use lime_agent::artifact_protocol::extract_artifact_protocol_paths_from_value; use serde::Serialize; use serde_json::Value; use tauri::State; @@ -318,32 +319,7 @@ fn derive_run_artifact_paths(run: &AgentRun) -> Vec { parsed_metadata .as_ref() - .map(|value| { - let mut paths = value - .get("artifact_paths") - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(Value::as_str) - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(str::to_string) - .collect::>() - }) - .unwrap_or_default(); - - for key in ["artifact_path", "source_file_name"] { - if let Some(path) = metadata_string(value, &[key]) { - let normalized = path.to_string(); - if !paths.iter().any(|existing| existing == &normalized) { - paths.push(normalized); - } - } - } - - paths - }) + .map(extract_artifact_protocol_paths_from_value) .unwrap_or_default() } @@ -661,6 +637,26 @@ mod tests { ); } + #[test] + fn derive_run_artifact_paths_should_recurse_nested_artifact_protocol_metadata() { + let run = sample_run_with_metadata(Some(serde_json::json!({ + "payload": { + "artifact_paths": ["social-posts\\nested.md"] + }, + "result": { + "absolute_path": " /tmp\\social-posts\\final.md " + } + }))); + + assert_eq!( + derive_run_artifact_paths(&run), + vec![ + "social-posts/nested.md".to_string(), + "/tmp/social-posts/final.md".to_string(), + ] + ); + } + #[test] fn derive_recent_terminal_items_should_keep_multiple_terminal_runs() { let mut latest_error_run = sample_run_with_metadata(Some(serde_json::json!({ diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 713c17cdb..59d64eff5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -55,6 +55,7 @@ pub mod route_cmd; pub mod screenshot_cmd; pub mod security_perf_cmd; pub mod session_files_cmd; +pub mod site_capability_cmd; pub mod skill_cmd; pub mod skill_error; pub mod skill_exec_cmd; diff --git a/src-tauri/src/commands/screenshot_cmd.rs b/src-tauri/src/commands/screenshot_cmd.rs index 64a033176..11263cb13 100644 --- a/src-tauri/src/commands/screenshot_cmd.rs +++ b/src-tauri/src/commands/screenshot_cmd.rs @@ -13,6 +13,12 @@ use crate::screenshot::{capture, shortcut}; use tauri::{AppHandle, Emitter, Manager, State}; use tracing::{debug, error, info}; +#[derive(Debug, Clone, serde::Serialize)] +pub struct ScreenshotShortcutRuntimeStatus { + pub shortcut_registered: bool, + pub registered_shortcut: Option, +} + /// 获取实验室功能配置 /// /// 从应用状态中获取当前的实验室功能配置 @@ -31,6 +37,16 @@ pub async fn get_experimental_config( Ok(config.experimental.clone()) } +/// 获取截图快捷键运行时状态 +#[tauri::command] +pub async fn get_screenshot_shortcut_runtime_status( +) -> Result { + Ok(ScreenshotShortcutRuntimeStatus { + shortcut_registered: shortcut::is_registered(), + registered_shortcut: shortcut::get_current(), + }) +} + /// 保存实验室功能配置 /// /// 将实验室功能配置保存到应用状态和配置文件 diff --git a/src-tauri/src/commands/site_capability_cmd.rs b/src-tauri/src/commands/site_capability_cmd.rs new file mode 100644 index 000000000..eb3e645ca --- /dev/null +++ b/src-tauri/src/commands/site_capability_cmd.rs @@ -0,0 +1,89 @@ +use crate::database::DbConnection; +use crate::services::site_adapter_registry::{ + apply_site_adapter_catalog_bootstrap, clear_site_adapter_catalog_cache, + get_site_adapter_catalog_status, SiteAdapterCatalogStatus, +}; +use crate::services::site_capability_service::{ + get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save, + save_existing_site_result_to_project, search_site_adapters, RunSiteAdapterRequest, + SaveSiteAdapterResultRequest, SavedSiteAdapterContent, SiteAdapterDefinition, + SiteAdapterRunResult, +}; +use serde::Deserialize; +use serde_json::Value; +use tauri::State; + +#[derive(Debug, Deserialize)] +pub struct SiteAdapterNameRequest { + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub struct SiteAdapterSearchRequest { + pub query: String, +} + +#[derive(Debug, Deserialize)] +pub struct SiteAdapterCatalogBootstrapRequest { + pub payload: Value, +} + +#[tauri::command] +pub fn site_list_adapters() -> Result, String> { + Ok(list_site_adapters()) +} + +#[tauri::command] +pub fn site_search_adapters( + request: SiteAdapterSearchRequest, +) -> Result, String> { + Ok(search_site_adapters(&request.query)) +} + +#[tauri::command] +pub fn site_get_adapter_info( + request: SiteAdapterNameRequest, +) -> Result { + get_site_adapter(&request.name).ok_or_else(|| "未找到对应的站点适配器".to_string()) +} + +#[tauri::command] +pub fn site_get_adapter_catalog_status() -> Result { + get_site_adapter_catalog_status() +} + +#[tauri::command] +pub fn site_apply_adapter_catalog_bootstrap( + request: SiteAdapterCatalogBootstrapRequest, +) -> Result { + apply_site_adapter_catalog_bootstrap(&request.payload) +} + +#[tauri::command] +pub fn site_clear_adapter_catalog_cache() -> Result { + clear_site_adapter_catalog_cache() +} + +#[tauri::command] +pub async fn site_run_adapter( + db: State<'_, DbConnection>, + request: RunSiteAdapterRequest, +) -> Result { + Ok(run_site_adapter_with_optional_save(db.inner(), request).await) +} + +#[tauri::command] +pub async fn site_debug_run_adapter( + db: State<'_, DbConnection>, + request: RunSiteAdapterRequest, +) -> Result { + Ok(run_site_adapter(db.inner(), request).await) +} + +#[tauri::command] +pub fn site_save_adapter_result( + db: State<'_, DbConnection>, + request: SaveSiteAdapterResultRequest, +) -> Result { + save_existing_site_result_to_project(db.inner(), request) +} diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index 90fa60710..c4aed5d75 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -2152,6 +2152,7 @@ async fn execute_extension_backend_action( target: None, text: None, url: action_arg_string(&args, &["url"]), + payload: None, wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true), timeout_ms: Some(normalize_action_timeout( action_arg_u64(&args, "timeout_ms").or(timeout_ms), @@ -2165,6 +2166,7 @@ async fn execute_extension_backend_action( target: None, text: None, url: None, + payload: None, wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true), timeout_ms: Some(normalize_action_timeout(timeout_ms)), }) @@ -2176,6 +2178,7 @@ async fn execute_extension_backend_action( target: None, text: None, url: None, + payload: None, wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true), timeout_ms: Some(normalize_action_timeout(timeout_ms)), }) @@ -2187,6 +2190,7 @@ async fn execute_extension_backend_action( target: None, text: None, url: None, + payload: None, wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true), timeout_ms: Some(normalize_action_timeout(timeout_ms)), }) @@ -2201,6 +2205,7 @@ async fn execute_extension_backend_action( target: None, text: None, url: None, + payload: None, wait_for_page_info: true, timeout_ms: Some(normalize_action_timeout(timeout_ms)), }) @@ -2215,6 +2220,7 @@ async fn execute_extension_backend_action( target: None, text: None, url: None, + payload: None, wait_for_page_info: true, timeout_ms: Some(normalize_action_timeout(timeout_ms)), }) @@ -2244,6 +2250,7 @@ async fn execute_extension_backend_action( target: action_arg_string(&args, &["ref_id", "target"]), text: action_arg_string(&args, &["value", "text"]), url: None, + payload: None, wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", false), timeout_ms: Some(normalize_action_timeout(timeout_ms)), }) @@ -2276,6 +2283,7 @@ async fn execute_extension_backend_action( target: action_arg_string(&args, &["ref_id", "target"]), text: text_payload, url: action_arg_string(&args, &["url"]), + payload: None, wait_for_page_info: action_arg_bool( &args, "wait_for_page_info", @@ -2306,6 +2314,7 @@ async fn execute_extension_backend_action( target: None, text: None, url: None, + payload: None, wait_for_page_info: false, timeout_ms: Some(normalize_action_timeout(timeout_ms)), }) @@ -2319,6 +2328,7 @@ async fn execute_extension_backend_action( target: action_arg_string(&args, &["target", "ref_id"]), text: action_arg_string(&args, &["text", "value"]), url: action_arg_string(&args, &["url"]), + payload: None, wait_for_page_info: action_arg_bool( &args, "wait_for_page_info", diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 3297dac24..4ac80b6aa 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -17,6 +17,7 @@ mod providers; mod runtime_queries; mod skills; mod tray; +mod voice; mod workspace; use crate::dev_bridge::DevBridgeState; @@ -97,6 +98,10 @@ pub async fn handle_command( return Ok(result); } + if let Some(result) = voice::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); } @@ -283,4 +288,28 @@ mod tests { assert_eq!(list.len(), 1); assert_eq!(list[0].id, created.id); } + + #[tokio::test] + async fn claw_solution_list_bridge_query_available() { + let state = make_test_state(); + let list_value = handle_command(&state, "claw_solution_list", None) + .await + .unwrap(); + let list = list_value.as_array().unwrap(); + + assert!(!list.is_empty()); + assert!(list[0]["id"].is_string()); + assert!(list[0]["readiness"].is_string()); + } + + #[tokio::test] + async fn voice_shortcut_status_bridge_query_available() { + let state = make_test_state(); + let status_value = handle_command(&state, "get_voice_shortcut_runtime_status", None) + .await + .unwrap(); + + assert!(status_value["shortcut_registered"].is_boolean()); + assert!(status_value["translate_shortcut_registered"].is_boolean()); + } } diff --git a/src-tauri/src/dev_bridge/dispatcher/browser.rs b/src-tauri/src/dev_bridge/dispatcher/browser.rs index 8e94df978..1450183ae 100644 --- a/src-tauri/src/dev_bridge/dispatcher/browser.rs +++ b/src-tauri/src/dev_bridge/dispatcher/browser.rs @@ -7,6 +7,7 @@ mod bridge; mod cdp; mod runtime; mod sessions; +mod site; type DynError = Box; @@ -41,5 +42,9 @@ pub(super) async fn try_handle( return Ok(Some(result)); } + if let Some(result) = site::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/site.rs b/src-tauri/src/dev_bridge/dispatcher/browser/site.rs new file mode 100644 index 000000000..77b2ad139 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/browser/site.rs @@ -0,0 +1,64 @@ +use super::super::get_db; +use super::{parse_request, DynError}; +use crate::dev_bridge::DevBridgeState; +use crate::services::site_adapter_registry::{ + apply_site_adapter_catalog_bootstrap, clear_site_adapter_catalog_cache, + get_site_adapter_catalog_status, +}; +use crate::services::site_capability_service::{ + get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save, + save_existing_site_result_to_project, search_site_adapters, +}; +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 { + "site_list_adapters" => serde_json::to_value(list_site_adapters())?, + "site_search_adapters" => { + let request: crate::commands::site_capability_cmd::SiteAdapterSearchRequest = + parse_request(args)?; + serde_json::to_value(search_site_adapters(&request.query))? + } + "site_get_adapter_info" => { + let request: crate::commands::site_capability_cmd::SiteAdapterNameRequest = + parse_request(args)?; + let adapter = get_site_adapter(&request.name) + .ok_or_else(|| "未找到对应的站点适配器".to_string())?; + serde_json::to_value(adapter)? + } + "site_get_adapter_catalog_status" => { + serde_json::to_value(get_site_adapter_catalog_status()?)? + } + "site_apply_adapter_catalog_bootstrap" => { + let request: crate::commands::site_capability_cmd::SiteAdapterCatalogBootstrapRequest = + parse_request(args)?; + serde_json::to_value(apply_site_adapter_catalog_bootstrap(&request.payload)?)? + } + "site_clear_adapter_catalog_cache" => { + serde_json::to_value(clear_site_adapter_catalog_cache()?)? + } + "site_run_adapter" | "site_debug_run_adapter" => { + let request: crate::services::site_capability_service::RunSiteAdapterRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + if cmd == "site_run_adapter" { + serde_json::to_value(run_site_adapter_with_optional_save(&db, request).await)? + } else { + serde_json::to_value(run_site_adapter(&db, request).await)? + } + } + "site_save_adapter_result" => { + let request: crate::services::site_capability_service::SaveSiteAdapterResultRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + serde_json::to_value(save_existing_site_result_to_project(&db, request)?)? + } + _ => 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 index 820e9ad57..6c5de3444 100644 --- a/src-tauri/src/dev_bridge/dispatcher/openclaw.rs +++ b/src-tauri/src/dev_bridge/dispatcher/openclaw.rs @@ -30,7 +30,7 @@ pub(super) async fn try_handle( cmd: &str, args: Option<&JsonValue>, ) -> Result, DynError> { - if let Some(result) = queries::try_handle(state, cmd).await? { + if let Some(result) = queries::try_handle(state, cmd, args).await? { return Ok(Some(result)); } diff --git a/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs b/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs index b85ce803f..133cec041 100644 --- a/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs +++ b/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs @@ -1,12 +1,51 @@ +use super::super::{args_or_default, get_db, get_string_arg, parse_optional_nested_arg}; use super::{openclaw_context, DynError}; use crate::dev_bridge::DevBridgeState; +use crate::services::claw_solution_service::{ClawSolutionContext, ClawSolutionService}; use serde_json::Value as JsonValue; pub(super) async fn try_handle( state: &DevBridgeState, cmd: &str, + args: Option<&JsonValue>, ) -> Result, DynError> { let result = match cmd { + "claw_solution_list" => { + let db = get_db(state)?; + serde_json::to_value(ClawSolutionService::default().list(db).await?)? + } + "claw_solution_detail" => { + let args = args_or_default(args); + let solution_id = get_string_arg(&args, "solutionId", "solution_id")?; + let db = get_db(state)?; + serde_json::to_value( + ClawSolutionService::default() + .detail(db, &solution_id) + .await?, + )? + } + "claw_solution_check_readiness" => { + let args = args_or_default(args); + let solution_id = get_string_arg(&args, "solutionId", "solution_id")?; + let _: Option = parse_optional_nested_arg(&args, "context")?; + let db = get_db(state)?; + serde_json::to_value( + ClawSolutionService::default() + .check_readiness(db, &solution_id) + .await?, + )? + } + "claw_solution_prepare" => { + let args = args_or_default(args); + let solution_id = get_string_arg(&args, "solutionId", "solution_id")?; + let context: Option = parse_optional_nested_arg(&args, "context")?; + let db = get_db(state)?; + serde_json::to_value( + ClawSolutionService::default() + .prepare(db, &solution_id, context) + .await?, + )? + } "openclaw_check_installed" => { let (_app_handle, service) = openclaw_context(state)?; let service = service.lock().await; diff --git a/src-tauri/src/dev_bridge/dispatcher/voice.rs b/src-tauri/src/dev_bridge/dispatcher/voice.rs new file mode 100644 index 000000000..50379f51c --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/voice.rs @@ -0,0 +1,189 @@ +use super::{args_or_default, get_string_arg, parse_nested_arg, require_app_handle}; +use crate::commands::asr_cmd::AddAsrCredentialRequest; +use crate::config::AsrCredentialEntry; +use crate::dev_bridge::DevBridgeState; +use crate::voice::commands::{RecordingStatus, StopRecordingResult, VoiceShortcutRuntimeStatus}; +use crate::voice::recording_service::RecordingServiceState; +use lime_core::config::{VoiceInputConfig, VoiceInstruction}; +use serde_json::Value as JsonValue; +use tauri::Manager; + +type DynError = Box; + +fn get_optional_string_arg(args: &JsonValue, primary: &str, secondary: &str) -> Option { + args.get(primary) + .or_else(|| args.get(secondary)) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn get_required_u32_arg(args: &JsonValue, primary: &str, secondary: &str) -> Result { + args.get(primary) + .or_else(|| args.get(secondary)) + .and_then(|value| value.as_u64()) + .map(|value| value as u32) + .ok_or_else(|| format!("缺少参数: {primary}/{secondary}").into()) +} + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "get_asr_credentials" => { + serde_json::to_value(crate::commands::asr_cmd::get_asr_credentials().await?)? + } + "add_asr_credential" => { + let args = args_or_default(args); + let entry: AddAsrCredentialRequest = parse_nested_arg(&args, "entry")?; + serde_json::to_value(crate::commands::asr_cmd::add_asr_credential(entry).await?)? + } + "update_asr_credential" => { + let args = args_or_default(args); + let entry: AsrCredentialEntry = parse_nested_arg(&args, "entry")?; + crate::commands::asr_cmd::update_asr_credential(entry).await?; + JsonValue::Null + } + "delete_asr_credential" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + crate::commands::asr_cmd::delete_asr_credential(id).await?; + JsonValue::Null + } + "set_default_asr_credential" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + crate::commands::asr_cmd::set_default_asr_credential(id).await?; + JsonValue::Null + } + "test_asr_credential" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + serde_json::to_value(crate::commands::asr_cmd::test_asr_credential(id).await?)? + } + "get_voice_input_config" => { + serde_json::to_value(crate::voice::commands::get_voice_input_config().await?)? + } + "get_voice_shortcut_runtime_status" => { + let status: VoiceShortcutRuntimeStatus = + crate::voice::commands::get_voice_shortcut_runtime_status().await?; + serde_json::to_value(status)? + } + "save_voice_input_config" => { + let args = args_or_default(args); + let voice_config: VoiceInputConfig = parse_nested_arg(&args, "voiceConfig")?; + let app_handle = require_app_handle(state)?; + crate::voice::commands::save_voice_input_config(app_handle, voice_config).await?; + JsonValue::Null + } + "get_voice_instructions" => { + serde_json::to_value(crate::voice::commands::get_voice_instructions().await?)? + } + "save_voice_instruction" => { + let args = args_or_default(args); + let instruction: VoiceInstruction = parse_nested_arg(&args, "instruction")?; + crate::voice::commands::save_voice_instruction(instruction).await?; + JsonValue::Null + } + "delete_voice_instruction" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + crate::voice::commands::delete_voice_instruction(id).await?; + JsonValue::Null + } + "open_voice_window" => { + let app_handle = require_app_handle(state)?; + crate::voice::commands::open_voice_window(app_handle).await?; + JsonValue::Null + } + "close_voice_window" => { + let app_handle = require_app_handle(state)?; + crate::voice::commands::close_voice_window(app_handle).await?; + JsonValue::Null + } + "list_audio_devices" => { + serde_json::to_value(crate::voice::recording_service::list_audio_devices()?)? + } + "transcribe_audio" => { + let args = args_or_default(args); + let audio_data = args + .get("audioData") + .or_else(|| args.get("audio_data")) + .cloned() + .ok_or_else(|| "缺少参数: audioData/audio_data".to_string())?; + let audio_data: Vec = serde_json::from_value(audio_data)?; + let sample_rate = get_required_u32_arg(&args, "sampleRate", "sample_rate")?; + let credential_id = get_optional_string_arg(&args, "credentialId", "credential_id"); + serde_json::to_value( + crate::voice::commands::transcribe_audio(audio_data, sample_rate, credential_id) + .await?, + )? + } + "polish_voice_text" => { + let args = args_or_default(args); + let text = get_string_arg(&args, "text", "text")?; + let instruction_id = get_optional_string_arg(&args, "instructionId", "instruction_id"); + serde_json::to_value( + crate::voice::commands::polish_voice_text(text, instruction_id).await?, + )? + } + "output_voice_text" => { + let args = args_or_default(args); + let text = get_string_arg(&args, "text", "text")?; + let mode = get_optional_string_arg(&args, "mode", "mode"); + crate::voice::commands::output_voice_text(text, mode).await?; + JsonValue::Null + } + "start_recording" => { + let args = args_or_default(args); + let device_id = get_optional_string_arg(&args, "deviceId", "device_id"); + let app_handle = require_app_handle(state)?; + let recording_service = app_handle.state::(); + let mut service = recording_service.0.lock(); + service.start(device_id)?; + JsonValue::Null + } + "stop_recording" => { + let app_handle = require_app_handle(state)?; + let recording_service = app_handle.state::(); + let mut service = recording_service.0.lock(); + let audio = service.stop()?; + serde_json::to_value(StopRecordingResult { + audio_data: audio.to_pcm16le_bytes(), + sample_rate: audio.sample_rate, + duration: audio.duration_secs, + })? + } + "cancel_recording" => { + let app_handle = require_app_handle(state)?; + let recording_service = app_handle.state::(); + if let Some(mut service) = recording_service.0.try_lock() { + service.cancel(); + } + JsonValue::Null + } + "get_recording_status" => { + let app_handle = require_app_handle(state)?; + let recording_service = app_handle.state::(); + let service = recording_service.0.lock(); + serde_json::to_value(RecordingStatus { + is_recording: service.is_recording(), + volume: service.get_volume(), + duration: service.get_duration(), + })? + } + "open_input_with_text" => { + let args = args_or_default(args); + let text = get_string_arg(&args, "text", "text")?; + let app_handle = require_app_handle(state)?; + crate::commands::screenshot_cmd::open_input_with_text(app_handle, text)?; + JsonValue::Null + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/screenshot/shortcut.rs b/src-tauri/src/screenshot/shortcut.rs index c60a22bc4..e55cf750f 100644 --- a/src-tauri/src/screenshot/shortcut.rs +++ b/src-tauri/src/screenshot/shortcut.rs @@ -290,6 +290,11 @@ fn handle_shortcut_triggered(app: &AppHandle) { mod tests { use super::*; + fn reset_runtime_state() { + IS_REGISTERED.store(false, Ordering::SeqCst); + *get_current_shortcut().write() = None; + } + #[test] fn test_validate_valid_shortcuts() { // 有效的快捷键格式 @@ -319,4 +324,20 @@ mod tests { assert!(validate("+++").is_err()); assert!(validate("Ctrl++").is_err()); } + + #[test] + fn test_runtime_status_accessors_reflect_state() { + reset_runtime_state(); + + assert!(!is_registered()); + assert_eq!(get_current(), None); + + IS_REGISTERED.store(true, Ordering::SeqCst); + *get_current_shortcut().write() = Some("CommandOrControl+Shift+4".to_string()); + + assert!(is_registered()); + assert_eq!(get_current().as_deref(), Some("CommandOrControl+Shift+4")); + + reset_runtime_state(); + } } diff --git a/src-tauri/src/services/agent_timeline_service.rs b/src-tauri/src/services/agent_timeline_service.rs index da499988c..a03edd42d 100644 --- a/src-tauri/src/services/agent_timeline_service.rs +++ b/src-tauri/src/services/agent_timeline_service.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use lime_agent::TauriAgentEvent; +use lime_agent::AgentEvent as RuntimeAgentEvent; use lime_core::database::dao::agent_timeline::{ AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn, AgentThreadTurnStatus, AgentTimelineDao, @@ -9,7 +9,7 @@ use serde_json::Value; use std::collections::HashMap; use tauri::{AppHandle, Emitter}; -fn emit_event(app: &AppHandle, event_name: &str, event: &TauriAgentEvent) { +fn emit_event(app: &AppHandle, event_name: &str, event: &RuntimeAgentEvent) { if let Err(error) = app.emit(event_name, event) { tracing::error!("[AgentTimeline] 发送事件失败: {}", error); } @@ -107,12 +107,12 @@ impl AgentTimelineRecorder { &mut self, app: &AppHandle, event_name: &str, - event: &TauriAgentEvent, + event: &RuntimeAgentEvent, _workspace_root: &str, ) -> Result<(), String> { match event { - TauriAgentEvent::ThreadStarted { .. } => {} - TauriAgentEvent::TurnStarted { turn } => { + RuntimeAgentEvent::ThreadStarted { .. } => {} + RuntimeAgentEvent::TurnStarted { turn } => { self.thread_id = turn.thread_id.clone(); self.turn_id = turn.id.clone(); self.turn = turn.clone(); @@ -121,33 +121,34 @@ impl AgentTimelineRecorder { AgentTimelineDao::upsert_turn(&conn, &self.turn) .map_err(|e| format!("同步 turn 启动态失败: {e}"))?; } - TauriAgentEvent::ItemStarted { item } => { + RuntimeAgentEvent::ItemStarted { item } => { self.persist_runtime_item( app, event_name, item.clone(), - TauriAgentEvent::ItemStarted { item: item.clone() }, + RuntimeAgentEvent::ItemStarted { item: item.clone() }, )?; } - TauriAgentEvent::ItemUpdated { item } => { + RuntimeAgentEvent::ItemUpdated { item } => { self.persist_runtime_item( app, event_name, item.clone(), - TauriAgentEvent::ItemUpdated { item: item.clone() }, + RuntimeAgentEvent::ItemUpdated { item: item.clone() }, )?; } - TauriAgentEvent::ItemCompleted { item } => { + RuntimeAgentEvent::ItemCompleted { item } => { self.persist_runtime_item( app, event_name, item.clone(), - TauriAgentEvent::ItemCompleted { item: item.clone() }, + RuntimeAgentEvent::ItemCompleted { item: item.clone() }, )?; } - TauriAgentEvent::RuntimeStatus { .. } => {} - TauriAgentEvent::ToolEnd { .. } => {} - TauriAgentEvent::ArtifactSnapshot { artifact } => { + RuntimeAgentEvent::RuntimeStatus { .. } => {} + RuntimeAgentEvent::TurnContext { .. } => {} + RuntimeAgentEvent::ToolEnd { .. } => {} + RuntimeAgentEvent::ArtifactSnapshot { artifact } => { let metadata_value = artifact .metadata .as_ref() @@ -170,8 +171,8 @@ impl AgentTimelineRecorder { ); self.persist_and_emit_item(app, event_name, item)?; } - TauriAgentEvent::ActionRequired { .. } => {} - TauriAgentEvent::ContextCompactionStarted { + RuntimeAgentEvent::ActionRequired { .. } => {} + RuntimeAgentEvent::ContextCompactionStarted { item_id, trigger, detail, @@ -188,7 +189,7 @@ impl AgentTimelineRecorder { ); self.persist_and_emit_item(app, event_name, item)?; } - TauriAgentEvent::ContextCompactionCompleted { + RuntimeAgentEvent::ContextCompactionCompleted { item_id, trigger, detail, @@ -205,7 +206,7 @@ impl AgentTimelineRecorder { ); self.persist_and_emit_item(app, event_name, item)?; } - TauriAgentEvent::Warning { code, message } => { + RuntimeAgentEvent::Warning { code, message } => { let item = self.build_item( format!("warning:{}:{}", self.turn_id, self.sequence_counter + 1), AgentThreadItemStatus::Completed, @@ -217,7 +218,7 @@ impl AgentTimelineRecorder { ); self.persist_and_emit_item(app, event_name, item)?; } - TauriAgentEvent::Error { message } => { + RuntimeAgentEvent::Error { message } => { let item = self.build_item( format!("error:{}", self.turn_id), AgentThreadItemStatus::Failed, @@ -260,7 +261,7 @@ impl AgentTimelineRecorder { emit_event( app, event_name, - &TauriAgentEvent::TurnCompleted { + &RuntimeAgentEvent::TurnCompleted { turn: self.turn.clone(), }, ); @@ -305,7 +306,7 @@ impl AgentTimelineRecorder { emit_event( app, event_name, - &TauriAgentEvent::TurnFailed { + &RuntimeAgentEvent::TurnFailed { turn: self.turn.clone(), }, ); @@ -391,13 +392,13 @@ impl AgentTimelineRecorder { .insert(item.id.clone(), item.status.clone()); let event = match (&previous_status, &item.status) { (None, AgentThreadItemStatus::InProgress) => { - TauriAgentEvent::ItemStarted { item: item.clone() } + RuntimeAgentEvent::ItemStarted { item: item.clone() } } - (None, _) => TauriAgentEvent::ItemCompleted { item: item.clone() }, + (None, _) => RuntimeAgentEvent::ItemCompleted { item: item.clone() }, (_, AgentThreadItemStatus::Completed | AgentThreadItemStatus::Failed) => { - TauriAgentEvent::ItemCompleted { item: item.clone() } + RuntimeAgentEvent::ItemCompleted { item: item.clone() } } - _ => TauriAgentEvent::ItemUpdated { item: item.clone() }, + _ => RuntimeAgentEvent::ItemUpdated { item: item.clone() }, }; emit_event(app, event_name, &event); Ok(()) @@ -408,7 +409,7 @@ impl AgentTimelineRecorder { app: &AppHandle, event_name: &str, item: AgentThreadItem, - event: TauriAgentEvent, + event: RuntimeAgentEvent, ) -> Result<(), String> { self.sync_runtime_item_state(&item); { diff --git a/src-tauri/src/services/artifact_diff_service.rs b/src-tauri/src/services/artifact_diff_service.rs new file mode 100644 index 000000000..d2541d699 --- /dev/null +++ b/src-tauri/src/services/artifact_diff_service.rs @@ -0,0 +1,369 @@ +//! Artifact 版本差异服务 +//! +//! 负责比较两个 ArtifactDocument 快照,生成可直接给 Workbench +//! 消费的 block 级 diff 摘要。 + +use serde_json::{Map, Value}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum ArtifactBlockChangeType { + Added, + Removed, + Updated, + Moved, +} + +impl ArtifactBlockChangeType { + fn as_str(self) -> &'static str { + match self { + Self::Added => "added", + Self::Removed => "removed", + Self::Updated => "updated", + Self::Moved => "moved", + } + } +} + +pub fn build_artifact_version_diff( + base_document: Option<&Value>, + base_version_id: Option<&str>, + base_version_no: Option, + target_document: &Value, + target_version_id: &str, + target_version_no: usize, +) -> Option> { + let base_record = base_document.and_then(Value::as_object)?; + let target_record = target_document.as_object()?; + let base_blocks = extract_blocks(base_record); + let target_blocks = extract_blocks(target_record); + + if base_blocks.is_empty() && target_blocks.is_empty() { + return None; + } + + let mut changed_blocks = Vec::new(); + let mut counts = HashMap::from([ + (ArtifactBlockChangeType::Added, 0usize), + (ArtifactBlockChangeType::Removed, 0usize), + (ArtifactBlockChangeType::Updated, 0usize), + (ArtifactBlockChangeType::Moved, 0usize), + ]); + let target_by_id = target_blocks + .iter() + .map(|block| (block.id.as_str(), block)) + .collect::>(); + let base_by_id = base_blocks + .iter() + .map(|block| (block.id.as_str(), block)) + .collect::>(); + + for target_block in &target_blocks { + let Some(base_block) = base_by_id.get(target_block.id.as_str()) else { + changed_blocks.push(build_changed_block_record( + ArtifactBlockChangeType::Added, + None, + Some(target_block), + )); + *counts.entry(ArtifactBlockChangeType::Added).or_default() += 1; + continue; + }; + + if base_block.block_type != target_block.block_type + || base_block.preview_text != target_block.preview_text + { + changed_blocks.push(build_changed_block_record( + ArtifactBlockChangeType::Updated, + Some(base_block), + Some(target_block), + )); + *counts.entry(ArtifactBlockChangeType::Updated).or_default() += 1; + continue; + } + + if base_block.index != target_block.index { + changed_blocks.push(build_changed_block_record( + ArtifactBlockChangeType::Moved, + Some(base_block), + Some(target_block), + )); + *counts.entry(ArtifactBlockChangeType::Moved).or_default() += 1; + } + } + + for base_block in &base_blocks { + if target_by_id.contains_key(base_block.id.as_str()) { + continue; + } + changed_blocks.push(build_changed_block_record( + ArtifactBlockChangeType::Removed, + Some(base_block), + None, + )); + *counts.entry(ArtifactBlockChangeType::Removed).or_default() += 1; + } + + if changed_blocks.is_empty() { + return None; + } + + let mut diff = Map::new(); + if let Some(version_id) = normalize_text(base_version_id) { + diff.insert("baseVersionId".to_string(), Value::String(version_id)); + } + if let Some(version_no) = base_version_no { + diff.insert("baseVersionNo".to_string(), Value::from(version_no as u64)); + } + diff.insert( + "targetVersionId".to_string(), + Value::String(target_version_id.to_string()), + ); + diff.insert( + "targetVersionNo".to_string(), + Value::from(target_version_no as u64), + ); + diff.insert( + "addedCount".to_string(), + Value::from(*counts.get(&ArtifactBlockChangeType::Added).unwrap_or(&0) as u64), + ); + diff.insert( + "removedCount".to_string(), + Value::from(*counts.get(&ArtifactBlockChangeType::Removed).unwrap_or(&0) as u64), + ); + diff.insert( + "updatedCount".to_string(), + Value::from(*counts.get(&ArtifactBlockChangeType::Updated).unwrap_or(&0) as u64), + ); + diff.insert( + "movedCount".to_string(), + Value::from(*counts.get(&ArtifactBlockChangeType::Moved).unwrap_or(&0) as u64), + ); + diff.insert( + "changedBlocks".to_string(), + Value::Array(changed_blocks.into_iter().map(Value::Object).collect()), + ); + Some(diff) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ArtifactBlockSnapshot { + id: String, + index: usize, + block_type: String, + preview_text: String, +} + +fn extract_blocks(record: &Map) -> Vec { + let Some(blocks) = record.get("blocks").and_then(Value::as_array) else { + return Vec::new(); + }; + + blocks + .iter() + .enumerate() + .filter_map(|(index, block)| { + let record = block.as_object()?; + let id = normalize_text(record.get("id").and_then(Value::as_str))?; + let block_type = normalize_text(record.get("type").and_then(Value::as_str)) + .unwrap_or_else(|| "rich_text".to_string()); + Some(ArtifactBlockSnapshot { + id, + index, + block_type, + preview_text: build_block_preview(record), + }) + }) + .collect() +} + +fn build_block_preview(record: &Map) -> String { + let block_type = normalize_text(record.get("type").and_then(Value::as_str)) + .unwrap_or_else(|| "rich_text".to_string()); + let preview = match block_type.as_str() { + "hero_summary" => normalize_text(record.get("summary").and_then(Value::as_str)), + "section_header" => normalize_text(record.get("title").and_then(Value::as_str)) + .or_else(|| normalize_text(record.get("description").and_then(Value::as_str))), + "callout" => normalize_text(record.get("title").and_then(Value::as_str)) + .or_else(|| normalize_text(record.get("content").and_then(Value::as_str))) + .or_else(|| normalize_text(record.get("text").and_then(Value::as_str))), + "rich_text" => normalize_text(record.get("markdown").and_then(Value::as_str)) + .or_else(|| normalize_text(record.get("text").and_then(Value::as_str))) + .or_else(|| normalize_text(record.get("content").and_then(Value::as_str))), + "key_points" | "checklist" | "metric_grid" => record + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .take(3) + .filter_map(value_to_preview_text) + .collect::>() + .join(";") + }) + .filter(|value| !value.trim().is_empty()), + "table" => record + .get("rows") + .and_then(Value::as_array) + .map(|rows| format!("{} 行表格", rows.len())), + "quote" => normalize_text(record.get("text").and_then(Value::as_str)) + .or_else(|| normalize_text(record.get("content").and_then(Value::as_str))), + _ => value_to_preview_text(&Value::Object(record.clone())), + }; + + preview + .map(|value| truncate_text(value.as_str(), 160)) + .unwrap_or_else(|| block_type) +} + +fn build_changed_block_record( + change_type: ArtifactBlockChangeType, + before: Option<&ArtifactBlockSnapshot>, + after: Option<&ArtifactBlockSnapshot>, +) -> Map { + let mut record = Map::new(); + record.insert( + "changeType".to_string(), + Value::String(change_type.as_str().to_string()), + ); + let block_id = after + .map(|block| block.id.clone()) + .or_else(|| before.map(|block| block.id.clone())) + .unwrap_or_else(|| "unknown-block".to_string()); + record.insert("blockId".to_string(), Value::String(block_id)); + if let Some(before) = before { + record.insert( + "beforeType".to_string(), + Value::String(before.block_type.clone()), + ); + record.insert("beforeIndex".to_string(), Value::from(before.index as u64)); + if !before.preview_text.is_empty() { + record.insert( + "beforeText".to_string(), + Value::String(before.preview_text.clone()), + ); + } + } + if let Some(after) = after { + record.insert( + "afterType".to_string(), + Value::String(after.block_type.clone()), + ); + record.insert("afterIndex".to_string(), Value::from(after.index as u64)); + if !after.preview_text.is_empty() { + record.insert( + "afterText".to_string(), + Value::String(after.preview_text.clone()), + ); + } + } + record.insert( + "summary".to_string(), + Value::String(build_change_summary(change_type, before, after)), + ); + record +} + +fn build_change_summary( + change_type: ArtifactBlockChangeType, + before: Option<&ArtifactBlockSnapshot>, + after: Option<&ArtifactBlockSnapshot>, +) -> String { + match change_type { + ArtifactBlockChangeType::Added => format!( + "新增 {} block", + after + .map(|block| block.block_type.as_str()) + .unwrap_or("artifact") + ), + ArtifactBlockChangeType::Removed => format!( + "删除 {} block", + before + .map(|block| block.block_type.as_str()) + .unwrap_or("artifact") + ), + ArtifactBlockChangeType::Updated => "更新 block 内容".to_string(), + ArtifactBlockChangeType::Moved => { + let from = before.map(|block| block.index + 1).unwrap_or(0); + let to = after.map(|block| block.index + 1).unwrap_or(0); + format!("block 位置从 #{from} 调整到 #{to}") + } + } +} + +fn value_to_preview_text(value: &Value) -> Option { + match value { + Value::String(text) => normalize_text(Some(text)), + Value::Array(items) => { + let joined = items + .iter() + .filter_map(value_to_preview_text) + .collect::>() + .join(";"); + normalize_text(Some(joined.as_str())) + } + Value::Object(record) => normalize_text( + record + .get("text") + .and_then(Value::as_str) + .or_else(|| record.get("label").and_then(Value::as_str)) + .or_else(|| record.get("title").and_then(Value::as_str)) + .or_else(|| record.get("content").and_then(Value::as_str)) + .or_else(|| record.get("summary").and_then(Value::as_str)), + ), + _ => None, + } +} + +fn normalize_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToString::to_string) +} + +fn truncate_text(value: &str, max_chars: usize) -> String { + value.trim().chars().take(max_chars).collect::() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn should_build_block_level_diff_summary() { + let previous = json!({ + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "旧摘要" }, + { "id": "body-1", "type": "rich_text", "markdown": "旧正文" } + ] + }); + let current = json!({ + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "新摘要" }, + { "id": "appendix-1", "type": "rich_text", "markdown": "新增附录" }, + { "id": "body-1", "type": "rich_text", "markdown": "旧正文" } + ] + }); + + let diff = build_artifact_version_diff( + Some(&previous), + Some("artifact:v1"), + Some(1), + ¤t, + "artifact:v2", + 2, + ) + .expect("diff"); + + assert_eq!(diff.get("updatedCount").and_then(Value::as_u64), Some(1)); + assert_eq!(diff.get("addedCount").and_then(Value::as_u64), Some(1)); + assert_eq!(diff.get("movedCount").and_then(Value::as_u64), Some(1)); + assert_eq!( + diff.get("changedBlocks") + .and_then(Value::as_array) + .map(|items| items.len()), + Some(3) + ); + } +} diff --git a/src-tauri/src/services/artifact_document_service.rs b/src-tauri/src/services/artifact_document_service.rs new file mode 100644 index 000000000..1b4d6903d --- /dev/null +++ b/src-tauri/src/services/artifact_document_service.rs @@ -0,0 +1,1310 @@ +//! ArtifactDocument 持久化服务 +//! +//! 负责在工作区内生成稳定路径、落盘 JSON 快照,并给前端 workbench +//! 提供可直接消费的 snapshot metadata。 + +use crate::services::artifact_document_validator::{ + validate_or_fallback_artifact_document, validate_or_repair_artifact_document_value, + ArtifactDocumentValidationContext, ArtifactDocumentValidationOutcome, + ARTIFACT_DOCUMENT_SCHEMA_VERSION, +}; +use chrono::{DateTime, Utc}; +use serde_json::{Map, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const ARTIFACT_RELATIVE_ROOT: &str = ".lime/artifacts"; +const ARTIFACT_VERSION_DIRECTORY: &str = "versions"; +const MAX_EMBEDDED_VERSION_HISTORY: usize = 12; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedArtifactDocument { + pub artifact_id: String, + pub current_version_id: String, + pub current_version_no: usize, + pub relative_path: String, + pub absolute_path: PathBuf, + pub serialized_document: String, + pub snapshot_metadata: Map, + pub title: String, + pub kind: String, + pub status: String, + pub repaired: bool, + pub fallback_used: bool, + pub issues: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArtifactDocumentPersistParams { + pub workspace_root: PathBuf, + pub workspace_id: Option, + pub thread_id: String, + pub turn_id: String, + pub request_metadata: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ArtifactVersionSummary { + id: String, + artifact_id: String, + version_no: usize, + summary: Option, + title: String, + kind: String, + status: String, + created_by: String, + created_at: String, + snapshot_path: String, +} + +impl ArtifactVersionSummary { + fn to_value(&self) -> Value { + let mut record = Map::new(); + record.insert("id".to_string(), Value::String(self.id.clone())); + record.insert( + "artifactId".to_string(), + Value::String(self.artifact_id.clone()), + ); + record.insert("versionNo".to_string(), Value::from(self.version_no as u64)); + record.insert("title".to_string(), Value::String(self.title.clone())); + record.insert("kind".to_string(), Value::String(self.kind.clone())); + record.insert("status".to_string(), Value::String(self.status.clone())); + record.insert( + "createdBy".to_string(), + Value::String(self.created_by.clone()), + ); + record.insert( + "createdAt".to_string(), + Value::String(self.created_at.clone()), + ); + record.insert( + "snapshotPath".to_string(), + Value::String(self.snapshot_path.clone()), + ); + if let Some(summary) = self.summary.as_ref() { + record.insert("summary".to_string(), Value::String(summary.clone())); + } + Value::Object(record) + } +} + +pub fn should_attempt_artifact_document_autopersist(request_metadata: Option<&Value>) -> bool { + let mode = extract_artifact_string(request_metadata, &["artifact_mode", "artifactMode"]); + if matches!(mode.as_deref(), Some("none")) { + return false; + } + + let stage = extract_artifact_string(request_metadata, &["artifact_stage", "artifactStage"]) + .or(mode.clone()) + .unwrap_or_else(|| "stage2".to_string()); + + !matches!(stage.as_str(), "stage1") +} + +pub fn persist_artifact_document_from_text( + raw_text: &str, + params: &ArtifactDocumentPersistParams, +) -> Result { + let request_metadata = params.request_metadata.as_ref(); + let request_id = extract_artifact_string( + request_metadata, + &["artifact_request_id", "artifactRequestId"], + ); + let artifact_id = request_id + .as_ref() + .map(|value| format!("artifact-document:{value}")) + .unwrap_or_else(|| { + format!( + "artifact-document:{}:{}", + normalize_slug(params.thread_id.as_str()), + normalize_slug(params.turn_id.as_str()) + ) + }); + + let validation_context = ArtifactDocumentValidationContext { + artifact_id: artifact_id.clone(), + workspace_id: params.workspace_id.clone(), + thread_id: Some(params.thread_id.clone()), + turn_id: Some(params.turn_id.clone()), + title_hint: extract_artifact_string(request_metadata, &["title", "run_title", "runTitle"]), + kind_hint: extract_artifact_string(request_metadata, &["artifact_kind", "artifactKind"]), + theme: extract_theme(request_metadata), + source_policy: extract_artifact_string( + request_metadata, + &["source_policy", "sourcePolicy"], + ), + request_id: request_id.clone(), + target_block_id: extract_artifact_string( + request_metadata, + &["artifact_target_block_id", "artifactTargetBlockId"], + ), + }; + + let mut operation_issues = Vec::new(); + let provisional_relative_path = request_id.as_deref().map(|request_id| { + build_artifact_relative_path( + Some(request_id), + params.thread_id.as_str(), + params.turn_id.as_str(), + "artifact", + "artifact", + ) + }); + let existing_document = provisional_relative_path + .as_deref() + .and_then(|relative_path| { + read_existing_artifact_document(¶ms.workspace_root, relative_path) + }); + let mut outcome = if let Some(ops_value) = + crate::services::artifact_ops_service::extract_artifact_ops_candidate(raw_text) + { + let applied = crate::services::artifact_ops_service::apply_artifact_ops_to_document( + existing_document.as_ref(), + &ops_value, + &validation_context, + ); + operation_issues = applied.issues; + validate_or_repair_artifact_document_value(&applied.document, raw_text, &validation_context) + } else { + validate_or_fallback_artifact_document(raw_text, &validation_context) + }; + if !operation_issues.is_empty() { + outcome.repaired = true; + outcome.issues.extend(operation_issues); + } + let relative_path = build_artifact_relative_path( + request_id.as_deref(), + params.thread_id.as_str(), + params.turn_id.as_str(), + outcome.kind.as_str(), + outcome.title.as_str(), + ); + let absolute_path = params + .workspace_root + .join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + if let Some(parent) = absolute_path.parent() { + fs::create_dir_all(parent).map_err(|error| format!("创建 Artifact 目录失败: {error}"))?; + } + + let mut version_history = bootstrap_existing_latest_version_if_needed( + ¶ms.workspace_root, + &absolute_path, + relative_path.as_str(), + artifact_id.as_str(), + )?; + let previous_version = version_history.first().cloned(); + let next_version_no = version_history + .iter() + .map(|version| version.version_no) + .max() + .unwrap_or(0) + + 1; + let current_version_path = + build_version_snapshot_relative_path(relative_path.as_str(), next_version_no); + let created_at = Utc::now().to_rfc3339(); + let current_version = build_version_summary_from_document( + &outcome.document, + artifact_id.as_str(), + next_version_no, + current_version_path.as_str(), + created_at, + ); + let version_diff = crate::services::artifact_diff_service::build_artifact_version_diff( + existing_document.as_ref(), + previous_version.as_ref().map(|version| version.id.as_str()), + previous_version.as_ref().map(|version| version.version_no), + &outcome.document, + current_version.id.as_str(), + current_version.version_no, + ); + version_history.push(current_version.clone()); + version_history.sort_by(|left, right| right.version_no.cmp(&left.version_no)); + let embedded_version_history = version_history + .iter() + .take(MAX_EMBEDDED_VERSION_HISTORY) + .cloned() + .collect::>(); + let source_links = derive_source_links_from_document(&outcome.document); + let enriched_document = enrich_document_with_history( + &outcome.document, + ¤t_version, + &embedded_version_history, + &source_links, + version_diff.as_ref(), + ); + let serialized_document = serde_json::to_string_pretty(&enriched_document) + .map_err(|error| format!("序列化 ArtifactDocument 失败: {error}"))?; + fs::write(&absolute_path, serialized_document.as_bytes()) + .map_err(|error| format!("写入 ArtifactDocument 失败: {error}"))?; + let current_version_absolute_path = params + .workspace_root + .join(current_version_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + if let Some(parent) = current_version_absolute_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 Artifact 版本目录失败: {error}"))?; + } + fs::write( + ¤t_version_absolute_path, + serialized_document.as_bytes(), + ) + .map_err(|error| format!("写入 Artifact 版本快照失败: {error}"))?; + + let preview_text = outcome + .document + .get("summary") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToString::to_string) + .or_else(|| Some(outcome.title.clone())); + let snapshot_metadata = build_snapshot_metadata( + &enriched_document, + &outcome, + relative_path.as_str(), + request_id.as_deref(), + preview_text.as_deref(), + ¤t_version, + &embedded_version_history, + &source_links, + version_diff.as_ref(), + ); + + Ok(PersistedArtifactDocument { + artifact_id, + current_version_id: current_version.id.clone(), + current_version_no: current_version.version_no, + relative_path, + absolute_path, + serialized_document, + snapshot_metadata, + title: outcome.title, + kind: outcome.kind, + status: outcome.status, + repaired: outcome.repaired, + fallback_used: outcome.fallback_used, + issues: outcome.issues, + }) +} + +fn build_snapshot_metadata( + document: &Value, + outcome: &ArtifactDocumentValidationOutcome, + relative_path: &str, + request_id: Option<&str>, + preview_text: Option<&str>, + current_version: &ArtifactVersionSummary, + version_history: &[ArtifactVersionSummary], + source_links: &[Map], + version_diff: Option<&Map>, +) -> Map { + let mut metadata = Map::new(); + metadata.insert( + "artifactSchema".to_string(), + Value::String(ARTIFACT_DOCUMENT_SCHEMA_VERSION.to_string()), + ); + metadata.insert( + "artifactType".to_string(), + Value::String("artifact_document".to_string()), + ); + metadata.insert( + "artifactKind".to_string(), + Value::String(outcome.kind.clone()), + ); + metadata.insert( + "artifactTitle".to_string(), + Value::String(outcome.title.clone()), + ); + metadata.insert( + "artifactStatus".to_string(), + Value::String(outcome.status.clone()), + ); + metadata.insert( + "artifactVersionNo".to_string(), + Value::from(current_version.version_no as u64), + ); + metadata.insert( + "artifactVersionId".to_string(), + Value::String(current_version.id.clone()), + ); + metadata.insert( + "artifact_path".to_string(), + Value::String(relative_path.to_string()), + ); + metadata.insert( + "artifact_paths".to_string(), + Value::Array(vec![Value::String(relative_path.to_string())]), + ); + metadata.insert("path".to_string(), Value::String(relative_path.to_string())); + metadata.insert("complete".to_string(), Value::Bool(true)); + metadata.insert("isPartial".to_string(), Value::Bool(false)); + metadata.insert( + "lastUpdateSource".to_string(), + Value::String("artifact_document_service".to_string()), + ); + metadata.insert("artifactDocument".to_string(), document.clone()); + metadata.insert("artifactVersion".to_string(), current_version.to_value()); + metadata.insert( + "artifactVersions".to_string(), + Value::Array( + version_history + .iter() + .map(ArtifactVersionSummary::to_value) + .collect(), + ), + ); + metadata.insert( + "artifactSourceLinks".to_string(), + Value::Array(source_links.iter().cloned().map(Value::Object).collect()), + ); + if let Some(version_diff) = version_diff.cloned() { + metadata.insert( + "artifactVersionDiff".to_string(), + Value::Object(version_diff), + ); + } + metadata.insert( + "artifactValidationRepaired".to_string(), + Value::Bool(outcome.repaired), + ); + metadata.insert( + "artifactFallbackUsed".to_string(), + Value::Bool(outcome.fallback_used), + ); + if !outcome.issues.is_empty() { + metadata.insert( + "artifactValidationIssues".to_string(), + Value::Array( + outcome + .issues + .iter() + .map(|issue| Value::String(issue.clone())) + .collect(), + ), + ); + } + if let Some(request_id) = request_id { + metadata.insert( + "artifactRequestId".to_string(), + Value::String(request_id.to_string()), + ); + } + if let Some(preview_text) = preview_text.map(str::trim).filter(|text| !text.is_empty()) { + metadata.insert( + "previewText".to_string(), + Value::String(truncate_text(preview_text, 240)), + ); + } + metadata +} + +fn build_version_id(artifact_id: &str, version_no: usize) -> String { + format!("{artifact_id}:v{version_no}") +} + +fn extract_artifact_file_stem(relative_path: &str) -> String { + let file_name = relative_path.rsplit('/').next().unwrap_or(relative_path); + if let Some(stripped) = file_name.strip_suffix(".artifact.json") { + return stripped.to_string(); + } + if let Some((stem, _)) = file_name.rsplit_once('.') { + return stem.to_string(); + } + file_name.to_string() +} + +fn build_version_directory_relative_path(relative_path: &str) -> String { + let stem = extract_artifact_file_stem(relative_path); + match relative_path.rsplit_once('/') { + Some((parent, _)) if !parent.is_empty() => { + format!("{parent}/{ARTIFACT_VERSION_DIRECTORY}/{stem}") + } + _ => format!("{ARTIFACT_VERSION_DIRECTORY}/{stem}"), + } +} + +fn build_version_snapshot_relative_path(relative_path: &str, version_no: usize) -> String { + format!( + "{}/v{:04}.artifact.json", + build_version_directory_relative_path(relative_path), + version_no + ) +} + +fn build_version_summary_from_document( + document: &Value, + artifact_id: &str, + version_no: usize, + snapshot_path: &str, + created_at: String, +) -> ArtifactVersionSummary { + let record = document.as_object(); + let metadata = record + .and_then(|record| record.get("metadata")) + .and_then(Value::as_object); + ArtifactVersionSummary { + id: build_version_id(artifact_id, version_no), + artifact_id: artifact_id.to_string(), + version_no, + summary: metadata + .and_then(|metadata| metadata.get("versionSummary")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| { + record + .and_then(|record| record.get("summary")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }), + title: record + .and_then(|record| record.get("title")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("未命名交付物") + .to_string(), + kind: record + .and_then(|record| record.get("kind")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("analysis") + .to_string(), + status: record + .and_then(|record| record.get("status")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("ready") + .to_string(), + created_by: metadata + .and_then(|metadata| metadata.get("generatedBy")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("agent") + .to_string(), + created_at, + snapshot_path: snapshot_path.to_string(), + } +} + +fn parse_version_no_from_relative_path(relative_path: &str) -> Option { + let file_name = relative_path.rsplit('/').next()?; + let raw = file_name + .strip_prefix('v')? + .strip_suffix(".artifact.json")?; + raw.parse::().ok() +} + +fn list_version_history( + workspace_root: &Path, + relative_path: &str, + artifact_id: &str, +) -> Result, String> { + let version_dir_relative = build_version_directory_relative_path(relative_path); + let version_dir_absolute = + workspace_root.join(version_dir_relative.replace('/', std::path::MAIN_SEPARATOR_STR)); + if !version_dir_absolute.exists() { + return Ok(Vec::new()); + } + + let mut versions = Vec::new(); + let entries = fs::read_dir(&version_dir_absolute) + .map_err(|error| format!("读取 Artifact 版本目录失败: {error}"))?; + for entry in entries { + let entry = entry.map_err(|error| format!("读取 Artifact 版本项失败: {error}"))?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let file_name = match path.file_name().and_then(|value| value.to_str()) { + Some(value) => value, + None => continue, + }; + if !file_name.ends_with(".artifact.json") { + continue; + } + + let relative = format!("{version_dir_relative}/{file_name}"); + let version_no = match parse_version_no_from_relative_path(relative.as_str()) { + Some(value) => value, + None => continue, + }; + let raw = fs::read_to_string(&path) + .map_err(|error| format!("读取 Artifact 版本快照失败: {error}"))?; + let created_at = + resolve_created_at_for_path(&path).unwrap_or_else(|| Utc::now().to_rfc3339()); + let document = match serde_json::from_str::(raw.as_str()) { + Ok(value) => value, + Err(_) => continue, + }; + versions.push(build_version_summary_from_document( + &document, + artifact_id, + version_no, + relative.as_str(), + created_at, + )); + } + + versions.sort_by(|left, right| right.version_no.cmp(&left.version_no)); + Ok(versions) +} + +fn read_existing_artifact_document(workspace_root: &Path, relative_path: &str) -> Option { + let absolute_path = + workspace_root.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + let raw = fs::read_to_string(absolute_path).ok()?; + serde_json::from_str::(raw.as_str()).ok() +} + +fn bootstrap_existing_latest_version_if_needed( + workspace_root: &Path, + absolute_path: &Path, + relative_path: &str, + artifact_id: &str, +) -> Result, String> { + let existing_versions = list_version_history(workspace_root, relative_path, artifact_id)?; + if !existing_versions.is_empty() || !absolute_path.exists() { + return Ok(existing_versions); + } + + let raw = fs::read_to_string(absolute_path) + .map_err(|error| format!("读取现有 Artifact 快照失败: {error}"))?; + let document = match serde_json::from_str::(raw.as_str()) { + Ok(value) => value, + Err(_) => return Ok(existing_versions), + }; + let version_path = build_version_snapshot_relative_path(relative_path, 1); + let version_absolute_path = + workspace_root.join(version_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + if let Some(parent) = version_absolute_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 Artifact 历史目录失败: {error}"))?; + } + fs::write(&version_absolute_path, raw.as_bytes()) + .map_err(|error| format!("迁移旧 Artifact 历史快照失败: {error}"))?; + Ok(vec![build_version_summary_from_document( + &document, + artifact_id, + 1, + version_path.as_str(), + resolve_created_at_for_path(absolute_path).unwrap_or_else(|| Utc::now().to_rfc3339()), + )]) +} + +fn resolve_created_at_for_path(path: &Path) -> Option { + let modified = fs::metadata(path).ok()?.modified().ok()?; + Some(DateTime::::from(modified).to_rfc3339()) +} + +fn enrich_document_with_history( + document: &Value, + current_version: &ArtifactVersionSummary, + version_history: &[ArtifactVersionSummary], + source_links: &[Map], + version_diff: Option<&Map>, +) -> Value { + let Some(record) = document.as_object() else { + return document.clone(); + }; + + let mut next = record.clone(); + let mut metadata = next + .get("metadata") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + metadata.insert( + "currentVersionId".to_string(), + Value::String(current_version.id.clone()), + ); + metadata.insert( + "currentVersionNo".to_string(), + Value::from(current_version.version_no as u64), + ); + metadata.insert( + "versionHistory".to_string(), + Value::Array( + version_history + .iter() + .map(ArtifactVersionSummary::to_value) + .collect(), + ), + ); + metadata.insert( + "sourceLinks".to_string(), + Value::Array(source_links.iter().cloned().map(Value::Object).collect()), + ); + if let Some(version_diff) = version_diff.cloned() { + metadata.insert( + "currentVersionDiff".to_string(), + Value::Object(version_diff), + ); + } + next.insert("metadata".to_string(), Value::Object(metadata)); + Value::Object(next) +} + +fn infer_source_type(source: &Map) -> String { + if let Some(kind) = source + .get("kind") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return kind.to_string(); + } + + if let Some(url) = source + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if url.starts_with("http://") || url.starts_with("https://") { + return "web".to_string(); + } + if url.starts_with("file://") { + return "file".to_string(); + } + } + + "unknown".to_string() +} + +fn derive_source_links_from_document(document: &Value) -> Vec> { + let Some(record) = document.as_object() else { + return Vec::new(); + }; + let artifact_id = record + .get("artifactId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("artifact-document"); + let Some(source_values) = record.get("sources").and_then(Value::as_array) else { + return Vec::new(); + }; + let Some(block_values) = record.get("blocks").and_then(Value::as_array) else { + return Vec::new(); + }; + + let mut source_map = std::collections::HashMap::new(); + for source in source_values.iter().filter_map(Value::as_object) { + if let Some(source_id) = source + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + source_map.insert(source_id.to_string(), source.clone()); + } + } + + let mut links = Vec::new(); + for block in block_values.iter().filter_map(Value::as_object) { + let Some(block_id) = block + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + + let source_ids = block + .get("sourceIds") + .or_else(|| block.get("source_ids")) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + + for source_id in source_ids { + let Some(source) = source_map.get(source_id) else { + continue; + }; + + let mut link = Map::new(); + link.insert( + "artifactId".to_string(), + Value::String(artifact_id.to_string()), + ); + link.insert("blockId".to_string(), Value::String(block_id.to_string())); + link.insert("sourceId".to_string(), Value::String(source_id.to_string())); + link.insert( + "sourceType".to_string(), + Value::String(infer_source_type(source)), + ); + link.insert( + "sourceRef".to_string(), + Value::String( + source + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(source_id) + .to_string(), + ), + ); + if let Some(label) = source + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + link.insert("label".to_string(), Value::String(label.to_string())); + } + if let Some(locator) = source.get("locator") { + link.insert("locator".to_string(), locator.clone()); + } else if let Some(url) = source + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + link.insert("locator".to_string(), Value::String(url.to_string())); + } + links.push(link); + } + } + + links +} + +fn build_artifact_relative_path( + request_id: Option<&str>, + thread_id: &str, + turn_id: &str, + kind: &str, + title: &str, +) -> String { + let file_stem = request_id + .map(normalize_slug) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + format!( + "{}-{}-{}", + normalize_slug(kind), + normalize_slug(turn_id), + normalize_slug(title), + ) + }); + let thread_segment = normalize_slug(thread_id); + format!( + "{}/{}/{}.artifact.json", + ARTIFACT_RELATIVE_ROOT, thread_segment, file_stem + ) +} + +fn extract_theme(request_metadata: Option<&Value>) -> Option { + extract_artifact_string( + request_metadata, + &["theme", "harness_theme", "harnessTheme"], + ) +} + +fn extract_artifact_object(request_metadata: Option<&Value>) -> Option<&Map> { + let metadata = request_metadata?; + let object = metadata.as_object()?; + if let Some(artifact) = object.get("artifact").and_then(Value::as_object) { + return Some(artifact); + } + Some(object) +} + +fn extract_artifact_string(request_metadata: Option<&Value>, keys: &[&str]) -> Option { + let artifact = extract_artifact_object(request_metadata)?; + keys.iter() + .filter_map(|key| artifact.get(*key)) + .find_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn normalize_slug(value: &str) -> String { + let mut slug = String::new(); + let mut last_was_separator = false; + for ch in value.trim().chars() { + let normalized = if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase()) + } else if ch == '-' || ch == '_' || ch.is_whitespace() || ch == '/' || ch == '\\' { + Some('-') + } else { + None + }; + + match normalized { + Some('-') => { + if !last_was_separator && !slug.is_empty() { + slug.push('-'); + last_was_separator = true; + } + } + Some(ch) => { + slug.push(ch); + last_was_separator = false; + } + None => {} + } + } + + let trimmed = slug.trim_matches('-').to_string(); + if trimmed.is_empty() { + format!("artifact-{:08x}", stable_hash(value)) + } else { + trimmed + } +} + +fn stable_hash(input: &str) -> u32 { + let mut hash: u32 = 0x811c9dc5; + for byte in input.as_bytes() { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(0x01000193); + } + hash +} + +fn truncate_text(value: &str, max_chars: usize) -> String { + value.trim().chars().take(max_chars).collect::() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn build_params() -> ArtifactDocumentPersistParams { + let workspace_root = tempdir().expect("tempdir").keep(); + ArtifactDocumentPersistParams { + workspace_root, + workspace_id: Some("workspace-1".to_string()), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + request_metadata: Some(serde_json::json!({ + "artifact_mode": "draft", + "artifact_stage": "stage2", + "artifact_kind": "analysis", + "source_policy": "required", + "artifact_request_id": "artifact:analysis:demo", + "theme": "knowledge" + })), + } + } + + #[test] + fn should_attempt_autopersist_should_skip_stage1_and_none() { + assert!(!should_attempt_artifact_document_autopersist(Some( + &serde_json::json!({ + "artifact_mode": "none" + }) + ))); + assert!(!should_attempt_artifact_document_autopersist(Some( + &serde_json::json!({ + "artifact_mode": "draft", + "artifact_stage": "stage1" + }) + ))); + assert!(should_attempt_artifact_document_autopersist(Some( + &serde_json::json!({ + "artifact_mode": "draft", + "artifact_stage": "stage2" + }) + ))); + } + + #[test] + fn persist_artifact_document_from_text_should_write_valid_document() { + let params = build_params(); + let raw = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "blocks": [ + { "type": "hero_summary", "summary": "摘要" } + ], + "sources": [ + { "title": "OpenAI", "url": "https://openai.com" } + ], + "metadata": {} + }) + .to_string(); + + let persisted = + persist_artifact_document_from_text(&raw, ¶ms).expect("persist should succeed"); + assert!(persisted.absolute_path.exists()); + assert!(persisted + .relative_path + .starts_with(".lime/artifacts/thread-1/")); + assert!(persisted.relative_path.ends_with(".artifact.json")); + assert_eq!( + persisted + .snapshot_metadata + .get("artifactKind") + .and_then(Value::as_str), + Some("analysis") + ); + assert_eq!( + persisted + .snapshot_metadata + .get("artifact_paths") + .and_then(Value::as_array) + .and_then(|paths| paths.first()) + .and_then(Value::as_str), + Some(persisted.relative_path.as_str()) + ); + } + + #[test] + fn persist_artifact_document_from_text_should_fallback_when_json_invalid() { + let params = build_params(); + let persisted = persist_artifact_document_from_text("普通文本输出", ¶ms) + .expect("fallback persist should succeed"); + assert_eq!(persisted.status, "failed"); + assert!(persisted.fallback_used); + assert_eq!( + persisted + .snapshot_metadata + .get("artifactFallbackUsed") + .and_then(Value::as_bool), + Some(true) + ); + } + + #[test] + fn persist_artifact_document_from_text_should_record_version_history_and_source_links() { + let params = build_params(); + let first = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "summary": "第一版摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "摘要", "sourceIds": ["source-1"] } + ], + "sources": [ + { "id": "source-1", "title": "OpenAI", "url": "https://openai.com" } + ], + "metadata": {} + }) + .to_string(); + let second = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "summary": "第二版摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "更新后的摘要", "sourceIds": ["source-1"] } + ], + "sources": [ + { "id": "source-1", "title": "OpenAI", "url": "https://openai.com" } + ], + "metadata": {} + }) + .to_string(); + + let persisted_first = + persist_artifact_document_from_text(&first, ¶ms).expect("first persist"); + assert_eq!(persisted_first.current_version_no, 1); + + let persisted_second = + persist_artifact_document_from_text(&second, ¶ms).expect("second persist"); + + assert_eq!(persisted_second.current_version_no, 2); + assert_eq!( + persisted_second + .snapshot_metadata + .get("artifactVersion") + .and_then(Value::as_object) + .and_then(|record| record.get("versionNo")) + .and_then(Value::as_u64), + Some(2) + ); + assert_eq!( + persisted_second + .snapshot_metadata + .get("artifactVersions") + .and_then(Value::as_array) + .map(|items| items.len()), + Some(2) + ); + assert_eq!( + persisted_second + .snapshot_metadata + .get("artifactSourceLinks") + .and_then(Value::as_array) + .map(|items| items.len()), + Some(1) + ); + assert_eq!( + persisted_second + .snapshot_metadata + .get("artifactVersionDiff") + .and_then(Value::as_object) + .and_then(|record| record.get("updatedCount")) + .and_then(Value::as_u64), + Some(1) + ); + assert!(persisted_second + .serialized_document + .contains("\"currentVersionNo\": 2")); + assert!(persisted_second + .serialized_document + .contains("\"versionHistory\"")); + assert!(persisted_second + .serialized_document + .contains("\"sourceLinks\"")); + assert!(persisted_second + .serialized_document + .contains("\"currentVersionDiff\"")); + } + + #[test] + fn persist_artifact_document_from_text_should_apply_artifact_ops_and_create_new_version() { + let params = build_params(); + let first = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "summary": "第一版摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" }, + { "id": "body-1", "type": "rich_text", "markdown": "旧正文" } + ], + "sources": [], + "metadata": {} + }) + .to_string(); + let ops = serde_json::json!({ + "type": "artifact_ops", + "artifactId": "artifact-document:artifact:analysis:demo", + "ops": [ + { + "op": "artifact.upsert_block", + "block": { + "id": "body-1", + "type": "rich_text", + "markdown": "更新后的正文" + } + }, + { + "op": "artifact.attach_source", + "blockId": "body-1", + "source": { + "id": "source-1", + "title": "OpenAI", + "url": "https://openai.com" + } + }, + { + "op": "artifact.finalize_version", + "summary": "只改写了正文并补上来源" + } + ] + }) + .to_string(); + + let persisted_first = + persist_artifact_document_from_text(&first, ¶ms).expect("first persist"); + let persisted_second = + persist_artifact_document_from_text(&ops, ¶ms).expect("ops persist"); + + assert_eq!(persisted_first.current_version_no, 1); + assert_eq!(persisted_second.current_version_no, 2); + assert!(persisted_second + .serialized_document + .contains("更新后的正文")); + assert!(persisted_second + .serialized_document + .contains("\"sourceLinks\"")); + assert_eq!( + persisted_second + .snapshot_metadata + .get("artifactSourceLinks") + .and_then(Value::as_array) + .map(|items| items.len()), + Some(1) + ); + assert_eq!( + persisted_second + .snapshot_metadata + .get("artifactVersion") + .and_then(Value::as_object) + .and_then(|record| record.get("summary")) + .and_then(Value::as_str), + Some("只改写了正文并补上来源") + ); + assert_eq!( + persisted_second + .snapshot_metadata + .get("artifactVersionDiff") + .and_then(Value::as_object) + .and_then(|record| record.get("updatedCount")) + .and_then(Value::as_u64), + Some(1) + ); + } + + #[test] + fn persist_artifact_document_from_text_should_restrict_rewrite_to_target_block() { + let mut params = build_params(); + params.request_metadata = Some(serde_json::json!({ + "artifact_mode": "rewrite", + "artifact_stage": "rewrite", + "artifact_kind": "analysis", + "source_policy": "required", + "artifact_request_id": "artifact:analysis:demo", + "artifact_target_block_id": "body-1", + "theme": "knowledge" + })); + + let first = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "summary": "第一版摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" }, + { "id": "body-1", "type": "rich_text", "markdown": "旧正文 1" }, + { "id": "body-2", "type": "rich_text", "markdown": "旧正文 2" } + ], + "sources": [ + { "id": "source-1", "title": "OpenAI", "url": "https://openai.com" } + ], + "metadata": {} + }) + .to_string(); + let ops = serde_json::json!({ + "type": "artifact_ops", + "artifactId": "artifact-document:artifact:analysis:demo", + "ops": [ + { + "op": "artifact.upsert_block", + "block": { + "id": "body-2", + "type": "rich_text", + "markdown": "不应被应用" + } + }, + { + "op": "artifact.upsert_block", + "block": { + "id": "body-1", + "type": "rich_text", + "markdown": "仅目标 block 被改写" + } + }, + { + "op": "artifact.finalize_version", + "summary": "只改写 body-1" + } + ] + }) + .to_string(); + + let persisted_first = + persist_artifact_document_from_text(&first, ¶ms).expect("first persist"); + let persisted_second = + persist_artifact_document_from_text(&ops, ¶ms).expect("ops persist"); + + assert_eq!(persisted_first.current_version_no, 1); + assert_eq!(persisted_second.current_version_no, 2); + assert!(persisted_second + .serialized_document + .contains("仅目标 block 被改写")); + assert!(persisted_second + .serialized_document + .contains("\"markdown\": \"旧正文 2\"")); + assert!(persisted_second + .issues + .iter() + .any(|issue| issue.contains("非目标 block `body-2`"))); + } + + #[test] + fn persist_artifact_document_from_text_should_accept_typed_rewrite_patch() { + let mut params = build_params(); + params.request_metadata = Some(serde_json::json!({ + "artifact_mode": "rewrite", + "artifact_stage": "rewrite", + "artifact_kind": "analysis", + "source_policy": "required", + "artifact_request_id": "artifact:analysis:demo", + "artifact_target_block_id": "body-1", + "theme": "knowledge" + })); + + let first = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "summary": "第一版摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" }, + { "id": "body-1", "type": "rich_text", "markdown": "旧正文 1" } + ], + "sources": [ + { "id": "source-1", "title": "OpenAI", "url": "https://openai.com" } + ], + "metadata": {} + }) + .to_string(); + let rewrite_patch = serde_json::json!({ + "type": "artifact_rewrite_patch", + "artifactId": "artifact-document:artifact:analysis:demo", + "targetBlockId": "body-1", + "block": { + "id": "body-1", + "type": "rich_text", + "markdown": "typed patch 改写后的正文" + }, + "source": { + "id": "source-2", + "title": "Anthropic", + "url": "https://anthropic.com" + }, + "summary": "通过 typed patch 改写正文" + }) + .to_string(); + + let persisted_first = + persist_artifact_document_from_text(&first, ¶ms).expect("first persist"); + let persisted_second = + persist_artifact_document_from_text(&rewrite_patch, ¶ms).expect("rewrite persist"); + + assert_eq!(persisted_first.current_version_no, 1); + assert_eq!(persisted_second.current_version_no, 2); + assert!(persisted_second + .serialized_document + .contains("typed patch 改写后的正文")); + assert!(persisted_second.serialized_document.contains("source-2")); + assert!(persisted_second + .snapshot_metadata + .get("artifactVersion") + .and_then(Value::as_object) + .and_then(|record| record.get("summary")) + .and_then(Value::as_str) + .is_some_and(|summary| summary.contains("typed patch"))); + } +} diff --git a/src-tauri/src/services/artifact_document_validator.rs b/src-tauri/src/services/artifact_document_validator.rs new file mode 100644 index 000000000..9d6f88581 --- /dev/null +++ b/src-tauri/src/services/artifact_document_validator.rs @@ -0,0 +1,1135 @@ +//! ArtifactDocument v1 校验与修复服务 +//! +//! 负责把模型返回的结构化 JSON 修正为可持久化、可渲染的 ArtifactDocument, +//! 并在必要时回退为失败态的 rich_text 文档。 + +use serde_json::{Map, Value}; +use std::collections::HashSet; + +pub const ARTIFACT_DOCUMENT_SCHEMA_VERSION: &str = "artifact_document.v1"; +const ARTIFACT_KIND_VALUES: &[&str] = &[ + "report", + "roadmap", + "prd", + "brief", + "analysis", + "comparison", + "plan", + "table_report", +]; +const ARTIFACT_STATUS_VALUES: &[&str] = &["draft", "streaming", "ready", "failed", "archived"]; +const ARTIFACT_BLOCK_TYPE_VALUES: &[&str] = &[ + "section_header", + "hero_summary", + "key_points", + "rich_text", + "callout", + "table", + "checklist", + "metric_grid", + "quote", + "citation_list", + "image", + "code_block", + "divider", +]; +const MAX_BLOCK_COUNT: usize = 40; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArtifactDocumentValidationContext { + pub artifact_id: String, + pub workspace_id: Option, + pub thread_id: Option, + pub turn_id: Option, + pub title_hint: Option, + pub kind_hint: Option, + pub theme: Option, + pub source_policy: Option, + pub request_id: Option, + pub target_block_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtifactDocumentValidationOutcome { + pub document: Value, + pub title: String, + pub kind: String, + pub status: String, + pub repaired: bool, + pub fallback_used: bool, + pub issues: Vec, +} + +pub fn validate_or_fallback_artifact_document( + raw_text: &str, + context: &ArtifactDocumentValidationContext, +) -> ArtifactDocumentValidationOutcome { + let Some(candidate) = extract_artifact_document_candidate(raw_text) else { + return build_failed_fallback_document( + raw_text, + "模型未返回合法的 ArtifactDocument JSON,已回退为失败态文档。", + context, + ); + }; + + validate_or_repair_artifact_document_value(&candidate, raw_text, context) +} + +pub fn validate_or_repair_artifact_document_value( + value: &Value, + raw_text: &str, + context: &ArtifactDocumentValidationContext, +) -> ArtifactDocumentValidationOutcome { + let Some(record) = value.as_object() else { + return build_failed_fallback_document( + raw_text, + "ArtifactDocument 顶层不是对象,已回退为失败态文档。", + context, + ); + }; + + let mut issues = Vec::new(); + let mut repaired = false; + + let kind = normalize_enum( + find_string(record, &["kind"]), + ARTIFACT_KIND_VALUES, + context.kind_hint.as_deref().unwrap_or("analysis"), + ); + if find_string(record, &["kind"]).as_deref() != Some(kind.as_str()) { + repaired = true; + issues.push(format!("kind 已规范化为 `{kind}`。")); + } + + let title = normalize_title( + find_string(record, &["title"]) + .or_else(|| find_string(record, &["name"])) + .or_else(|| context.title_hint.clone()), + ); + if find_string(record, &["title"]).as_deref() != Some(title.as_str()) { + repaired = true; + issues.push("title 缺失或为空,已使用兜底标题。".to_string()); + } + + let sources = normalize_sources(record.get("sources"), &mut repaired, &mut issues); + let source_id_set = sources + .iter() + .filter_map(|source| { + source + .get("id") + .and_then(Value::as_str) + .map(ToString::to_string) + }) + .collect::>(); + + let blocks = normalize_blocks( + record.get("blocks"), + raw_text, + &source_id_set, + &sources, + &mut repaired, + &mut issues, + ); + + if blocks.is_empty() { + return build_failed_fallback_document( + raw_text, + "ArtifactDocument 未提供可渲染 block,已回退为失败态文档。", + context, + ); + } + + let mut status = normalize_enum( + find_string(record, &["status"]), + ARTIFACT_STATUS_VALUES, + "ready", + ); + let source_policy = context + .source_policy + .as_deref() + .map(str::trim) + .unwrap_or("none"); + if source_policy == "required" && sources.is_empty() { + repaired = true; + status = "failed".to_string(); + issues.push( + "当前回合 source_policy=required,但文档没有 sources,已标记为 failed。".to_string(), + ); + } + + if find_string(record, &["status"]).as_deref() != Some(status.as_str()) { + repaired = true; + } + + let mut metadata = record + .get("metadata") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + merge_string_field(&mut metadata, "theme", context.theme.as_deref()); + merge_string_field(&mut metadata, "generatedBy", Some("agent")); + merge_source_run_binding(&mut metadata, context); + if let Some(request_id) = context.request_id.as_deref() { + metadata.insert( + "artifactRequestId".to_string(), + Value::String(request_id.to_string()), + ); + } + + let document = Value::Object(Map::from_iter([ + ( + "schemaVersion".to_string(), + Value::String(ARTIFACT_DOCUMENT_SCHEMA_VERSION.to_string()), + ), + ( + "artifactId".to_string(), + Value::String(context.artifact_id.clone()), + ), + ( + "workspaceId".to_string(), + optional_string_value(context.workspace_id.as_deref()), + ), + ( + "threadId".to_string(), + optional_string_value(context.thread_id.as_deref()), + ), + ( + "turnId".to_string(), + optional_string_value(context.turn_id.as_deref()), + ), + ("kind".to_string(), Value::String(kind.clone())), + ("title".to_string(), Value::String(title.clone())), + ("status".to_string(), Value::String(status.clone())), + ("language".to_string(), Value::String("zh-CN".to_string())), + ( + "summary".to_string(), + optional_string_value( + find_string(record, &["summary"]) + .or_else(|| derive_summary_from_blocks(&blocks)) + .as_deref(), + ), + ), + ( + "blocks".to_string(), + Value::Array(blocks.into_iter().map(Value::Object).collect()), + ), + ( + "sources".to_string(), + Value::Array(sources.into_iter().map(Value::Object).collect()), + ), + ("metadata".to_string(), Value::Object(metadata)), + ])); + + ArtifactDocumentValidationOutcome { + document, + title, + kind, + status, + repaired, + fallback_used: false, + issues, + } +} + +fn build_failed_fallback_document( + raw_text: &str, + reason: &str, + context: &ArtifactDocumentValidationContext, +) -> ArtifactDocumentValidationOutcome { + let title = normalize_title(context.title_hint.clone()); + let kind = normalize_enum(context.kind_hint.clone(), ARTIFACT_KIND_VALUES, "analysis"); + let fallback_markdown = build_fallback_markdown(raw_text, reason); + let mut metadata = Map::new(); + merge_string_field(&mut metadata, "theme", context.theme.as_deref()); + merge_string_field(&mut metadata, "generatedBy", Some("agent")); + merge_source_run_binding(&mut metadata, context); + if let Some(request_id) = context.request_id.as_deref() { + metadata.insert( + "artifactRequestId".to_string(), + Value::String(request_id.to_string()), + ); + } + + let document = Value::Object(Map::from_iter([ + ( + "schemaVersion".to_string(), + Value::String(ARTIFACT_DOCUMENT_SCHEMA_VERSION.to_string()), + ), + ( + "artifactId".to_string(), + Value::String(context.artifact_id.clone()), + ), + ( + "workspaceId".to_string(), + optional_string_value(context.workspace_id.as_deref()), + ), + ( + "threadId".to_string(), + optional_string_value(context.thread_id.as_deref()), + ), + ( + "turnId".to_string(), + optional_string_value(context.turn_id.as_deref()), + ), + ("kind".to_string(), Value::String(kind.clone())), + ("title".to_string(), Value::String(title.clone())), + ("status".to_string(), Value::String("failed".to_string())), + ("language".to_string(), Value::String("zh-CN".to_string())), + ( + "summary".to_string(), + Value::String("结构化交付失败,已回退为失败态文档。".to_string()), + ), + ( + "blocks".to_string(), + Value::Array(vec![Value::Object(Map::from_iter([ + ("id".to_string(), Value::String("fallback-1".to_string())), + ("type".to_string(), Value::String("rich_text".to_string())), + ("markdown".to_string(), Value::String(fallback_markdown)), + ]))]), + ), + ("sources".to_string(), Value::Array(Vec::new())), + ("metadata".to_string(), Value::Object(metadata)), + ])); + + ArtifactDocumentValidationOutcome { + document, + title, + kind, + status: "failed".to_string(), + repaired: true, + fallback_used: true, + issues: vec![reason.to_string()], + } +} + +fn extract_artifact_document_candidate(raw_text: &str) -> Option { + let trimmed = raw_text.trim(); + if trimmed.is_empty() { + return None; + } + + let candidates = [ + trimmed.to_string(), + strip_outer_code_fence(trimmed), + extract_first_fenced_payload(trimmed).unwrap_or_default(), + extract_braced_json_candidate(trimmed).unwrap_or_default(), + ]; + + for candidate in candidates { + let normalized = candidate.trim(); + if normalized.is_empty() { + continue; + } + let Ok(parsed) = serde_json::from_str::(normalized) else { + continue; + }; + if let Some(document) = unwrap_artifact_document_envelope(&parsed) { + return Some(document.clone()); + } + } + + None +} + +fn unwrap_artifact_document_envelope(value: &Value) -> Option<&Value> { + let record = value.as_object()?; + if is_artifact_document_record(record) { + return Some(value); + } + + if record.get("type").and_then(Value::as_str).map(str::trim) == Some("artifact_document_draft") + { + return record + .get("document") + .filter(|candidate| candidate.is_object()); + } + + record.get("document").and_then(|candidate| { + let object = candidate.as_object()?; + if is_artifact_document_record(object) { + Some(candidate) + } else { + None + } + }) +} + +fn is_artifact_document_record(record: &Map) -> bool { + record + .get("schemaVersion") + .or_else(|| record.get("schema_version")) + .and_then(Value::as_str) + .map(str::trim) + == Some(ARTIFACT_DOCUMENT_SCHEMA_VERSION) +} + +fn strip_outer_code_fence(raw: &str) -> String { + let trimmed = raw.trim(); + if !(trimmed.starts_with("```") && trimmed.ends_with("```")) { + return trimmed.to_string(); + } + let mut lines = trimmed.lines(); + let _ = lines.next(); + let mut body = lines.collect::>(); + if !body.is_empty() { + body.pop(); + } + body.join("\n").trim().to_string() +} + +fn extract_first_fenced_payload(raw: &str) -> Option { + let start = raw.find("```")?; + let remainder = &raw[start + 3..]; + let newline_idx = remainder.find('\n')?; + let content_start = start + 3 + newline_idx + 1; + let end = raw[content_start..].find("```")?; + Some(raw[content_start..content_start + end].trim().to_string()) +} + +fn extract_braced_json_candidate(raw: &str) -> Option { + let start = raw.find('{')?; + let end = raw.rfind('}')?; + if end <= start { + return None; + } + Some(raw[start..=end].trim().to_string()) +} + +fn normalize_sources( + value: Option<&Value>, + repaired: &mut bool, + issues: &mut Vec, +) -> Vec> { + let Some(items) = value.and_then(Value::as_array) else { + return Vec::new(); + }; + + let mut seen_ids = HashSet::new(); + let mut sources = Vec::new(); + for (index, item) in items.iter().enumerate() { + let Some(record) = item.as_object() else { + *repaired = true; + issues.push(format!("sources[{}] 不是对象,已忽略。", index)); + continue; + }; + let id = normalize_text( + record + .get("id") + .or_else(|| record.get("sourceId")) + .and_then(Value::as_str), + ) + .unwrap_or_else(|| format!("source-{}", index + 1)); + if !seen_ids.insert(id.clone()) { + *repaired = true; + issues.push(format!("sources 中存在重复 id `{id}`,已忽略后续重复项。")); + continue; + } + + let title = find_string(record, &["title", "label"]); + let url = find_string(record, &["url", "href", "link"]); + let note = find_string(record, &["note", "summary", "description"]); + let kind = find_string(record, &["kind", "type"]); + let quote = find_string(record, &["quote"]); + let published_at = find_string(record, &["publishedAt", "published_at"]); + + if title.is_none() && url.is_none() && note.is_none() && quote.is_none() { + *repaired = true; + issues.push(format!("sources[{}] 缺少可展示字段,已忽略。", index)); + continue; + } + + let mut normalized = record.clone(); + normalized.insert("id".to_string(), Value::String(id)); + upsert_optional_string(&mut normalized, "title", title.as_deref()); + upsert_optional_string(&mut normalized, "url", url.as_deref()); + upsert_optional_string(&mut normalized, "note", note.as_deref()); + upsert_optional_string(&mut normalized, "kind", kind.as_deref()); + upsert_optional_string(&mut normalized, "quote", quote.as_deref()); + upsert_optional_string(&mut normalized, "publishedAt", published_at.as_deref()); + sources.push(normalized); + } + + sources +} + +fn normalize_blocks( + value: Option<&Value>, + raw_text: &str, + source_id_set: &HashSet, + sources: &[Map], + repaired: &mut bool, + issues: &mut Vec, +) -> Vec> { + let Some(items) = value.and_then(Value::as_array) else { + return vec![build_fallback_rich_text_block( + "block-1", + raw_text, + Some("missing_blocks"), + )]; + }; + + let mut seen_ids = HashSet::new(); + let mut blocks = Vec::new(); + for (index, item) in items.iter().enumerate() { + let Some((mut block, block_repaired)) = + normalize_block(item, index, source_id_set, sources) + else { + *repaired = true; + issues.push(format!( + "blocks[{}] 无法修复为可渲染 block,已忽略。", + index + )); + continue; + }; + if block_repaired { + *repaired = true; + issues.push(format!("blocks[{index}] 已自动修复或降级为可渲染 block。")); + } + + let base_id = normalize_text(block.get("id").and_then(Value::as_str)) + .unwrap_or_else(|| format!("block-{}", index + 1)); + let block_id = dedupe_id(base_id, &mut seen_ids); + if block.get("id").and_then(Value::as_str).map(str::trim) != Some(block_id.as_str()) { + *repaired = true; + } + block.insert("id".to_string(), Value::String(block_id)); + blocks.push(block); + } + + if blocks.len() > MAX_BLOCK_COUNT { + *repaired = true; + issues.push(format!( + "blocks 数量超过上限 {MAX_BLOCK_COUNT},已截断后续 block。" + )); + blocks.truncate(MAX_BLOCK_COUNT); + } + + if blocks.is_empty() { + vec![build_fallback_rich_text_block( + "block-1", + raw_text, + Some("empty_blocks"), + )] + } else { + blocks + } +} + +fn normalize_block( + value: &Value, + index: usize, + source_id_set: &HashSet, + sources: &[Map], +) -> Option<(Map, bool)> { + let record = value.as_object()?; + let mut repaired = false; + let block_type = normalize_text(find_string(record, &["type"]).as_deref()) + .unwrap_or_else(|| "rich_text".to_string()); + if !ARTIFACT_BLOCK_TYPE_VALUES.contains(&block_type.as_str()) { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value) + .or_else(|| serde_json::to_string_pretty(value).ok()) + .as_deref() + .unwrap_or(""), + Some(block_type.as_str()), + ), + true, + )); + } + + let mut normalized = record.clone(); + normalized.insert( + "id".to_string(), + Value::String( + normalize_text(find_string(record, &["id"]).as_deref()) + .unwrap_or_else(|| format!("block-{}", index + 1)), + ), + ); + normalized.insert("type".to_string(), Value::String(block_type.clone())); + + if let Some(section_id) = find_string(record, &["sectionId", "section_id"]) { + normalized.insert("sectionId".to_string(), Value::String(section_id)); + } else { + normalized.remove("sectionId"); + } + + if let Some(source_ids) = normalize_string_array(record, &["sourceIds", "source_ids"]) { + let filtered = source_ids + .into_iter() + .filter(|source_id| source_id_set.contains(source_id)) + .map(Value::String) + .collect::>(); + if filtered.is_empty() { + normalized.remove("sourceIds"); + } else { + normalized.insert("sourceIds".to_string(), Value::Array(filtered)); + } + } + + match block_type.as_str() { + "section_header" => { + if find_string(record, &["title"]).is_none() { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("section_header"), + ), + true, + )); + } + } + "hero_summary" => { + if find_string(record, &["summary"]).is_none() { + let fallback = extract_portable_text(value)?; + normalized.insert("summary".to_string(), Value::String(fallback)); + repaired = true; + } + } + "key_points" => { + if !has_non_empty_string_array(record.get("items")) { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("key_points"), + ), + true, + )); + } + } + "rich_text" => { + if extract_rich_text_body(record).is_none() { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("rich_text"), + ), + true, + )); + } + } + "callout" => { + let content = + find_string(record, &["content", "text"]).or_else(|| extract_portable_text(value)); + if find_string(record, &["title"]).is_none() && content.is_none() { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("callout"), + ), + true, + )); + } + if find_string(record, &["content", "text"]).is_none() { + if let Some(text) = content { + normalized.insert("content".to_string(), Value::String(text)); + repaired = true; + } + } + } + "table" => { + let has_columns = record + .get("columns") + .and_then(Value::as_array) + .map(|items| !items.is_empty()) + .unwrap_or(false); + let has_rows = record + .get("rows") + .and_then(Value::as_array) + .map(|items| !items.is_empty()) + .unwrap_or(false); + if !has_columns && !has_rows { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("table"), + ), + true, + )); + } + } + "checklist" | "metric_grid" => { + let has_items = record + .get("items") + .and_then(Value::as_array) + .map(|items| !items.is_empty()) + .unwrap_or(false); + let has_metrics = record + .get("metrics") + .and_then(Value::as_array) + .map(|items| !items.is_empty()) + .unwrap_or(false); + if !has_items && !has_metrics { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some(block_type.as_str()), + ), + true, + )); + } + } + "quote" => { + if find_string(record, &["quote", "text"]).is_none() { + let fallback = extract_portable_text(value)?; + normalized.insert("quote".to_string(), Value::String(fallback)); + repaired = true; + } + } + "citation_list" => { + let has_items = record + .get("items") + .and_then(Value::as_array) + .map(|items| !items.is_empty()) + .unwrap_or(false); + if !has_items { + let items = normalize_citation_items_for_block(&normalized, sources); + if items.is_empty() { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("citation_list"), + ), + true, + )); + } + normalized.insert("items".to_string(), Value::Array(items)); + repaired = true; + } + } + "image" => { + if find_string(record, &["url", "src", "imageUrl"]).is_none() { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("image"), + ), + true, + )); + } + } + "code_block" => { + if find_string(record, &["code", "content"]).is_none() + && extract_portable_text(value).is_none() + { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + serde_json::to_string_pretty(value) + .ok() + .as_deref() + .unwrap_or(""), + Some("code_block"), + ), + true, + )); + } + } + "divider" => {} + _ => {} + } + + Some((normalized, repaired)) +} + +fn normalize_citation_items_for_block( + block: &Map, + sources: &[Map], +) -> Vec { + let source_ids = + normalize_string_array(block, &["sourceIds", "source_ids"]).unwrap_or_default(); + let preferred_ids = source_ids.into_iter().collect::>(); + let selected_sources = if preferred_ids.is_empty() { + sources.iter().collect::>() + } else { + sources + .iter() + .filter(|source| { + source + .get("id") + .and_then(Value::as_str) + .map(|id| preferred_ids.contains(id)) + .unwrap_or(false) + }) + .collect::>() + }; + + selected_sources + .into_iter() + .map(|source| { + let mut item = Map::new(); + for key in ["title", "url", "note", "quote", "kind", "publishedAt"] { + if let Some(value) = source.get(key).cloned() { + item.insert(key.to_string(), value); + } + } + Value::Object(item) + }) + .collect() +} + +fn build_fallback_rich_text_block( + id: &str, + raw_text: &str, + original_type: Option<&str>, +) -> Map { + let markdown = if raw_text.trim().is_empty() { + "当前结构块缺少可渲染内容。".to_string() + } else { + raw_text.trim().to_string() + }; + let mut block = Map::from_iter([ + ("id".to_string(), Value::String(id.to_string())), + ("type".to_string(), Value::String("rich_text".to_string())), + ("markdown".to_string(), Value::String(markdown)), + ]); + if let Some(value) = original_type + .map(str::trim) + .filter(|value| !value.is_empty()) + { + block.insert("originalType".to_string(), Value::String(value.to_string())); + } + block +} + +fn extract_rich_text_body(record: &Map) -> Option { + find_string(record, &["markdown", "text", "content"]) + .or_else(|| record.get("content").and_then(extract_portable_text)) + .or_else(|| record.get("tiptap").and_then(extract_portable_text)) + .or_else(|| record.get("proseMirror").and_then(extract_portable_text)) +} + +fn derive_summary_from_blocks(blocks: &[Map]) -> Option { + for block in blocks { + let block_type = block + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + match block_type { + "hero_summary" => { + if let Some(summary) = find_string(block, &["summary"]) { + return Some(summary); + } + } + "rich_text" => { + if let Some(text) = extract_rich_text_body(block) { + return Some(truncate_text(&text, 180)); + } + } + _ => {} + } + } + None +} + +fn build_fallback_markdown(raw_text: &str, reason: &str) -> String { + let trimmed = raw_text.trim(); + if trimmed.is_empty() { + format!("> {reason}\n") + } else { + trimmed.to_string() + } +} + +fn normalize_title(value: Option) -> String { + value + .map(|title| truncate_text(&title, 120)) + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| "未命名交付物".to_string()) +} + +fn truncate_text(value: &str, max_chars: usize) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return String::new(); + } + let collected = trimmed.chars().take(max_chars).collect::(); + if collected.is_empty() { + "未命名交付物".to_string() + } else { + collected + } +} + +fn find_string(record: &Map, keys: &[&str]) -> Option { + keys.iter() + .filter_map(|key| record.get(*key)) + .find_map(Value::as_str) + .and_then(|value| normalize_text(Some(value))) +} + +fn normalize_string_array(record: &Map, keys: &[&str]) -> Option> { + let values = keys + .iter() + .filter_map(|key| record.get(*key)) + .find_map(Value::as_array)?; + let items = values + .iter() + .filter_map(Value::as_str) + .filter_map(|value| normalize_text(Some(value))) + .collect::>(); + if items.is_empty() { + None + } else { + Some(items) + } +} + +fn has_non_empty_string_array(value: Option<&Value>) -> bool { + value + .and_then(Value::as_array) + .map(|items| { + items.iter().any(|item| { + item.as_str() + .map(str::trim) + .map(|text| !text.is_empty()) + .unwrap_or(false) + }) + }) + .unwrap_or(false) +} + +fn normalize_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToString::to_string) +} + +fn normalize_enum(value: Option, allowed: &[&str], default: &str) -> String { + let normalized = value + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(|text| text.to_ascii_lowercase()) + .unwrap_or_else(|| default.to_string()); + if allowed.contains(&normalized.as_str()) { + normalized + } else { + default.to_string() + } +} + +fn optional_string_value(value: Option<&str>) -> Value { + value + .map(|text| Value::String(text.to_string())) + .unwrap_or(Value::Null) +} + +fn merge_string_field(target: &mut Map, key: &str, value: Option<&str>) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + target.insert(key.to_string(), Value::String(value.to_string())); + } +} + +fn upsert_optional_string(target: &mut Map, key: &str, value: Option<&str>) { + if let Some(value) = value.map(str::trim).filter(|text| !text.is_empty()) { + target.insert(key.to_string(), Value::String(value.to_string())); + } else { + target.remove(key); + } +} + +fn merge_source_run_binding( + metadata: &mut Map, + context: &ArtifactDocumentValidationContext, +) { + let mut binding = metadata + .get("sourceRunBinding") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + merge_string_field(&mut binding, "threadId", context.thread_id.as_deref()); + merge_string_field(&mut binding, "turnId", context.turn_id.as_deref()); + if !binding.is_empty() { + metadata.insert("sourceRunBinding".to_string(), Value::Object(binding)); + } +} + +fn dedupe_id(base_id: String, seen: &mut HashSet) -> String { + if seen.insert(base_id.clone()) { + return base_id; + } + let mut index = 2usize; + loop { + let candidate = format!("{base_id}-{index}"); + if seen.insert(candidate.clone()) { + return candidate; + } + index += 1; + } +} + +fn extract_portable_text(value: &Value) -> Option { + match value { + Value::String(text) => normalize_text(Some(text)), + Value::Array(items) => { + let parts = items + .iter() + .filter_map(extract_portable_text) + .collect::>(); + if parts.is_empty() { + None + } else { + Some(parts.join("\n").trim().to_string()) + } + } + Value::Object(record) => { + if let Some(text) = record.get("text").and_then(Value::as_str) { + return normalize_text(Some(text)); + } + if let Some(content) = record.get("content").and_then(extract_portable_text) { + return Some(content); + } + None + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_context() -> ArtifactDocumentValidationContext { + ArtifactDocumentValidationContext { + artifact_id: "artifact-1".to_string(), + workspace_id: Some("workspace-1".to_string()), + thread_id: Some("thread-1".to_string()), + turn_id: Some("turn-1".to_string()), + title_hint: Some("默认标题".to_string()), + kind_hint: Some("analysis".to_string()), + theme: Some("knowledge".to_string()), + source_policy: Some("required".to_string()), + request_id: Some("artifact:test".to_string()), + target_block_id: None, + } + } + + #[test] + fn validate_or_fallback_should_accept_plain_document_json() { + let raw = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "artifactId": "ignored", + "kind": "report", + "title": "结构化报告", + "status": "ready", + "language": "en-US", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "摘要" } + ], + "sources": [ + { "id": "source-1", "title": "OpenAI", "url": "https://openai.com" } + ], + "metadata": {} + }) + .to_string(); + + let outcome = validate_or_fallback_artifact_document(&raw, &base_context()); + assert_eq!(outcome.title, "结构化报告"); + assert_eq!(outcome.status, "ready"); + assert!(!outcome.fallback_used); + assert_eq!( + outcome.document.get("artifactId").and_then(Value::as_str), + Some("artifact-1") + ); + assert_eq!( + outcome.document.get("language").and_then(Value::as_str), + Some("zh-CN") + ); + } + + #[test] + fn validate_or_fallback_should_accept_draft_envelope_and_repair_blocks() { + let raw = serde_json::json!({ + "type": "artifact_document_draft", + "document": { + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "包裹文档", + "status": "ready", + "blocks": [ + { "type": "unknown_block", "text": "正文" }, + { "type": "citation_list", "sourceIds": ["source-1"] } + ], + "sources": [ + { "id": "source-1", "title": "OpenAI", "url": "https://openai.com" } + ], + "metadata": {} + } + }) + .to_string(); + + let outcome = validate_or_fallback_artifact_document(&raw, &base_context()); + let blocks = outcome + .document + .get("blocks") + .and_then(Value::as_array) + .expect("blocks should exist"); + + assert!(outcome.repaired); + assert_eq!(blocks.len(), 2); + assert_eq!( + blocks[0].get("type").and_then(Value::as_str), + Some("rich_text") + ); + assert_eq!( + blocks[1] + .get("items") + .and_then(Value::as_array) + .map(|items| !items.is_empty()), + Some(true) + ); + } + + #[test] + fn validate_or_fallback_should_mark_failed_when_sources_required_but_missing() { + let raw = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "缺来源文档", + "blocks": [ + { "type": "hero_summary", "summary": "摘要" } + ], + "sources": [], + "metadata": {} + }) + .to_string(); + + let outcome = validate_or_fallback_artifact_document(&raw, &base_context()); + assert_eq!(outcome.status, "failed"); + assert!(outcome.repaired); + assert!(!outcome.fallback_used); + } + + #[test] + fn validate_or_fallback_should_build_failed_fallback_for_invalid_json() { + let outcome = validate_or_fallback_artifact_document( + "这不是 JSON,只是一段普通文本。", + &base_context(), + ); + assert_eq!(outcome.status, "failed"); + assert!(outcome.fallback_used); + assert!( + outcome + .document + .get("blocks") + .and_then(Value::as_array) + .expect("blocks should exist") + .first() + .and_then(|block| block.get("type")) + .and_then(Value::as_str) + == Some("rich_text") + ); + } +} diff --git a/src-tauri/src/services/artifact_ops_service.rs b/src-tauri/src/services/artifact_ops_service.rs new file mode 100644 index 000000000..c18bcb1a8 --- /dev/null +++ b/src-tauri/src/services/artifact_ops_service.rs @@ -0,0 +1,1081 @@ +//! Artifact ops 应用服务 +//! +//! 负责解析 `artifact_ops` 包络,并把增量操作应用到现有 +//! `ArtifactDocument v1`,为后续 rewrite / diff / 版本化打地基。 + +use crate::services::artifact_document_validator::{ + ArtifactDocumentValidationContext, ARTIFACT_DOCUMENT_SCHEMA_VERSION, +}; +use serde_json::{Map, Value}; + +const ARTIFACT_OPS_ENVELOPE_TYPE: &str = "artifact_ops"; +const ARTIFACT_REWRITE_PATCH_ENVELOPE_TYPE: &str = "artifact_rewrite_patch"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtifactOpsApplyOutcome { + pub document: Value, + pub issues: Vec, +} + +pub fn extract_artifact_ops_candidate(raw_text: &str) -> Option { + let trimmed = raw_text.trim(); + if trimmed.is_empty() { + return None; + } + + let candidates = [ + trimmed.to_string(), + strip_outer_code_fence(trimmed), + extract_first_fenced_payload(trimmed).unwrap_or_default(), + extract_braced_json_candidate(trimmed).unwrap_or_default(), + ]; + + for candidate in candidates { + let normalized = candidate.trim(); + if normalized.is_empty() { + continue; + } + let Ok(parsed) = serde_json::from_str::(normalized) else { + continue; + }; + if let Some(ops) = unwrap_artifact_ops_envelope(&parsed) { + return Some(ops.clone()); + } + if let Some(rewrite_patch) = unwrap_artifact_rewrite_patch_envelope(&parsed) { + if let Some(ops) = convert_rewrite_patch_to_artifact_ops(rewrite_patch) { + return Some(ops); + } + } + } + + None +} + +pub fn apply_artifact_ops_to_document( + base_document: Option<&Value>, + ops_value: &Value, + context: &ArtifactDocumentValidationContext, +) -> ArtifactOpsApplyOutcome { + let mut issues = Vec::new(); + let mut document = initialize_document(base_document, context); + let Some(record) = ops_value.as_object() else { + issues.push("artifact_ops 顶层不是对象,已回退为空文档骨架。".to_string()); + return ArtifactOpsApplyOutcome { document, issues }; + }; + + let ops = record + .get("ops") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if ops.is_empty() { + issues.push("artifact_ops 没有提供可执行的 ops,已沿用现有文档骨架。".to_string()); + return ArtifactOpsApplyOutcome { document, issues }; + } + + for (index, op) in ops.iter().enumerate() { + apply_single_op(&mut document, op, index, context, &mut issues); + } + + ArtifactOpsApplyOutcome { document, issues } +} + +fn unwrap_artifact_ops_envelope(value: &Value) -> Option<&Value> { + let record = value.as_object()?; + let op_type = record + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if op_type == Some(ARTIFACT_OPS_ENVELOPE_TYPE) { + return Some(value); + } + + if record.get("ops").and_then(Value::as_array).is_some() { + return Some(value); + } + + None +} + +fn unwrap_artifact_rewrite_patch_envelope(value: &Value) -> Option<&Value> { + let record = value.as_object()?; + let patch_type = record + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if patch_type == Some(ARTIFACT_REWRITE_PATCH_ENVELOPE_TYPE) { + return Some(value); + } + + if record + .get("targetBlockId") + .or_else(|| record.get("target_block_id")) + .is_some() + && record.get("block").and_then(Value::as_object).is_some() + { + return Some(value); + } + + None +} + +fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option { + let record = value.as_object()?; + let block_record = record.get("block").and_then(Value::as_object)?.clone(); + let block_id = normalize_text(block_record.get("id").and_then(Value::as_str))?; + let target_block_id = normalize_text( + record + .get("targetBlockId") + .or_else(|| record.get("target_block_id")) + .and_then(Value::as_str), + ) + .unwrap_or_else(|| block_id.clone()); + if block_id != target_block_id { + return None; + } + + let mut ops = vec![serde_json::json!({ + "op": "artifact.upsert_block", + "block": Value::Object(block_record) + })]; + + if let Some(source) = record.get("source") { + ops.push(serde_json::json!({ + "op": "artifact.attach_source", + "blockId": target_block_id, + "source": source + })); + } + if let Some(sources) = record.get("sources").and_then(Value::as_array) { + for source in sources { + ops.push(serde_json::json!({ + "op": "artifact.attach_source", + "blockId": target_block_id, + "source": source + })); + } + } + + let mut finalize_record = Map::new(); + finalize_record.insert( + "op".to_string(), + Value::String("artifact.finalize_version".to_string()), + ); + if let Some(summary) = normalize_text(record.get("summary").and_then(Value::as_str)) { + finalize_record.insert("summary".to_string(), Value::String(summary)); + } + if let Some(status) = normalize_text(record.get("status").and_then(Value::as_str)) { + finalize_record.insert("status".to_string(), Value::String(status)); + } + if finalize_record.len() > 1 { + ops.push(Value::Object(finalize_record)); + } + + let mut envelope = Map::new(); + envelope.insert( + "type".to_string(), + Value::String(ARTIFACT_OPS_ENVELOPE_TYPE.to_string()), + ); + if let Some(artifact_id) = normalize_text( + record + .get("artifactId") + .or_else(|| record.get("artifact_id")) + .and_then(Value::as_str), + ) { + envelope.insert("artifactId".to_string(), Value::String(artifact_id)); + } + envelope.insert("ops".to_string(), Value::Array(ops)); + Some(Value::Object(envelope)) +} + +fn strip_outer_code_fence(raw: &str) -> String { + let trimmed = raw.trim(); + if !(trimmed.starts_with("```") && trimmed.ends_with("```")) { + return trimmed.to_string(); + } + let mut lines = trimmed.lines(); + let _ = lines.next(); + let mut body = lines.collect::>(); + if !body.is_empty() { + body.pop(); + } + body.join("\n").trim().to_string() +} + +fn extract_first_fenced_payload(raw: &str) -> Option { + let start = raw.find("```")?; + let remainder = &raw[start + 3..]; + let newline_idx = remainder.find('\n')?; + let content_start = start + 3 + newline_idx + 1; + let end = raw[content_start..].find("```")?; + Some(raw[content_start..content_start + end].trim().to_string()) +} + +fn extract_braced_json_candidate(raw: &str) -> Option { + let start = raw.find('{')?; + let end = raw.rfind('}')?; + if end <= start { + return None; + } + Some(raw[start..=end].trim().to_string()) +} + +fn initialize_document( + base_document: Option<&Value>, + context: &ArtifactDocumentValidationContext, +) -> Value { + let mut document = base_document + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + document.insert( + "schemaVersion".to_string(), + Value::String(ARTIFACT_DOCUMENT_SCHEMA_VERSION.to_string()), + ); + document.insert( + "artifactId".to_string(), + Value::String(context.artifact_id.clone()), + ); + upsert_optional_string_field( + &mut document, + "workspaceId", + context.workspace_id.as_deref(), + ); + upsert_optional_string_field(&mut document, "threadId", context.thread_id.as_deref()); + upsert_optional_string_field(&mut document, "turnId", context.turn_id.as_deref()); + ensure_text_field( + &mut document, + "kind", + context.kind_hint.as_deref().unwrap_or("analysis"), + ); + ensure_text_field( + &mut document, + "title", + context.title_hint.as_deref().unwrap_or("未命名交付物"), + ); + ensure_text_field(&mut document, "status", "draft"); + ensure_text_field(&mut document, "language", "zh-CN"); + ensure_array_field(&mut document, "blocks"); + ensure_array_field(&mut document, "sources"); + let metadata = ensure_object_field(&mut document, "metadata"); + if metadata + .get("generatedBy") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + metadata.insert( + "generatedBy".to_string(), + Value::String("agent".to_string()), + ); + } + if metadata + .get("theme") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + if let Some(theme) = context.theme.as_deref() { + metadata.insert("theme".to_string(), Value::String(theme.to_string())); + } + } + if let Some(request_id) = context.request_id.as_deref() { + metadata.insert( + "artifactRequestId".to_string(), + Value::String(request_id.to_string()), + ); + } + Value::Object(document) +} + +fn apply_single_op( + document: &mut Value, + op_value: &Value, + index: usize, + context: &ArtifactDocumentValidationContext, + issues: &mut Vec, +) { + let Some(op_record) = op_value.as_object() else { + issues.push(format!("ops[{index}] 不是对象,已忽略。")); + return; + }; + let Some(op_name) = normalize_text(op_record.get("op").and_then(Value::as_str)) else { + issues.push(format!("ops[{index}] 缺少 op 字段,已忽略。")); + return; + }; + + match op_name.as_str() { + "artifact.create" => { + if context.target_block_id.is_some() { + issues.push(format!( + "ops[{index}] 在局部 rewrite 模式下不允许 `artifact.create`,已忽略。" + )); + return; + } + apply_create_op(document, op_record) + } + "artifact.set_meta" => apply_set_meta_op(document, op_record), + "artifact.upsert_block" => { + apply_upsert_block_op(document, op_record, index, context, issues) + } + "artifact.reorder_blocks" => { + if context.target_block_id.is_some() { + issues.push(format!( + "ops[{index}] 在局部 rewrite 模式下不允许重排 blocks,已忽略。" + )); + return; + } + apply_reorder_blocks_op(document, op_record, index, issues) + } + "artifact.remove_block" => { + apply_remove_block_op(document, op_record, index, context, issues) + } + "artifact.attach_source" => { + apply_attach_source_op(document, op_record, index, context, issues) + } + "artifact.finalize_version" => apply_finalize_version_op(document, op_record), + "artifact.fail" => apply_fail_op(document, op_record), + _ => issues.push(format!("ops[{index}].op `{op_name}` 暂不支持,已忽略。")), + } +} + +fn apply_create_op(document: &mut Value, op_record: &Map) { + let Some(document_record) = document.as_object_mut() else { + return; + }; + if let Some(patch) = op_record.get("document").and_then(Value::as_object) { + merge_document_patch(document_record, patch); + } + merge_meta_fields(document_record, op_record); +} + +fn apply_set_meta_op(document: &mut Value, op_record: &Map) { + let Some(document_record) = document.as_object_mut() else { + return; + }; + merge_meta_fields(document_record, op_record); +} + +fn apply_upsert_block_op( + document: &mut Value, + op_record: &Map, + index: usize, + context: &ArtifactDocumentValidationContext, + issues: &mut Vec, +) { + let Some(block_record) = op_record.get("block").and_then(Value::as_object) else { + issues.push(format!("ops[{index}] 缺少 block 对象,已忽略。")); + return; + }; + let Some(block_id) = normalize_text(block_record.get("id").and_then(Value::as_str)) else { + issues.push(format!("ops[{index}].block 缺少稳定 id,已忽略。")); + return; + }; + if !is_allowed_target_block(context, block_id.as_str()) { + issues.push(format!( + "ops[{index}] 试图改写非目标 block `{block_id}`,当前仅允许 `{}`,已忽略。", + context.target_block_id.as_deref().unwrap_or_default() + )); + return; + } + + let Some(document_record) = document.as_object_mut() else { + return; + }; + let blocks = ensure_array_field(document_record, "blocks"); + let previous_index = blocks.iter().position(|block| { + block + .as_object() + .and_then(|record| record.get("id")) + .and_then(Value::as_str) + .map(str::trim) + == Some(block_id.as_str()) + }); + if let Some(existing_index) = previous_index { + blocks.remove(existing_index); + } + + let insert_index = if context.target_block_id.is_some() { + previous_index.unwrap_or(blocks.len()) + } else { + resolve_block_insert_index(blocks, op_record) + }; + let block = Value::Object(block_record.clone()); + if insert_index >= blocks.len() { + blocks.push(block); + } else { + blocks.insert(insert_index, block); + } +} + +fn apply_reorder_blocks_op( + document: &mut Value, + op_record: &Map, + index: usize, + issues: &mut Vec, +) { + let desired_order = op_record + .get("blockIds") + .or_else(|| op_record.get("block_ids")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .filter_map(|value| normalize_text(Some(value))) + .collect::>() + }) + .unwrap_or_default(); + if desired_order.is_empty() { + issues.push(format!("ops[{index}] 缺少 blockIds,已忽略。")); + return; + } + + let Some(document_record) = document.as_object_mut() else { + return; + }; + let blocks = ensure_array_field(document_record, "blocks"); + let mut remaining = blocks.clone(); + let mut reordered = Vec::new(); + + for block_id in desired_order { + if let Some(position) = remaining.iter().position(|block| { + block + .as_object() + .and_then(|record| record.get("id")) + .and_then(Value::as_str) + .map(str::trim) + == Some(block_id.as_str()) + }) { + reordered.push(remaining.remove(position)); + } else { + issues.push(format!( + "ops[{index}] 指定的 block `{block_id}` 不存在,已忽略该排序项。" + )); + } + } + + reordered.extend(remaining); + *blocks = reordered; +} + +fn apply_remove_block_op( + document: &mut Value, + op_record: &Map, + index: usize, + context: &ArtifactDocumentValidationContext, + issues: &mut Vec, +) { + let Some(block_id) = normalize_text( + op_record + .get("blockId") + .or_else(|| op_record.get("block_id")) + .and_then(Value::as_str), + ) else { + issues.push(format!("ops[{index}] 缺少 blockId,已忽略。")); + return; + }; + if !is_allowed_target_block(context, block_id.as_str()) { + issues.push(format!( + "ops[{index}] 试图删除非目标 block `{block_id}`,当前仅允许 `{}`,已忽略。", + context.target_block_id.as_deref().unwrap_or_default() + )); + return; + }; + if context.target_block_id.is_some() { + issues.push(format!( + "ops[{index}] 局部 rewrite 不允许删除目标 block `{block_id}`,已忽略。" + )); + return; + } + + let Some(document_record) = document.as_object_mut() else { + return; + }; + let blocks = ensure_array_field(document_record, "blocks"); + let previous_len = blocks.len(); + blocks.retain(|block| { + block + .as_object() + .and_then(|record| record.get("id")) + .and_then(Value::as_str) + .map(str::trim) + != Some(block_id.as_str()) + }); + if previous_len == blocks.len() { + issues.push(format!( + "ops[{index}] 指定的 block `{block_id}` 不存在,已忽略。" + )); + } +} + +fn apply_attach_source_op( + document: &mut Value, + op_record: &Map, + index: usize, + context: &ArtifactDocumentValidationContext, + issues: &mut Vec, +) { + let Some(block_id) = normalize_text( + op_record + .get("blockId") + .or_else(|| op_record.get("block_id")) + .and_then(Value::as_str), + ) else { + issues.push(format!("ops[{index}] 缺少 blockId,已忽略来源绑定。")); + return; + }; + if !is_allowed_target_block(context, block_id.as_str()) { + issues.push(format!( + "ops[{index}] 试图给非目标 block `{block_id}` 绑定来源,当前仅允许 `{}`,已忽略。", + context.target_block_id.as_deref().unwrap_or_default() + )); + return; + } + let Some(source_record) = op_record.get("source").and_then(Value::as_object) else { + issues.push(format!("ops[{index}] 缺少 source 对象,已忽略来源绑定。")); + return; + }; + + let Some(document_record) = document.as_object_mut() else { + return; + }; + let sources = ensure_array_field(document_record, "sources"); + let next_source_id = normalize_text(source_record.get("id").and_then(Value::as_str)) + .unwrap_or_else(|| format!("source-{}", sources.len() + 1)); + let mut merged_source = source_record.clone(); + merged_source.insert("id".to_string(), Value::String(next_source_id.clone())); + if let Some(link_record) = op_record.get("sourceLink").and_then(Value::as_object) { + if !merged_source.contains_key("locator") { + if let Some(locator) = link_record.get("locator") { + merged_source.insert("locator".to_string(), locator.clone()); + } + } + if merged_source + .get("kind") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + if let Some(source_type) = normalize_text( + link_record + .get("sourceType") + .or_else(|| link_record.get("source_type")) + .and_then(Value::as_str), + ) { + merged_source.insert("kind".to_string(), Value::String(source_type)); + } + } + if merged_source + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + if let Some(source_ref) = normalize_text( + link_record + .get("sourceRef") + .or_else(|| link_record.get("source_ref")) + .and_then(Value::as_str), + ) { + merged_source.insert("url".to_string(), Value::String(source_ref)); + } + } + } + + if let Some(existing_index) = sources.iter().position(|source| { + source + .as_object() + .and_then(|record| record.get("id")) + .and_then(Value::as_str) + .map(str::trim) + == Some(next_source_id.as_str()) + }) { + if let Some(existing) = sources[existing_index].as_object_mut() { + for (key, value) in merged_source { + existing.insert(key, value); + } + } + } else { + sources.push(Value::Object(merged_source)); + } + + let blocks = ensure_array_field(document_record, "blocks"); + let Some(block) = blocks.iter_mut().find(|block| { + block + .as_object() + .and_then(|record| record.get("id")) + .and_then(Value::as_str) + .map(str::trim) + == Some(block_id.as_str()) + }) else { + issues.push(format!( + "ops[{index}] 目标 block `{block_id}` 不存在,已保留 source 但未完成绑定。" + )); + return; + }; + let Some(block_record) = block.as_object_mut() else { + return; + }; + let source_ids = ensure_array_field(block_record, "sourceIds"); + let already_exists = source_ids + .iter() + .any(|value| value.as_str().map(str::trim) == Some(next_source_id.as_str())); + if !already_exists { + source_ids.push(Value::String(next_source_id)); + } +} + +fn is_allowed_target_block(context: &ArtifactDocumentValidationContext, block_id: &str) -> bool { + context + .target_block_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none_or(|target_block_id| target_block_id == block_id) +} + +fn apply_finalize_version_op(document: &mut Value, op_record: &Map) { + let Some(document_record) = document.as_object_mut() else { + return; + }; + if let Some(status) = normalize_text(op_record.get("status").and_then(Value::as_str)) { + document_record.insert("status".to_string(), Value::String(status)); + } + if let Some(summary) = normalize_text(op_record.get("summary").and_then(Value::as_str)) { + let metadata = ensure_object_field(document_record, "metadata"); + metadata.insert("versionSummary".to_string(), Value::String(summary)); + } +} + +fn apply_fail_op(document: &mut Value, op_record: &Map) { + let Some(document_record) = document.as_object_mut() else { + return; + }; + document_record.insert("status".to_string(), Value::String("failed".to_string())); + if let Some(reason) = normalize_text( + op_record + .get("reason") + .or_else(|| op_record.get("message")) + .and_then(Value::as_str), + ) { + let metadata = ensure_object_field(document_record, "metadata"); + metadata.insert("failureReason".to_string(), Value::String(reason)); + } +} + +fn merge_document_patch(document_record: &mut Map, patch: &Map) { + for key in [ + "schemaVersion", + "artifactId", + "workspaceId", + "threadId", + "turnId", + "kind", + "title", + "status", + "language", + "summary", + "blocks", + "sources", + ] { + if let Some(value) = patch.get(key) { + document_record.insert(key.to_string(), value.clone()); + } + } + if let Some(metadata) = patch.get("metadata").and_then(Value::as_object) { + let target = ensure_object_field(document_record, "metadata"); + for (key, value) in metadata { + target.insert(key.clone(), value.clone()); + } + } +} + +fn merge_meta_fields(document_record: &mut Map, op_record: &Map) { + for field in ["title", "kind", "status", "summary"] { + if let Some(value) = op_record.get(field).and_then(Value::as_str) { + if let Some(normalized) = normalize_text(Some(value)) { + document_record.insert(field.to_string(), Value::String(normalized)); + } + } + } + if let Some(metadata) = op_record.get("metadata").and_then(Value::as_object) { + let target = ensure_object_field(document_record, "metadata"); + for (key, value) in metadata { + target.insert(key.clone(), value.clone()); + } + } +} + +fn resolve_block_insert_index(blocks: &[Value], op_record: &Map) -> usize { + if let Some(before_block_id) = normalize_text( + op_record + .get("beforeBlockId") + .or_else(|| op_record.get("before_block_id")) + .and_then(Value::as_str), + ) { + if let Some(position) = blocks.iter().position(|block| { + block + .as_object() + .and_then(|record| record.get("id")) + .and_then(Value::as_str) + .map(str::trim) + == Some(before_block_id.as_str()) + }) { + return position; + } + } + + if let Some(after_block_id) = normalize_text( + op_record + .get("afterBlockId") + .or_else(|| op_record.get("after_block_id")) + .and_then(Value::as_str), + ) { + if let Some(position) = blocks.iter().position(|block| { + block + .as_object() + .and_then(|record| record.get("id")) + .and_then(Value::as_str) + .map(str::trim) + == Some(after_block_id.as_str()) + }) { + return position + 1; + } + } + + blocks.len() +} + +fn ensure_array_field<'a>(record: &'a mut Map, key: &str) -> &'a mut Vec { + if !record.get(key).is_some_and(Value::is_array) { + record.insert(key.to_string(), Value::Array(Vec::new())); + } + record + .get_mut(key) + .and_then(Value::as_array_mut) + .expect("array field should exist") +} + +fn ensure_object_field<'a>( + record: &'a mut Map, + key: &str, +) -> &'a mut Map { + if !record.get(key).is_some_and(Value::is_object) { + record.insert(key.to_string(), Value::Object(Map::new())); + } + record + .get_mut(key) + .and_then(Value::as_object_mut) + .expect("object field should exist") +} + +fn ensure_text_field(record: &mut Map, key: &str, fallback: &str) { + let has_value = record + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + if !has_value { + record.insert(key.to_string(), Value::String(fallback.to_string())); + } +} + +fn upsert_optional_string_field(record: &mut Map, key: &str, value: Option<&str>) { + if let Some(normalized) = normalize_text(value) { + record.insert(key.to_string(), Value::String(normalized)); + } +} + +fn normalize_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn build_context() -> ArtifactDocumentValidationContext { + ArtifactDocumentValidationContext { + artifact_id: "artifact-document:demo".to_string(), + workspace_id: Some("workspace-1".to_string()), + thread_id: Some("thread-1".to_string()), + turn_id: Some("turn-2".to_string()), + title_hint: Some("季度结论".to_string()), + kind_hint: Some("analysis".to_string()), + theme: Some("knowledge".to_string()), + source_policy: Some("required".to_string()), + request_id: Some("artifact:demo".to_string()), + target_block_id: None, + } + } + + #[test] + fn should_extract_artifact_ops_from_fenced_json() { + let raw = r#" +```json +{ + "type": "artifact_ops", + "ops": [ + { + "op": "artifact.set_meta", + "title": "更新后的标题" + } + ] +} +``` +"#; + + let extracted = extract_artifact_ops_candidate(raw).expect("ops envelope"); + assert_eq!( + extracted.get("type").and_then(Value::as_str).map(str::trim), + Some("artifact_ops") + ); + } + + #[test] + fn should_convert_rewrite_patch_to_artifact_ops_candidate() { + let raw = r#" +{ + "type": "artifact_rewrite_patch", + "artifactId": "artifact-document:demo", + "targetBlockId": "body-1", + "block": { + "id": "body-1", + "type": "rich_text", + "markdown": "改写后的正文" + }, + "source": { + "id": "source-1", + "title": "OpenAI", + "url": "https://openai.com" + }, + "summary": "仅改写目标正文" +} +"#; + + let extracted = extract_artifact_ops_candidate(raw).expect("ops envelope"); + let ops = extracted + .get("ops") + .and_then(Value::as_array) + .expect("ops array"); + + assert_eq!( + extracted.get("type").and_then(Value::as_str).map(str::trim), + Some("artifact_ops") + ); + assert_eq!(ops.len(), 3); + assert_eq!( + ops.first() + .and_then(|op| op.get("op")) + .and_then(Value::as_str), + Some("artifact.upsert_block") + ); + assert_eq!( + ops.get(1) + .and_then(|op| op.get("op")) + .and_then(Value::as_str), + Some("artifact.attach_source") + ); + assert_eq!( + ops.get(2) + .and_then(|op| op.get("summary")) + .and_then(Value::as_str), + Some("仅改写目标正文") + ); + } + + #[test] + fn should_apply_upsert_block_and_attach_source_on_existing_document() { + let base_document = json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "artifactId": "artifact-document:demo", + "kind": "analysis", + "title": "季度结论", + "status": "ready", + "language": "zh-CN", + "summary": "旧摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "旧摘要" }, + { "id": "body-1", "type": "rich_text", "markdown": "旧正文" } + ], + "sources": [], + "metadata": { + "generatedBy": "agent" + } + }); + let ops = json!({ + "type": "artifact_ops", + "ops": [ + { + "op": "artifact.upsert_block", + "block": { + "id": "body-1", + "type": "rich_text", + "markdown": "新正文" + }, + "afterBlockId": "hero-1" + }, + { + "op": "artifact.attach_source", + "blockId": "body-1", + "source": { + "id": "source-1", + "title": "OpenAI", + "url": "https://openai.com" + } + }, + { + "op": "artifact.finalize_version", + "summary": "补充了新的证据来源" + } + ] + }); + + let outcome = apply_artifact_ops_to_document(Some(&base_document), &ops, &build_context()); + + assert!(outcome.issues.is_empty()); + let body_block = outcome + .document + .get("blocks") + .and_then(Value::as_array) + .and_then(|blocks| { + blocks.iter().find(|block| { + block.get("id").and_then(Value::as_str).map(str::trim) == Some("body-1") + }) + }) + .expect("body block"); + assert_eq!( + body_block + .get("markdown") + .and_then(Value::as_str) + .map(str::trim), + Some("新正文") + ); + assert_eq!( + body_block + .get("sourceIds") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_str), + Some("source-1") + ); + assert_eq!( + outcome + .document + .get("metadata") + .and_then(Value::as_object) + .and_then(|metadata| metadata.get("versionSummary")) + .and_then(Value::as_str) + .map(str::trim), + Some("补充了新的证据来源") + ); + } + + #[test] + fn should_restrict_rewrite_ops_to_target_block() { + let base_document = json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "artifactId": "artifact-document:demo", + "kind": "analysis", + "title": "季度结论", + "status": "ready", + "language": "zh-CN", + "summary": "旧摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "旧摘要" }, + { "id": "body-1", "type": "rich_text", "markdown": "旧正文 1" }, + { "id": "body-2", "type": "rich_text", "markdown": "旧正文 2" } + ], + "sources": [], + "metadata": { + "generatedBy": "agent" + } + }); + let ops = json!({ + "type": "artifact_ops", + "ops": [ + { + "op": "artifact.upsert_block", + "block": { + "id": "body-2", + "type": "rich_text", + "markdown": "不应被应用" + } + }, + { + "op": "artifact.attach_source", + "blockId": "body-2", + "source": { + "id": "source-2", + "title": "不应绑定", + "url": "https://example.com" + } + }, + { + "op": "artifact.upsert_block", + "block": { + "id": "body-1", + "type": "rich_text", + "markdown": "目标正文" + }, + "afterBlockId": "body-2" + } + ] + }); + let mut context = build_context(); + context.target_block_id = Some("body-1".to_string()); + + let outcome = apply_artifact_ops_to_document(Some(&base_document), &ops, &context); + + let blocks = outcome + .document + .get("blocks") + .and_then(Value::as_array) + .expect("blocks"); + let body_1 = blocks + .iter() + .find(|block| block.get("id").and_then(Value::as_str) == Some("body-1")) + .expect("body-1"); + let body_2 = blocks + .iter() + .find(|block| block.get("id").and_then(Value::as_str) == Some("body-2")) + .expect("body-2"); + + assert_eq!( + body_1.get("markdown").and_then(Value::as_str), + Some("目标正文") + ); + assert_eq!( + body_2.get("markdown").and_then(Value::as_str), + Some("旧正文 2") + ); + assert_eq!( + blocks + .iter() + .position(|block| block.get("id").and_then(Value::as_str) == Some("body-1")), + Some(1) + ); + assert_eq!( + blocks + .iter() + .position(|block| block.get("id").and_then(Value::as_str) == Some("body-2")), + Some(2) + ); + assert_eq!(outcome.issues.len(), 2); + assert!(outcome + .issues + .iter() + .any(|issue| issue.contains("非目标 block `body-2`"))); + } +} diff --git a/src-tauri/src/services/artifact_output_schema_service.rs b/src-tauri/src/services/artifact_output_schema_service.rs new file mode 100644 index 000000000..e100b580c --- /dev/null +++ b/src-tauri/src/services/artifact_output_schema_service.rs @@ -0,0 +1,1077 @@ +//! Artifact 输出 Schema 服务 +//! +//! 根据 turn metadata 为 Artifact 回合生成 turn-level output schema, +//! 让模型输出从“提示词倾向”升级为“运行时结构合同”。 + +use aster::session::{TurnContextOverride, TurnOutputSchemaSource}; +use serde_json::{json, Value}; + +const ARTIFACT_DOCUMENT_SCHEMA_VERSION: &str = "artifact_document.v1"; +const ARTIFACT_KIND_VALUES: &[&str] = &[ + "report", + "roadmap", + "prd", + "brief", + "analysis", + "comparison", + "plan", + "table_report", +]; +const ARTIFACT_STAGE1_BLOCK_TYPE_VALUES: &[&str] = &[ + "section_header", + "hero_summary", + "key_points", + "rich_text", + "callout", + "table", + "checklist", + "metric_grid", + "citation_list", +]; +const ARTIFACT_BLOCK_TYPE_VALUES: &[&str] = &[ + "section_header", + "hero_summary", + "key_points", + "rich_text", + "callout", + "table", + "checklist", + "metric_grid", + "quote", + "citation_list", + "image", + "code_block", + "divider", +]; +const ARTIFACT_STATUS_VALUES: &[&str] = &["draft", "streaming", "ready", "failed", "archived"]; +const ARTIFACT_SOURCE_POLICY_VALUES: &[&str] = &["required", "preferred", "none"]; +const ARTIFACT_STAGE2_OP_VALUES: &[&str] = &[ + "artifact.create", + "artifact.set_meta", + "artifact.upsert_block", + "artifact.reorder_blocks", + "artifact.remove_block", + "artifact.attach_source", + "artifact.finalize_version", + "artifact.fail", +]; +const ARTIFACT_REWRITE_OP_VALUES: &[&str] = &[ + "artifact.set_meta", + "artifact.upsert_block", + "artifact.attach_source", + "artifact.finalize_version", + "artifact.fail", +]; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ArtifactOutputSchemaContext { + mode: Option, + kind: Option, + stage: Option, + source_policy: Option, + target_block_id: Option, +} + +fn normalize_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn extract_artifact_object( + request_metadata: Option<&Value>, +) -> Option<&serde_json::Map> { + let metadata = request_metadata?; + let object = metadata.as_object()?; + if let Some(artifact) = object.get("artifact").and_then(Value::as_object) { + return Some(artifact); + } + Some(object) +} + +fn extract_artifact_string(request_metadata: Option<&Value>, keys: &[&str]) -> Option { + let artifact = extract_artifact_object(request_metadata)?; + keys.iter() + .filter_map(|key| artifact.get(*key)) + .find_map(Value::as_str) + .and_then(|value| normalize_text(Some(value))) +} + +fn build_artifact_output_schema_context( + request_metadata: Option<&Value>, +) -> Option { + let context = ArtifactOutputSchemaContext { + mode: extract_artifact_string(request_metadata, &["artifact_mode", "artifactMode"]), + kind: extract_artifact_string(request_metadata, &["artifact_kind", "artifactKind"]), + stage: extract_artifact_string(request_metadata, &["artifact_stage", "artifactStage"]), + source_policy: extract_artifact_string( + request_metadata, + &["source_policy", "sourcePolicy"], + ), + target_block_id: extract_artifact_string( + request_metadata, + &["artifact_target_block_id", "artifactTargetBlockId"], + ), + }; + + if context.mode.is_none() + && context.kind.is_none() + && context.stage.is_none() + && context.source_policy.is_none() + && context.target_block_id.is_none() + { + None + } else { + Some(context) + } +} + +fn build_string_schema( + values: &[&str], + narrowed: Option<&str>, + fallback_default: Option<&str>, +) -> Value { + if let Some(value) = narrowed { + return json!({ + "type": "string", + "enum": [value] + }); + } + + if let Some(default_value) = fallback_default { + json!({ + "type": "string", + "enum": values, + "default": default_value + }) + } else { + json!({ + "type": "string", + "enum": values + }) + } +} + +fn build_stage1_output_schema(context: &ArtifactOutputSchemaContext) -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "lime_artifact_stage1_result", + "type": "object", + "additionalProperties": false, + "required": [ + "needsArtifact", + "kind", + "title", + "sourcePolicy", + "outline", + "blockPlan" + ], + "properties": { + "needsArtifact": { + "type": "boolean" + }, + "kind": build_string_schema( + ARTIFACT_KIND_VALUES, + context.kind.as_deref(), + Some("analysis") + ), + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "sourcePolicy": build_string_schema( + ARTIFACT_SOURCE_POLICY_VALUES, + context.source_policy.as_deref(), + Some("preferred") + ), + "outline": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "goal"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "goal": { + "type": "string", + "minLength": 1 + } + } + } + }, + "blockPlan": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "purpose"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ARTIFACT_STAGE1_BLOCK_TYPE_VALUES + }, + "sectionId": { + "type": "string", + "minLength": 1 + }, + "purpose": { + "type": "string", + "minLength": 1 + } + } + } + }, + "gaps": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + }) +} + +fn build_stage2_document_schema(context: &ArtifactOutputSchemaContext) -> Value { + let required_source_count = if context.source_policy.as_deref() == Some("required") { + 1 + } else { + 0 + }; + + json!({ + "type": "object", + "additionalProperties": true, + "required": [ + "schemaVersion", + "kind", + "title", + "status", + "language", + "blocks", + "sources", + "metadata" + ], + "properties": { + "schemaVersion": { + "type": "string", + "enum": [ARTIFACT_DOCUMENT_SCHEMA_VERSION] + }, + "artifactId": { + "type": "string", + "minLength": 1 + }, + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "threadId": { + "type": "string", + "minLength": 1 + }, + "turnId": { + "type": "string", + "minLength": 1 + }, + "kind": build_string_schema( + ARTIFACT_KIND_VALUES, + context.kind.as_deref(), + Some("analysis") + ), + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "status": { + "type": "string", + "enum": ARTIFACT_STATUS_VALUES + }, + "language": { + "type": "string", + "enum": ["zh-CN"] + }, + "summary": { + "type": "string" + }, + "blocks": { + "type": "array", + "minItems": 1, + "maxItems": 40, + "items": { + "type": "object", + "additionalProperties": true, + "required": ["id", "type"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ARTIFACT_BLOCK_TYPE_VALUES + }, + "sectionId": { + "type": "string", + "minLength": 1 + }, + "hidden": { + "type": "boolean" + }, + "sourceIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "sources": { + "type": "array", + "minItems": required_source_count, + "items": { + "type": "object", + "additionalProperties": true, + "required": ["id"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "note": { + "type": "string" + }, + "quote": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "publishedAt": { + "type": "string" + } + }, + "anyOf": [ + { "required": ["title"] }, + { "required": ["url"] }, + { "required": ["note"] }, + { "required": ["quote"] } + ] + } + }, + "metadata": { + "type": "object" + } + } + }) +} + +fn build_artifact_block_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": true, + "required": ["id", "type"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ARTIFACT_BLOCK_TYPE_VALUES + }, + "sectionId": { + "type": "string", + "minLength": 1 + }, + "hidden": { + "type": "boolean" + }, + "sourceIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + }) +} + +fn build_artifact_source_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "note": { + "type": "string" + }, + "quote": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "publishedAt": { + "type": "string" + }, + "locator": {} + }, + "anyOf": [ + { "required": ["id"] }, + { "required": ["title"] }, + { "required": ["url"] }, + { "required": ["note"] }, + { "required": ["quote"] } + ] + }) +} + +fn build_stage2_document_envelope_schema(context: &ArtifactOutputSchemaContext) -> Value { + json!({ + "title": "lime_artifact_document_draft", + "type": "object", + "additionalProperties": false, + "required": ["type", "document"], + "properties": { + "type": { + "type": "string", + "enum": ["artifact_document_draft"] + }, + "document": build_stage2_document_schema(context) + } + }) +} + +fn build_block_id_constraint_schema(target_block_id: Option<&str>) -> Value { + if let Some(target_block_id) = target_block_id { + json!({ + "type": "string", + "enum": [target_block_id] + }) + } else { + json!({ + "type": "string", + "minLength": 1 + }) + } +} + +fn build_artifact_block_schema_with_target_id(target_block_id: Option<&str>) -> Value { + let mut schema = build_artifact_block_schema(); + if let Some(target_block_id) = target_block_id { + if let Some(properties) = schema + .as_object_mut() + .and_then(|record| record.get_mut("properties")) + .and_then(Value::as_object_mut) + { + properties.insert( + "id".to_string(), + build_block_id_constraint_schema(Some(target_block_id)), + ); + } + } + schema +} + +fn build_artifact_ops_item_schema(op_values: &[&str], target_block_id: Option<&str>) -> Value { + let mut item_schemas = vec![ + json!({ + "type": "object", + "additionalProperties": false, + "required": ["op"], + "properties": { + "op": build_string_schema(op_values, Some("artifact.set_meta"), None), + "title": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ARTIFACT_KIND_VALUES + }, + "status": { + "type": "string", + "enum": ARTIFACT_STATUS_VALUES + }, + "summary": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "anyOf": [ + { "required": ["title"] }, + { "required": ["kind"] }, + { "required": ["status"] }, + { "required": ["summary"] }, + { "required": ["metadata"] } + ] + }), + json!({ + "type": "object", + "additionalProperties": false, + "required": ["op", "block"], + "properties": { + "op": build_string_schema(op_values, Some("artifact.upsert_block"), None), + "block": build_artifact_block_schema_with_target_id(target_block_id), + "beforeBlockId": build_block_id_constraint_schema(None), + "afterBlockId": build_block_id_constraint_schema(None) + } + }), + json!({ + "type": "object", + "additionalProperties": false, + "required": ["op", "blockId", "source"], + "properties": { + "op": build_string_schema(op_values, Some("artifact.attach_source"), None), + "blockId": build_block_id_constraint_schema(target_block_id), + "source": build_artifact_source_schema(), + "sourceLink": { + "type": "object", + "additionalProperties": true + } + } + }), + json!({ + "type": "object", + "additionalProperties": false, + "required": ["op"], + "properties": { + "op": build_string_schema(op_values, Some("artifact.finalize_version"), None), + "summary": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ARTIFACT_STATUS_VALUES + } + } + }), + json!({ + "type": "object", + "additionalProperties": false, + "required": ["op", "reason"], + "properties": { + "op": build_string_schema(op_values, Some("artifact.fail"), None), + "reason": { + "type": "string", + "minLength": 1 + } + } + }), + ]; + + if op_values.contains(&"artifact.create") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["op"], + "properties": { + "op": build_string_schema(op_values, Some("artifact.create"), None), + "document": { + "type": "object" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ARTIFACT_KIND_VALUES + }, + "status": { + "type": "string", + "enum": ARTIFACT_STATUS_VALUES + }, + "summary": { + "type": "string" + }, + "metadata": { + "type": "object" + } + } + })); + } + + if op_values.contains(&"artifact.reorder_blocks") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["op", "blockIds"], + "properties": { + "op": build_string_schema(op_values, Some("artifact.reorder_blocks"), None), + "blockIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + } + })); + } + + if op_values.contains(&"artifact.remove_block") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["op", "blockId"], + "properties": { + "op": build_string_schema(op_values, Some("artifact.remove_block"), None), + "blockId": build_block_id_constraint_schema(target_block_id) + } + })); + } + + if target_block_id.is_some() { + item_schemas = item_schemas + .into_iter() + .map(|schema| { + let Some(record) = schema.as_object() else { + return schema; + }; + let Some(properties) = record.get("properties").and_then(Value::as_object) else { + return schema; + }; + if !properties.contains_key("block") { + return schema; + } + + let mut next_record = record.clone(); + if let Some(next_properties) = next_record + .get_mut("properties") + .and_then(Value::as_object_mut) + { + next_properties.remove("beforeBlockId"); + next_properties.remove("afterBlockId"); + } + Value::Object(next_record) + }) + .collect(); + } + + json!({ + "oneOf": item_schemas + }) +} + +fn build_artifact_ops_output_schema(op_values: &[&str], target_block_id: Option<&str>) -> Value { + json!({ + "title": "lime_artifact_ops", + "type": "object", + "additionalProperties": false, + "required": ["type", "ops"], + "properties": { + "type": { + "type": "string", + "enum": ["artifact_ops"] + }, + "artifactId": { + "type": "string", + "minLength": 1 + }, + "ops": { + "type": "array", + "minItems": 1, + "items": build_artifact_ops_item_schema(op_values, target_block_id) + } + } + }) +} + +fn build_artifact_rewrite_patch_output_schema(context: &ArtifactOutputSchemaContext) -> Value { + json!({ + "title": "lime_artifact_rewrite_patch", + "type": "object", + "additionalProperties": false, + "required": ["type", "targetBlockId", "block"], + "properties": { + "type": { + "type": "string", + "enum": ["artifact_rewrite_patch"] + }, + "artifactId": { + "type": "string", + "minLength": 1 + }, + "targetBlockId": build_block_id_constraint_schema(context.target_block_id.as_deref()), + "block": build_artifact_block_schema_with_target_id(context.target_block_id.as_deref()), + "source": build_artifact_source_schema(), + "sources": { + "type": "array", + "minItems": 1, + "items": build_artifact_source_schema() + }, + "summary": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ARTIFACT_STATUS_VALUES + } + } + }) +} + +fn build_stage2_output_schema(context: &ArtifactOutputSchemaContext) -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "lime_artifact_stage2_result", + "oneOf": [ + build_stage2_document_envelope_schema(context), + build_artifact_ops_output_schema(ARTIFACT_STAGE2_OP_VALUES, None) + ] + }) +} + +fn build_rewrite_output_schema(context: &ArtifactOutputSchemaContext) -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "lime_artifact_rewrite_result", + "oneOf": [ + build_artifact_rewrite_patch_output_schema(context), + build_artifact_ops_output_schema( + ARTIFACT_REWRITE_OP_VALUES, + context.target_block_id.as_deref() + ) + ] + }) +} + +pub fn build_artifact_output_schema(request_metadata: Option<&Value>) -> Option { + let context = build_artifact_output_schema_context(request_metadata)?; + if matches!(context.mode.as_deref(), Some("none")) { + return None; + } + + let stage = context + .stage + .as_deref() + .or(context.mode.as_deref()) + .unwrap_or("stage2"); + + match stage { + "stage1" => Some(build_stage1_output_schema(&context)), + "rewrite" => Some(build_rewrite_output_schema(&context)), + "stage2" => Some(build_stage2_output_schema(&context)), + _ => Some(build_stage2_output_schema(&context)), + } +} + +pub fn merge_turn_context_with_artifact_output_schema( + turn_context: Option, + request_metadata: Option<&Value>, +) -> Option { + let Some(output_schema) = build_artifact_output_schema(request_metadata) else { + return turn_context; + }; + + match turn_context { + Some(mut turn_context) => { + if turn_context.output_schema.is_none() { + turn_context.output_schema = Some(output_schema); + } + if turn_context.output_schema_source.is_none() { + turn_context.output_schema_source = Some(TurnOutputSchemaSource::Turn); + } + Some(turn_context) + } + None => Some(TurnContextOverride { + output_schema: Some(output_schema), + output_schema_source: Some(TurnOutputSchemaSource::Turn), + ..TurnContextOverride::default() + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage1_should_build_outline_schema() { + let metadata = json!({ + "artifact_mode": "draft", + "artifact_stage": "stage1", + "artifact_kind": "analysis", + "source_policy": "required" + }); + + let schema = build_artifact_output_schema(Some(&metadata)).expect("schema"); + assert_eq!(schema.get("type").and_then(Value::as_str), Some("object")); + assert_eq!( + schema + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("kind")) + .and_then(|kind| kind.get("enum")) + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_str), + Some("analysis") + ); + assert!(schema + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("blockPlan")) + .is_some()); + } + + #[test] + fn stage2_should_build_required_source_document_schema() { + let metadata = json!({ + "artifact": { + "artifact_mode": "draft", + "artifact_stage": "stage2", + "artifact_kind": "report", + "source_policy": "required" + } + }); + + let schema = build_artifact_output_schema(Some(&metadata)).expect("schema"); + assert_eq!( + schema + .get("oneOf") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("properties")) + .and_then(Value::as_object) + .and_then(|properties| properties.get("type")) + .and_then(|kind| kind.get("enum")) + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_str), + Some("artifact_document_draft") + ); + assert_eq!( + schema + .get("oneOf") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("properties")) + .and_then(Value::as_object) + .and_then(|properties| properties.get("document")) + .and_then(|document| document.get("properties")) + .and_then(Value::as_object) + .and_then(|properties| properties.get("sources")) + .and_then(|sources| sources.get("minItems")) + .and_then(Value::as_u64), + Some(1) + ); + } + + #[test] + fn rewrite_should_build_artifact_ops_schema() { + let metadata = json!({ + "artifact": { + "artifact_mode": "rewrite", + "artifact_stage": "rewrite", + "artifact_kind": "report", + "artifact_target_block_id": "body-1" + } + }); + + let schema = build_artifact_output_schema(Some(&metadata)).expect("schema"); + assert_eq!( + schema + .get("oneOf") + .and_then(Value::as_array) + .map(|items| items.len()), + Some(2) + ); + let rewrite_patch_schema = schema + .get("oneOf") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("op")) + .is_none() + && item + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("type")) + .and_then(|op| op.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str) + == Some("artifact_rewrite_patch") + }) + }) + .expect("rewrite patch schema"); + assert_eq!( + rewrite_patch_schema + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("targetBlockId")) + .and_then(|id| id.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str), + Some("body-1") + ); + assert_eq!( + rewrite_patch_schema + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("block")) + .and_then(|block| block.get("properties")) + .and_then(Value::as_object) + .and_then(|properties| properties.get("id")) + .and_then(|id| id.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str), + Some("body-1") + ); + let ops_schema = schema + .get("oneOf") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("type")) + .and_then(|op| op.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str) + == Some("artifact_ops") + }) + }) + .expect("ops schema"); + let upsert_schema = ops_schema + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("ops")) + .and_then(|ops| ops.get("items")) + .and_then(|items| items.get("oneOf")) + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("op")) + .and_then(|op| op.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str) + == Some("artifact.upsert_block") + }) + }) + .expect("upsert schema"); + assert_eq!( + upsert_schema + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("block")) + .and_then(|block| block.get("properties")) + .and_then(Value::as_object) + .and_then(|properties| properties.get("id")) + .and_then(|id| id.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str), + Some("body-1") + ); + assert!(upsert_schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(|properties| !properties.contains_key("beforeBlockId"))); + } + + #[test] + fn merge_turn_context_should_inject_output_schema_without_dropping_existing_context() { + let metadata = json!({ + "artifact_mode": "draft", + "artifact_stage": "stage2", + "artifact_kind": "plan" + }); + let turn_context = TurnContextOverride { + model: Some("gpt-5.4".to_string()), + metadata: std::collections::HashMap::from([( + "theme".to_string(), + Value::String("planning".to_string()), + )]), + ..TurnContextOverride::default() + }; + + let merged = + merge_turn_context_with_artifact_output_schema(Some(turn_context), Some(&metadata)) + .expect("merged turn context"); + + assert_eq!(merged.model.as_deref(), Some("gpt-5.4")); + assert_eq!( + merged.metadata.get("theme").and_then(Value::as_str), + Some("planning") + ); + assert!(merged.output_schema.is_some()); + assert_eq!( + merged.output_schema_source, + Some(TurnOutputSchemaSource::Turn) + ); + } + + #[test] + fn merge_turn_context_should_not_override_existing_output_schema() { + let existing_schema = json!({ + "type": "object", + "properties": { + "answer": { + "type": "string" + } + } + }); + let turn_context = TurnContextOverride { + output_schema: Some(existing_schema.clone()), + ..TurnContextOverride::default() + }; + let metadata = json!({ + "artifact_mode": "draft", + "artifact_stage": "stage2" + }); + + let merged = + merge_turn_context_with_artifact_output_schema(Some(turn_context), Some(&metadata)) + .expect("merged"); + + assert_eq!(merged.output_schema, Some(existing_schema)); + } +} diff --git a/src-tauri/src/services/artifact_prompt_service.rs b/src-tauri/src/services/artifact_prompt_service.rs new file mode 100644 index 000000000..25f3d8795 --- /dev/null +++ b/src-tauri/src/services/artifact_prompt_service.rs @@ -0,0 +1,429 @@ +//! Artifact Workbench 提示词装配服务 +//! +//! 统一根据 turn metadata 组装 Artifact 交付策略、来源策略与阶段合同, +//! 避免前端或 runtime_turn 直接散落拼接规则。 + +use serde_json::Value; + +const ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER: &str = "【Artifact 交付策略】"; +const ARTIFACT_SOURCE_POLICY_PROMPT_MARKER: &str = "【Artifact 来源策略】"; +const ARTIFACT_STAGE1_PROMPT_MARKER: &str = "【Artifact Stage 1 合同】"; +const ARTIFACT_STAGE2_PROMPT_MARKER: &str = "【Artifact Stage 2 合同】"; +const ARTIFACT_REWRITE_PROMPT_MARKER: &str = "【Artifact Rewrite 合同】"; +const ARTIFACT_SCHEMA_HINT_PROMPT_MARKER: &str = "【Artifact 输出 Schema 提示】"; +const ARTIFACT_DOCUMENT_SCHEMA_VERSION: &str = "artifact_document.v1"; +const ARTIFACT_ALLOWED_BLOCKS: &[&str] = &[ + "section_header", + "hero_summary", + "key_points", + "rich_text", + "callout", + "table", + "checklist", + "metric_grid", + "quote", + "citation_list", + "image", + "code_block", + "divider", +]; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ArtifactPromptContext { + mode: Option, + kind: Option, + stage: Option, + source_policy: Option, + workbench_surface: Option, + request_id: Option, + target_block_id: Option, + rewrite_instruction: Option, +} + +fn normalize_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn extract_artifact_object( + request_metadata: Option<&Value>, +) -> Option<&serde_json::Map> { + let metadata = request_metadata?; + let object = metadata.as_object()?; + if let Some(artifact) = object.get("artifact").and_then(Value::as_object) { + return Some(artifact); + } + Some(object) +} + +fn extract_artifact_string(request_metadata: Option<&Value>, keys: &[&str]) -> Option { + let artifact = extract_artifact_object(request_metadata)?; + keys.iter() + .filter_map(|key| artifact.get(*key)) + .find_map(Value::as_str) + .and_then(|value| normalize_text(Some(value))) +} + +fn build_artifact_prompt_context( + request_metadata: Option<&Value>, +) -> Option { + let context = ArtifactPromptContext { + mode: extract_artifact_string(request_metadata, &["artifact_mode", "artifactMode"]), + kind: extract_artifact_string(request_metadata, &["artifact_kind", "artifactKind"]), + stage: extract_artifact_string(request_metadata, &["artifact_stage", "artifactStage"]), + source_policy: extract_artifact_string( + request_metadata, + &["source_policy", "sourcePolicy"], + ), + workbench_surface: extract_artifact_string( + request_metadata, + &["workbench_surface", "workbenchSurface"], + ), + request_id: extract_artifact_string( + request_metadata, + &["artifact_request_id", "artifactRequestId"], + ), + target_block_id: extract_artifact_string( + request_metadata, + &["artifact_target_block_id", "artifactTargetBlockId"], + ), + rewrite_instruction: extract_artifact_string( + request_metadata, + &["artifact_rewrite_instruction", "artifactRewriteInstruction"], + ), + }; + + let has_meaningful_fields = [ + context.mode.as_ref(), + context.kind.as_ref(), + context.stage.as_ref(), + context.source_policy.as_ref(), + context.workbench_surface.as_ref(), + context.request_id.as_ref(), + context.target_block_id.as_ref(), + context.rewrite_instruction.as_ref(), + ] + .iter() + .any(|value| value.is_some()); + + if has_meaningful_fields { + Some(context) + } else { + None + } +} + +fn merge_prompt_section( + base_prompt: Option, + section_prompt: Option, + marker: &str, +) -> Option { + match (base_prompt, section_prompt) { + (Some(base), Some(section)) => { + if base.contains(marker) { + Some(base) + } else if base.trim().is_empty() { + Some(section) + } else { + Some(format!("{base}\n\n{section}")) + } + } + (Some(base), None) => Some(base), + (None, Some(section)) => Some(section), + (None, None) => None, + } +} + +fn build_artifact_delivery_prompt(context: &ArtifactPromptContext) -> Option { + if matches!(context.mode.as_deref(), Some("none")) { + return None; + } + + let mut lines = vec![ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER.to_string()]; + match context.mode.as_deref() { + Some("rewrite") => { + lines + .push("- 当前回合是对现有 Artifact 的定向改写,不是一次普通聊天回复。".to_string()); + } + _ => { + lines.push("- 当前回合需要进入 Artifact Workbench 正式交付链。".to_string()); + } + } + + if let Some(kind) = context.kind.as_deref() { + lines.push(format!("- 目标交付物类型:{kind}。")); + } + if let Some(surface) = context.workbench_surface.as_deref() { + lines.push(format!("- 主要承载面:{surface}。")); + } + if let Some(request_id) = context.request_id.as_deref() { + lines.push(format!("- 本轮 artifact request id:{request_id}。")); + } + + lines.push("执行要求:".to_string()); + lines.push( + "1. 消息区只保留进度、结论、缺口与下一步,不要把整篇正式产物再贴回消息区。".to_string(), + ); + lines.push("2. 只要需要正式交付物,优先通过文件写入或 write_file 工具把结果落到工作区,以便右侧 Artifact Workbench 实时预览。".to_string()); + lines.push( + "3. 正式交付物优先使用 ArtifactDocument v1 JSON,而不是自由 Markdown 长文。".to_string(), + ); + lines.push("4. 如果信息不足以完成正式交付,先明确缺口,再产出最稳妥的结构化草稿。".to_string()); + + Some(lines.join("\n")) +} + +fn build_artifact_source_policy_prompt(context: &ArtifactPromptContext) -> Option { + let source_policy = context.source_policy.as_deref()?; + let policy_text = match source_policy { + "required" => "本轮来源为强约束。关键结论、比较和事实判断必须绑定 sources,不要编造来源。", + "preferred" => "本轮来源为软约束。有真实来源时应尽量保留 sources,没有就明确说明来源不足。", + "none" => "本轮来源不是硬门槛,但仍然禁止伪造来源或把推断冒充成已验证事实。", + _ => return None, + }; + + Some(format!( + "{ARTIFACT_SOURCE_POLICY_PROMPT_MARKER}\n\ +- source_policy:{source_policy}\n\ +- {policy_text}" + )) +} + +fn build_artifact_stage_prompt(context: &ArtifactPromptContext) -> Option { + let stage = context + .stage + .as_deref() + .or(context.mode.as_deref()) + .unwrap_or("stage2"); + + match stage { + "stage1" => Some(format!( + "{ARTIFACT_STAGE1_PROMPT_MARKER}\n\ +执行目标:\n\ +1. 判断是否需要正式 Artifact。\n\ +2. 锁定 kind、标题、source policy、section outline 与 block plan。\n\ +3. 标出当前缺口、假设与风险。\n\ +禁止项:\n\ +1. 不要直接写完整正文。\n\ +2. 不要输出 HTML / CSS / 视觉样式说明。\n\ +3. 不要在消息区和交付区重复粘贴同一份长文。\ +" + )), + "rewrite" => { + let mut lines = vec![ARTIFACT_REWRITE_PROMPT_MARKER.to_string()]; + lines.push( + "- 当前回合只改写指定 Artifact 范围,除非明确要求,不要重写整份文档。".to_string(), + ); + if let Some(target_block_id) = context.target_block_id.as_deref() { + lines.push(format!("- 目标 block:{target_block_id}。")); + } + if let Some(instruction) = context.rewrite_instruction.as_deref() { + lines.push(format!("- 改写指令:{instruction}")); + } + lines.push("执行要求:".to_string()); + lines.push( + "1. 默认输出 `artifact_rewrite_patch`;只有兼容旧链路时才回退到 `artifact_ops`。" + .to_string(), + ); + lines.push( + "2. 如果提供了目标 block,则不要改写其他 block,也不要借机重排整个文档结构。" + .to_string(), + ); + lines.push("3. 保留原有结构与来源绑定,优先最小改动。".to_string()); + lines.push("4. 若局部改写无法满足要求,再明确说明需要扩大的范围。".to_string()); + Some(lines.join("\n")) + } + _ => Some(format!( + "{ARTIFACT_STAGE2_PROMPT_MARKER}\n\ +执行目标:\n\ +1. 输出正式结构化交付物草稿。\n\ +2. 初次生成可输出 `artifact_document_draft`;对已有文档做增量补充时优先输出 `artifact_ops`。\n\ +3. 交付物必须满足 ArtifactDocument v1。\n\ +4. block 类型只能来自白名单,不要自由发明新 block。\n\ +5. 若已知 sources,应挂到 sources[] 并让 block.sourceIds 指向已有来源。" + )), + } +} + +fn build_artifact_schema_hint_prompt(context: &ArtifactPromptContext) -> Option { + if matches!(context.mode.as_deref(), Some("none")) + || matches!(context.stage.as_deref(), Some("stage1")) + { + return None; + } + + let is_rewrite = matches!( + context.stage.as_deref().or(context.mode.as_deref()), + Some("rewrite") + ); + let output_contract = if is_rewrite { + "本轮优先输出 `artifact_rewrite_patch`;兼容情况下也可输出 `artifact_ops`,不要返回整篇 `artifact_document_draft`。" + } else { + "本轮可以输出 `artifact_document_draft`,也可以在已有文档上输出 `artifact_ops`。" + }; + let shape_hint = if is_rewrite { + "`artifact_rewrite_patch` 顶层字段优先包含:type、artifactId、targetBlockId、block\n- 可选补充 `source / sources / summary / status`\n- 若需兼容旧链路,也可回退到 `artifact_ops`\n- 若存在 target block,schema 与运行时都会限制改写范围,只允许命中该 block" + } else { + "顶层字段优先包含:artifactId、kind、title、status、language、summary、blocks、sources、metadata\n- 若使用 `artifact_ops`,顶层字段包含:type、artifactId、ops" + }; + let example = if is_rewrite { + "{\n \ +\"type\": \"artifact_rewrite_patch\",\n \ +\"artifactId\": \"artifact-demo\",\n \ +\"targetBlockId\": \"body-1\",\n \ +\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"改写后的正文\" },\n \ +\"summary\": \"把正文改成更适合董事会的措辞\"\n\ +}" + } else { + "{\n \ +\"type\": \"artifact_document_draft\",\n \ +\"document\": {\n \ +\"schemaVersion\": \"artifact_document.v1\",\n \ +\"kind\": \"report\",\n \ +\"title\": \"示例标题\",\n \ +\"status\": \"ready\",\n \ +\"language\": \"zh-CN\",\n \ +\"summary\": \"一句话摘要\",\n \ +\"blocks\": [\n \ +{ \"id\": \"hero-1\", \"type\": \"hero_summary\", \"summary\": \"核心结论\" },\n \ +{ \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"正文内容\" }\n \ +],\n \ +\"sources\": [],\n \ +\"metadata\": {}\n \ +}\n\ +}" + }; + + Some(format!( + "{ARTIFACT_SCHEMA_HINT_PROMPT_MARKER}\n\ +{output_contract}\n\ +本轮正式交付物优先满足以下结构:\n\ +- schemaVersion 必须为 `{ARTIFACT_DOCUMENT_SCHEMA_VERSION}`\n\ +- {shape_hint}\n\ +- blocks 至少 1 个,允许的 block 类型只有:{allowed_blocks}\n\ +- rich_text 可以承载 markdown 或编辑器 JSON,但文档顶层不能退化成整篇无结构长文\n\n\ +最小可用示例(你的输出必须是这种 JSON 结构,不要包裹在 markdown code fence 中):\n\ +{example}", + allowed_blocks = ARTIFACT_ALLOWED_BLOCKS.join(", ") + )) +} + +pub fn merge_system_prompt_with_artifact_context( + base_prompt: Option, + request_metadata: Option<&Value>, +) -> Option { + let Some(context) = build_artifact_prompt_context(request_metadata) else { + return base_prompt; + }; + + let with_delivery = merge_prompt_section( + base_prompt, + build_artifact_delivery_prompt(&context), + ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER, + ); + let with_sources = merge_prompt_section( + with_delivery, + build_artifact_source_policy_prompt(&context), + ARTIFACT_SOURCE_POLICY_PROMPT_MARKER, + ); + let with_stage = merge_prompt_section( + with_sources, + build_artifact_stage_prompt(&context), + if matches!(context.stage.as_deref(), Some("stage1")) { + ARTIFACT_STAGE1_PROMPT_MARKER + } else if matches!( + context.stage.as_deref().or(context.mode.as_deref()), + Some("rewrite") + ) { + ARTIFACT_REWRITE_PROMPT_MARKER + } else { + ARTIFACT_STAGE2_PROMPT_MARKER + }, + ); + + merge_prompt_section( + with_stage, + build_artifact_schema_hint_prompt(&context), + ARTIFACT_SCHEMA_HINT_PROMPT_MARKER, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_ignore_empty_artifact_metadata() { + let merged = merge_system_prompt_with_artifact_context(None, None); + assert!(merged.is_none()); + } + + #[test] + fn should_build_draft_prompt_sections() { + let metadata = serde_json::json!({ + "artifact": { + "artifact_mode": "draft", + "artifact_kind": "report", + "artifact_stage": "stage2", + "source_policy": "required", + "workbench_surface": "right_panel" + } + }); + + let merged = + merge_system_prompt_with_artifact_context(None, Some(&metadata)).unwrap_or_default(); + + assert!(merged.contains(ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER)); + assert!(merged.contains(ARTIFACT_SOURCE_POLICY_PROMPT_MARKER)); + assert!(merged.contains(ARTIFACT_STAGE2_PROMPT_MARKER)); + assert!(merged.contains(ARTIFACT_SCHEMA_HINT_PROMPT_MARKER)); + assert!(merged.contains("ArtifactDocument v1")); + assert!(merged.contains("artifact_ops")); + } + + #[test] + fn should_build_rewrite_prompt() { + let metadata = serde_json::json!({ + "artifact": { + "artifact_mode": "rewrite", + "artifact_stage": "rewrite", + "artifact_target_block_id": "block-3", + "artifact_rewrite_instruction": "把语言改得更适合董事会" + } + }); + + let merged = + merge_system_prompt_with_artifact_context(None, Some(&metadata)).unwrap_or_default(); + + assert!(merged.contains(ARTIFACT_REWRITE_PROMPT_MARKER)); + assert!(merged.contains("block-3")); + assert!(merged.contains("更适合董事会")); + assert!(merged.contains("artifact_rewrite_patch")); + assert!(merged.contains("artifact_ops")); + } + + #[test] + fn should_not_duplicate_existing_marker() { + let metadata = serde_json::json!({ + "artifact": { + "artifact_mode": "draft", + "artifact_kind": "analysis" + } + }); + let base = Some(format!( + "已有内容\n\n{ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER}\n已有 Artifact 段" + )); + + let merged = merge_system_prompt_with_artifact_context(base.clone(), Some(&metadata)); + let merged_text = merged.unwrap_or_default(); + + assert_eq!( + merged_text + .matches(ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER) + .count(), + 1 + ); + } +} diff --git a/src-tauri/src/services/artifact_request_metadata_service.rs b/src-tauri/src/services/artifact_request_metadata_service.rs new file mode 100644 index 000000000..1c35aa99a --- /dev/null +++ b/src-tauri/src/services/artifact_request_metadata_service.rs @@ -0,0 +1,345 @@ +//! Artifact request metadata 归一化服务 +//! +//! 统一根据 request metadata 中的 harness / artifact 上下文补全 Artifact +//! 默认值,避免前端继续预计算 draft kind / source policy / request id。 + +use serde_json::{Map, Value}; + +const ARTIFACT_MEANINGFUL_KEYS: &[&str] = &[ + "artifact_mode", + "artifactMode", + "artifact_kind", + "artifactKind", + "artifact_stage", + "artifactStage", + "source_policy", + "sourcePolicy", + "workbench_surface", + "workbenchSurface", + "artifact_request_id", + "artifactRequestId", + "artifact_target_block_id", + "artifactTargetBlockId", + "artifact_rewrite_instruction", + "artifactRewriteInstruction", +]; + +fn normalize_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn root_object(request_metadata: Option<&Value>) -> Option<&Map> { + request_metadata?.as_object() +} + +fn extract_harness_object(request_metadata: Option<&Value>) -> Option<&Map> { + let object = root_object(request_metadata)?; + if let Some(harness) = object.get("harness").and_then(Value::as_object) { + return Some(harness); + } + Some(object) +} + +fn extract_harness_string(request_metadata: Option<&Value>, keys: &[&str]) -> Option { + let harness = extract_harness_object(request_metadata)?; + keys.iter() + .filter_map(|key| harness.get(*key)) + .find_map(Value::as_str) + .and_then(|value| normalize_text(Some(value))) +} + +fn is_flat_artifact_metadata_key(key: &str) -> bool { + matches!( + key, + "artifact_mode" + | "artifactMode" + | "artifact_kind" + | "artifactKind" + | "artifact_stage" + | "artifactStage" + | "source_policy" + | "sourcePolicy" + | "workbench_surface" + | "workbenchSurface" + | "artifact_request_id" + | "artifactRequestId" + | "artifact_target_block_id" + | "artifactTargetBlockId" + | "artifact_rewrite_instruction" + | "artifactRewriteInstruction" + ) +} + +fn extract_existing_artifact_object( + request_metadata: Option<&Value>, +) -> Option> { + let object = root_object(request_metadata)?; + if let Some(artifact) = object.get("artifact").and_then(Value::as_object) { + return Some(artifact.clone()); + } + + let artifact = object + .iter() + .filter(|(key, _)| is_flat_artifact_metadata_key(key)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + + if artifact.is_empty() { + None + } else { + Some(artifact) + } +} + +fn extract_artifact_string(artifact: &Map, keys: &[&str]) -> Option { + keys.iter() + .filter_map(|key| artifact.get(*key)) + .find_map(Value::as_str) + .and_then(|value| normalize_text(Some(value))) +} + +fn infer_artifact_kind(theme: &str) -> Option<&'static str> { + match theme.trim().to_ascii_lowercase().as_str() { + "document" => Some("report"), + "knowledge" => Some("analysis"), + "planning" => Some("plan"), + "general" => Some("brief"), + _ => None, + } +} + +fn infer_source_policy(kind: Option<&str>) -> Option<&'static str> { + match kind { + Some("report") | Some("analysis") | Some("comparison") => Some("required"), + Some("roadmap") | Some("prd") | Some("brief") | Some("plan") => Some("preferred"), + _ => None, + } +} + +fn should_enable_artifact_draft(request_metadata: Option<&Value>) -> bool { + if extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref() + != Some("theme_workbench") + { + return false; + } + + if extract_harness_string( + request_metadata, + &["turn_purpose", "turnPurpose", "purpose"], + ) + .is_some() + { + return false; + } + + extract_harness_string( + request_metadata, + &["theme", "harness_theme", "harnessTheme"], + ) + .as_deref() + .and_then(infer_artifact_kind) + .is_some() +} + +fn is_meaningful_artifact_value(value: Option<&Value>) -> bool { + value + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + +pub fn normalize_request_metadata_with_artifact_defaults( + request_metadata: Option, +) -> Option { + let request_metadata = request_metadata?; + let Some(root) = request_metadata.as_object() else { + return Some(request_metadata); + }; + + let request_metadata_ref = Some(&request_metadata); + let inferred_kind = extract_harness_string( + request_metadata_ref, + &["theme", "harness_theme", "harnessTheme"], + ) + .as_deref() + .and_then(infer_artifact_kind) + .map(str::to_string); + let should_enable_draft = should_enable_artifact_draft(request_metadata_ref); + let content_id = extract_harness_string(request_metadata_ref, &["content_id", "contentId"]); + + let mut artifact = extract_existing_artifact_object(request_metadata_ref).unwrap_or_default(); + let explicit_artifact_kind = + extract_artifact_string(&artifact, &["artifact_kind", "artifactKind"]); + let artifact_mode = extract_artifact_string(&artifact, &["artifact_mode", "artifactMode"]) + .or_else(|| should_enable_draft.then(|| "draft".to_string())); + let artifact_kind = explicit_artifact_kind + .clone() + .or_else(|| should_enable_draft.then(|| inferred_kind.clone()).flatten()); + let artifact_stage = extract_artifact_string(&artifact, &["artifact_stage", "artifactStage"]) + .or_else(|| should_enable_draft.then(|| "stage2".to_string())); + let source_policy = extract_artifact_string(&artifact, &["source_policy", "sourcePolicy"]) + .or_else(|| { + if should_enable_draft || explicit_artifact_kind.is_some() { + infer_source_policy(artifact_kind.as_deref()).map(str::to_string) + } else { + None + } + }); + let workbench_surface = + extract_artifact_string(&artifact, &["workbench_surface", "workbenchSurface"]) + .or_else(|| should_enable_draft.then(|| "right_panel".to_string())); + + if let Some(value) = artifact_mode.as_ref() { + artifact.insert("artifact_mode".to_string(), Value::String(value.clone())); + } + if let Some(value) = artifact_kind.as_ref() { + artifact.insert("artifact_kind".to_string(), Value::String(value.clone())); + } + if let Some(value) = artifact_stage.as_ref() { + artifact.insert("artifact_stage".to_string(), Value::String(value.clone())); + } + if let Some(value) = source_policy.as_ref() { + artifact.insert("source_policy".to_string(), Value::String(value.clone())); + } + if let Some(value) = workbench_surface.as_ref() { + artifact.insert( + "workbench_surface".to_string(), + Value::String(value.clone()), + ); + } + if extract_artifact_string(&artifact, &["artifact_request_id", "artifactRequestId"]).is_none() + && artifact_mode.as_deref() == Some("draft") + { + if let Some(content_id) = content_id { + artifact.insert( + "artifact_request_id".to_string(), + Value::String(format!("artifact:{content_id}")), + ); + } + } + + let mut normalized = root.clone(); + let has_meaningful_artifact_metadata = ARTIFACT_MEANINGFUL_KEYS + .iter() + .any(|key| is_meaningful_artifact_value(artifact.get(*key))); + + if has_meaningful_artifact_metadata { + normalized.insert("artifact".to_string(), Value::Object(artifact)); + } else { + normalized.remove("artifact"); + } + + Some(Value::Object(normalized)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn should_infer_theme_workbench_artifact_defaults_from_harness() { + let metadata = json!({ + "harness": { + "theme": "document", + "session_mode": "theme_workbench", + "content_id": "content-1" + } + }); + + let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata)) + .expect("normalized metadata"); + + assert_eq!( + normalized + .pointer("/artifact/artifact_mode") + .and_then(Value::as_str), + Some("draft") + ); + assert_eq!( + normalized + .pointer("/artifact/artifact_kind") + .and_then(Value::as_str), + Some("report") + ); + assert_eq!( + normalized + .pointer("/artifact/artifact_stage") + .and_then(Value::as_str), + Some("stage2") + ); + assert_eq!( + normalized + .pointer("/artifact/source_policy") + .and_then(Value::as_str), + Some("required") + ); + assert_eq!( + normalized + .pointer("/artifact/workbench_surface") + .and_then(Value::as_str), + Some("right_panel") + ); + assert_eq!( + normalized + .pointer("/artifact/artifact_request_id") + .and_then(Value::as_str), + Some("artifact:content-1") + ); + } + + #[test] + fn should_skip_auto_draft_when_turn_purpose_is_present() { + let metadata = json!({ + "harness": { + "theme": "document", + "session_mode": "theme_workbench", + "turn_purpose": "content_review", + "content_id": "content-1" + } + }); + + let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata)) + .expect("normalized metadata"); + + assert!(normalized.get("artifact").is_none()); + } + + #[test] + fn should_preserve_explicit_artifact_and_fill_missing_source_policy() { + let metadata = json!({ + "harness": { + "theme": "knowledge", + "session_mode": "default" + }, + "artifact": { + "artifact_kind": "analysis" + } + }); + + let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata)) + .expect("normalized metadata"); + + assert_eq!( + normalized + .pointer("/artifact/artifact_kind") + .and_then(Value::as_str), + Some("analysis") + ); + assert_eq!( + normalized + .pointer("/artifact/source_policy") + .and_then(Value::as_str), + Some("required") + ); + assert_eq!( + normalized + .pointer("/artifact/artifact_mode") + .and_then(Value::as_str), + None + ); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 9b28a38ed..53118ef6d 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -5,6 +5,13 @@ // 保留在主 crate 的 Tauri 相关服务 pub mod agent_timeline_service; +pub mod artifact_diff_service; +pub mod artifact_document_service; +pub mod artifact_document_validator; +pub mod artifact_ops_service; +pub mod artifact_output_schema_service; +pub mod artifact_prompt_service; +pub mod artifact_request_metadata_service; pub mod auto_memory_service; pub mod automation_service; pub mod browser_environment_service; @@ -23,6 +30,8 @@ pub mod memory_source_resolver_service; pub mod novel_service; pub mod openclaw_service; pub mod runtime_agents_template_service; +pub mod site_adapter_registry; +pub mod site_capability_service; pub mod sysinfo_service; pub mod thread_reliability_projection_service; pub mod update_check_service; diff --git a/src-tauri/src/services/site_adapter_registry.rs b/src-tauri/src/services/site_adapter_registry.rs new file mode 100644 index 000000000..08da049a3 --- /dev/null +++ b/src-tauri/src/services/site_adapter_registry.rs @@ -0,0 +1,982 @@ +use once_cell::sync::Lazy; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const BUNDLED_ADAPTER_RELATIVE_DIR: &str = "resources/site-adapters/bundled"; +const SERVER_SYNCED_ADAPTER_RELATIVE_DIR: &str = "site-adapters/server-synced"; +const BUNDLED_INDEX_FALLBACK: &str = + include_str!("../../resources/site-adapters/bundled/index.json"); +const DEFAULT_REGISTRY_VERSION: u32 = 1; + +static TEMPLATE_TOKEN_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"\{\{\s*([a-zA-Z0-9_]+)(?:\s*\|\s*(urlencode))?\s*\}\}") + .expect("site adapter entry template regex should compile") +}); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SiteAdapterArgType { + String, + Integer, +} + +impl SiteAdapterArgType { + pub fn schema_type(self) -> &'static str { + match self { + Self::String => "string", + Self::Integer => "integer", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SiteAdapterSourceKind { + Bundled, + ServerSynced, +} + +impl SiteAdapterSourceKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Bundled => "bundled", + Self::ServerSynced => "server_synced", + } + } +} + +#[derive(Debug, Clone)] +pub struct SiteAdapterArgSpec { + pub name: String, + pub description: String, + pub required: bool, + pub arg_type: SiteAdapterArgType, + pub example: Option, +} + +#[derive(Debug, Clone)] +pub enum SiteAdapterEntrySpec { + FixedUrl { url: String }, + UrlTemplate { template: String }, + Builder { id: String }, +} + +#[derive(Debug, Clone)] +pub struct SiteAdapterSpec { + pub name: String, + pub domain: String, + pub description: String, + pub read_only: bool, + pub capabilities: Vec, + pub args: Vec, + pub example: String, + pub auth_hint: Option, + pub entry: SiteAdapterEntrySpec, + pub script: String, + pub source_kind: SiteAdapterSourceKind, + pub source_version: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct SiteAdapterRegistryDocument { + #[serde(default = "default_registry_version")] + registry_version: u32, + #[serde(default, alias = "catalogVersion", alias = "version")] + catalog_version: Option, + #[serde(default, alias = "tenantId")] + tenant_id: Option, + #[serde(default, alias = "syncedAt")] + synced_at: Option, + adapters: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct SiteAdapterManifestEntry { + name: String, + domain: String, + description: String, + #[serde(default = "default_read_only", alias = "readOnly")] + read_only: bool, + #[serde(default)] + capabilities: Vec, + #[serde(default)] + args: Vec, + example: String, + #[serde(default, alias = "authHint")] + auth_hint: Option, + entry: SiteAdapterEntryManifest, + #[serde(alias = "scriptFile")] + script_file: String, + #[serde(default, alias = "sourceVersion")] + source_version: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct SiteAdapterArgManifest { + name: String, + description: String, + required: bool, + arg_type: SiteAdapterArgTypeManifest, + #[serde(default)] + example: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum SiteAdapterArgTypeManifest { + String, + Integer, +} + +impl From for SiteAdapterArgType { + fn from(value: SiteAdapterArgTypeManifest) -> Self { + match value { + SiteAdapterArgTypeManifest::String => Self::String, + SiteAdapterArgTypeManifest::Integer => Self::Integer, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum SiteAdapterEntryManifest { + FixedUrl { url: String }, + UrlTemplate { template: String }, + Builder { id: String }, +} + +impl From for SiteAdapterEntrySpec { + fn from(value: SiteAdapterEntryManifest) -> Self { + match value { + SiteAdapterEntryManifest::FixedUrl { url } => Self::FixedUrl { url }, + SiteAdapterEntryManifest::UrlTemplate { template } => Self::UrlTemplate { template }, + SiteAdapterEntryManifest::Builder { id } => Self::Builder { id }, + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SiteAdapterCatalogStatus { + pub exists: bool, + pub source_kind: String, + pub registry_version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub directory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub catalog_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub synced_at: Option, + pub adapter_count: usize, +} + +#[derive(Debug, Deserialize)] +struct SiteAdapterCatalogBootstrapDocument { + #[serde(default = "default_registry_version")] + registry_version: u32, + #[serde(default, alias = "catalogVersion", alias = "version")] + catalog_version: Option, + #[serde(default, alias = "tenantId")] + tenant_id: Option, + #[serde(default, alias = "syncedAt")] + synced_at: Option, + adapters: Vec, +} + +#[derive(Debug, Deserialize)] +struct SiteAdapterCatalogBootstrapEntry { + name: String, + domain: String, + description: String, + #[serde(default = "default_read_only", alias = "readOnly")] + read_only: bool, + #[serde(default)] + capabilities: Vec, + #[serde(default)] + args: Vec, + example: String, + #[serde(default, alias = "authHint")] + auth_hint: Option, + entry: SiteAdapterEntryManifest, + #[serde(default, alias = "sourceVersion")] + source_version: Option, + script: String, +} + +pub fn normalize_site_adapter_name(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +pub fn load_site_adapter_specs() -> Result, String> { + let mut merged = BTreeMap::new(); + for spec in load_bundled_site_adapters()? { + merged.insert(normalize_site_adapter_name(&spec.name), spec); + } + for spec in load_server_synced_site_adapters()? { + merged.insert(normalize_site_adapter_name(&spec.name), spec); + } + Ok(merged.into_values().collect()) +} + +pub fn find_site_adapter_spec(name: &str) -> Result, String> { + let normalized = normalize_site_adapter_name(name); + let adapters = load_site_adapter_specs()?; + Ok(adapters + .into_iter() + .find(|spec| normalize_site_adapter_name(&spec.name) == normalized)) +} + +pub fn get_site_adapter_catalog_status() -> Result { + get_site_adapter_catalog_status_from_dir(resolve_server_synced_adapter_dir()) +} + +pub fn apply_site_adapter_catalog_bootstrap( + payload: &Value, +) -> Result { + let Some(dir) = resolve_server_synced_adapter_dir() else { + return Err("无法解析站点适配器缓存目录".to_string()); + }; + apply_site_adapter_catalog_bootstrap_to_dir(&dir, payload) +} + +pub fn clear_site_adapter_catalog_cache() -> Result { + clear_site_adapter_catalog_cache_at_dir(resolve_server_synced_adapter_dir()) +} + +pub fn build_entry_url( + spec: &SiteAdapterSpec, + args: &Map, +) -> Result { + match &spec.entry { + SiteAdapterEntrySpec::FixedUrl { url } => Ok(url.clone()), + SiteAdapterEntrySpec::UrlTemplate { template } => render_entry_template(template, args), + SiteAdapterEntrySpec::Builder { id } => build_entry_url_with_builder(id, args), + } +} + +fn load_bundled_site_adapters() -> Result, String> { + if let Some(dir) = resolve_bundled_adapter_dir() { + return load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::Bundled); + } + + load_site_adapters_from_embedded_index(SiteAdapterSourceKind::Bundled) +} + +fn load_server_synced_site_adapters() -> Result, String> { + let Some(dir) = resolve_server_synced_adapter_dir() else { + return Ok(Vec::new()); + }; + if !dir.exists() { + return Ok(Vec::new()); + } + load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::ServerSynced) +} + +fn get_site_adapter_catalog_status_from_dir( + dir: Option, +) -> Result { + let directory = dir.as_ref().map(|value| value.display().to_string()); + let Some(dir) = dir else { + return Ok(SiteAdapterCatalogStatus { + exists: false, + source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(), + registry_version: default_registry_version(), + directory, + catalog_version: None, + tenant_id: None, + synced_at: None, + adapter_count: 0, + }); + }; + + let index_path = dir.join("index.json"); + if !index_path.exists() { + return Ok(SiteAdapterCatalogStatus { + exists: false, + source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(), + registry_version: default_registry_version(), + directory, + catalog_version: None, + tenant_id: None, + synced_at: None, + adapter_count: 0, + }); + } + + let content = fs::read_to_string(&index_path) + .map_err(|error| format!("读取站点适配器索引失败 {}: {error}", index_path.display()))?; + let document: SiteAdapterRegistryDocument = serde_json::from_str(&content) + .map_err(|error| format!("解析站点适配器索引失败: {error}"))?; + + Ok(SiteAdapterCatalogStatus { + exists: true, + source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(), + registry_version: document.registry_version, + directory, + catalog_version: document.catalog_version, + tenant_id: document.tenant_id, + synced_at: document.synced_at, + adapter_count: document.adapters.len(), + }) +} + +fn apply_site_adapter_catalog_bootstrap_to_dir( + dir: &Path, + payload: &Value, +) -> Result { + let catalog_value = extract_site_adapter_catalog_from_bootstrap_payload(payload) + .ok_or_else(|| "payload 中未找到 siteAdapterCatalog".to_string())?; + let document = parse_site_adapter_catalog_bootstrap_document(catalog_value)?; + write_server_synced_catalog_to_dir(dir, document)?; + get_site_adapter_catalog_status_from_dir(Some(dir.to_path_buf())) +} + +fn clear_site_adapter_catalog_cache_at_dir( + dir: Option, +) -> Result { + let directory = dir.as_ref().map(|value| value.display().to_string()); + let Some(dir) = dir else { + return Ok(SiteAdapterCatalogStatus { + exists: false, + source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(), + registry_version: default_registry_version(), + directory, + catalog_version: None, + tenant_id: None, + synced_at: None, + adapter_count: 0, + }); + }; + + if dir.exists() { + fs::remove_dir_all(&dir) + .map_err(|error| format!("清理站点适配器缓存失败 {}: {error}", dir.display()))?; + } + + get_site_adapter_catalog_status_from_dir(Some(dir)) +} + +fn load_site_adapters_from_dir( + dir: &Path, + source_kind: SiteAdapterSourceKind, +) -> Result, String> { + let index_path = dir.join("index.json"); + let content = fs::read_to_string(&index_path) + .map_err(|error| format!("读取站点适配器索引失败 {}: {error}", index_path.display()))?; + load_site_adapters_from_str(&content, Some(dir), source_kind) +} + +fn load_site_adapters_from_embedded_index( + source_kind: SiteAdapterSourceKind, +) -> Result, String> { + load_site_adapters_from_str(BUNDLED_INDEX_FALLBACK, None, source_kind) +} + +fn load_site_adapters_from_str( + content: &str, + dir: Option<&Path>, + source_kind: SiteAdapterSourceKind, +) -> Result, String> { + let document: SiteAdapterRegistryDocument = serde_json::from_str(content) + .map_err(|error| format!("解析站点适配器索引失败: {error}"))?; + + document + .adapters + .into_iter() + .map(|entry| manifest_entry_to_spec(entry, dir, source_kind)) + .collect() +} + +fn manifest_entry_to_spec( + entry: SiteAdapterManifestEntry, + dir: Option<&Path>, + source_kind: SiteAdapterSourceKind, +) -> Result { + let script = if let Some(base_dir) = dir { + let script_path = base_dir.join(&entry.script_file); + fs::read_to_string(&script_path) + .map_err(|error| format!("读取站点适配器脚本失败 {}: {error}", script_path.display()))? + } else { + load_embedded_bundled_script(&entry.script_file)?.to_string() + }; + + Ok(SiteAdapterSpec { + name: entry.name, + domain: entry.domain, + description: entry.description, + read_only: entry.read_only, + capabilities: entry.capabilities, + args: entry + .args + .into_iter() + .map(|arg| SiteAdapterArgSpec { + name: arg.name, + description: arg.description, + required: arg.required, + arg_type: arg.arg_type.into(), + example: arg.example, + }) + .collect(), + example: entry.example, + auth_hint: entry.auth_hint, + entry: entry.entry.into(), + script, + source_kind, + source_version: entry.source_version, + }) +} + +fn extract_site_adapter_catalog_from_bootstrap_payload<'a>( + payload: &'a Value, +) -> Option<&'a Value> { + if looks_like_site_adapter_catalog_document(payload) { + return Some(payload); + } + + let record = payload.as_object()?; + + for key in [ + "siteAdapterCatalog", + "site_adapter_catalog", + "bootstrap", + "data", + ] { + if let Some(nested) = record.get(key) { + if let Some(found) = extract_site_adapter_catalog_from_bootstrap_payload(nested) { + return Some(found); + } + } + } + + None +} + +fn looks_like_site_adapter_catalog_document(payload: &Value) -> bool { + payload + .as_object() + .and_then(|record| record.get("adapters")) + .is_some_and(Value::is_array) +} + +fn parse_site_adapter_catalog_bootstrap_document( + payload: &Value, +) -> Result { + serde_json::from_value::(payload.clone()) + .map_err(|error| format!("解析站点适配器 bootstrap payload 失败: {error}")) +} + +fn write_server_synced_catalog_to_dir( + dir: &Path, + payload: SiteAdapterCatalogBootstrapDocument, +) -> Result<(), String> { + if dir.exists() { + fs::remove_dir_all(dir) + .map_err(|error| format!("清理旧站点适配器目录失败 {}: {error}", dir.display()))?; + } + fs::create_dir_all(dir.join("scripts")) + .map_err(|error| format!("创建站点适配器目录失败 {}: {error}", dir.display()))?; + + let mut seen_names = BTreeSet::new(); + let mut adapters = Vec::with_capacity(payload.adapters.len()); + for entry in payload.adapters { + let normalized_name = normalize_site_adapter_name(&entry.name); + if normalized_name.is_empty() { + return Err("站点适配器 name 不能为空".to_string()); + } + if !seen_names.insert(normalized_name) { + return Err(format!("站点适配器重复: {}", entry.name)); + } + + let script = normalize_required_text(&entry.script, "script")?; + let script_file = build_server_synced_script_file(&entry.name); + let script_path = dir.join(&script_file); + if let Some(parent) = script_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("创建站点适配器脚本目录失败 {}: {error}", parent.display()) + })?; + } + fs::write(&script_path, script).map_err(|error| { + format!("写入站点适配器脚本失败 {}: {error}", script_path.display()) + })?; + + adapters.push(SiteAdapterManifestEntry { + name: normalize_required_text(&entry.name, "name")?, + domain: normalize_required_text(&entry.domain, "domain")?, + description: normalize_required_text(&entry.description, "description")?, + read_only: entry.read_only, + capabilities: entry.capabilities, + args: entry.args, + example: normalize_required_text(&entry.example, "example")?, + auth_hint: normalize_optional_text(entry.auth_hint), + entry: entry.entry, + script_file, + source_version: normalize_optional_text(entry.source_version), + }); + } + + let document = SiteAdapterRegistryDocument { + registry_version: if payload.registry_version == 0 { + default_registry_version() + } else { + payload.registry_version + }, + catalog_version: normalize_optional_text(payload.catalog_version), + tenant_id: normalize_optional_text(payload.tenant_id), + synced_at: normalize_optional_text(payload.synced_at), + adapters, + }; + let index_path = dir.join("index.json"); + let content = serde_json::to_string_pretty(&document) + .map_err(|error| format!("序列化站点适配器索引失败: {error}"))?; + fs::write(&index_path, content) + .map_err(|error| format!("写入站点适配器索引失败 {}: {error}", index_path.display())) +} + +fn load_embedded_bundled_script(script_file: &str) -> Result<&'static str, String> { + match script_file { + "scripts/36kr-newsflash.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/36kr-newsflash.js" + )), + "scripts/bilibili-search.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/bilibili-search.js" + )), + "scripts/github-issues.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/github-issues.js" + )), + "scripts/github-search.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/github-search.js" + )), + "scripts/zhihu-hot.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/zhihu-hot.js" + )), + "scripts/zhihu-search.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/zhihu-search.js" + )), + _ => Err(format!("未注册的 bundled 适配器脚本: {script_file}")), + } +} + +fn resolve_bundled_adapter_dir() -> Option { + let dev_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/site-adapters/bundled"); + if dev_dir.exists() { + return Some(dev_dir); + } + + resolve_packaged_resource_root() + .map(|root| root.join(BUNDLED_ADAPTER_RELATIVE_DIR)) + .filter(|dir| dir.exists()) +} + +fn resolve_server_synced_adapter_dir() -> Option { + lime_core::app_paths::preferred_data_dir() + .ok() + .map(|root| root.join(SERVER_SYNCED_ADAPTER_RELATIVE_DIR)) +} + +fn resolve_packaged_resource_root() -> Option { + let mut path = std::env::current_exe().ok()?; + path.pop(); + + #[cfg(target_os = "macos")] + { + path.pop(); + path.push("Resources"); + } + + Some(path) +} + +fn build_server_synced_script_file(adapter_name: &str) -> String { + let file_name = sanitize_path_segment(adapter_name); + format!("scripts/{file_name}.js") +} + +fn sanitize_path_segment(value: &str) -> String { + let mut sanitized = String::with_capacity(value.len()); + let mut last_was_dash = false; + + for ch in value.chars() { + let normalized = if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase()) + } else if matches!(ch, '/' | '\\' | '-' | '_' | ' ') { + Some('-') + } else { + None + }; + + let Some(next_char) = normalized else { + continue; + }; + if next_char == '-' { + if last_was_dash { + continue; + } + last_was_dash = true; + sanitized.push(next_char); + continue; + } + + last_was_dash = false; + sanitized.push(next_char); + } + + let trimmed = sanitized.trim_matches('-'); + if trimmed.is_empty() { + "adapter".to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_required_text(value: &str, field: &str) -> Result { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(format!("站点适配器字段 {field} 不能为空")); + } + Ok(normalized.to_string()) +} + +fn normalize_optional_text(value: Option) -> Option { + value.and_then(|item| { + let normalized = item.trim(); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } + }) +} + +fn render_entry_template(template: &str, args: &Map) -> Result { + let mut missing_arg = None::; + let rendered = TEMPLATE_TOKEN_REGEX.replace_all(template, |captures: ®ex::Captures<'_>| { + let arg_name = captures + .get(1) + .map(|value| value.as_str()) + .unwrap_or_default(); + let Some(raw_value) = args.get(arg_name) else { + missing_arg = Some(arg_name.to_string()); + return String::new(); + }; + + let string_value = match value_to_url_token(raw_value) { + Some(value) => value, + None => { + missing_arg = Some(arg_name.to_string()); + return String::new(); + } + }; + + match captures.get(2).map(|value| value.as_str()) { + Some("urlencode") => urlencoding::encode(&string_value).into_owned(), + _ => string_value, + } + }); + + if let Some(arg_name) = missing_arg { + return Err(format!("缺少入口 URL 所需参数: {arg_name}")); + } + + Ok(rendered.into_owned()) +} + +fn build_entry_url_with_builder(id: &str, args: &Map) -> Result { + match id { + "github_issues" => build_github_issues_url(args), + _ => Err(format!("不支持的入口构造器: {id}")), + } +} + +fn build_github_issues_url(args: &Map) -> Result { + let repo = get_required_string_arg(args, "repo")?; + let query = get_optional_string_arg(args, "query"); + let state = get_optional_string_arg(args, "state"); + + let mut query_parts = Vec::new(); + if let Some(value) = query { + query_parts.push(value); + } + if let Some(value) = state { + match value.as_str() { + "open" | "closed" => query_parts.push(format!("state:{value}")), + "all" => {} + _ => return Err("state 仅支持 open / closed / all".to_string()), + } + } + + if query_parts.is_empty() { + Ok(format!("https://github.com/{repo}/issues")) + } else { + Ok(format!( + "https://github.com/{repo}/issues?q={}", + urlencoding::encode(&query_parts.join(" ")) + )) + } +} + +fn get_required_string_arg(args: &Map, key: &str) -> Result { + get_optional_string_arg(args, key).ok_or_else(|| format!("参数 {key} 不能为空")) +} + +fn get_optional_string_arg(args: &Map, key: &str) -> Option { + args.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn value_to_url_token(value: &Value) -> Option { + value + .as_str() + .map(str::trim) + .filter(|raw| !raw.is_empty()) + .map(ToString::to_string) + .or_else(|| value.as_i64().map(|raw| raw.to_string())) + .or_else(|| value.as_u64().map(|raw| raw.to_string())) +} + +fn default_read_only() -> bool { + true +} + +fn default_registry_version() -> u32 { + DEFAULT_REGISTRY_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn should_load_bundled_registry_from_resources() { + let adapters = load_bundled_site_adapters().expect("bundled adapters should load"); + assert!(adapters + .iter() + .any(|adapter| adapter.name == "github/search")); + let github = adapters + .iter() + .find(|adapter| adapter.name == "github/search") + .expect("github/search should exist"); + assert_eq!(github.source_kind, SiteAdapterSourceKind::Bundled); + assert_eq!(github.source_version.as_deref(), Some("2026-03-25")); + assert!(github.script.contains("a.v-align-middle")); + } + + #[test] + fn should_render_url_template_with_urlencode() { + let spec = SiteAdapterSpec { + name: "github/search".to_string(), + domain: "github.com".to_string(), + description: String::new(), + read_only: true, + capabilities: Vec::new(), + args: Vec::new(), + example: String::new(), + auth_hint: None, + entry: SiteAdapterEntrySpec::UrlTemplate { + template: "https://github.com/search?q={{query|urlencode}}".to_string(), + }, + script: String::new(), + source_kind: SiteAdapterSourceKind::Bundled, + source_version: None, + }; + let mut args = Map::new(); + args.insert( + "query".to_string(), + Value::String("model context protocol".to_string()), + ); + + let url = build_entry_url(&spec, &args).expect("template url should render"); + assert_eq!( + url, + "https://github.com/search?q=model%20context%20protocol" + ); + } + + #[test] + fn should_allow_server_synced_adapters_to_override_bundled_name() { + let temp_dir = tempdir().expect("temp dir should exist"); + let dir = temp_dir.path(); + fs::create_dir_all(dir.join("scripts")).expect("scripts dir should exist"); + fs::write( + dir.join("index.json"), + r#" + { + "adapters": [ + { + "name": "github/search", + "domain": "github.com", + "description": "server synced", + "read_only": true, + "capabilities": ["search"], + "args": [], + "example": "github/search {}", + "entry": { + "kind": "fixed_url", + "url": "https://github.com/search" + }, + "script_file": "scripts/github-search.js", + "source_version": "sync-1" + } + ] + } + "#, + ) + .expect("index should write"); + fs::write( + dir.join("scripts/github-search.js"), + "async () => ({ ok: true })", + ) + .expect("script should write"); + + let adapters = load_site_adapters_from_dir(dir, SiteAdapterSourceKind::ServerSynced) + .expect("server synced adapters should load"); + assert_eq!(adapters.len(), 1); + assert_eq!(adapters[0].name, "github/search"); + assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::ServerSynced); + assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1")); + } + + #[test] + fn should_extract_site_adapter_catalog_from_nested_bootstrap_payload() { + let payload = serde_json::json!({ + "data": { + "bootstrap": { + "siteAdapterCatalog": { + "catalogVersion": "tenant-sync-1", + "adapters": [] + } + } + } + }); + + let extracted = extract_site_adapter_catalog_from_bootstrap_payload(&payload) + .expect("nested catalog should extract"); + assert_eq!( + extracted["catalogVersion"], + Value::String("tenant-sync-1".to_string()) + ); + } + + #[test] + fn should_persist_server_synced_bootstrap_catalog_with_inline_scripts() { + let temp_dir = tempdir().expect("temp dir should exist"); + let payload = serde_json::json!({ + "bootstrap": { + "siteAdapterCatalog": { + "catalogVersion": "tenant-sync-1", + "tenantId": "tenant-demo", + "syncedAt": "2026-03-25T10:00:00.000Z", + "adapters": [ + { + "name": "github/search", + "domain": "github.com", + "description": "server synced github search", + "read_only": true, + "capabilities": ["search"], + "args": [], + "example": "github/search {\"query\":\"lime\"}", + "entry": { + "kind": "fixed_url", + "url": "https://github.com/search" + }, + "script": "async () => ({ items: [] })", + "sourceVersion": "tenant-sync-1" + } + ] + } + } + }); + + let status = apply_site_adapter_catalog_bootstrap_to_dir(temp_dir.path(), &payload) + .expect("bootstrap catalog should persist"); + assert_eq!( + status, + SiteAdapterCatalogStatus { + exists: true, + source_kind: "server_synced".to_string(), + registry_version: 1, + directory: Some(temp_dir.path().display().to_string()), + catalog_version: Some("tenant-sync-1".to_string()), + tenant_id: Some("tenant-demo".to_string()), + synced_at: Some("2026-03-25T10:00:00.000Z".to_string()), + adapter_count: 1, + } + ); + + let index_content = fs::read_to_string(temp_dir.path().join("index.json")) + .expect("index.json should exist"); + assert!(index_content.contains("\"catalog_version\": \"tenant-sync-1\"")); + assert!(index_content.contains("\"script_file\": \"scripts/github-search.js\"")); + + let script_content = fs::read_to_string(temp_dir.path().join("scripts/github-search.js")) + .expect("script file should exist"); + assert_eq!(script_content, "async () => ({ items: [] })"); + + let adapters = + load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced) + .expect("persisted adapters should load"); + assert_eq!(adapters.len(), 1); + assert_eq!(adapters[0].name, "github/search"); + assert_eq!(adapters[0].source_version.as_deref(), Some("tenant-sync-1")); + } + + #[test] + fn should_clear_server_synced_catalog_cache() { + let temp_dir = tempdir().expect("temp dir should exist"); + let payload = serde_json::json!({ + "siteAdapterCatalog": { + "catalogVersion": "tenant-sync-1", + "adapters": [ + { + "name": "zhihu/hot", + "domain": "www.zhihu.com", + "description": "server synced zhihu hot", + "read_only": true, + "capabilities": ["hot"], + "args": [], + "example": "zhihu/hot {}", + "entry": { + "kind": "fixed_url", + "url": "https://www.zhihu.com/hot" + }, + "script": "async () => ({ items: [] })" + } + ] + } + }); + apply_site_adapter_catalog_bootstrap_to_dir(temp_dir.path(), &payload) + .expect("bootstrap catalog should persist"); + + let status = clear_site_adapter_catalog_cache_at_dir(Some(temp_dir.path().to_path_buf())) + .expect("cache should clear"); + assert!(!status.exists); + assert_eq!(status.adapter_count, 0); + assert!(!temp_dir.path().exists()); + } + + #[test] + fn should_build_github_issues_url_with_state_filter() { + let mut args = Map::new(); + args.insert( + "repo".to_string(), + Value::String("rust-lang/rust".to_string()), + ); + args.insert( + "query".to_string(), + Value::String("borrow checker".to_string()), + ); + args.insert("state".to_string(), Value::String("open".to_string())); + + let url = build_entry_url_with_builder("github_issues", &args) + .expect("github issues url should build"); + assert!(url.contains("rust-lang/rust/issues")); + assert!(url.contains("borrow%20checker")); + assert!(url.contains("state%3Aopen")); + } +} diff --git a/src-tauri/src/services/site_capability_service.rs b/src-tauri/src/services/site_capability_service.rs new file mode 100644 index 000000000..a9a3f9a47 --- /dev/null +++ b/src-tauri/src/services/site_capability_service.rs @@ -0,0 +1,1675 @@ +use crate::commands::webview_cmd::{ + close_cdp_session_global, ensure_managed_chrome_profile_global, list_cdp_targets_global, + open_cdp_session_global, shared_browser_runtime, BrowserSessionStateRequest, + ListCdpTargetsRequest, OpenCdpSessionRequest, +}; +use crate::content::{ContentCreateRequest, ContentManager, ContentType}; +use crate::database::{lock_db, DbConnection}; +use crate::services::site_adapter_registry::{ + build_entry_url, find_site_adapter_spec, load_site_adapter_specs, normalize_site_adapter_name, + SiteAdapterArgType, SiteAdapterSpec, +}; +use lime_browser_runtime::{CdpSessionState, CdpTargetInfo}; +use lime_core::database::dao::browser_profile::{BrowserProfileDao, BrowserProfileTransportKind}; +use lime_server::chrome_bridge::{self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::time::{Duration, Instant}; +use url::Url; + +const DEFAULT_PROFILE_KEY: &str = "default"; +const DEFAULT_TIMEOUT_MS: u64 = 20_000; +const MIN_ADAPTER_EVALUATE_TIMEOUT_MS: u64 = 30_000; +const MAX_TIMEOUT_MS: u64 = 120_000; +const EXPLICIT_PROJECT_SAVE_SOURCE: &str = "explicit_project"; + +#[derive(Debug, Clone, Serialize)] +pub struct SiteAdapterArgumentDefinition { + pub name: String, + pub description: String, + pub required: bool, + pub arg_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SiteAdapterDefinition { + pub name: String, + pub domain: String, + pub description: String, + pub read_only: bool, + pub capabilities: Vec, + pub input_schema: Value, + pub example_args: Value, + pub example: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_version: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RunSiteAdapterRequest { + pub adapter_name: String, + #[serde(default)] + pub args: Value, + #[serde(default)] + pub profile_key: Option, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub timeout_ms: Option, + #[serde(default)] + pub project_id: Option, + #[serde(default)] + pub save_title: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SiteAdapterRunResult { + pub ok: bool, + pub adapter: String, + pub domain: String, + pub profile_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_id: Option, + pub entry_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_project_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub save_skipped_project_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub save_skipped_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub save_error_message: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SaveSiteAdapterResultRequest { + pub project_id: String, + #[serde(default)] + pub save_title: Option, + pub run_request: RunSiteAdapterRequest, + pub result: SiteAdapterRunResult, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedSiteAdapterContent { + pub content_id: String, + pub project_id: String, + pub title: String, +} + +#[derive(Debug, Clone)] +struct AdapterExecutionState { + session_id: Option, + target_id: Option, + source_url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SiteAdapterTransportRoute { + ManagedCdp, + ExistingSession, +} + +const ADAPTER_HELPERS_SCRIPT: &str = r#" +const helpers = { + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + text: (value) => { + const text = value?.textContent || ""; + return text.replace(/\s+/g, " ").trim(); + }, + absoluteUrl: (value) => { + try { + return new URL(value, location.href).toString(); + } catch { + return ""; + } + }, + uniqueBy: (items, getKey) => { + const seen = new Set(); + return items.filter((item) => { + const key = getKey(item); + if (!key || seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + }, + waitFor: async (test, timeoutMs = 12000, intervalMs = 250) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const value = await test(); + if (value) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + return null; + }, + take: (items, limit) => items.slice(0, Math.max(1, limit)), + number: (value, fallbackValue) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallbackValue; + }, + looksLikeLoginWall: () => { + const text = (document.body?.textContent || "").slice(0, 4000); + return /(登录|登入|sign in|log in|继续访问|验证你是人类|扫码登录)/i.test(text); + }, +}; +"#; + +pub fn list_site_adapters() -> Vec { + load_site_adapter_specs() + .map(|adapters| { + adapters + .iter() + .map(build_adapter_definition) + .collect::>() + }) + .unwrap_or_default() +} + +pub fn search_site_adapters(query: &str) -> Vec { + let normalized = query.trim().to_ascii_lowercase(); + let Ok(adapters) = load_site_adapter_specs() else { + return Vec::new(); + }; + + adapters + .iter() + .filter(|spec| { + normalized.is_empty() + || spec.name.to_ascii_lowercase().contains(&normalized) + || spec.domain.to_ascii_lowercase().contains(&normalized) + || spec.description.to_ascii_lowercase().contains(&normalized) + || spec + .capabilities + .iter() + .any(|value| value.to_ascii_lowercase().contains(&normalized)) + }) + .map(build_adapter_definition) + .collect() +} + +pub fn get_site_adapter(name: &str) -> Option { + find_site_adapter_spec(name) + .ok() + .flatten() + .map(|spec| build_adapter_definition(&spec)) +} + +pub fn build_site_result_document_title(adapter_name: &str, custom_title: Option<&str>) -> String { + let normalized_custom_title = custom_title + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + if let Some(title) = normalized_custom_title { + return title; + } + + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"); + format!("站点采集 {adapter_name} {timestamp}") +} + +pub fn build_site_result_document_body( + adapter: &SiteAdapterDefinition, + request: &RunSiteAdapterRequest, + result: &SiteAdapterRunResult, +) -> String { + let mut lines = vec![ + "# 站点采集结果".to_string(), + String::new(), + format!("- 生成时间:{}", chrono::Utc::now().to_rfc3339()), + format!("- 适配器:{}", adapter.name), + format!("- 站点域名:{}", adapter.domain), + format!("- 浏览器资料:{}", result.profile_key), + format!("- 执行状态:{}", if result.ok { "成功" } else { "失败" }), + format!("- 入口页面:{}", result.entry_url), + ]; + + if let Some(source_kind) = adapter.source_kind.as_deref() { + lines.push(format!("- 脚本来源:{source_kind}")); + } + if let Some(source_version) = adapter.source_version.as_deref() { + lines.push(format!("- 脚本版本:{source_version}")); + } + if let Some(source_url) = result.source_url.as_deref() { + lines.push(format!("- 来源页面:{source_url}")); + } + if let Some(error_code) = result.error_code.as_deref() { + lines.push(format!("- 错误码:{error_code}")); + } + if let Some(error_message) = result.error_message.as_deref() { + lines.push(format!("- 错误信息:{error_message}")); + } + + lines.extend([ + String::new(), + "## 适配器说明".to_string(), + String::new(), + adapter.description.clone(), + String::new(), + "## 执行参数".to_string(), + String::new(), + "```json".to_string(), + serde_json::to_string_pretty(&request.args).unwrap_or_else(|_| request.args.to_string()), + "```".to_string(), + String::new(), + "## 结构化结果".to_string(), + String::new(), + "```json".to_string(), + serde_json::to_string_pretty( + &result + .data + .clone() + .unwrap_or_else(|| serde_json::json!(result)), + ) + .unwrap_or_else(|_| { + result + .data + .clone() + .unwrap_or_else(|| serde_json::json!(result)) + .to_string() + }), + "```".to_string(), + ]); + + if let Some(auth_hint) = result.auth_hint.as_deref() { + lines.extend([ + String::new(), + "## 登录提示".to_string(), + String::new(), + auth_hint.to_string(), + ]); + } + + lines.join("\n") +} + +pub fn save_site_result_to_project( + db: &DbConnection, + project_id: &str, + save_title: Option<&str>, + adapter: &SiteAdapterDefinition, + request: &RunSiteAdapterRequest, + result: &SiteAdapterRunResult, +) -> Result { + let normalized_project_id = project_id.trim(); + if normalized_project_id.is_empty() { + return Err("project_id 不能为空".to_string()); + } + + let manager = ContentManager::new(db.clone()); + let title = build_site_result_document_title(&adapter.name, save_title); + let body = build_site_result_document_body(adapter, request, result); + let metadata = serde_json::json!({ + "resourceKind": "document", + "siteAdapterName": adapter.name, + "siteAdapterDomain": adapter.domain, + "siteAdapterProfileKey": result.profile_key, + "siteAdapterEntryUrl": result.entry_url, + "siteAdapterSourceUrl": result.source_url, + "siteAdapterSourceKind": adapter.source_kind, + "siteAdapterSourceVersion": adapter.source_version, + }); + let content = manager + .create(ContentCreateRequest { + project_id: normalized_project_id.to_string(), + title: title.clone(), + content_type: Some(ContentType::Document), + order: None, + body: Some(body), + metadata: Some(metadata), + }) + .map_err(|error| format!("保存站点结果到项目失败: {error}"))?; + + Ok(SavedSiteAdapterContent { + content_id: content.id, + project_id: content.project_id, + title, + }) +} + +pub fn save_existing_site_result_to_project( + db: &DbConnection, + request: SaveSiteAdapterResultRequest, +) -> Result { + if !request.result.ok { + return Err("仅支持保存成功的站点结果".to_string()); + } + + let adapter_name = normalize_site_adapter_name(&request.run_request.adapter_name); + let adapter = + get_site_adapter(&adapter_name).ok_or_else(|| "未找到对应的站点适配器".to_string())?; + save_site_result_to_project( + db, + &request.project_id, + request.save_title.as_deref(), + &adapter, + &request.run_request, + &request.result, + ) +} + +pub async fn run_site_adapter_with_optional_save( + db: &DbConnection, + request: RunSiteAdapterRequest, +) -> SiteAdapterRunResult { + let result = run_site_adapter(db, request.clone()).await; + attach_requested_site_result_save(db, &request, result) +} + +pub async fn run_site_adapter( + db: &DbConnection, + request: RunSiteAdapterRequest, +) -> SiteAdapterRunResult { + let normalized_name = normalize_site_adapter_name(&request.adapter_name); + let spec = match find_site_adapter_spec(&normalized_name) { + Ok(Some(spec)) => spec, + Ok(None) => { + return SiteAdapterRunResult { + ok: false, + adapter: normalized_name, + domain: String::new(), + profile_key: resolve_requested_profile_key(request.profile_key.as_deref()), + session_id: None, + target_id: None, + entry_url: String::new(), + source_url: None, + data: None, + error_code: Some("adapter_not_found".to_string()), + error_message: Some("未找到对应的站点适配器".to_string()), + auth_hint: None, + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }; + } + Err(error) => { + return SiteAdapterRunResult { + ok: false, + adapter: normalized_name, + domain: String::new(), + profile_key: resolve_requested_profile_key(request.profile_key.as_deref()), + session_id: None, + target_id: None, + entry_url: String::new(), + source_url: None, + data: None, + error_code: Some("internal_error".to_string()), + error_message: Some(error), + auth_hint: None, + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }; + } + }; + + let profile_key = resolve_requested_profile_key(request.profile_key.as_deref()); + let args = match normalize_adapter_args(request.args) { + Ok(value) => value, + Err(error) => { + return build_error_result( + &spec, + profile_key, + None, + None, + String::new(), + "invalid_args", + &error, + ); + } + }; + + if let Err(error) = validate_adapter_args(&spec, &args) { + return build_error_result( + &spec, + profile_key, + None, + None, + String::new(), + "invalid_args", + &error, + ); + } + + let entry_url = match build_entry_url(&spec, &args) { + Ok(value) => value, + Err(error) => { + return build_error_result( + &spec, + profile_key, + None, + None, + String::new(), + "invalid_args", + &error, + ); + } + }; + + let transport_route = match resolve_transport_route(db, &profile_key) { + Ok(value) => value, + Err(error) => { + return build_error_result( + &spec, + profile_key, + None, + None, + entry_url, + "internal_error", + &error, + ); + } + }; + + let wrapped_script = match build_wrapped_adapter_script(&spec.script, &args) { + Ok(value) => value, + Err(error) => { + return build_error_result( + &spec, + profile_key, + None, + None, + entry_url, + "internal_error", + &format!("构造适配器脚本失败: {error}"), + ); + } + }; + let timeout_ms = normalize_timeout_ms(request.timeout_ms); + + match transport_route { + SiteAdapterTransportRoute::ExistingSession => { + run_existing_session_adapter( + &spec, + profile_key, + request.target_id, + entry_url, + timeout_ms, + wrapped_script, + ) + .await + } + SiteAdapterTransportRoute::ManagedCdp => { + run_managed_cdp_adapter( + db, + &spec, + profile_key, + request.target_id, + entry_url, + timeout_ms, + wrapped_script, + ) + .await + } + } +} + +fn build_adapter_definition(spec: &SiteAdapterSpec) -> SiteAdapterDefinition { + SiteAdapterDefinition { + name: spec.name.clone(), + domain: spec.domain.clone(), + description: spec.description.clone(), + read_only: spec.read_only, + capabilities: spec.capabilities.clone(), + input_schema: build_input_schema(&spec.args), + example_args: build_example_args(&spec.args), + example: spec.example.clone(), + auth_hint: spec.auth_hint.clone(), + source_kind: Some(spec.source_kind.as_str().to_string()), + source_version: spec.source_version.clone(), + } +} + +fn build_input_schema( + args: &[crate::services::site_adapter_registry::SiteAdapterArgSpec], +) -> Value { + let mut required = Vec::new(); + let mut properties = Map::new(); + for arg in args { + if arg.required { + required.push(Value::String(arg.name.clone())); + } + let mut property = Map::new(); + property.insert( + "type".to_string(), + Value::String(arg.arg_type.schema_type().to_string()), + ); + property.insert( + "description".to_string(), + Value::String(arg.description.clone()), + ); + if let Some(example) = arg.example.clone() { + property.insert("example".to_string(), example); + } + properties.insert(arg.name.clone(), Value::Object(property)); + } + + json!({ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": true, + }) +} + +fn build_example_args( + args: &[crate::services::site_adapter_registry::SiteAdapterArgSpec], +) -> Value { + let mut object = Map::new(); + for arg in args { + if let Some(example) = arg.example.clone() { + object.insert(arg.name.clone(), example); + } + } + Value::Object(object) +} + +fn normalize_adapter_args(value: Value) -> Result, String> { + match value { + Value::Null => Ok(Map::new()), + Value::Object(map) => Ok(map), + _ => Err("args 必须是 JSON object".to_string()), + } +} + +fn validate_adapter_args(spec: &SiteAdapterSpec, args: &Map) -> Result<(), String> { + for arg in &spec.args { + let value = args.get(&arg.name); + if arg.required && value.is_none() { + return Err(format!("缺少必填参数: {}", arg.name)); + } + if let Some(value) = value { + let valid = match arg.arg_type { + SiteAdapterArgType::String => value.is_string(), + SiteAdapterArgType::Integer => value.as_i64().is_some() || value.as_u64().is_some(), + }; + if !valid { + return Err(format!( + "参数 {} 类型不正确,期望 {}", + arg.name, + arg.arg_type.schema_type() + )); + } + } + } + Ok(()) +} + +fn resolve_requested_profile_key(profile_key: Option<&str>) -> String { + profile_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_PROFILE_KEY) + .to_string() +} + +fn resolve_transport_route( + db: &DbConnection, + profile_key: &str, +) -> Result { + match load_profile_transport(db, profile_key)? { + Some(BrowserProfileTransportKind::ExistingSession) => { + Ok(SiteAdapterTransportRoute::ExistingSession) + } + _ => Ok(SiteAdapterTransportRoute::ManagedCdp), + } +} + +fn load_profile_transport( + db: &DbConnection, + profile_key: &str, +) -> Result, String> { + let conn = lock_db(db)?; + let profile = BrowserProfileDao::get_by_profile_key(&conn, profile_key) + .map_err(|error| format!("读取浏览器资料失败: {error}"))?; + Ok(profile + .filter(|record| record.archived_at.is_none()) + .map(|record| record.transport_kind)) +} + +async fn run_existing_session_adapter( + spec: &SiteAdapterSpec, + profile_key: String, + target_id: Option, + entry_url: String, + timeout_ms: u64, + wrapped_script: String, +) -> SiteAdapterRunResult { + let navigation_result = match execute_bridge_adapter_command(ChromeBridgeCommandRequest { + profile_key: Some(profile_key.clone()), + command: "open_url".to_string(), + target: target_id.clone(), + text: None, + url: Some(entry_url.clone()), + payload: None, + wait_for_page_info: true, + timeout_ms: Some(timeout_ms), + }) + .await + { + Ok(result) => result, + Err(error) => { + return build_error_result( + spec, + profile_key, + None, + target_id, + entry_url, + "site_unreachable", + &format!("当前 Chrome 导航失败: {error}"), + ); + } + }; + + let bridged_target_id = navigation_result + .data + .as_ref() + .and_then(|data| data.get("tab_id")) + .and_then(value_to_string) + .or(target_id.clone()); + let latest_source_url = navigation_result + .page_info + .as_ref() + .and_then(|page| page.url.clone()); + + let adapter_output = match execute_bridge_adapter_command(ChromeBridgeCommandRequest { + profile_key: Some(profile_key.clone()), + command: "run_adapter".to_string(), + target: bridged_target_id.clone(), + text: None, + url: None, + payload: Some(json!({ + "script": wrapped_script, + })), + wait_for_page_info: false, + timeout_ms: Some(normalize_adapter_evaluate_timeout_ms(timeout_ms)), + }) + .await + { + Ok(result) => result.data.unwrap_or(Value::Null), + Err(error) => { + return build_error_result( + spec, + profile_key, + None, + bridged_target_id, + entry_url, + "adapter_failed", + &error, + ); + } + }; + + normalize_adapter_output( + spec, + profile_key, + entry_url, + AdapterExecutionState { + session_id: None, + target_id: bridged_target_id, + source_url: latest_source_url, + }, + adapter_output, + ) +} + +async fn run_managed_cdp_adapter( + db: &DbConnection, + spec: &SiteAdapterSpec, + profile_key: String, + target_id: Option, + entry_url: String, + timeout_ms: u64, + wrapped_script: String, +) -> SiteAdapterRunResult { + let _ = db; + + if let Err(error) = + ensure_managed_chrome_profile_global(profile_key.clone(), Some(entry_url.clone())).await + { + return build_error_result( + spec, + profile_key, + None, + None, + entry_url, + "site_unreachable", + &format!("启动浏览器资料失败: {error}"), + ); + } + + let target_id = match resolve_target_id(&profile_key, &spec.domain, target_id.as_deref()).await + { + Ok(value) => value, + Err(error) => { + return build_error_result( + spec, + profile_key, + None, + None, + entry_url, + "site_unreachable", + &error, + ); + } + }; + + let session = match ensure_runtime_session_for_target(&profile_key, target_id.as_deref()).await + { + Ok(value) => value, + Err(error) => { + return build_error_result( + spec, + profile_key, + None, + target_id, + entry_url, + "site_unreachable", + &format!("建立浏览器会话失败: {error}"), + ); + } + }; + + let runtime = shared_browser_runtime(); + if let Err(error) = runtime + .execute_action( + &session.session_id, + "navigate", + json!({ + "action": "goto", + "url": entry_url, + "timeout_ms": timeout_ms, + }), + ) + .await + { + return build_error_result( + spec, + profile_key, + Some(session.session_id), + Some(session.target_id), + entry_url, + "site_unreachable", + &format!("导航站点失败: {error}"), + ); + } + + let refreshed_session = runtime + .refresh_page_info(&session.session_id) + .await + .unwrap_or(session.clone()); + let refreshed_session = + wait_for_navigation_settle(&runtime, refreshed_session, &entry_url, timeout_ms).await; + + let adapter_output = match evaluate_session_script( + &refreshed_session.session_id, + &wrapped_script, + normalize_adapter_evaluate_timeout_ms(timeout_ms), + ) + .await + { + Ok(value) => value, + Err(error) => { + return build_error_result( + spec, + profile_key, + Some(refreshed_session.session_id), + Some(refreshed_session.target_id), + entry_url, + "adapter_failed", + &error, + ); + } + }; + + let latest_session = runtime + .refresh_page_info(&refreshed_session.session_id) + .await + .unwrap_or(refreshed_session); + + normalize_adapter_output( + spec, + profile_key, + entry_url, + AdapterExecutionState { + session_id: Some(latest_session.session_id), + target_id: Some(latest_session.target_id), + source_url: latest_session + .last_page_info + .as_ref() + .map(|page| page.url.clone()), + }, + adapter_output, + ) +} + +async fn execute_bridge_adapter_command( + request: ChromeBridgeCommandRequest, +) -> Result { + let result = chrome_bridge::chrome_bridge_hub() + .execute_api_command(request) + .await?; + if result.success { + Ok(result) + } else { + Err(result + .error + .or(result.message) + .unwrap_or_else(|| "Chrome 执行失败".to_string())) + } +} + +async fn resolve_target_id( + profile_key: &str, + domain: &str, + requested_target_id: Option<&str>, +) -> Result, String> { + if let Some(target_id) = requested_target_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(Some(target_id.to_string())); + } + + let targets = list_cdp_targets_global(ListCdpTargetsRequest { + profile_key: Some(profile_key.to_string()), + }) + .await?; + + Ok(find_target_for_domain(&targets, domain) + .or_else(|| { + targets + .iter() + .find(|target| target.target_type == "page") + .map(|target| target.id.clone()) + }) + .or_else(|| targets.first().map(|target| target.id.clone()))) +} + +fn find_target_for_domain(targets: &[CdpTargetInfo], domain: &str) -> Option { + targets.iter().find_map(|target| { + let hostname = Url::parse(&target.url) + .ok() + .and_then(|url| url.host_str().map(ToString::to_string))?; + if hostname == domain || hostname.ends_with(&format!(".{domain}")) { + Some(target.id.clone()) + } else { + None + } + }) +} + +async fn ensure_runtime_session_for_target( + profile_key: &str, + target_id: Option<&str>, +) -> Result { + let runtime = shared_browser_runtime(); + if let Some(existing) = runtime.find_session_by_profile_key(profile_key).await { + if target_id.is_none() || Some(existing.target_id.as_str()) == target_id { + return Ok(existing); + } + let _ = close_cdp_session_global(BrowserSessionStateRequest { + session_id: existing.session_id, + }) + .await?; + } + + open_cdp_session_global(OpenCdpSessionRequest { + profile_key: profile_key.to_string(), + target_id: target_id.map(ToString::to_string), + environment_preset_id: None, + environment_preset_name: None, + }) + .await +} + +fn normalize_timeout_ms(timeout_ms: Option) -> u64 { + timeout_ms + .unwrap_or(DEFAULT_TIMEOUT_MS) + .clamp(1_000, MAX_TIMEOUT_MS) +} + +fn normalize_optional_project_id(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn attach_requested_site_result_save( + db: &DbConnection, + request: &RunSiteAdapterRequest, + mut result: SiteAdapterRunResult, +) -> SiteAdapterRunResult { + let Some(project_id) = normalize_optional_project_id(request.project_id.as_deref()) else { + return result; + }; + + if !result.ok { + result.save_skipped_project_id = Some(project_id); + result.save_skipped_by = Some(EXPLICIT_PROJECT_SAVE_SOURCE.to_string()); + return result; + } + + let adapter_name = normalize_site_adapter_name(&request.adapter_name); + let Some(adapter) = get_site_adapter(&adapter_name) else { + result.save_skipped_project_id = Some(project_id); + result.save_skipped_by = Some(EXPLICIT_PROJECT_SAVE_SOURCE.to_string()); + result.save_error_message = Some("未找到对应的站点适配器".to_string()); + return result; + }; + + match save_site_result_to_project( + db, + &project_id, + request.save_title.as_deref(), + &adapter, + request, + &result, + ) { + Ok(saved_content) => { + result.saved_content = Some(saved_content); + result.saved_project_id = Some(project_id); + result.saved_by = Some(EXPLICIT_PROJECT_SAVE_SOURCE.to_string()); + } + Err(error) => { + result.save_skipped_project_id = Some(project_id); + result.save_skipped_by = Some(EXPLICIT_PROJECT_SAVE_SOURCE.to_string()); + result.save_error_message = Some(error); + } + } + + result +} + +fn normalize_adapter_evaluate_timeout_ms(timeout_ms: u64) -> u64 { + timeout_ms + .max(MIN_ADAPTER_EVALUATE_TIMEOUT_MS) + .clamp(1_000, MAX_TIMEOUT_MS) +} + +fn build_wrapped_adapter_script( + adapter_script: &str, + args: &Map, +) -> Result { + let args_literal = serde_json::to_string(&Value::Object(args.clone())) + .map_err(|error| format!("编码适配器参数失败: {error}"))?; + + Ok(format!( + r#" +(async () => {{ + const args = {args_literal}; + {helpers} + const adapter = {adapter}; + try {{ + const result = await adapter(args, helpers); + if (result && typeof result === "object" && Object.prototype.hasOwnProperty.call(result, "ok")) {{ + return result; + }} + return {{ + ok: true, + data: result ?? null, + source_url: location.href, + }}; + }} catch (error) {{ + return {{ + ok: false, + error_code: "adapter_failed", + error_message: error?.message || String(error), + source_url: location.href, + }}; + }} +}})() +"#, + args_literal = args_literal, + helpers = ADAPTER_HELPERS_SCRIPT, + adapter = adapter_script, + )) +} + +async fn evaluate_session_script( + session_id: &str, + expression: &str, + timeout_ms: u64, +) -> Result { + let response = shared_browser_runtime() + .send_command( + session_id, + "Runtime.evaluate", + json!({ + "expression": expression, + "returnByValue": true, + "awaitPromise": true, + }), + timeout_ms, + ) + .await?; + + if let Some(exception) = response.get("exceptionDetails") { + return Err(format!("页面脚本执行失败: {exception}")); + } + + let result = response.get("result").cloned().unwrap_or(Value::Null); + Ok(result.get("value").cloned().unwrap_or(result)) +} + +fn normalize_adapter_output( + spec: &SiteAdapterSpec, + profile_key: String, + entry_url: String, + execution_state: AdapterExecutionState, + adapter_output: Value, +) -> SiteAdapterRunResult { + let source_url = adapter_output + .get("source_url") + .and_then(Value::as_str) + .map(ToString::to_string) + .or(execution_state.source_url); + + let ok = adapter_output + .get("ok") + .and_then(Value::as_bool) + .unwrap_or(true); + let data = adapter_output.get("data").cloned().or_else(|| { + if ok { + Some(adapter_output.clone()) + } else { + None + } + }); + let error_code = adapter_output + .get("error_code") + .and_then(Value::as_str) + .map(ToString::to_string); + let error_message = adapter_output + .get("error_message") + .and_then(Value::as_str) + .map(ToString::to_string); + let auth_hint = adapter_output + .get("auth_hint") + .and_then(Value::as_str) + .map(ToString::to_string) + .or_else(|| spec.auth_hint.clone()); + + SiteAdapterRunResult { + ok, + adapter: spec.name.clone(), + domain: spec.domain.clone(), + profile_key, + session_id: execution_state.session_id, + target_id: execution_state.target_id, + entry_url, + source_url, + data, + error_code, + error_message, + auth_hint, + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + } +} + +async fn wait_for_navigation_settle( + runtime: &lime_browser_runtime::BrowserRuntimeManager, + session: CdpSessionState, + entry_url: &str, + timeout_ms: u64, +) -> CdpSessionState { + let previous_url = session.last_page_info.as_ref().map(|page| page.url.clone()); + let max_wait_ms = timeout_ms.clamp(1_000, 8_000); + let started_at = Instant::now(); + let mut latest_session = session; + + loop { + let current_url = latest_session + .last_page_info + .as_ref() + .map(|page| page.url.as_str()); + if navigation_reached_expected_page(current_url, previous_url.as_deref(), entry_url) { + return latest_session; + } + + if started_at.elapsed() >= Duration::from_millis(max_wait_ms) { + return latest_session; + } + + tokio::time::sleep(Duration::from_millis(250)).await; + latest_session = runtime + .refresh_page_info(&latest_session.session_id) + .await + .unwrap_or(latest_session); + } +} + +fn navigation_reached_expected_page( + current_url: Option<&str>, + previous_url: Option<&str>, + entry_url: &str, +) -> bool { + let Some(current_url) = current_url.map(str::trim).filter(|value| !value.is_empty()) else { + return false; + }; + + if url_matches_expected_entry(current_url, entry_url) { + return true; + } + + let Some(expected_host) = parse_url_host(entry_url) else { + return false; + }; + let Some(current_host) = parse_url_host(current_url) else { + return false; + }; + if current_host != expected_host { + return false; + } + + let Some(previous_url) = previous_url + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return true; + }; + + current_url != previous_url +} + +fn url_matches_expected_entry(current_url: &str, entry_url: &str) -> bool { + let Ok(current) = Url::parse(current_url) else { + return false; + }; + let Ok(expected) = Url::parse(entry_url) else { + return false; + }; + + if current.host_str().map(str::to_ascii_lowercase) + != expected.host_str().map(str::to_ascii_lowercase) + { + return false; + } + + if normalize_url_path(current.path()) != normalize_url_path(expected.path()) { + return false; + } + + let current_query = current.query_pairs().collect::>(); + expected + .query_pairs() + .all(|(expected_key, expected_value)| { + current_query.iter().any(|(current_key, current_value)| { + current_key == &expected_key && current_value == &expected_value + }) + }) +} + +fn parse_url_host(url: &str) -> Option { + Url::parse(url) + .ok()? + .host_str() + .map(|value| value.to_ascii_lowercase()) +} + +fn normalize_url_path(path: &str) -> &str { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/" + } else { + trimmed + } +} + +fn build_error_result( + spec: &SiteAdapterSpec, + profile_key: String, + session_id: Option, + target_id: Option, + entry_url: String, + error_code: &str, + error_message: &str, +) -> SiteAdapterRunResult { + SiteAdapterRunResult { + ok: false, + adapter: spec.name.clone(), + domain: spec.domain.clone(), + profile_key, + session_id, + target_id, + entry_url, + source_url: None, + data: None, + error_code: Some(error_code.to_string()), + error_message: Some(error_message.to_string()), + auth_hint: spec.auth_hint.clone(), + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + } +} + +fn value_to_string(value: &Value) -> Option { + value + .as_str() + .map(ToString::to_string) + .or_else(|| value.as_i64().map(|raw| raw.to_string())) + .or_else(|| value.as_u64().map(|raw| raw.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{ContentManager, ContentType}; + use crate::database::schema::create_tables; + use crate::workspace::{WorkspaceManager, WorkspaceType}; + use lime_core::database::dao::browser_profile::UpsertBrowserProfileInput; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + use tempfile::tempdir; + + fn setup_test_db() -> DbConnection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + Arc::new(Mutex::new(conn)) + } + + #[test] + fn should_list_site_adapters_with_example_args() { + let adapters = list_site_adapters(); + assert!(adapters + .iter() + .any(|adapter| adapter.name == "github/search")); + let github = adapters + .into_iter() + .find(|adapter| adapter.name == "github/search") + .expect("github/search should exist"); + assert_eq!( + github.example_args["query"], + Value::String("AI Agent".to_string()) + ); + assert_eq!(github.example_args["limit"], Value::from(5)); + assert_eq!(github.source_kind.as_deref(), Some("bundled")); + } + + #[test] + fn should_search_site_adapters_by_keyword() { + let adapters = search_site_adapters("issue"); + assert_eq!(adapters.len(), 1); + assert_eq!(adapters[0].name, "github/issues"); + } + + #[test] + fn should_reject_missing_required_arg() { + let spec = find_site_adapter_spec("github/search") + .expect("registry should load") + .expect("spec exists"); + let error = validate_adapter_args(&spec, &Map::new()).unwrap_err(); + assert!(error.contains("query")); + } + + #[test] + fn should_normalize_timeout_range() { + assert_eq!(normalize_timeout_ms(Some(500)), 1_000); + assert_eq!(normalize_timeout_ms(Some(200_000)), MAX_TIMEOUT_MS); + assert_eq!(normalize_timeout_ms(None), DEFAULT_TIMEOUT_MS); + } + + #[test] + fn should_keep_adapter_evaluate_timeout_above_minimum() { + assert_eq!(normalize_adapter_evaluate_timeout_ms(5_000), 30_000); + assert_eq!(normalize_adapter_evaluate_timeout_ms(45_000), 45_000); + assert_eq!( + normalize_adapter_evaluate_timeout_ms(200_000), + MAX_TIMEOUT_MS + ); + } + + #[test] + fn should_detect_navigation_when_url_matches_expected_query() { + assert!(navigation_reached_expected_page( + Some("https://github.com/search?type=repositories&q=model%20context%20protocol"), + Some("https://www.36kr.com/newsflashes"), + "https://github.com/search?q=model%20context%20protocol&type=repositories", + )); + } + + #[test] + fn should_detect_navigation_when_host_changes_from_previous_page() { + assert!(navigation_reached_expected_page( + Some("https://search.bilibili.com/all?keyword=AI%20Agent"), + Some("https://www.36kr.com/newsflashes"), + "https://search.bilibili.com/all?keyword=AI%20Agent", + )); + } + + #[test] + fn should_not_accept_stale_page_on_same_host() { + assert!(!navigation_reached_expected_page( + Some("https://github.com/search?q=old&type=repositories"), + Some("https://github.com/search?q=old&type=repositories"), + "https://github.com/search?q=new&type=repositories", + )); + } + + #[test] + fn should_save_existing_site_result_to_project_as_document() { + let db = setup_test_db(); + let workspace_root = tempdir().expect("创建临时目录失败"); + let workspace = WorkspaceManager::new(db.clone()) + .create_with_type( + "站点采集项目".to_string(), + workspace_root.path().join("site-capability-project"), + WorkspaceType::Document, + ) + .expect("创建测试项目失败"); + let request = SaveSiteAdapterResultRequest { + project_id: workspace.id.clone(), + save_title: Some("GitHub MCP 搜索结果".to_string()), + run_request: RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({"query":"mcp","limit":5}), + profile_key: Some("general_browser_assist".to_string()), + target_id: Some("target-1".to_string()), + timeout_ms: Some(20_000), + project_id: None, + save_title: None, + }, + result: SiteAdapterRunResult { + ok: true, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: Some("session-1".to_string()), + target_id: Some("target-1".to_string()), + entry_url: "https://github.com/search?q=mcp&type=repositories".to_string(), + source_url: Some("https://github.com/search?q=mcp&type=repositories".to_string()), + data: Some(serde_json::json!({ + "items": [ + {"title": "modelcontextprotocol/servers"} + ] + })), + error_code: None, + error_message: None, + auth_hint: Some("请先登录 GitHub。".to_string()), + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }, + }; + + let saved_content = + save_existing_site_result_to_project(&db, request).expect("保存站点结果到项目失败"); + let manager = ContentManager::new(db); + let content = manager + .get(&saved_content.content_id) + .expect("读取内容失败") + .expect("内容应存在"); + + assert_eq!(content.project_id, workspace.id); + assert_eq!(content.title, "GitHub MCP 搜索结果"); + assert_eq!(content.content_type, ContentType::Document); + assert!(content.body.contains("# 站点采集结果")); + assert!(content.body.contains("\"query\": \"mcp\"")); + assert!(content.body.contains("modelcontextprotocol/servers")); + assert_eq!( + content + .metadata + .as_ref() + .and_then(|metadata| metadata.get("siteAdapterName")), + Some(&serde_json::json!("github/search")) + ); + assert_eq!( + content + .metadata + .as_ref() + .and_then(|metadata| metadata.get("siteAdapterSourceKind")), + Some(&serde_json::json!("bundled")) + ); + } + + #[test] + fn should_reject_saving_failed_site_result() { + let db = setup_test_db(); + let request = SaveSiteAdapterResultRequest { + project_id: "project-1".to_string(), + save_title: None, + run_request: RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({"query":"mcp"}), + profile_key: Some("general_browser_assist".to_string()), + target_id: None, + timeout_ms: None, + project_id: None, + save_title: None, + }, + result: SiteAdapterRunResult { + ok: false, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: None, + target_id: None, + entry_url: "https://github.com/search?q=mcp&type=repositories".to_string(), + source_url: None, + data: None, + error_code: Some("adapter_failed".to_string()), + error_message: Some("mock error".to_string()), + auth_hint: None, + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }, + }; + + let error = + save_existing_site_result_to_project(&db, request).expect_err("失败结果不应允许保存"); + assert!(error.contains("仅支持保存成功")); + } + + #[test] + fn should_attach_saved_content_when_run_request_includes_project_id() { + let db = setup_test_db(); + let workspace_root = tempdir().expect("创建临时目录失败"); + let workspace = WorkspaceManager::new(db.clone()) + .create_with_type( + "站点采集项目".to_string(), + workspace_root + .path() + .join("site-capability-auto-save-project"), + WorkspaceType::Document, + ) + .expect("创建测试项目失败"); + let request = RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({"query":"mcp","limit":5}), + profile_key: Some("general_browser_assist".to_string()), + target_id: Some("target-1".to_string()), + timeout_ms: Some(20_000), + project_id: Some(workspace.id.clone()), + save_title: Some("自动保存的 GitHub MCP 搜索结果".to_string()), + }; + let result = SiteAdapterRunResult { + ok: true, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: Some("session-1".to_string()), + target_id: Some("target-1".to_string()), + entry_url: "https://github.com/search?q=mcp&type=repositories".to_string(), + source_url: Some("https://github.com/search?q=mcp&type=repositories".to_string()), + data: Some(serde_json::json!({ + "items": [ + {"title": "modelcontextprotocol/servers"} + ] + })), + error_code: None, + error_message: None, + auth_hint: Some("请先登录 GitHub。".to_string()), + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }; + + let saved_result = attach_requested_site_result_save(&db, &request, result); + let manager = ContentManager::new(db); + let saved_content = saved_result.saved_content.as_ref().expect("应写入保存结果"); + let content = manager + .get(&saved_content.content_id) + .expect("读取内容失败") + .expect("内容应存在"); + + assert_eq!( + saved_result.saved_project_id.as_deref(), + Some(workspace.id.as_str()) + ); + assert_eq!( + saved_result.saved_by.as_deref(), + Some(EXPLICIT_PROJECT_SAVE_SOURCE) + ); + assert_eq!(saved_result.save_error_message, None); + assert_eq!(content.project_id, workspace.id); + assert_eq!(content.title, "自动保存的 GitHub MCP 搜索结果"); + } + + #[test] + fn should_mark_save_skipped_when_project_save_is_requested_for_failed_run() { + let db = setup_test_db(); + let request = RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({"query":"mcp"}), + profile_key: Some("general_browser_assist".to_string()), + target_id: None, + timeout_ms: None, + project_id: Some("project-1".to_string()), + save_title: None, + }; + let result = SiteAdapterRunResult { + ok: false, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: None, + target_id: None, + entry_url: "https://github.com/search?q=mcp&type=repositories".to_string(), + source_url: None, + data: None, + error_code: Some("adapter_failed".to_string()), + error_message: Some("mock error".to_string()), + auth_hint: None, + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }; + + let saved_result = attach_requested_site_result_save(&db, &request, result); + + assert!(saved_result.saved_content.is_none()); + assert_eq!( + saved_result.save_skipped_project_id.as_deref(), + Some("project-1") + ); + assert_eq!( + saved_result.save_skipped_by.as_deref(), + Some(EXPLICIT_PROJECT_SAVE_SOURCE) + ); + } + + #[tokio::test] + async fn should_route_existing_session_without_transport_block_error() { + let db = setup_test_db(); + { + let conn = lock_db(&db).expect("lock db should succeed"); + 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, + }, + ) + .expect("existing_session profile should save"); + } + + let result = run_site_adapter( + &db, + RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({"query":"mcp"}), + profile_key: Some("weibo_attach".to_string()), + target_id: None, + timeout_ms: Some(5_000), + project_id: None, + save_title: None, + }, + ) + .await; + + assert!(!result.ok); + assert_ne!( + result.error_code.as_deref(), + Some("unsupported_profile_transport") + ); + assert_eq!(result.error_code.as_deref(), Some("site_unreachable")); + assert!( + result + .error_message + .unwrap_or_default() + .contains("Chrome observer") + || result.auth_hint.unwrap_or_default().contains("GitHub") + ); + } +} diff --git a/src-tauri/src/services/thread_reliability_projection_service.rs b/src-tauri/src/services/thread_reliability_projection_service.rs index f99db6b6f..13fa52f0c 100644 --- a/src-tauri/src/services/thread_reliability_projection_service.rs +++ b/src-tauri/src/services/thread_reliability_projection_service.rs @@ -183,6 +183,7 @@ mod tests { workspace_id: None, messages: Vec::new(), execution_strategy: None, + execution_runtime: None, turns: Vec::new(), items: Vec::new(), todo_items: Vec::new(), diff --git a/src-tauri/src/skills/catalog.rs b/src-tauri/src/skills/catalog.rs index f8e63fb52..878db2268 100644 --- a/src-tauri/src/skills/catalog.rs +++ b/src-tauri/src/skills/catalog.rs @@ -202,6 +202,7 @@ mod tests { success: true, output: Some("Hello, world!".to_string()), error: None, + artifact_paths: vec![], steps_completed: vec![StepResult { step_id: "step-1".to_string(), step_name: "Process".to_string(), diff --git a/src-tauri/src/skills/execution.rs b/src-tauri/src/skills/execution.rs index 6300d7388..7972ed149 100644 --- a/src-tauri/src/skills/execution.rs +++ b/src-tauri/src/skills/execution.rs @@ -4,9 +4,10 @@ //! 本模块只保留 Tauri emitter 与错误码映射。 use lime_agent::{ + artifact_protocol::extend_unique_artifact_protocol_paths, execute_skill_prompt as execute_agent_skill_prompt, - execute_skill_workflow as execute_agent_skill_workflow, AsterAgentState, SkillEventEmitter, - SkillExecutionError, SkillWorkflowExecution, TauriAgentEvent, + execute_skill_workflow as execute_agent_skill_workflow, AgentEvent as RuntimeAgentEvent, + AsterAgentState, SkillEventEmitter, SkillExecutionError, SkillWorkflowExecution, }; use lime_skills::{ExecutionCallback, LoadedSkillDefinition}; use std::sync::{Arc, Mutex}; @@ -89,7 +90,7 @@ impl ExecutionCallback for TauriExecutionCallbackAdapter<'_> { fn create_skill_event_emitter(app_handle: &AppHandle) -> SkillEventEmitter { let app_handle = app_handle.clone(); - Arc::new(move |event_name: String, event: TauriAgentEvent| { + Arc::new(move |event_name: String, event: RuntimeAgentEvent| { if let Err(error) = app_handle.emit(&event_name, &event) { tracing::error!("[execute_skill_workflow] 发送事件失败: {}", error); } @@ -98,7 +99,7 @@ fn create_skill_event_emitter(app_handle: &AppHandle) -> SkillEventEmitter { fn emit_skill_final_done(app_handle: &AppHandle, execution_id: &str) { let event_name = format!("skill-exec-{execution_id}"); - if let Err(error) = app_handle.emit(&event_name, TauriAgentEvent::FinalDone { usage: None }) { + if let Err(error) = app_handle.emit(&event_name, RuntimeAgentEvent::FinalDone { usage: None }) { tracing::error!("[execute_skill] 发送完成事件失败: {}", error); } } @@ -274,20 +275,21 @@ pub async fn execute_skill_prompt( return Ok(result); } - let final_output = finalize_skill_output( + let finalized = finalize_skill_output( app_handle, &skill.skill_name, user_input, execution_id, result.output.as_deref().unwrap_or(""), ); - result.output = Some(final_output.clone()); + extend_unique_artifact_protocol_paths(&mut result.artifact_paths, &finalized.artifact_paths); + result.output = Some(finalized.final_output.clone()); if let Some(step_result) = result.steps_completed.get_mut(0) { - step_result.output = Some(final_output.clone()); + step_result.output = Some(finalized.final_output.clone()); } - callback_adapter.on_step_complete("main", &final_output); - callback_adapter.on_complete(true, Some(&final_output), None); + callback_adapter.on_step_complete("main", &finalized.final_output); + callback_adapter.on_complete(true, Some(&finalized.final_output), None); emit_skill_final_done(app_handle, execution_id); Ok(result) } diff --git a/src-tauri/src/skills/mod.rs b/src-tauri/src/skills/mod.rs index 7e039edc7..e137ca7b6 100644 --- a/src-tauri/src/skills/mod.rs +++ b/src-tauri/src/skills/mod.rs @@ -24,7 +24,7 @@ pub use runtime::{ build_skill_run_finish_decision, build_skill_run_start_metadata, prepare_skill_execution, PreparedSkillExecution, SkillProviderSelection, }; -pub use social_post::{collect_social_artifact_paths_from_output, infer_theme_workbench_gate_key}; +pub use social_post::infer_theme_workbench_gate_key; // Tauri 实现(留在主 crate) pub use default_skills::ensure_default_local_skills; pub use execution_callback::TauriExecutionCallback; diff --git a/src-tauri/src/skills/runtime.rs b/src-tauri/src/skills/runtime.rs index 743091f5e..fb4633022 100644 --- a/src-tauri/src/skills/runtime.rs +++ b/src-tauri/src/skills/runtime.rs @@ -17,9 +17,7 @@ use std::path::Path; use super::execution::SkillExecutionResult; use super::execution_callback::TauriExecutionCallback; -use super::social_post::{ - collect_social_artifact_paths_from_output, infer_theme_workbench_gate_key, -}; +use super::social_post::infer_theme_workbench_gate_key; #[derive(Debug, Clone)] pub struct SkillProviderSelection { @@ -335,7 +333,7 @@ pub fn build_skill_run_finish_decision( provider_override, model_override, provider_selection, - collect_social_artifact_paths_from_output(execution.output.as_deref()), + execution.artifact_paths.clone(), )), }, Ok(execution) => RunFinishDecision { @@ -366,3 +364,49 @@ pub fn build_skill_run_finish_decision( }, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::skills::StepResult; + + #[test] + fn test_build_skill_run_finish_decision_uses_execution_artifact_paths() { + let result = Ok(SkillExecutionResult { + success: true, + output: Some("纯文本输出,不含 write_file block".to_string()), + error: None, + artifact_paths: vec![ + "social-posts/demo.md".to_string(), + "social-posts/demo.cover.json".to_string(), + "social-posts/demo.publish-pack.json".to_string(), + ], + steps_completed: vec![StepResult { + step_id: "main".to_string(), + step_name: "social_post_with_cover".to_string(), + success: true, + output: Some("done".to_string()), + error: None, + }], + }); + + let decision = build_skill_run_finish_decision( + SOCIAL_POST_WITH_COVER_SKILL_NAME, + "exec-1", + None, + None, + None, + &result, + ); + let metadata = decision.metadata.expect("metadata should exist"); + + assert_eq!( + metadata["artifact_paths"], + serde_json::json!([ + "social-posts/demo.md", + "social-posts/demo.cover.json", + "social-posts/demo.publish-pack.json" + ]) + ); + } +} diff --git a/src-tauri/src/skills/social_post.rs b/src-tauri/src/skills/social_post.rs index a8d83766c..62a624fd6 100644 --- a/src-tauri/src/skills/social_post.rs +++ b/src-tauri/src/skills/social_post.rs @@ -1,6 +1,6 @@ -use crate::agent::TauriAgentEvent; +use crate::agent::AgentEvent as RuntimeAgentEvent; use chrono::Utc; -use lime_agent::event_converter::{TauriArtifactSnapshot, TauriToolResult}; +use lime_agent::{build_write_tool_artifact_events, AgentToolResult}; use tauri::{AppHandle, Emitter}; const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover"; @@ -11,6 +11,12 @@ const SOCIAL_POST_FALLBACK_COVER_URL: &str = "cover-generation-failed"; const SOCIAL_POST_FALLBACK_COVER_NOTE: &str = "封面图生成失败,可稍后仅重试配图。"; const SOCIAL_POST_DEFAULT_IMAGE_SIZE: &str = "1024x1024"; +#[derive(Debug, Clone)] +pub struct FinalizedSkillOutput { + pub final_output: String, + pub artifact_paths: Vec, +} + #[derive(Debug, Clone)] struct SocialSkillOutputEnvelope { final_output: String, @@ -50,13 +56,17 @@ pub fn finalize_skill_output( user_input: &str, execution_id: &str, raw_output: &str, -) -> String { +) -> FinalizedSkillOutput { let Some(social_output) = normalize_social_post_output(skill_name, user_input, execution_id, raw_output) else { - return raw_output.to_string(); + return FinalizedSkillOutput { + final_output: raw_output.to_string(), + artifact_paths: Vec::new(), + }; }; + let artifact_paths = build_social_artifact_paths(&social_output.file_path); emit_social_write_file_events( app_handle, execution_id, @@ -72,21 +82,10 @@ pub fn finalize_skill_output( emit_social_write_file_events(app_handle, execution_id, &artifact_path, &artifact_content); } - social_output.final_output -} - -pub fn collect_social_artifact_paths_from_output(output: Option<&str>) -> Vec { - let Some(raw_output) = output else { - return Vec::new(); - }; - let Some((_, maybe_path, _)) = extract_first_write_file_block(raw_output) else { - return Vec::new(); - }; - let Some(article_path) = maybe_path else { - return Vec::new(); - }; - let (cover_meta_path, publish_pack_path) = derive_social_auxiliary_paths(&article_path); - vec![article_path, cover_meta_path, publish_pack_path] + FinalizedSkillOutput { + final_output: social_output.final_output, + artifact_paths, + } } fn normalize_social_post_output( @@ -243,6 +242,11 @@ fn derive_social_auxiliary_paths(article_path: &str) -> (String, String) { ) } +fn build_social_artifact_paths(article_path: &str) -> Vec { + let (cover_meta_path, publish_pack_path) = derive_social_auxiliary_paths(article_path); + vec![article_path.to_string(), cover_meta_path, publish_pack_path] +} + fn summarize_social_content(content: &str) -> String { let compact = content .lines() @@ -375,6 +379,28 @@ fn build_social_tool_event_id(execution_id: &str, file_path: &str) -> String { format!("social-write-{execution_id}-{hash:08x}") } +fn build_social_write_tool_events( + execution_id: &str, + file_path: &str, + file_content: &str, +) -> Vec { + let tool_id = build_social_tool_event_id(execution_id, file_path); + build_write_tool_artifact_events( + &format!("skill-exec-{execution_id}"), + SOCIAL_POST_WRITE_TOOL_NAME, + &tool_id, + file_path, + file_content, + AgentToolResult { + success: true, + output: format!("写入社媒文稿: {file_path}"), + error: None, + images: None, + metadata: None, + }, + ) +} + fn emit_social_write_file_events( app_handle: &AppHandle, execution_id: &str, @@ -382,78 +408,10 @@ fn emit_social_write_file_events( file_content: &str, ) { let event_name = format!("skill-exec-{execution_id}"); - let tool_id = build_social_tool_event_id(execution_id, file_path); - let artifact_id = format!("{tool_id}:artifact"); - let arguments = serde_json::json!({ - "path": file_path, - "content": file_content, - }) - .to_string(); - let preview_text = file_content.trim().chars().take(480).collect::(); - let latest_chunk = file_content - .trim() - .chars() - .rev() - .take(240) - .collect::>() - .into_iter() - .rev() - .collect::(); - let mut artifact_metadata = std::collections::HashMap::from([ - ("complete".to_string(), serde_json::json!(true)), - ("writePhase".to_string(), serde_json::json!("persisted")), - ("isPartial".to_string(), serde_json::json!(false)), - ( - "lastUpdateSource".to_string(), - serde_json::json!("tool_result"), - ), - ]); - if !preview_text.is_empty() { - artifact_metadata.insert("previewText".to_string(), serde_json::json!(preview_text)); - } - if !latest_chunk.is_empty() { - artifact_metadata.insert("latestChunk".to_string(), serde_json::json!(latest_chunk)); - } - - let tool_start = TauriAgentEvent::ToolStart { - tool_name: SOCIAL_POST_WRITE_TOOL_NAME.to_string(), - tool_id: tool_id.clone(), - arguments: Some(arguments), - }; - if let Err(err) = app_handle.emit(&event_name, &tool_start) { - tracing::warn!("[execute_skill] 发送社媒写入工具开始事件失败: {}", err); - } - - let artifact_snapshot = TauriAgentEvent::ArtifactSnapshot { - artifact: TauriArtifactSnapshot { - artifact_id: artifact_id.clone(), - file_path: file_path.to_string(), - content: Some(file_content.to_string()), - metadata: Some(artifact_metadata.clone()), - }, - }; - if let Err(err) = app_handle.emit(&event_name, &artifact_snapshot) { - tracing::warn!("[execute_skill] 发送社媒产物快照事件失败: {}", err); - } - - let mut tool_end_metadata = artifact_metadata; - tool_end_metadata.insert("artifact_streamed".to_string(), serde_json::json!(true)); - tool_end_metadata.insert("artifact_id".to_string(), serde_json::json!(artifact_id)); - tool_end_metadata.insert("artifact_path".to_string(), serde_json::json!(file_path)); - tool_end_metadata.insert("path".to_string(), serde_json::json!(file_path)); - tool_end_metadata.insert("file_path".to_string(), serde_json::json!(file_path)); - let tool_end = TauriAgentEvent::ToolEnd { - tool_id, - result: TauriToolResult { - success: true, - output: format!("写入社媒文稿: {file_path}"), - error: None, - images: None, - metadata: Some(tool_end_metadata), - }, - }; - if let Err(err) = app_handle.emit(&event_name, &tool_end) { - tracing::warn!("[execute_skill] 发送社媒写入工具完成事件失败: {}", err); + for event in build_social_write_tool_events(execution_id, file_path, file_content) { + if let Err(err) = app_handle.emit(&event_name, &event) { + tracing::warn!("[execute_skill] 发送社媒写入事件失败: {}", err); + } } } @@ -540,15 +498,81 @@ mod tests { } #[test] - fn test_collect_social_artifact_paths_from_output_should_expand_auxiliary_files() { - let output = "\n# 标题\n\n正文\n"; - let paths = collect_social_artifact_paths_from_output(Some(output)); + fn test_build_social_artifact_paths_should_expand_auxiliary_files() { + let paths = build_social_artifact_paths("social-posts/demo.md"); assert_eq!(paths.len(), 3); assert_eq!(paths[0], "social-posts/demo.md"); assert!(paths[1].ends_with(".cover.json")); assert!(paths[2].ends_with(".publish-pack.json")); } + #[test] + fn test_build_social_write_tool_events_reuses_unified_artifact_emitter() { + let events = + build_social_write_tool_events("exec123", "social-posts/demo.md", "# 标题\n\n正文"); + + assert_eq!(events.len(), 4); + + match &events[0] { + RuntimeAgentEvent::ArtifactSnapshot { artifact } => { + assert_eq!(artifact.file_path, "social-posts/demo.md"); + assert_eq!( + artifact + .metadata + .as_ref() + .and_then(|metadata| metadata.get("writePhase")) + .and_then(serde_json::Value::as_str), + Some("streaming") + ); + } + other => panic!("expected ArtifactSnapshot, got {other:?}"), + } + + match &events[1] { + RuntimeAgentEvent::ToolStart { tool_name, .. } => { + assert_eq!(tool_name, SOCIAL_POST_WRITE_TOOL_NAME); + } + other => panic!("expected ToolStart, got {other:?}"), + } + + match &events[2] { + RuntimeAgentEvent::ArtifactSnapshot { artifact } => { + assert_eq!(artifact.file_path, "social-posts/demo.md"); + assert_eq!( + artifact + .metadata + .as_ref() + .and_then(|metadata| metadata.get("writePhase")) + .and_then(serde_json::Value::as_str), + Some("completed") + ); + } + other => panic!("expected ArtifactSnapshot, got {other:?}"), + } + + match &events[3] { + RuntimeAgentEvent::ToolEnd { result, .. } => { + assert_eq!( + result + .metadata + .as_ref() + .and_then(|metadata| metadata.get("artifact_streamed")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + result + .metadata + .as_ref() + .and_then(|metadata| metadata.get("file_path")) + .and_then(serde_json::Value::as_str), + Some("social-posts/demo.md") + ); + } + other => panic!("expected ToolEnd, got {other:?}"), + } + } + #[test] fn test_build_social_post_slug_fallback_to_post() { assert_eq!(build_social_post_slug(""), "post"); diff --git a/src-tauri/src/voice/commands.rs b/src-tauri/src/voice/commands.rs index ceed7e97a..4aa8641b4 100644 --- a/src-tauri/src/voice/commands.rs +++ b/src-tauri/src/voice/commands.rs @@ -21,6 +21,14 @@ fn normalize_shortcut(value: Option) -> Option { }) } +#[derive(Debug, Clone, serde::Serialize)] +pub struct VoiceShortcutRuntimeStatus { + pub shortcut_registered: bool, + pub registered_shortcut: Option, + pub translate_shortcut_registered: bool, + pub registered_translate_shortcut: Option, +} + /// 获取所有可用的麦克风设备 #[command] pub async fn list_audio_devices() -> Result, String> { @@ -33,6 +41,17 @@ pub async fn get_voice_input_config() -> Result { config::load_voice_config() } +/// 获取语音快捷键运行时状态 +#[command] +pub async fn get_voice_shortcut_runtime_status() -> Result { + Ok(VoiceShortcutRuntimeStatus { + shortcut_registered: super::shortcut::is_registered(), + registered_shortcut: super::shortcut::get_current(), + translate_shortcut_registered: super::shortcut::is_translate_registered(), + registered_translate_shortcut: super::shortcut::get_current_translate(), + }) +} + /// 保存语音输入配置 #[command] pub async fn save_voice_input_config( diff --git a/src-tauri/src/voice/shortcut.rs b/src-tauri/src/voice/shortcut.rs index 01f5c135a..2c98511d3 100644 --- a/src-tauri/src/voice/shortcut.rs +++ b/src-tauri/src/voice/shortcut.rs @@ -166,6 +166,11 @@ pub fn is_registered() -> bool { IS_REGISTERED.load(Ordering::SeqCst) } +/// 获取当前已注册的主语音快捷键 +pub fn get_current() -> Option { + get_current_shortcut().read().clone() +} + /// 注册翻译模式快捷键 pub fn register_translate( app: &AppHandle, @@ -274,6 +279,16 @@ pub fn unregister_translate(app: &AppHandle) -> Result<(), String> { Ok(()) } +/// 检查翻译快捷键是否已注册 +pub fn is_translate_registered() -> bool { + IS_TRANSLATE_REGISTERED.load(Ordering::SeqCst) +} + +/// 获取当前已注册的翻译快捷键 +pub fn get_current_translate() -> Option { + get_translate_shortcut().read().clone() +} + /// 更新翻译快捷键 pub fn update_translate( app: &AppHandle, @@ -311,3 +326,50 @@ pub fn update_translate( } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn reset_runtime_state() { + IS_REGISTERED.store(false, Ordering::SeqCst); + IS_TRANSLATE_REGISTERED.store(false, Ordering::SeqCst); + *get_current_shortcut().write() = None; + *get_translate_shortcut().write() = None; + } + + #[test] + fn test_main_shortcut_runtime_accessors_reflect_state() { + reset_runtime_state(); + + assert!(!is_registered()); + assert_eq!(get_current(), None); + + IS_REGISTERED.store(true, Ordering::SeqCst); + *get_current_shortcut().write() = Some("CommandOrControl+Shift+V".to_string()); + + assert!(is_registered()); + assert_eq!(get_current().as_deref(), Some("CommandOrControl+Shift+V")); + + reset_runtime_state(); + } + + #[test] + fn test_translate_shortcut_runtime_accessors_reflect_state() { + reset_runtime_state(); + + assert!(!is_translate_registered()); + assert_eq!(get_current_translate(), None); + + IS_TRANSLATE_REGISTERED.store(true, Ordering::SeqCst); + *get_translate_shortcut().write() = Some("CommandOrControl+Shift+T".to_string()); + + assert!(is_translate_registered()); + assert_eq!( + get_current_translate().as_deref(), + Some("CommandOrControl+Shift+T") + ); + + reset_runtime_state(); + } +} diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 24fe85e76..80790a674 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "0.95.1", + "version": "0.96.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 555eb5098..204ddd5fd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "0.95.1", + "version": "0.96.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", @@ -61,6 +61,7 @@ "icons/tray/*", "../scripts/playwright-login/**/*", "resources/models/**/*", + "resources/site-adapters/**/*", "../extensions/lime-chrome/**/*" ], "macOS": { diff --git a/src-tauri/test_serialize.rs b/src-tauri/test_serialize.rs index 7bd209c5f..5af478ab4 100644 --- a/src-tauri/test_serialize.rs +++ b/src-tauri/test_serialize.rs @@ -2,7 +2,7 @@ use serde::{Serialize, Deserialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] -pub enum TauriMessageContent { +pub enum AgentMessageContent { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "thinking")] @@ -11,8 +11,12 @@ pub enum TauriMessageContent { fn main() { let content = vec![ - TauriMessageContent::Text { text: "Hello".to_string() }, - TauriMessageContent::Thinking { text: "Thinking...".to_string() }, + AgentMessageContent::Text { + text: "Hello".to_string(), + }, + AgentMessageContent::Thinking { + text: "Thinking...".to_string(), + }, ]; let json = serde_json::to_string_pretty(&content).unwrap(); diff --git a/src-tauri/tests/real_web_search_policy.rs b/src-tauri/tests/real_web_search_policy.rs index 34984cf8b..742ac4026 100644 --- a/src-tauri/tests/real_web_search_policy.rs +++ b/src-tauri/tests/real_web_search_policy.rs @@ -1,7 +1,8 @@ use futures::StreamExt; +use lime_agent::event_converter::convert_agent_event; use lime_agent::{ - convert_agent_event, merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy, - AsterAgentState, SessionConfigBuilder, TauriAgentEvent, WebSearchExecutionTracker, + merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy, AgentEvent, + AsterAgentState, SessionConfigBuilder, WebSearchExecutionTracker, }; use lime_core::database::dao::api_key_provider::ApiProviderType; use lime_core::database::init_database; @@ -133,7 +134,7 @@ async fn run_real_case( Ok(agent_event) => { for event in convert_agent_event(agent_event) { match &event { - TauriAgentEvent::ToolStart { + AgentEvent::ToolStart { tool_name, tool_id, .. } => { summary.tool_start_count += 1; @@ -142,7 +143,7 @@ async fn run_real_case( summary.web_search_tool_names.push(tool_name.clone()); } } - TauriAgentEvent::ToolEnd { tool_id, result } => { + AgentEvent::ToolEnd { tool_id, result } => { summary.tool_end_count += 1; tracker.record_tool_end( &policy, @@ -151,8 +152,8 @@ async fn run_real_case( result.error.as_deref(), ); } - TauriAgentEvent::TextDelta { text } => text_buffer.push_str(text), - TauriAgentEvent::Error { message } => summary.errors.push(message.clone()), + AgentEvent::TextDelta { text } => text_buffer.push_str(text), + AgentEvent::Error { message } => summary.errors.push(message.clone()), _ => {} } } diff --git a/src-tauri/tests/real_web_search_preflight_short_input.rs b/src-tauri/tests/real_web_search_preflight_short_input.rs index 1961816c8..1f82b72e0 100644 --- a/src-tauri/tests/real_web_search_preflight_short_input.rs +++ b/src-tauri/tests/real_web_search_preflight_short_input.rs @@ -109,11 +109,11 @@ async fn test_real_web_search_preflight_short_input_continue() { let mut tool_names = Vec::new(); for event in execution.events { match event { - lime_agent::TauriAgentEvent::ToolStart { tool_name, .. } => { + lime_agent::AgentEvent::ToolStart { tool_name, .. } => { tool_start_count += 1; tool_names.push(tool_name); } - lime_agent::TauriAgentEvent::ToolEnd { .. } => { + lime_agent::AgentEvent::ToolEnd { .. } => { tool_end_count += 1; } _ => {} diff --git a/src/App.tsx b/src/App.tsx index f64398a41..cee7b3365 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { showRegistryLoadError } from "./lib/utils/connectError"; import { useDeepLink } from "./hooks/useDeepLink"; import { useRelayRegistry } from "./hooks/useRelayRegistry"; import { useServiceSkillCatalogBootstrap } from "./hooks/useServiceSkillCatalogBootstrap"; +import { useSiteAdapterCatalogBootstrap } from "./hooks/useSiteAdapterCatalogBootstrap"; import { useGlobalTrayModelSync } from "./hooks/useGlobalTrayModelSync"; import { useOemLimeHubProviderSync } from "./hooks/useOemLimeHubProviderSync"; import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; @@ -33,6 +34,7 @@ import { SoundProvider } from "./contexts/SoundProvider"; import { ComponentDebugOverlay } from "./components/dev"; import { AgentPageParams, + AutomationPageParams, getThemeByWorkspacePage, getThemeWorkspacePage, isThemeWorkspacePage, @@ -246,6 +248,7 @@ function AppContent() { } | null>(null); useServiceSkillCatalogBootstrap(); + useSiteAdapterCatalogBootstrap(); useOemLimeHubProviderSync(); useGlobalTrayModelSync({ currentPage, @@ -575,7 +578,10 @@ function AppContent() { flexDirection: "column", }} > - +
); } @@ -595,6 +601,9 @@ function AppContent() { onNavigate={handleNavigate} projectId={(pageParams as AgentPageParams).projectId} contentId={(pageParams as AgentPageParams).contentId} + initialRequestMetadata={ + (pageParams as AgentPageParams).initialRequestMetadata + } initialUserPrompt={ (pageParams as AgentPageParams).initialUserPrompt } @@ -698,7 +707,7 @@ function AppContent() { if (currentPage === "browser-runtime") { return ( - + ); } diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index e44392c5b..cf3be9c04 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -91,19 +91,81 @@ function scheduleSidebarPluginLoad(task: () => void): () => void { }; } -const Container = styled.aside<{ $collapsed?: boolean }>` +const Container = styled.aside<{ + $collapsed?: boolean; + $themeMode: "light" | "dark"; +}>` + --sidebar-surface-top: ${({ $themeMode }) => + $themeMode === "dark" ? "#15202b" : "#eef6f0"}; + --sidebar-surface-middle: ${({ $themeMode }) => + $themeMode === "dark" ? "#17232d" : "#f5f5ef"}; + --sidebar-surface-bottom: ${({ $themeMode }) => + $themeMode === "dark" ? "#1a2530" : "#f4f8f8"}; + --sidebar-foreground: ${({ $themeMode }) => + $themeMode === "dark" ? "#eef4f7" : "#1f2937"}; + --sidebar-muted: ${({ $themeMode }) => + $themeMode === "dark" ? "#a1afbd" : "#60707d"}; + --sidebar-border: ${({ $themeMode }) => + $themeMode === "dark" ? "#2d3a46" : "#d8e4dc"}; + --sidebar-divider: ${({ $themeMode }) => + $themeMode === "dark" ? "rgba(148, 163, 184, 0.14)" : "rgba(148, 163, 184, 0.18)"}; + --sidebar-hover: ${({ $themeMode }) => + $themeMode === "dark" ? "#22303c" : "#e4ede6"}; + --sidebar-active: ${({ $themeMode }) => + $themeMode === "dark" ? "#2a3e3b" : "#d9e9df"}; + --sidebar-active-foreground: ${({ $themeMode }) => + $themeMode === "dark" ? "#dff4ea" : "#1b4332"}; + --sidebar-search-bg: ${({ $themeMode }) => + $themeMode === "dark" ? "#1f2b36" : "#fbfcf9"}; + --sidebar-search-hover: ${({ $themeMode }) => + $themeMode === "dark" ? "#24313d" : "#f2f6f2"}; + --sidebar-search-border-hover: ${({ $themeMode }) => + $themeMode === "dark" ? "#3a4a57" : "#cfded4"}; display: flex; flex-direction: column; width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")}; min-width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")}; height: 100vh; padding: ${({ $collapsed }) => ($collapsed ? "12px 6px" : "12px 10px")}; - background-color: hsl(var(--card)); - border-right: 1px solid hsl(var(--border)); + position: relative; + isolation: isolate; + background: + linear-gradient( + 180deg, + var(--sidebar-surface-top) 0%, + var(--sidebar-surface-middle) 34%, + var(--sidebar-surface-bottom) 100% + ); + border-right: 1px solid var(--sidebar-border); + box-shadow: 16px 0 36px -34px rgba(15, 23, 42, 0.42); transition: width 180ms ease, min-width 180ms ease, padding 180ms ease; + + &::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient( + circle at top left, + rgba(88, 160, 123, 0.14) 0%, + rgba(88, 160, 123, 0) 44% + ), + radial-gradient( + circle at bottom left, + rgba(109, 153, 219, 0.12) 0%, + rgba(109, 153, 219, 0) 36% + ); + pointer-events: none; + z-index: 0; + } + + > * { + position: relative; + z-index: 1; + } `; const HeaderArea = styled.div<{ $collapsed?: boolean }>` @@ -135,11 +197,14 @@ const UserButton = styled.button<{ $collapsed?: boolean }>` border-radius: 10px; padding: ${({ $collapsed }) => ($collapsed ? "8px" : "8px 10px")}; cursor: pointer; - color: hsl(var(--foreground)); + color: var(--sidebar-foreground); justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")}; + transition: + background-color 0.18s ease, + color 0.18s ease; &:hover { - background: hsl(var(--muted) / 0.55); + background: var(--sidebar-hover); } `; @@ -149,6 +214,9 @@ const Avatar = styled.div` border-radius: 8px; overflow: hidden; flex-shrink: 0; + box-shadow: + 0 8px 18px -14px rgba(15, 23, 42, 0.35), + inset 0 0 0 1px rgba(255, 255, 255, 0.78); img { width: 100%; @@ -174,21 +242,29 @@ const SearchButton = styled.button<{ $collapsed?: boolean }>` gap: 8px; width: 100%; height: 34px; - border-radius: 10px; - border: 1px solid hsl(var(--border)); - background: hsl(var(--background)); - color: hsl(var(--muted-foreground)); + border-radius: 12px; + border: 1px solid var(--sidebar-border); + background: var(--sidebar-search-bg); + color: var(--sidebar-muted); padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")}; cursor: pointer; justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")}; + transition: + border-color 0.18s ease, + background-color 0.18s ease, + color 0.18s ease, + box-shadow 0.18s ease; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); &:hover { - border-color: hsl(var(--primary) / 0.35); - color: hsl(var(--foreground)); + border-color: var(--sidebar-search-border-hover); + background: var(--sidebar-search-hover); + color: var(--sidebar-foreground); } span { font-size: 13px; + font-weight: 500; display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")}; } `; @@ -209,7 +285,7 @@ const MenuScroll = styled.div` } &::-webkit-scrollbar-thumb { - background: hsl(var(--border)); + background: var(--sidebar-border); border-radius: 9999px; } `; @@ -225,7 +301,7 @@ const SectionTitle = styled.div<{ $collapsed?: boolean }>` padding: 0 10px; font-size: 12px; font-weight: 500; - color: hsl(var(--muted-foreground)); + color: var(--sidebar-muted); opacity: 0.9; display: ${({ $collapsed }) => ($collapsed ? "none" : "block")}; `; @@ -237,26 +313,31 @@ const NavButton = styled.button<{ $active?: boolean; $collapsed?: boolean }>` width: 100%; height: 38px; border: none; - border-radius: 10px; + border-radius: 12px; padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")}; background: ${({ $active }) => - $active ? "hsl(var(--accent))" : "transparent"}; + $active ? "var(--sidebar-active)" : "transparent"}; color: ${({ $active }) => - $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; + $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-muted)"}; cursor: pointer; - transition: all 0.18s ease; + transition: + background-color 0.18s ease, + color 0.18s ease, + transform 0.18s ease; justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")}; &:hover { - background: hsl(var(--accent)); - color: hsl(var(--foreground)); + background: ${({ $active }) => + $active ? "var(--sidebar-active)" : "var(--sidebar-hover)"}; + color: ${({ $active }) => + $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-foreground)"}; } svg { width: 17px; height: 17px; flex-shrink: 0; - opacity: 0.9; + opacity: ${({ $active }) => ($active ? 1 : 0.92)}; } `; @@ -274,7 +355,7 @@ const NavLabel = styled.span<{ $collapsed?: boolean }>` const FooterArea = styled.div<{ $collapsed?: boolean }>` margin-top: auto; padding-top: 10px; - border-top: 1px solid hsl(var(--border)); + border-top: 1px solid var(--sidebar-divider); display: flex; flex-direction: column; gap: 8px; @@ -291,19 +372,24 @@ const IconActionButton = styled.button<{ $active?: boolean }>` width: 30px; height: 30px; border: none; - border-radius: 8px; + border-radius: 10px; display: inline-flex; align-items: center; justify-content: center; background: ${({ $active }) => - $active ? "hsl(var(--accent))" : "transparent"}; + $active ? "var(--sidebar-active)" : "transparent"}; color: ${({ $active }) => - $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; + $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-muted)"}; cursor: pointer; + transition: + background-color 0.18s ease, + color 0.18s ease; &:hover { - background: hsl(var(--accent)); - color: hsl(var(--foreground)); + background: ${({ $active }) => + $active ? "var(--sidebar-active)" : "var(--sidebar-hover)"}; + color: ${({ $active }) => + $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-foreground)"}; } svg { @@ -637,7 +723,7 @@ export function AppSidebar({ return ( - + {maybeWrapWithTooltip( diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx index 62b8903a6..76c5af5e5 100644 --- a/src/components/agent/chat/AgentChatHomeShell.test.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.test.tsx @@ -18,7 +18,9 @@ const { mockSetModel, mockSetProviderType, mockLoadConfiguredProviders, - mockLoadProviderModels, + mockGetModelRegistry, + mockGetProviderAliasConfig, + mockFetchProviderModelsAuto, mockFilterModelsByTheme, mockSaveChatToolPreferences, mockPrepareClawSolution, @@ -27,6 +29,14 @@ const { mockClawSolutions, mockUseServiceSkills, mockRecordServiceSkillUsage, + mockRecordServiceSkillAutomationLink, + mockCreateServiceSkillRun, + mockGetServiceSkillRun, + mockIsTerminalServiceSkillRunStatus, + mockToastLoading, + mockToastSuccess, + mockToastError, + mockToastInfo, mockServiceSkills, } = vi.hoisted(() => { const mockClawSolutions = [ @@ -90,6 +100,7 @@ const { runnerTone: "emerald", runnerDescription: "客户端起步版可直接进入工作区执行。", actionLabel: "填写参数", + automationStatus: null, }, { id: "daily-trend-briefing", @@ -134,23 +145,41 @@ const { isRecent: false, runnerLabel: "本地计划任务", runnerTone: "sky", - runnerDescription: "当前先进入工作区生成首版任务方案,后续再接本地自动化。", + runnerDescription: + "当前先进入工作区生成首版任务方案,后续再接本地自动化。", actionLabel: "先做方案", + automationStatus: { + jobId: "automation-job-daily-trend", + jobName: "每日趋势摘要", + statusLabel: "成功", + tone: "emerald", + detail: "下次 03/24 09:00", + }, }, ]; const mockRecordClawSolutionUsage = vi.fn(); const mockRecordServiceSkillUsage = vi.fn(); + const mockRecordServiceSkillAutomationLink = vi.fn(); + const mockCreateServiceSkillRun = vi.fn(); + const mockGetServiceSkillRun = vi.fn(); + const mockIsTerminalServiceSkillRunStatus = vi.fn(); + const mockToastLoading = vi.fn(); + const mockToastSuccess = vi.fn(); + const mockToastError = vi.fn(); + const mockToastInfo = vi.fn(); return { mockBuildClawAgentParams: vi.fn((overrides?: Record) => ({ agentEntry: "claw", ...(overrides || {}), })), - mockCreateAutomationJob: vi.fn(async (request: Record) => ({ - id: "automation-job-1", - ...request, - })), + mockCreateAutomationJob: vi.fn( + async (request: Record) => ({ + id: "automation-job-1", + ...request, + }), + ), mockHomeShellProviderType: { current: "mock-provider" }, mockHomeShellModel: { current: "mock-model" }, mockHomeShellExecutionStrategy: { current: "react" }, @@ -174,9 +203,15 @@ const { mockLoadConfiguredProviders: vi.fn( async (): Promise => [], ), - mockLoadProviderModels: vi.fn( + mockGetModelRegistry: vi.fn( async (): Promise => [], ), + mockGetProviderAliasConfig: vi.fn(async () => null), + mockFetchProviderModelsAuto: vi.fn(async () => ({ + models: [], + source: "LocalFallback", + error: null, + })), mockFilterModelsByTheme: vi.fn( (_theme: string | undefined, models: unknown[]) => ({ models, @@ -202,8 +237,17 @@ const { error: null, refresh: vi.fn(), recordUsage: mockRecordServiceSkillUsage, + catalogMeta: null, })), mockRecordServiceSkillUsage, + mockRecordServiceSkillAutomationLink, + mockCreateServiceSkillRun, + mockGetServiceSkillRun, + mockIsTerminalServiceSkillRunStatus, + mockToastLoading, + mockToastSuccess, + mockToastError, + mockToastInfo, mockServiceSkills, }; }); @@ -315,16 +359,35 @@ vi.mock("@/lib/api/automation", () => ({ createAutomationJob: mockCreateAutomationJob, })); +vi.mock("@/lib/api/serviceSkillRuns", () => ({ + createServiceSkillRun: mockCreateServiceSkillRun, + getServiceSkillRun: mockGetServiceSkillRun, + isTerminalServiceSkillRunStatus: mockIsTerminalServiceSkillRunStatus, +})); + vi.mock("@/lib/api/project", () => ({ listProjects: mockListProjects, })); +vi.mock("sonner", () => ({ + toast: { + loading: mockToastLoading, + success: mockToastSuccess, + error: mockToastError, + info: mockToastInfo, + }, +})); + vi.mock("@/hooks/useConfiguredProviders", () => ({ loadConfiguredProviders: mockLoadConfiguredProviders, })); -vi.mock("@/hooks/useProviderModels", () => ({ - loadProviderModels: mockLoadProviderModels, +vi.mock("@/lib/api/modelRegistry", () => ({ + modelRegistryApi: { + getModelRegistry: mockGetModelRegistry, + getProviderAliasConfig: mockGetProviderAliasConfig, + fetchProviderModelsAuto: mockFetchProviderModelsAuto, + }, })); vi.mock("./utils/modelThemePolicy", () => ({ @@ -362,24 +425,48 @@ vi.mock("./service-skills/useServiceSkills", () => ({ useServiceSkills: mockUseServiceSkills, })); +vi.mock("./service-skills/automationLinkStorage", () => ({ + recordServiceSkillAutomationLink: mockRecordServiceSkillAutomationLink, +})); + vi.mock("./service-skills/ServiceSkillHomePanel", () => ({ ServiceSkillHomePanel: ({ skills, onSelect, + onOpenAutomationJob, }: { - skills: Array<{ id: string; title: string }>; + skills: Array<{ + id: string; + title: string; + automationStatus?: { jobId: string } | null; + }>; onSelect: (skill: { id: string; title: string }) => void; + onOpenAutomationJob?: (skill: { + id: string; + title: string; + automationStatus?: { jobId: string } | null; + }) => void; }) => ( <> {skills.map((skill) => ( - + + + {skill.automationStatus && onOpenAutomationJob ? ( + + ) : null} + ))} ), @@ -444,105 +531,110 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({ ) : null, })); -vi.mock("@/components/settings-v2/system/automation/AutomationJobDialog", () => ({ - AutomationJobDialog: ({ - open, - mode, - initialValues, - onSubmit, - }: { - open: boolean; - mode: "create" | "edit"; - initialValues?: Record | null; - onSubmit: (payload: { - mode: "create"; - request: Record; - }) => Promise; - }) => - open ? ( -
- {mode} - - {typeof initialValues?.schedule_kind === "string" - ? initialValues.schedule_kind - : "-"} - - - {typeof initialValues?.name === "string" ? initialValues.name : "-"} - - -
- ) : null, -})); + }) + } + > + 提交自动化 + + + ) : null, + }), +); const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; @@ -568,9 +660,16 @@ beforeEach(() => { error: null, refresh: vi.fn(), recordUsage: mockRecordServiceSkillUsage, + catalogMeta: null, })); mockLoadConfiguredProviders.mockResolvedValue([]); - mockLoadProviderModels.mockResolvedValue([]); + mockGetModelRegistry.mockResolvedValue([]); + mockGetProviderAliasConfig.mockResolvedValue(null); + mockFetchProviderModelsAuto.mockResolvedValue({ + models: [], + source: "LocalFallback", + error: null, + }); mockListProjects.mockResolvedValue([ { id: "project-1", @@ -593,6 +692,22 @@ beforeEach(() => { policyName: "mock", }), ); + mockCreateServiceSkillRun.mockResolvedValue({ + id: "service-skill-run-1", + status: "success", + outputSummary: "云端结果已生成", + }); + mockGetServiceSkillRun.mockResolvedValue({ + id: "service-skill-run-1", + status: "success", + }); + mockIsTerminalServiceSkillRunStatus.mockImplementation((status: string) => + ["success", "failed", "canceled", "timeout"].includes(status), + ); + mockToastLoading.mockImplementation(() => "toast-loading"); + mockToastSuccess.mockImplementation(() => undefined); + mockToastError.mockImplementation(() => undefined); + mockToastInfo.mockImplementation(() => undefined); }); afterEach(() => { @@ -745,7 +860,7 @@ describe("AgentChatHomeShell", () => { type: "openai", }, ]); - mockLoadProviderModels.mockResolvedValueOnce([ + mockGetModelRegistry.mockResolvedValueOnce([ { id: "social-model-1", display_name: "Social Model 1", @@ -862,7 +977,7 @@ describe("AgentChatHomeShell", () => { type: "openai", }, ]); - mockLoadProviderModels.mockResolvedValueOnce([ + mockGetModelRegistry.mockResolvedValueOnce([ { id: "custom-social-model", display_name: "Custom Social Model", @@ -925,11 +1040,8 @@ describe("AgentChatHomeShell", () => { expect(mockSetProviderType).not.toHaveBeenCalled(); expect(mockSetModel).toHaveBeenCalledWith("custom-social-model"); - expect(mockLoadProviderModels).toHaveBeenCalledWith( - expect.objectContaining({ - key: "custom-social-provider", - }), - ); + expect(mockGetModelRegistry).toHaveBeenCalled(); + expect(mockFetchProviderModelsAuto).not.toHaveBeenCalled(); expect(onNavigate).toHaveBeenCalledWith( "agent", expect.objectContaining({ @@ -1031,9 +1143,8 @@ describe("AgentChatHomeShell", () => { projectId: "project-1", theme: "video", initialCreationMode: "guided", - initialUserPrompt: expect.stringContaining( - "[服务型技能] 复制短视频脚本", - ), + initialUserPrompt: + expect.stringContaining("[服务型技能] 复制短视频脚本"), }), ); expect(onEnterWorkspace).toHaveBeenCalledWith( @@ -1049,6 +1160,84 @@ describe("AgentChatHomeShell", () => { }); }); + it("cloud_required 服务型技能应提交云端运行且不进入本地工作区", async () => { + const onEnterWorkspace = vi.fn(); + mockUseServiceSkills.mockImplementation(() => ({ + skills: [ + { + ...mockServiceSkills[0], + id: "cloud-video-dubbing", + title: "云端视频配音", + executionLocation: "cloud_required", + defaultExecutorBinding: "cloud_scene", + runnerLabel: "云端托管执行", + runnerTone: "slate", + runnerDescription: "提交到 OEM 云端执行,结果由服务端异步返回。", + actionLabel: "提交云端", + }, + ], + isLoading: false, + error: null, + refresh: vi.fn(), + recordUsage: mockRecordServiceSkillUsage, + catalogMeta: null, + })); + + const { container } = renderShell({ + onNavigate: undefined, + onEnterWorkspace, + }); + + await flushEffects(); + + const serviceSkillButton = container.querySelector( + '[data-testid="home-shell-service-skill-cloud-video-dubbing"]', + ) as HTMLButtonElement | null; + + expect(serviceSkillButton).toBeTruthy(); + + act(() => { + serviceSkillButton?.click(); + }); + + await flushEffects(); + + const launchButton = container.querySelector( + '[data-testid="home-shell-service-skill-launch"]', + ) as HTMLButtonElement | null; + + expect(launchButton).toBeTruthy(); + + act(() => { + launchButton?.click(); + }); + + await flushEffects(); + + expect(mockCreateServiceSkillRun).toHaveBeenCalledWith( + "cloud-video-dubbing", + expect.stringContaining("[服务型技能] 云端视频配音"), + ); + expect(mockCreateServiceSkillRun).toHaveBeenCalledWith( + "cloud-video-dubbing", + expect.stringContaining("- 参考视频链接/素材: https://example.com/video"), + ); + expect(onEnterWorkspace).not.toHaveBeenCalled(); + expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ + skillId: "cloud-video-dubbing", + runnerType: "instant", + }); + expect(mockToastLoading).toHaveBeenCalledWith( + "正在提交 云端视频配音 到云端...", + ); + expect(mockToastSuccess).toHaveBeenCalledWith( + "云端视频配音 云端运行完成:云端结果已生成", + { + id: "toast-loading", + }, + ); + }); + it("点击定时服务型技能创建任务后应先建本地 automation 再进入工作区", async () => { const onEnterWorkspace = vi.fn(); const { container } = renderShell({ @@ -1124,12 +1313,41 @@ describe("AgentChatHomeShell", () => { initialUserPrompt: expect.stringContaining("[服务型技能] 每日趋势摘要"), }), ); + expect(mockRecordServiceSkillAutomationLink).toHaveBeenCalledWith({ + skillId: "daily-trend-briefing", + jobId: "automation-job-1", + jobName: expect.any(String), + }); expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ skillId: "daily-trend-briefing", runnerType: "scheduled", }); }); + it("点击服务型技能任务状态后应跳转到 automation 对应任务", async () => { + const onNavigate = vi.fn(); + const { container } = renderShell({ + onNavigate, + }); + + await flushEffects(); + + const openAutomationButton = container.querySelector( + '[data-testid="home-shell-service-skill-open-automation-daily-trend-briefing"]', + ) as HTMLButtonElement | null; + + expect(openAutomationButton).toBeTruthy(); + + act(() => { + openAutomationButton?.click(); + }); + + expect(onNavigate).toHaveBeenCalledWith("automation", { + selectedJobId: "automation-job-daily-trend", + workspaceTab: "tasks", + }); + }); + it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => { const onNavigate = vi.fn(); const onEnterWorkspace = vi.fn(); diff --git a/src/components/agent/chat/AgentChatHomeShell.tsx b/src/components/agent/chat/AgentChatHomeShell.tsx index af482cfae..62cf5918b 100644 --- a/src/components/agent/chat/AgentChatHomeShell.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.tsx @@ -3,6 +3,12 @@ import styled from "styled-components"; import { toast } from "sonner"; import { createAutomationJob } from "@/lib/api/automation"; import { prepareClawSolution } from "@/lib/api/clawSolutions"; +import { + createServiceSkillRun, + getServiceSkillRun, + isTerminalServiceSkillRunStatus, + type ServiceSkillRun, +} from "@/lib/api/serviceSkillRuns"; import { listProjects, type Project } from "@/lib/api/project"; import { AutomationJobDialog, @@ -50,6 +56,7 @@ import { buildServiceSkillAutomationInitialValues, supportsServiceSkillLocalAutomation, } from "./service-skills/automationDraft"; +import { recordServiceSkillAutomationLink } from "./service-skills/automationLinkStorage"; import type { ServiceSkillHomeItem, ServiceSkillSlotValues, @@ -214,6 +221,37 @@ function prioritizeAutomationWorkspaces( return [fallbackWorkspace, ...remaining]; } +const SERVICE_SKILL_RUN_STATUS_LABELS: Record = { + queued: "排队中", + running: "运行中", + success: "已完成", + failed: "执行失败", + canceled: "已取消", + timeout: "已超时", +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +function getServiceSkillRunStatusLabel(status: string): string { + return SERVICE_SKILL_RUN_STATUS_LABELS[status] ?? status; +} + +function buildServiceSkillRunSuccessMessage( + skill: ServiceSkillHomeItem, + run: ServiceSkillRun, +): string { + const summary = run.outputSummary || run.outputText || run.inputSummary; + if (summary) { + return `${skill.title} 云端运行完成:${summary}`; + } + + return `${skill.title} 云端运行完成。`; +} + interface PendingServiceSkillAutomationLaunch { enterWorkspacePayload: HomeShellEnterWorkspacePayload; usage: { @@ -278,6 +316,7 @@ export function AgentChatHomeShell({ } = useClawSolutions(activeTheme === "general"); const { skills: serviceSkills, + catalogMeta: serviceSkillCatalogMeta, isLoading: serviceSkillsLoading, error: serviceSkillsError, recordUsage: recordServiceSkillUsage, @@ -475,6 +514,21 @@ export function AgentChatHomeShell({ setServiceSkillDialogOpen(true); }, []); + const handleOpenServiceSkillAutomationJob = useCallback( + (skill: ServiceSkillHomeItem) => { + const jobId = skill.automationStatus?.jobId; + if (!jobId || !onNavigate) { + return; + } + + onNavigate("automation", { + selectedJobId: jobId, + workspaceTab: "tasks", + }); + }, + [onNavigate], + ); + const handleServiceSkillLaunch = useCallback( async ( skill: ServiceSkillHomeItem, @@ -486,6 +540,64 @@ export function AgentChatHomeShell({ userInput: input.trim() || undefined, }); + if (skill.executionLocation === "cloud_required") { + const toastId = toast.loading(`正在提交 ${skill.title} 到云端...`); + + try { + setServiceSkillDialogOpen(false); + setSelectedServiceSkill(null); + + let run = await createServiceSkillRun(skill.id, prompt); + recordServiceSkillUsage({ + skillId: skill.id, + runnerType: skill.runnerType, + }); + + if (!isTerminalServiceSkillRunStatus(run.status)) { + toast.loading( + `${skill.title} ${getServiceSkillRunStatusLabel(run.status)},正在等待结果...`, + { + id: toastId, + }, + ); + + for (let attempt = 0; attempt < 12; attempt += 1) { + await sleep(2_000); + run = await getServiceSkillRun(run.id); + if (isTerminalServiceSkillRunStatus(run.status)) { + break; + } + } + } + + if (run.status === "success") { + toast.success(buildServiceSkillRunSuccessMessage(skill, run), { + id: toastId, + }); + return; + } + + if (isTerminalServiceSkillRunStatus(run.status)) { + throw new Error( + run.errorMessage || + `${skill.title} ${getServiceSkillRunStatusLabel(run.status)}`, + ); + } + + toast.info( + `${skill.title} 已提交云端,当前仍在 ${getServiceSkillRunStatusLabel(run.status)}。`, + { + id: toastId, + }, + ); + } catch (error) { + toast.error(`提交云端运行失败:${getErrorMessage(error)}`, { + id: toastId, + }); + } + return; + } + if (skill.runnerType !== "instant") { toast.info("当前先进入工作区生成首版方案,下一阶段再接本地自动化任务。"); } @@ -605,6 +717,11 @@ export function AgentChatHomeShell({ return; } + recordServiceSkillAutomationLink({ + skillId: pendingLaunch.usage.skillId, + jobId: createdJob.id, + jobName: createdJob.name, + }); recordServiceSkillUsage(pendingLaunch.usage); const entered = handleEnterWorkspace(pendingLaunch.enterWorkspacePayload); if (!entered) { @@ -730,8 +847,10 @@ export function AgentChatHomeShell({ <> ( initialCreationMode ?? "guided", ); - const { chatToolPreferences, setChatToolPreferences } = + const { + chatToolPreferences, + setChatToolPreferences, + syncChatToolPreferencesSource, + } = useThemeScopedChatToolPreferences(activeTheme); const { projectId, @@ -593,6 +605,15 @@ export function AgentChatWorkspace({ useState("split"); const [browserTaskPreflight, setBrowserTaskPreflight] = useState(null); + const [focusedArtifactBlockId, setFocusedArtifactBlockId] = useState< + string | null + >(null); + const [artifactBlockFocusRequestKey, setArtifactBlockFocusRequestKey] = + useState(0); + const [focusedTimelineItemId, setFocusedTimelineItemId] = useState< + string | null + >(null); + const [timelineFocusRequestKey, setTimelineFocusRequestKey] = useState(0); const autoCollapsedTopicSidebarRef = useRef(false); useEffect(() => { @@ -606,6 +627,17 @@ export function AgentChatWorkspace({ const handleNavigateToSkillSettings = useCallback(() => { _onNavigate?.("settings", { tab: SettingsTabs.Skills }); }, [_onNavigate]); + const handleOpenSavedSiteContent = useCallback( + ({ projectId, contentId }: SiteSavedContentTarget) => { + _onNavigate?.("agent", { + projectId, + contentId, + lockTheme: true, + fromResources: true, + }); + }, + [_onNavigate], + ); const handleRefreshSkills = useCallback(async () => { await loadSkills(true); @@ -1034,6 +1066,8 @@ export function AgentChatWorkspace({ subagentParentContext = null, queuedTurns = [], threadRead = null, + executionRuntime = null, + activeExecutionRuntime = null, isSending, sendMessage, compactSession = async () => undefined, @@ -1081,20 +1115,45 @@ export function AgentChatWorkspace({ } void originalSwitchTopic(parentSessionId); }, [originalSwitchTopic, subagentParentContext?.parent_session_id]); + const runtimeChatToolPreferences = useMemo( + () => createChatToolPreferencesFromExecutionRuntime(executionRuntime), + [executionRuntime], + ); + + useEffect(() => { + syncChatToolPreferencesSource(activeTheme, runtimeChatToolPreferences); + }, [ + activeTheme, + runtimeChatToolPreferences, + syncChatToolPreferencesSource, + ]); + const hasRealTeamGraph = childSubagentSessions.length > 0 || Boolean(subagentParentContext); const { - runtimeTeamState, - clearRuntimeTeamState, + clearRuntimeTeamState: clearPreparedRuntimeTeamState, prepareRuntimeTeamBeforeSend, } = useRuntimeTeamFormation({ - activeTheme, projectId, sessionId, selectedTeam, subagentEnabled: chatToolPreferences.subagent, hasRealTeamGraph, }); + const { + runtimeTeamDispatchPreview, + runtimeTeamPreviewState, + clearRuntimeTeamDispatchPreview, + setRuntimeTeamDispatchPreview, + } = useWorkspaceRuntimeTeamDispatchPreviewRuntime({ + messagesLength: messages.length, + sessionId, + }); + const teamDispatchPreviewState = runtimeTeamPreviewState; + const clearRuntimeTeamState = useCallback(() => { + clearPreparedRuntimeTeamState(); + clearRuntimeTeamDispatchPreview(); + }, [clearPreparedRuntimeTeamState, clearRuntimeTeamDispatchPreview]); const imageWorkbenchSessionKey = useMemo( () => sessionId?.trim() || "__local_image_workbench__", [sessionId], @@ -1136,7 +1195,6 @@ export function AgentChatWorkspace({ queuedTurnCount: queuedTurns.length, isSending, subagentEnabled: chatToolPreferences.subagent, - runtimeTeamState, childSubagentSessions, subagentParentContext, }); @@ -1152,7 +1210,7 @@ export function AgentChatWorkspace({ } = useWorkspaceTeamWorkbenchAutoOpenRuntime({ hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph, layoutMode, - runtimeTeamRequestId: runtimeTeamState?.requestId ?? null, + runtimeTeamRequestId: runtimeTeamDispatchPreview?.key ?? null, sessionId, setLayoutMode, }); @@ -1452,12 +1510,12 @@ export function AgentChatWorkspace({ () => buildHarnessRequestMetadata({ theme: mappedTheme, - creationMode, - chatMode, - webSearchEnabled: chatToolPreferences.webSearch, - thinkingEnabled: chatToolPreferences.thinking, - taskModeEnabled: chatToolPreferences.task, - subagentModeEnabled: chatToolPreferences.subagent, + preferences: { + webSearch: chatToolPreferences.webSearch, + thinking: chatToolPreferences.thinking, + task: chatToolPreferences.task, + subagent: chatToolPreferences.subagent, + }, sessionMode: isThemeWorkbench ? "theme_workbench" : "default", gateKey: isThemeWorkbench ? currentGate.key : undefined, runTitle: themeWorkbenchActiveQueueItem?.title?.trim() || undefined, @@ -1474,13 +1532,11 @@ export function AgentChatWorkspace({ selectedTeamRoles: selectedTeam?.roles, }), [ - chatMode, chatToolPreferences.subagent, chatToolPreferences.task, chatToolPreferences.thinking, chatToolPreferences.webSearch, contentId, - creationMode, currentGate.key, isThemeWorkbench, mappedTheme, @@ -1564,12 +1620,6 @@ export function AgentChatWorkspace({ // 用于追踪是否已触发过 AI 引导 const hasTriggeredGuide = useRef(false); const consumedInitialPromptRef = useRef(null); - const { runtimeTeamDispatchPreview, setRuntimeTeamDispatchPreview } = - useWorkspaceRuntimeTeamDispatchPreviewRuntime({ - messagesLength: messages.length, - runtimeTeamState, - sessionId, - }); const { initialDispatchKey, isBootstrapDispatchPending, @@ -1766,8 +1816,6 @@ export function AgentChatWorkspace({ setChatToolPreferences, activeTheme, mappedTheme, - creationMode, - chatMode, isThemeWorkbench, contextWorkspace: { enabled: contextWorkspace.enabled, @@ -1775,10 +1823,6 @@ export function AgentChatWorkspace({ }, runtimeStyleMessagePrompt, projectId, - sessionId, - providerType, - model, - setModel, executionStrategy, preferredTeamPresetId, selectedTeam, @@ -1787,6 +1831,7 @@ export function AgentChatWorkspace({ currentGateKey: currentGate.key, themeWorkbenchActiveQueueTitle: themeWorkbenchActiveQueueItem?.title, contentId, + workspaceRequestMetadataBase: initialRequestMetadata, messagesCount: messages.length, sendMessage, resolveSendBoundary, @@ -2035,6 +2080,48 @@ export function AgentChatWorkspace({ handleWriteFileRef.current = handleWriteFile; }, [handleWriteFile]); + const handleSaveArtifactDocument = useCallback( + async (artifact: Artifact, document: ArtifactDocumentV1) => { + const filePath = resolveArtifactProtocolFilePath(artifact); + const serializedDocument = JSON.stringify(document, null, 2); + + await Promise.resolve( + handleWriteFile(serializedDocument, filePath, { + artifactId: artifact.id, + source: "message_content", + status: "complete", + artifact: { + ...artifact, + content: serializedDocument, + status: "complete", + meta: { + ...artifact.meta, + artifactDocument: document, + language: "json", + filePath: + typeof artifact.meta.filePath === "string" && + artifact.meta.filePath.trim() + ? artifact.meta.filePath + : filePath, + filename: + typeof artifact.meta.filename === "string" && + artifact.meta.filename.trim() + ? artifact.meta.filename + : artifact.title, + }, + updatedAt: Date.now(), + }, + metadata: { + writePhase: "persisted", + previewText: document.summary || document.title, + lastUpdateSource: "message_content", + }, + }), + ); + }, + [handleWriteFile], + ); + const { handleHarnessLoadFilePreview, handleArtifactClick, @@ -2063,6 +2150,44 @@ export function AgentChatWorkspace({ setCanvasState, upsertNovelCanvasState, }); + const handleWorkspaceFileClick = useCallback( + (fileName: string, content: string) => { + setFocusedArtifactBlockId(null); + handleFileClick(fileName, content); + }, + [handleFileClick], + ); + const handleWorkspaceArtifactClick = useCallback( + (artifact: Artifact) => { + setFocusedArtifactBlockId(null); + handleArtifactClick(artifact); + }, + [handleArtifactClick], + ); + const handleOpenArtifactFromTimeline = useCallback( + (target: ArtifactTimelineOpenTarget) => { + handleWorkspaceFileClick(target.filePath, target.content); + + const normalizedBlockId = target.blockId?.trim(); + if (!normalizedBlockId) { + return; + } + + setFocusedArtifactBlockId(normalizedBlockId); + setArtifactBlockFocusRequestKey((current) => current + 1); + }, + [handleWorkspaceFileClick], + ); + const handleJumpToTimelineItem = useCallback((itemId: string) => { + const normalizedItemId = itemId.trim(); + if (!normalizedItemId) { + return; + } + + setLayoutMode((current) => (current === "canvas" ? "chat-canvas" : current)); + setFocusedTimelineItemId(normalizedItemId); + setTimelineFocusRequestKey((current) => current + 1); + }, []); useWorkspaceAutoGuideRuntime({ contentId, @@ -2123,7 +2248,7 @@ export function AgentChatWorkspace({ themeWorkbenchRunState, currentGateStatus: currentGate.status, hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph, - runtimeTeamState, + teamDispatchPreviewState, }); const themeWorkbenchShellRuntime = useWorkspaceThemeWorkbenchShellRuntime({ showChatPanel, @@ -2147,7 +2272,7 @@ export function AgentChatWorkspace({ selectedTeamRoles: selectedTeam?.roles, handleOpenSubagentSession, handleHarnessLoadFilePreview, - handleFileClick, + handleFileClick: handleWorkspaceFileClick, }); useWorkspaceWorkflowProgressSync({ @@ -2180,7 +2305,7 @@ export function AgentChatWorkspace({ subagentParentContext, selectedTeamLabel, selectedTeamSummary, - runtimeTeamState, + teamDispatchPreviewState, teamSessionRuntime, teamSessionControlRuntime, handleOpenSubagentSession, @@ -2200,6 +2325,8 @@ export function AgentChatWorkspace({ setProviderType, model, setModel, + sessionExecutionRuntime: executionRuntime, + isExecutionRuntimeActive: Boolean(activeExecutionRuntime), projectId: projectId ?? null, executionStrategy, setExecutionStrategy, @@ -2222,6 +2349,7 @@ export function AgentChatWorkspace({ threadItems: effectiveThreadItems, currentTurnId, threadRead, + activeExecutionRuntime, pendingActions, submittedActionsInFlight, messages: displayMessages, @@ -2242,7 +2370,7 @@ export function AgentChatWorkspace({ harnessInventoryRuntime, mappedTheme, handleHarnessLoadFilePreview, - handleFileClick, + handleFileClick: handleWorkspaceFileClick, shellChromeRuntime, handleActivateTeamWorkbench, chatToolPreferences, @@ -2269,6 +2397,11 @@ export function AgentChatWorkspace({ setArtifactViewMode, artifactPreviewSize, setArtifactPreviewSize, + onSaveArtifactDocument: handleSaveArtifactDocument, + threadItems: effectiveThreadItems, + focusedBlockId: focusedArtifactBlockId, + blockFocusRequestKey: artifactBlockFocusRequestKey, + onJumpToTimelineItem: handleJumpToTimelineItem, handleCloseCanvas, currentImageWorkbenchState, imageWorkbenchPreferenceSummary, @@ -2297,7 +2430,7 @@ export function AgentChatWorkspace({ teamSessionRuntime, teamSessionControlRuntime, teamWorkbenchAutoFocusToken, - runtimeTeamState, + teamDispatchPreviewState, }); const workspaceShellSceneRuntime = @@ -2402,8 +2535,10 @@ export function AgentChatWorkspace({ editMessage, handleA2UISubmit, handleWriteFile, - handleFileClick, - handleArtifactClick, + handleFileClick: handleWorkspaceFileClick, + handleOpenArtifactFromTimeline, + handleOpenSavedSiteContent, + handleArtifactClick: handleWorkspaceArtifactClick, handleOpenSubagentSession, handlePermissionResponseWithBrowserPreflight, pendingPromotedA2UIActionRequest, @@ -2425,6 +2560,8 @@ export function AgentChatWorkspace({ setCanvasWorkbenchLayoutMode, workspacePathMissing: Boolean(workspacePathMissing), workspaceHealthError, + focusedTimelineItemId, + timelineFocusRequestKey, }); return workspaceShellSceneRuntime.shellSceneNode; diff --git a/src/components/agent/chat/agentChatWorkspaceContract.ts b/src/components/agent/chat/agentChatWorkspaceContract.ts index b8aa4a421..1b82951db 100644 --- a/src/components/agent/chat/agentChatWorkspaceContract.ts +++ b/src/components/agent/chat/agentChatWorkspaceContract.ts @@ -16,6 +16,7 @@ export interface AgentChatWorkspaceProps { onNavigate?: (page: Page, params?: PageParams) => void; projectId?: string; contentId?: string; + initialRequestMetadata?: Record; agentEntry?: "new-task" | "claw"; immersiveHome?: boolean; theme?: string; diff --git a/src/components/agent/chat/components/AgentRuntimeStrip.tsx b/src/components/agent/chat/components/AgentRuntimeStrip.tsx index 06521cfa4..5b3809cc7 100644 --- a/src/components/agent/chat/components/AgentRuntimeStrip.tsx +++ b/src/components/agent/chat/components/AgentRuntimeStrip.tsx @@ -1,11 +1,18 @@ import React, { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; -import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime"; +import type { + AsterSessionExecutionRuntime, + AsterSubagentSessionInfo, +} from "@/lib/api/agentRuntime"; import type { ChatToolPreferences } from "../utils/chatToolPreferences"; import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime"; import type { HarnessSessionState } from "../utils/harnessState"; +import { + getExecutionRuntimeDisplayLabel, + getOutputSchemaRuntimeLabel, +} from "../utils/sessionExecutionRuntime"; interface AgentRuntimeStripProps { activeTheme?: string; @@ -18,6 +25,8 @@ interface AgentRuntimeStripProps { >; variant?: "standalone" | "embedded"; isSending?: boolean; + executionRuntime?: AsterSessionExecutionRuntime | null; + isExecutionRuntimeActive?: boolean; runtimeStatusTitle?: string | null; selectedTeamLabel?: string | null; selectedTeamSummary?: string | null; @@ -50,6 +59,8 @@ export const AgentRuntimeStrip: React.FC = ({ childSubagentSessions = [], variant = "standalone", isSending = false, + executionRuntime = null, + isExecutionRuntimeActive = false, runtimeStatusTitle = null, selectedTeamLabel = null, selectedTeamSummary = null, @@ -77,6 +88,13 @@ export const AgentRuntimeStrip: React.FC = ({ const statusItems = useMemo(() => { const nextItems: StatusItem[] = []; + const executionRuntimeLabel = getExecutionRuntimeDisplayLabel( + executionRuntime, + { active: isExecutionRuntimeActive }, + ); + const outputSchemaLabel = getOutputSchemaRuntimeLabel( + executionRuntime?.output_schema_runtime, + ); const runningTeamSessions = childSubagentSessions.filter( (session) => session.runtime_status === "running", ).length; @@ -99,6 +117,22 @@ export const AgentRuntimeStrip: React.FC = ({ }); } + if (executionRuntimeLabel) { + nextItems.push({ + key: "execution_runtime", + label: executionRuntimeLabel, + tone: isExecutionRuntimeActive ? "secondary" : "outline", + }); + } + + if (outputSchemaLabel) { + nextItems.push({ + key: "output_schema_runtime", + label: `结构化输出 ${outputSchemaLabel}`, + tone: "outline", + }); + } + if (harnessState.plan.phase === "planning") { nextItems.push({ key: "planning", @@ -182,7 +216,9 @@ export const AgentRuntimeStrip: React.FC = ({ childSubagentSessions, compatSubagentRuntime.isRunning, compatSubagentRuntime.progress, + executionRuntime, harnessState, + isExecutionRuntimeActive, isSending, runtimeStatusTitle, ]); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 4b3466552..a69b2e3d2 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -10,8 +10,29 @@ import type { AgentThreadTurn, } from "../types"; import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; +import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation"; const parseAIResponseMock = vi.fn(); +const mockToolCallItem = vi.fn( + ({ + toolCall, + onOpenSavedSiteContent, + }: { + toolCall: { name: string }; + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; + }) => ( +
+ {toolCall.name} +
+ ), +); vi.mock("@/components/content-creator/a2ui/parser", () => ({ parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args), @@ -29,9 +50,14 @@ vi.mock("./A2UITaskCard", () => ({ })); vi.mock("./ToolCallDisplay", () => ({ - ToolCallItem: ({ toolCall }: { toolCall: { name: string } }) => ( -
{toolCall.name}
- ), + ToolCallItem: (props: { + toolCall: { name: string }; + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; + }) => mockToolCallItem(props), })); vi.mock("./DecisionPanel", () => ({ @@ -59,6 +85,7 @@ beforeEach(() => { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + HTMLElement.prototype.scrollIntoView = vi.fn(); parseAIResponseMock.mockImplementation((content: string) => ({ parts: content.trim() ? [{ type: "text", content: content.trim() }] : [], hasA2UI: false, @@ -133,7 +160,15 @@ function renderTimeline( turn?: Partial; threadRead?: AgentRuntimeThreadReadModel | null; actionRequests?: ActionRequired[]; + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; onOpenSubagentSession?: (sessionId: string) => void; + onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void; + focusedItemId?: string | null; + focusRequestKey?: number; }, ): HTMLDivElement { const container = document.createElement("div"); @@ -148,7 +183,11 @@ function renderTimeline( threadRead={props?.threadRead} actionRequests={props?.actionRequests} isCurrentTurn={props?.isCurrentTurn} + onOpenArtifactFromTimeline={props?.onOpenArtifactFromTimeline} + onOpenSavedSiteContent={props?.onOpenSavedSiteContent} onOpenSubagentSession={props?.onOpenSubagentSession} + focusedItemId={props?.focusedItemId} + focusRequestKey={props?.focusRequestKey} />, ); }); @@ -170,7 +209,126 @@ function clickTimelineToggle(container: HTMLElement) { }); } +function createFileArtifactItem( + overrides: Partial> = {}, +): Extract { + return { + ...createBaseItem("artifact-1", 1), + type: "file_artifact", + path: ".lime/artifacts/thread-1/demo.artifact.json", + source: "artifact_snapshot", + content: JSON.stringify({ + schemaVersion: "artifact_document.v1", + artifactId: "artifact-document:demo", + kind: "analysis", + title: "季度复盘", + status: "ready", + language: "zh-CN", + blocks: [ + { id: "hero-1", type: "hero_summary", summary: "摘要" }, + { id: "body-1", type: "rich_text", markdown: "正文" }, + ], + sources: [], + metadata: {}, + }), + metadata: { + artifact_id: "artifact-document:demo", + artifact_block_id: ["hero-1", "body-1"], + }, + ...overrides, + }; +} + describe("AgentThreadTimeline", () => { + it("file_artifact 命中多个 block 时应提供精确跳转按钮", () => { + const onOpenArtifactFromTimeline = vi.fn(); + const container = renderTimeline([createFileArtifactItem()], { + onOpenArtifactFromTimeline, + }); + + clickTimelineToggle(container); + + const heroJumpButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("跳到 block hero-1")); + expect(heroJumpButton).not.toBeUndefined(); + + act(() => { + heroJumpButton?.click(); + }); + + expect(onOpenArtifactFromTimeline).toHaveBeenCalledWith( + expect.objectContaining({ + timelineItemId: "artifact-1", + filePath: ".lime/artifacts/thread-1/demo.artifact.json", + blockId: "hero-1", + }), + ); + }); + + it("收到 timeline 聚焦请求时应自动展开并高亮目标项", () => { + const container = renderTimeline( + [ + { + ...createBaseItem("browser-1", 1), + type: "tool_call", + tool_name: "browser_click", + arguments: { selector: "#publish" }, + }, + ], + { + turn: { + status: "completed", + }, + focusedItemId: "browser-1", + focusRequestKey: 1, + }, + ); + + expect( + container.querySelector('[data-testid="agent-thread-details"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-details-toggle"]'), + ).toBeNull(); + + const focusedEntry = container.querySelector( + '[data-thread-item-id="browser-1"]', + ); + expect(focusedEntry?.className).toContain("ring-2"); + expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); + }); + + it("应向时间线内的工具项透传已保存站点内容打开回调", () => { + const onOpenSavedSiteContent = vi.fn(); + const container = renderTimeline( + [ + { + ...createBaseItem("site-tool-1", 1), + type: "tool_call", + tool_name: "lime_site_run", + arguments: { adapter_name: "github/search" }, + output: "ok", + metadata: { + tool_family: "site", + saved_content: { + content_id: "content-1", + project_id: "project-1", + title: "GitHub 搜索结果", + }, + }, + }, + ], + { onOpenSavedSiteContent }, + ); + + clickTimelineToggle(container); + + expect(mockToolCallItem).toHaveBeenCalledWith( + expect.objectContaining({ onOpenSavedSiteContent }), + ); + }); + it("应在时间线头部展示当前 turn 的 compact outcome 与 incident 徽标", () => { const container = renderTimeline( [ diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 6f20b8b82..9d947f9d3 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -22,12 +22,13 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import type { ToolCallState } from "@/lib/api/agentStream"; +import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; import type { ActionRequired, AgentThreadItem, AgentThreadTurn, ConfirmResponse, + SiteSavedContentTarget, } from "../types"; import { buildAgentThreadDisplayModel, @@ -53,6 +54,10 @@ import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard"; import { ToolCallItem } from "./ToolCallDisplay"; import { DecisionPanel } from "./DecisionPanel"; import { AgentPlanBlock } from "./AgentPlanBlock"; +import { + resolveTimelineArtifactNavigation, + type ArtifactTimelineOpenTarget, +} from "../utils/artifactTimelineNavigation"; interface AgentThreadTimelineProps { turn: AgentThreadTurn; @@ -61,8 +66,12 @@ interface AgentThreadTimelineProps { actionRequests?: ActionRequired[]; isCurrentTurn?: boolean; onFileClick?: (fileName: string, content: string) => void; + onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void; + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void; onOpenSubagentSession?: (sessionId: string) => void; onPermissionResponse?: (response: ConfirmResponse) => void; + focusedItemId?: string | null; + focusRequestKey?: number; } interface TurnStatusMeta { @@ -802,6 +811,8 @@ function renderThinkingItemDetails(item: AgentThreadItem) { function renderGroupItemDetails( item: AgentThreadItem, onFileClick?: (fileName: string, content: string) => void, + onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void, + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void, onOpenSubagentSession?: (sessionId: string) => void, onPermissionResponse?: (response: ConfirmResponse) => void, ) { @@ -869,37 +880,69 @@ function renderGroupItemDetails( toolCall={toolCall} defaultExpanded={item.status !== "completed"} onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} /> ); } if (item.type === "file_artifact") { + const navigation = resolveTimelineArtifactNavigation(item); + const blockTargets = navigation?.blockTargets || []; + const shouldOpenFocusedBlock = + Boolean(onOpenArtifactFromTimeline) && blockTargets.length === 1; + return ( - + + {onOpenArtifactFromTimeline && blockTargets.length > 1 ? ( +
+ {blockTargets.slice(0, 4).map((target) => ( + + ))}
- )} - + ) : null} + ); } @@ -1372,8 +1415,12 @@ function TimelineBlockCard({ emphasis, isExpanded, onFileClick, + onOpenArtifactFromTimeline, + onOpenSavedSiteContent, onOpenSubagentSession, onPermissionResponse, + focusedItemId, + focusRequestKey, }: { block: AgentThreadOrderedBlock; index: number; @@ -1381,8 +1428,12 @@ function TimelineBlockCard({ emphasis: "active" | "default" | "quiet"; isExpanded: boolean; onFileClick?: (fileName: string, content: string) => void; + onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void; + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void; onOpenSubagentSession?: (sessionId: string) => void; onPermissionResponse?: (response: ConfirmResponse) => void; + focusedItemId?: string | null; + focusRequestKey?: number; }) { const Icon = resolveGroupIcon(block.kind); const timestamp = formatTimestamp(block.startedAt); @@ -1391,6 +1442,10 @@ function TimelineBlockCard({ const isActive = emphasis === "active"; const isQuiet = emphasis === "quiet"; const stageLabel = `步骤 ${String(index + 1).padStart(2, "0")}`; + const focusedEntryRef = useRef(null); + const hasFocusedItem = Boolean( + focusedItemId && block.items.some((item) => item.id === focusedItemId), + ); const detailEntries = block.items.flatMap((item) => { const content = block.kind === "thinking" @@ -1398,6 +1453,8 @@ function TimelineBlockCard({ : renderGroupItemDetails( item, onFileClick, + onOpenArtifactFromTimeline, + onOpenSavedSiteContent, onOpenSubagentSession, onPermissionResponse, ); @@ -1405,6 +1462,7 @@ function TimelineBlockCard({ return content ? [{ id: item.id, content }] : []; }); const hasDetailEntries = detailEntries.length > 0; + const detailsExpanded = isExpanded || hasFocusedItem; const cardClassName = isActive ? "overflow-hidden rounded-2xl border border-primary/25 bg-primary/[0.045] shadow-md shadow-primary/10" : isCompact @@ -1420,6 +1478,17 @@ function TimelineBlockCard({ hasDetailEntries ? "cursor-pointer" : "cursor-default", ); + useEffect(() => { + if (!hasFocusedItem || !focusRequestKey) { + return; + } + + focusedEntryRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, [focusRequestKey, hasFocusedItem]); + return (
@@ -1523,7 +1592,17 @@ function TimelineBlockCard({ data-testid={`${dataTestId}:details`} > {detailEntries.map((entry) => ( -
{entry.content}
+
+ {entry.content} +
))}
@@ -1594,8 +1673,12 @@ export const AgentThreadTimeline: React.FC = ({ actionRequests = [], isCurrentTurn = false, onFileClick, + onOpenArtifactFromTimeline, + onOpenSavedSiteContent, onOpenSubagentSession, onPermissionResponse, + focusedItemId = null, + focusRequestKey = 0, }) => { const visibleItems = useMemo( () => @@ -1670,6 +1753,24 @@ export const AgentThreadTimeline: React.FC = ({ () => buildCompactReliabilityBadges(turn, threadRead), [threadRead, turn], ); + const hasFocusedItem = useMemo( + () => + Boolean( + focusedItemId && + displayModel.orderedBlocks.some((block) => + block.items.some((item) => item.id === focusedItemId), + ), + ), + [displayModel.orderedBlocks, focusedItemId], + ); + + useEffect(() => { + if (!hasFocusedItem || focusRequestKey <= 0) { + return; + } + + setDetailsExpanded(true); + }, [focusRequestKey, hasFocusedItem]); if (visibleItems.length === 0) { return null; @@ -1983,8 +2084,12 @@ export const AgentThreadTimeline: React.FC = ({ } isExpanded={expandedBlockIndexes.has(index)} onFileClick={onFileClick} + onOpenArtifactFromTimeline={onOpenArtifactFromTimeline} + onOpenSavedSiteContent={onOpenSavedSiteContent} onOpenSubagentSession={onOpenSubagentSession} onPermissionResponse={onPermissionResponse} + focusedItemId={focusedItemId} + focusRequestKey={focusRequestKey} /> ))}
diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index e2e94b813..d9a01e800 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -26,6 +26,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; import { Badge } from "@/components/ui/badge"; import { CompactRightDockButton } from "@/components/ui/compact-right-dock-button"; import { @@ -299,22 +300,14 @@ function resolvePreviousArtifactContent( artifact: Artifact, artifacts: Artifact[], ): string | null { - const currentPath = normalizePath( - typeof artifact.meta.filePath === "string" - ? artifact.meta.filePath - : artifact.meta.filename || artifact.title, - ); + const currentPath = normalizePath(resolveArtifactProtocolFilePath(artifact)); for (let index = artifacts.length - 1; index >= 0; index -= 1) { const candidate = artifacts[index]; if (candidate.id === artifact.id) { continue; } - const candidatePath = normalizePath( - typeof candidate.meta.filePath === "string" - ? candidate.meta.filePath - : candidate.meta.filename || candidate.title, - ); + const candidatePath = normalizePath(resolveArtifactProtocolFilePath(candidate)); if (candidatePath === currentPath && candidate.content.trim()) { return candidate.content; } @@ -352,9 +345,7 @@ function resolveMappedPreviousContentForPath( const matchedArtifact = artifacts.find((artifact) => { const artifactPath = resolveAbsoluteWorkspacePath( workspaceRoot, - typeof artifact.meta.filePath === "string" - ? artifact.meta.filePath - : artifact.meta.filename || artifact.title, + resolveArtifactProtocolFilePath(artifact), ); return artifactPath ? normalizePath(artifactPath) === normalizedTarget : false; }); @@ -374,10 +365,7 @@ function buildEntries( .slice() .reverse() .map((artifact) => { - const filePath = - typeof artifact.meta.filePath === "string" - ? artifact.meta.filePath - : artifact.meta.filename || artifact.title; + const filePath = resolveArtifactProtocolFilePath(artifact); const writePhase = resolveArtifactWritePhase(artifact); return { key: `artifact:${artifact.id}`, diff --git a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx index 97ce378ef..96a07e7a5 100644 --- a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx +++ b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx @@ -9,7 +9,7 @@ const { mockListAgentRuntimeSessions, mockGetAgentRuntimeSession, mockUpdateAgentRuntimeSession, - mockParseStreamEvent, + mockParseAgentEvent, mockSafeListen, mockToast, mockUseConfiguredProviders, @@ -23,7 +23,7 @@ const { mockListAgentRuntimeSessions: vi.fn(), mockGetAgentRuntimeSession: vi.fn(), mockUpdateAgentRuntimeSession: vi.fn(), - mockParseStreamEvent: vi.fn((payload: unknown) => payload), + mockParseAgentEvent: vi.fn((payload: unknown) => payload), mockSafeListen: vi.fn(), mockToast: { success: vi.fn(), @@ -53,9 +53,16 @@ vi.mock("@/lib/api/agentRuntime", async () => { }; }); -vi.mock("@/lib/api/agentStream", () => ({ - parseStreamEvent: mockParseStreamEvent, -})); +vi.mock("@/lib/api/agentProtocol", async () => { + const actual = + await vi.importActual( + "@/lib/api/agentProtocol", + ); + return { + ...actual, + parseAgentEvent: mockParseAgentEvent, + }; +}); vi.mock("@/lib/dev-bridge", async () => { const actual = await vi.importActual( diff --git a/src/components/agent/chat/components/EmptyStateQuickActions.tsx b/src/components/agent/chat/components/EmptyStateQuickActions.tsx index 236d1da30..f89063949 100644 --- a/src/components/agent/chat/components/EmptyStateQuickActions.tsx +++ b/src/components/agent/chat/components/EmptyStateQuickActions.tsx @@ -1,4 +1,5 @@ import styled, { keyframes } from "styled-components"; +import type { KeyboardEvent, ReactNode } from "react"; import { EMPTY_STATE_META_PILL_CLASSNAME, EMPTY_STATE_PANEL_CLASSNAME, @@ -75,7 +76,7 @@ const PresetButton = styled.button.attrs({ } `; -const RecommendationCard = styled.button.attrs({ +const RecommendationCard = styled.div.attrs({ className: EMPTY_STATE_RECOMMENDATION_CARD_CLASSNAME, })<{ $index: number }>` animation: ${itemReveal} 520ms cubic-bezier(0.22, 1, 0.36, 1) both; @@ -108,6 +109,9 @@ export interface EmptyStateQuickActionItem { statusLabel?: string; statusTone?: "slate" | "sky" | "emerald" | "amber"; statusDescription?: string; + secondaryStatusLabel?: string; + secondaryStatusTone?: "slate" | "sky" | "emerald" | "amber"; + secondaryStatusDescription?: string; testId?: string; solutionId?: string; } @@ -122,12 +126,14 @@ export interface EmptyStateQuickPresetItem { interface EmptyStateQuickActionsProps { title: string; description: string; + headerAddon?: ReactNode; selectedTextPreview?: string; presets?: EmptyStateQuickPresetItem[]; items: EmptyStateQuickActionItem[]; embedded?: boolean; loading?: boolean; onPresetAction?: (item: EmptyStateQuickPresetItem) => void; + onSecondaryStatusAction?: (item: EmptyStateQuickActionItem) => void; onAction: (item: EmptyStateQuickActionItem) => void; } @@ -141,15 +147,17 @@ const STATUS_TONE_CLASSNAMES = { export function EmptyStateQuickActions({ title, description, + headerAddon, selectedTextPreview, presets = [], items, embedded = false, loading = false, onPresetAction, + onSecondaryStatusAction, onAction, }: EmptyStateQuickActionsProps) { - if (!loading && items.length === 0 && presets.length === 0) { + if (!loading && items.length === 0 && presets.length === 0 && !headerAddon) { return null; } @@ -168,6 +176,7 @@ export function EmptyStateQuickActions({

{description}

+ {headerAddon ?
{headerAddon}
: null} @@ -204,15 +213,27 @@ export function EmptyStateQuickActions({
正在加载推荐方案…
+ ) : items.length === 0 ? ( +
+ 当前目录暂无可用项。 +
) : (
{items.map((item, index) => ( onAction(item)} + onKeyDown={(event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + onAction(item); + }} >
@@ -234,6 +255,38 @@ export function EmptyStateQuickActions({ 产出:{item.outputHint}

) : null} + {item.secondaryStatusLabel ? ( +
+ {onSecondaryStatusAction ? ( + + ) : ( + + {item.secondaryStatusLabel} + + )} + {item.secondaryStatusDescription ? ( + + {item.secondaryStatusDescription} + + ) : null} +
+ ) : null} {item.statusLabel ? (
{ HTMLElement.prototype.scrollIntoView = originalScrollIntoView; }); + it("待审批区块应通过 artifact protocol 展示嵌套参数里的路径", () => { + renderPanel({ + harnessState: createHarnessState({ + pendingApprovals: [ + { + requestId: "approval-path-1", + actionType: "tool_confirmation", + prompt: "确认写入主稿", + toolName: "write_file", + arguments: { + payload: { + filePath: "workspace/approval-draft.md", + }, + }, + }, + ], + }), + }); + + expect(document.body.textContent).toContain("workspace/approval-draft.md"); + }); + it("应渲染最近文件活动区块", () => { renderPanel({ harnessState: createHarnessState({ diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 6db3d1f76..b76befbad 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -58,6 +58,7 @@ import { openPathWithDefaultApp, revealPathInFinder, } from "@/lib/api/fileSystem"; +import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol"; import { SearchResultPreviewList } from "./SearchResultPreviewList"; import type { ActionRequired, @@ -547,29 +548,7 @@ function matchesOutputFilter( function pickPathFromArguments( argumentsValue?: Record, ): string | undefined { - if (!argumentsValue) { - return undefined; - } - - for (const key of [ - "path", - "filePath", - "file_path", - "fileName", - "file_name", - "filename", - "targetPath", - "target_path", - "outputPath", - "output_path", - ]) { - const value = argumentsValue[key]; - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - } - - return undefined; + return extractArtifactProtocolPathsFromValue(argumentsValue)[0]; } function describeApproval(item: ActionRequired): string | undefined { diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index 08e183e8f..abb03ca3c 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -2,7 +2,10 @@ import React, { useState } from "react"; import type { ChatInputAdapter } from "@/components/input-kit/adapters/types"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; -import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime"; +import type { + AsterSessionExecutionRuntime, + QueuedTurnSnapshot, +} from "@/lib/api/agentRuntime"; import type { MessageImage } from "../../../types"; import { CharacterMention } from "./CharacterMention"; import { InputbarCore } from "./InputbarCore"; @@ -64,6 +67,8 @@ interface InputbarComposerSectionProps { isThemeWorkbenchVariant: boolean; activeTheme?: string; onManageProviders?: () => void; + executionRuntime?: AsterSessionExecutionRuntime | null; + isExecutionRuntimeActive?: boolean; setExecutionStrategy?: ( strategy: "react" | "code_orchestrated" | "auto", ) => void; @@ -113,6 +118,8 @@ export const InputbarComposerSection: React.FC< isThemeWorkbenchVariant, activeTheme, onManageProviders, + executionRuntime, + isExecutionRuntimeActive, setExecutionStrategy, topExtra, queuedTurns, @@ -294,6 +301,8 @@ export const InputbarComposerSection: React.FC< setModel={inputAdapter.actions.setModel} activeTheme={activeTheme} onManageProviders={onManageProviders} + executionRuntime={executionRuntime} + isExecutionRuntimeActive={isExecutionRuntimeActive} /> } diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx index e71063573..0798e53f5 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx @@ -1,5 +1,11 @@ import React from "react"; +import { Badge } from "@/components/ui/badge"; +import type { AsterSessionExecutionRuntime } from "@/lib/api/agentRuntime"; import { ChatModelSelector } from "../../ChatModelSelector"; +import { + getExecutionRuntimeDisplayLabel, + getOutputSchemaRuntimeLabel, +} from "../../../utils/sessionExecutionRuntime"; interface InputbarModelExtraProps { isFullscreen?: boolean; @@ -10,6 +16,8 @@ interface InputbarModelExtraProps { setModel?: (model: string) => void; activeTheme?: string; onManageProviders?: () => void; + executionRuntime?: AsterSessionExecutionRuntime | null; + isExecutionRuntimeActive?: boolean; } const NOOP_SET_PROVIDER_TYPE = (_type: string) => {}; @@ -24,11 +32,24 @@ export const InputbarModelExtra: React.FC = ({ setModel, activeTheme, onManageProviders, + executionRuntime = null, + isExecutionRuntimeActive = false, }) => { if (isFullscreen || isThemeWorkbenchVariant || !providerType || !model) { return null; } + const executionRuntimeLabel = getExecutionRuntimeDisplayLabel( + executionRuntime, + { active: isExecutionRuntimeActive }, + ); + const outputSchemaLabel = getOutputSchemaRuntimeLabel( + executionRuntime?.output_schema_runtime, + ); + const executionRuntimeBadgeClass = isExecutionRuntimeActive + ? "max-w-[220px] truncate border-emerald-200 bg-emerald-50 text-emerald-900" + : "max-w-[220px] truncate text-muted-foreground"; + return (
= ({ popoverSide="top" onManageProviders={onManageProviders} /> + {executionRuntimeLabel ? ( + + {executionRuntimeLabel} + + ) : null} + {outputSchemaLabel ? ( + + 结构化输出 {outputSchemaLabel} + + ) : null}
); }; diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index 230d30bb0..b23b3c3a0 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -228,6 +228,37 @@ describe("Inputbar", () => { expect(latestCall.skills).toEqual([]); }); + it("存在 executionRuntime 时应展示最近执行模型与结构化输出提示", async () => { + const container = renderInputbar({ + providerType: "openai", + setProviderType: vi.fn(), + model: "gpt-5.4-mini", + setModel: vi.fn(), + executionRuntime: { + session_id: "session-1", + provider_selector: "openai", + provider_name: "openai", + model_name: "gpt-5.4", + source: "turn_context", + output_schema_runtime: { + source: "turn", + strategy: "native", + providerName: "openai", + modelName: "gpt-5.4", + }, + }, + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(container.textContent).toContain("最近执行模型 OpenAI · gpt-5.4"); + expect(container.textContent).toContain( + "结构化输出 Native schema · turn contract", + ); + }); + it("受控模式下点击联网搜索应透传状态变更", async () => { const onToolStatesChange = vi.fn(); const container = document.createElement("div"); diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index 2cde440de..493ca47b9 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -2,7 +2,10 @@ import React from "react"; import type { MessageImage } from "../../types"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; -import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime"; +import type { + AsterSessionExecutionRuntime, + QueuedTurnSnapshot, +} from "@/lib/api/agentRuntime"; import type { TaskFile } from "../TaskFiles"; import { InputbarComposerSection } from "./components/InputbarComposerSection"; import { InputbarOverlayShell } from "./components/InputbarOverlayShell"; @@ -70,6 +73,8 @@ interface InputbarProps { setProviderType?: (type: string) => void; model?: string; setModel?: (model: string) => void; + executionRuntime?: AsterSessionExecutionRuntime | null; + isExecutionRuntimeActive?: boolean; workspaceId?: string | null; executionStrategy?: "react" | "code_orchestrated" | "auto"; setExecutionStrategy?: ( @@ -126,6 +131,8 @@ export const Inputbar: React.FC = ({ setProviderType, model, setModel, + executionRuntime, + isExecutionRuntimeActive, workspaceId, executionStrategy, setExecutionStrategy, @@ -271,6 +278,8 @@ export const Inputbar: React.FC = ({ providerType={providerType} model={model} onManageProviders={onManageProviders} + executionRuntime={executionRuntime} + isExecutionRuntimeActive={isExecutionRuntimeActive} setExecutionStrategy={setExecutionStrategy} topExtra={topExtra} queuedTurns={queuedTurns} diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index 441212b3e..0740f41d6 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -14,20 +14,40 @@ vi.mock("./MarkdownRenderer", () => ({ const mockStreamingRenderer = vi.fn( ({ content, + onOpenSavedSiteContent, }: { content: string; renderA2UIInline?: boolean; + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; }) => ( -
{content || ""}
+
+ {content || ""} +
), ); const mockAgentThreadTimeline = vi.fn( ({ actionRequests, + onOpenSavedSiteContent, }: { actionRequests?: Array>; + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; }) => ( -
+
执行轨迹{actionRequests?.length ? `:${actionRequests.length}` : ""}
), @@ -155,6 +175,25 @@ describe("MessageList", () => { ); }); + it("应向助手消息正文透传已保存站点内容打开回调", () => { + const onOpenSavedSiteContent = vi.fn(); + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-site-open", + role: "assistant", + content: "已保存站点结果。", + timestamp: now, + }, + ]; + + render(messages, { onOpenSavedSiteContent }); + + expect(mockStreamingRenderer).toHaveBeenCalledWith( + expect.objectContaining({ onOpenSavedSiteContent }), + ); + }); + it("应按回合分组展示同一轮用户与后续助手回复", () => { const now = new Date(); const messages: Message[] = [ @@ -448,6 +487,54 @@ describe("MessageList", () => { ]); }); + it("应向执行轨迹透传已保存站点内容打开回调", () => { + const onOpenSavedSiteContent = vi.fn(); + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-site-timeline", + role: "assistant", + content: "站点结果已沉淀。", + timestamp: now, + }, + ]; + + render(messages, { + onOpenSavedSiteContent, + turns: [ + { + id: "turn-site-open", + thread_id: "thread-1", + prompt_text: "采集站点内容", + status: "completed", + started_at: "2026-03-25T09:00:00Z", + completed_at: "2026-03-25T09:00:05Z", + created_at: "2026-03-25T09:00:00Z", + updated_at: "2026-03-25T09:00:05Z", + }, + ], + threadItems: [ + { + id: "item-site-open-1", + thread_id: "thread-1", + turn_id: "turn-site-open", + sequence: 1, + status: "completed", + started_at: "2026-03-25T09:00:01Z", + completed_at: "2026-03-25T09:00:02Z", + updated_at: "2026-03-25T09:00:02Z", + type: "tool_call", + tool_name: "lime_site_run", + arguments: { adapter_name: "github/search" }, + }, + ], + }); + + expect(mockAgentThreadTimeline).toHaveBeenCalledWith( + expect.objectContaining({ onOpenSavedSiteContent }), + ); + }); + it("当前 turn 映射错位时,应优先显示在最后一个助手消息上", () => { const messages: Message[] = [ { diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index b615c42ba..5c87f3fa4 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -12,6 +12,7 @@ import { import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import type { Artifact } from "@/lib/artifact/types"; +import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; import { MessageListContainer, MessageWrapper, @@ -43,6 +44,7 @@ import { type ActionRequired, type AgentThreadItem, type AgentThreadTurn, + type SiteSavedContentTarget, type WriteArtifactContext, } from "../types"; import type { A2UIFormData } from "@/components/content-creator/a2ui/types"; @@ -54,6 +56,7 @@ import type { import { buildMessageTurnTimeline } from "../utils/threadTimelineView"; import { buildMessageTurnGroups } from "../utils/messageTurnGrouping"; import logoImg from "/logo.png"; +import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation"; interface MessageListProps { messages: Message[]; @@ -84,6 +87,10 @@ interface MessageListProps { ) => void; /** 文件点击回调 */ onFileClick?: (fileName: string, content: string) => void; + /** 时间线内 artifact 精确跳转 */ + onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void; + /** 打开站点能力已保存内容 */ + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void; /** Artifact 点击回调 */ onArtifactClick?: (artifact: Artifact) => void; /** 打开子代理会话 */ @@ -111,6 +118,10 @@ interface MessageListProps { onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise; /** 是否压缩左侧留白,适用于工作台右栏 */ compactLeadingSpacing?: boolean; + /** 需要高亮的 timeline item */ + focusedTimelineItemId?: string | null; + /** 触发 timeline item 聚焦的请求序号 */ + timelineFocusRequestKey?: number; } const MessageListInner: React.FC = ({ @@ -128,6 +139,8 @@ const MessageListInner: React.FC = ({ onA2UIFormChange, onWriteFile, onFileClick, + onOpenArtifactFromTimeline, + onOpenSavedSiteContent, onArtifactClick, onOpenSubagentSession, onPermissionResponse, @@ -136,6 +149,8 @@ const MessageListInner: React.FC = ({ onCodeBlockClick, promoteActionRequestsToA2UI = false, compactLeadingSpacing = false, + focusedTimelineItemId = null, + timelineFocusRequestKey = 0, }) => { const scrollRef = useRef(null); const containerRef = useRef(null); @@ -404,6 +419,7 @@ const MessageListInner: React.FC = ({ : undefined } onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} onPermissionResponse={onPermissionResponse} collapseCodeBlocks={collapseCodeBlocks} shouldCollapseCodeBlock={shouldCollapseCodeBlock} @@ -448,8 +464,12 @@ const MessageListInner: React.FC = ({ actionRequests={msg.actionRequests} isCurrentTurn={timeline.turn.id === currentTurnId} onFileClick={onFileClick} + onOpenArtifactFromTimeline={onOpenArtifactFromTimeline} + onOpenSavedSiteContent={onOpenSavedSiteContent} onOpenSubagentSession={onOpenSubagentSession} onPermissionResponse={onPermissionResponse} + focusedItemId={focusedTimelineItemId} + focusRequestKey={timelineFocusRequestKey} /> ) : null} @@ -529,10 +549,7 @@ const MessageListInner: React.FC = ({ return (
{artifacts.map((artifact) => { - const filePath = - typeof artifact.meta.filePath === "string" - ? artifact.meta.filePath - : artifact.meta.filename || artifact.title; + const filePath = resolveArtifactProtocolFilePath(artifact); const writePhase = resolveArtifactWritePhase(artifact); const statusLabel = formatArtifactWritePhaseLabel(writePhase); const previewText = resolveArtifactPreviewText(artifact, 180); diff --git a/src/components/agent/chat/components/StreamingRenderer.test.tsx b/src/components/agent/chat/components/StreamingRenderer.test.tsx index e32b10931..68c8b4842 100644 --- a/src/components/agent/chat/components/StreamingRenderer.test.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StreamingRenderer } from "./StreamingRenderer"; +import type { AgentToolCallState } from "@/lib/api/agentProtocol"; import type { AgentRuntimeStatus, ActionRequired, @@ -11,6 +12,38 @@ import type { } from "../types"; const parseAIResponseMock = vi.fn(); +const mockToolCallList = vi.fn( + ({ + onOpenSavedSiteContent, + }: { + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; + }) => ( +
+ ), +); +const mockToolCallItem = vi.fn( + ({ + onOpenSavedSiteContent, + }: { + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; + }) => ( +
+ ), +); vi.mock("@/components/content-creator/a2ui/parser", () => ({ parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args), @@ -32,8 +65,20 @@ vi.mock("./A2UITaskCard", () => ({ })); vi.mock("./ToolCallDisplay", () => ({ - ToolCallList: () =>
, - ToolCallItem: () =>
, + ToolCallList: (props: { + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; + }) => mockToolCallList(props), + ToolCallItem: (props: { + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; + }) => mockToolCallItem(props), })); vi.mock("./DecisionPanel", () => ({ @@ -96,6 +141,7 @@ function renderHarness(props: { renderA2UIInline?: boolean; runtimeStatus?: AgentRuntimeStatus; showRuntimeStatusInline?: boolean; + toolCalls?: AgentToolCallState[]; actionRequests?: ActionRequired[]; promoteActionRequestsToA2UI?: boolean; onPermissionResponse?: (payload: unknown) => void; @@ -104,6 +150,11 @@ function renderHarness(props: { fileName: string, context?: WriteArtifactContext, ) => void; + onOpenSavedSiteContent?: (target: { + projectId: string; + contentId: string; + title?: string; + }) => void; }) { const container = document.createElement("div"); document.body.appendChild(container); @@ -180,6 +231,57 @@ describe("StreamingRenderer", () => { expect(parseAIResponseMock).toHaveBeenCalledTimes(1); }); + it("普通工具列表应透传已保存站点内容打开回调", () => { + const onOpenSavedSiteContent = vi.fn(); + + renderHarness({ + content: "工具执行完成", + toolCalls: [ + { + id: "tool-site-run-streaming-list", + name: "lime_site_run", + arguments: JSON.stringify({ adapter_name: "github/search" }), + status: "completed", + result: { success: true, output: "ok" }, + startTime: new Date("2026-03-25T10:00:00.000Z"), + endTime: new Date("2026-03-25T10:00:01.000Z"), + }, + ], + onOpenSavedSiteContent, + }); + + expect(mockToolCallList).toHaveBeenCalledWith( + expect.objectContaining({ onOpenSavedSiteContent }), + ); + }); + + it("交错工具片段应透传已保存站点内容打开回调", () => { + const onOpenSavedSiteContent = vi.fn(); + + renderHarness({ + content: "", + contentParts: [ + { + type: "tool_use", + toolCall: { + id: "tool-site-run-streaming-item", + name: "lime_site_run", + arguments: JSON.stringify({ adapter_name: "github/search" }), + status: "completed", + result: { success: true, output: "ok" }, + startTime: new Date("2026-03-25T10:01:00.000Z"), + endTime: new Date("2026-03-25T10:01:01.000Z"), + }, + }, + ], + onOpenSavedSiteContent, + }); + + expect(mockToolCallItem).toHaveBeenCalledWith( + expect.objectContaining({ onOpenSavedSiteContent }), + ); + }); + it("关闭内联 A2UI 时应仅保留普通文本片段", () => { parseAIResponseMock.mockReturnValue({ parts: [ diff --git a/src/components/agent/chat/components/StreamingRenderer.tsx b/src/components/agent/chat/components/StreamingRenderer.tsx index 1a4747a8f..eae273c9b 100644 --- a/src/components/agent/chat/components/StreamingRenderer.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.tsx @@ -30,12 +30,13 @@ import type { ParsedMessageContent, } from "@/components/content-creator/a2ui/types"; import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets"; -import type { ToolCallState } from "@/lib/api/agentStream"; +import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; import type { AgentRuntimeStatus, ContentPart, ActionRequired, ConfirmResponse, + SiteSavedContentTarget, WriteArtifactContext, } from "../types"; import { @@ -550,6 +551,7 @@ interface StreamingRendererProps { ) => void; /** 文件点击回调 */ onFileClick?: (fileName: string, content: string) => void; + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void; /** 权限确认响应回调 */ onPermissionResponse?: (response: ConfirmResponse) => void; /** 是否折叠代码块(当画布打开时) */ @@ -686,6 +688,7 @@ export const StreamingRenderer: React.FC = memo( renderA2UIInline = true, onWriteFile, onFileClick, + onOpenSavedSiteContent, onPermissionResponse, collapseCodeBlocks, shouldCollapseCodeBlock, @@ -1011,6 +1014,7 @@ export const StreamingRenderer: React.FC = memo( toolCall={part.toolCall} isMessageStreaming={isStreaming} onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} /> ); } else if (part.type === "action_required") { @@ -1191,6 +1195,7 @@ export const StreamingRenderer: React.FC = memo( toolCalls={toolCalls} isMessageStreaming={isStreaming} onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} /> )} diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx index a0f03d65c..62c050f66 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx @@ -32,7 +32,7 @@ interface TeamWorkbenchSummaryPanelProps { selectedTeamLabel?: string | null; selectedTeamSummary?: string | null; selectedTeamRoles?: TeamRoleDefinition[] | null; - runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null; + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; } function buildOperationSummary(params: { @@ -75,8 +75,9 @@ export function TeamWorkbenchSummaryPanel({ selectedTeamLabel, selectedTeamSummary, selectedTeamRoles = [], - runtimeTeamState = null, + teamDispatchPreviewState = null, }: TeamWorkbenchSummaryPanelProps) { + const runtimeTeamState = teamDispatchPreviewState; const executionSummary = summarizeTeamWorkspaceExecution({ currentSessionId, currentSessionRuntimeStatus, diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx index 606fc0c48..a4dc3d7de 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx @@ -508,7 +508,7 @@ describe("TeamWorkspaceBoard", () => { role_key: "explorer", }, ], - runtimeTeamState: { + teamDispatchPreviewState: { requestId: "runtime-formed-ordered", status: "formed", label: "修复 Team", @@ -2015,7 +2015,7 @@ describe("TeamWorkspaceBoard", () => { it("本轮 Team 准备中时,应在空 shell 展示组建状态", async () => { const container = await renderBoard({ shellVisible: true, - runtimeTeamState: { + teamDispatchPreviewState: { requestId: "runtime-forming-1", status: "forming", label: "排障 Team", @@ -2040,7 +2040,7 @@ describe("TeamWorkspaceBoard", () => { const container = await renderBoard({ shellVisible: true, defaultShellExpanded: true, - runtimeTeamState: { + teamDispatchPreviewState: { requestId: "runtime-formed-1", status: "formed", label: "修复 Team", @@ -2096,7 +2096,7 @@ describe("TeamWorkspaceBoard", () => { const container = await renderBoard({ shellVisible: true, defaultShellExpanded: true, - runtimeTeamState: { + teamDispatchPreviewState: { requestId: "runtime-failed-1", status: "failed", label: "失败的 Team", diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.tsx index a271a064a..911f0bf9a 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.tsx @@ -21,7 +21,7 @@ import { import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { getAgentRuntimeSession } from "@/lib/api/agentRuntime"; -import type { AgentThreadItem } from "@/lib/api/agentStream"; +import type { AgentThreadItem } from "@/lib/api/agentProtocol"; import type { AsterSessionDetail, AsterSubagentSkillInfo, @@ -118,7 +118,7 @@ interface TeamWorkspaceBoardProps { selectedTeamLabel?: string | null; selectedTeamSummary?: string | null; selectedTeamRoles?: TeamRoleDefinition[] | null; - runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null; + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; } interface TeamSessionCard { @@ -380,9 +380,22 @@ function extractMessageActivityEntries( } for (const content of message.content) { - const title = content.error ? "错误" : content.output ? "输出" : "回复"; + const title = + content.type === "tool_response" + ? content.error + ? "错误" + : content.output + ? "输出" + : "回复" + : "回复"; + const previewSource = + content.type === "tool_response" + ? content.error || content.output + : content.type === "text" || content.type === "thinking" + ? content.text + : undefined; const detailText = normalizeActivityPreviewText( - content.error || content.output || content.text, + previewSource, ACTIVITY_TIMELINE_DETAIL_MAX_LENGTH, ); @@ -787,16 +800,16 @@ function resolveSessionBlueprintRoleId( function orderSessionsByRuntimeRoles( sessions: TeamSessionCard[], - runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null, + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null, ): TeamSessionCard[] { - if (sessions.length <= 1 || !runtimeTeamState) { + if (sessions.length <= 1 || !teamDispatchPreviewState) { return sessions; } const runtimeRoles = ( - runtimeTeamState.members.length > 0 - ? runtimeTeamState.members - : runtimeTeamState.blueprint?.roles ?? [] + teamDispatchPreviewState.members.length > 0 + ? teamDispatchPreviewState.members + : teamDispatchPreviewState.blueprint?.roles ?? [] ).map((role) => ({ id: role.id, label: role.label, @@ -902,9 +915,9 @@ function buildFallbackSummary(params: { } function buildRuntimeFormationHint( - runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null, + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null, ) { - switch (runtimeTeamState?.status) { + switch (teamDispatchPreviewState?.status) { case "forming": return "系统正在准备当前任务的协作分工,成员接入后会自动开始处理。"; case "formed": @@ -917,16 +930,16 @@ function buildRuntimeFormationHint( } function buildRuntimeFormationEmptyDetail( - runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null, + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null, ) { - switch (runtimeTeamState?.status) { + switch (teamDispatchPreviewState?.status) { case "forming": return "系统正在根据当前任务准备协作分工。完成后,这里会先展示当前成员卡片,再接入真实处理进展。"; case "formed": return "当前协作方案已经准备好。画布会先展示当前分工,等成员真正开始处理后,再自动切换为实时进展。"; case "failed": return ( - runtimeTeamState.errorMessage?.trim() || + teamDispatchPreviewState.errorMessage?.trim() || "当前协作准备失败,暂时无法展示当前成员。" ); default: @@ -1063,26 +1076,31 @@ function buildCanvasStageHint(params: { hasRealTeamGraph: boolean; hasRuntimeFormation: boolean; hasSelectedTeamPlan: boolean; - runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null; + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; }) { - const { hasRealTeamGraph, hasRuntimeFormation, hasSelectedTeamPlan, runtimeTeamState } = + const { + hasRealTeamGraph, + hasRuntimeFormation, + hasSelectedTeamPlan, + teamDispatchPreviewState, + } = params; if (hasRealTeamGraph) { return "拖动画布空白处可平移,滚轮配合 Ctrl/Cmd 可缩放,拖动成员卡片可调整布局。"; } - if (runtimeTeamState?.status === "forming") { + if (teamDispatchPreviewState?.status === "forming") { return "当前协作分工正在准备中,成员加入后会接手这些位置。"; } - if (runtimeTeamState?.status === "formed") { + if (teamDispatchPreviewState?.status === "formed") { return "当前协作分工已经准备好,成员加入后会自动接手这些位置。"; } - if (runtimeTeamState?.status === "failed") { + if (teamDispatchPreviewState?.status === "failed") { return ( - runtimeTeamState.errorMessage?.trim() || + teamDispatchPreviewState.errorMessage?.trim() || "当前协作准备失败,暂时无法生成成员画布。" ); } @@ -1374,8 +1392,9 @@ export function TeamWorkspaceBoard({ selectedTeamLabel = null, selectedTeamSummary = null, selectedTeamRoles = [], - runtimeTeamState = null, + teamDispatchPreviewState = null, }: TeamWorkspaceBoardProps) { + const runtimeTeamState = teamDispatchPreviewState; const isChildSession = Boolean(subagentParentContext); const canvasStorageScopeId = currentSessionId?.trim() || @@ -1822,9 +1841,14 @@ export function TeamWorkspaceBoard({ isChildSession ? dedupeSessions([currentChildSession, ...visibleSessions]) : visibleSessions, - runtimeTeamState, + teamDispatchPreviewState, ), - [currentChildSession, isChildSession, runtimeTeamState, visibleSessions], + [ + currentChildSession, + isChildSession, + teamDispatchPreviewState, + visibleSessions, + ], ); const railSessions = useMemo( () => @@ -2906,9 +2930,14 @@ export function TeamWorkspaceBoard({ hasRealTeamGraph, hasRuntimeFormation, hasSelectedTeamPlan, - runtimeTeamState, + teamDispatchPreviewState, }), - [hasRealTeamGraph, hasRuntimeFormation, hasSelectedTeamPlan, runtimeTeamState], + [ + hasRealTeamGraph, + hasRuntimeFormation, + hasSelectedTeamPlan, + teamDispatchPreviewState, + ], ); const statusSummary = useMemo(() => { diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx index caf1cfb6a..80da9abea 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx @@ -10,10 +10,10 @@ const { mockGetAgentRuntimeSession } = vi.hoisted(() => ({ })); const { mockSafeListen, - mockParseStreamEvent, + mockParseAgentEvent, } = vi.hoisted(() => ({ mockSafeListen: vi.fn(), - mockParseStreamEvent: vi.fn((payload: unknown) => payload), + mockParseAgentEvent: vi.fn((payload: unknown) => payload), })); vi.mock("@/lib/api/agentRuntime", async () => { @@ -27,14 +27,14 @@ vi.mock("@/lib/api/agentRuntime", async () => { }; }); -vi.mock("@/lib/api/agentStream", async () => { +vi.mock("@/lib/api/agentProtocol", async () => { const actual = - await vi.importActual( - "@/lib/api/agentStream", + await vi.importActual( + "@/lib/api/agentProtocol", ); return { ...actual, - parseStreamEvent: mockParseStreamEvent, + parseAgentEvent: mockParseAgentEvent, }; }); @@ -82,7 +82,7 @@ beforeEach(() => { createSessionDetail(sessionId), ); mockSafeListen.mockResolvedValue(() => {}); - mockParseStreamEvent.mockImplementation((payload: unknown) => payload); + mockParseAgentEvent.mockImplementation((payload: unknown) => payload); }); afterEach(() => { @@ -262,7 +262,7 @@ describe("TeamWorkspaceDock", () => { it("仅处于协作准备中时,不应自动展开面板,但应显示提醒", async () => { const { container } = await renderDock({ - runtimeTeamState: { + teamDispatchPreviewState: { requestId: "runtime-forming-1", status: "forming", label: "修复 Team", @@ -288,7 +288,7 @@ describe("TeamWorkspaceDock", () => { it("协作方案已就绪但真实成员未出现时,不应自动展开面板", async () => { const { container } = await renderDock({ - runtimeTeamState: { + teamDispatchPreviewState: { requestId: "runtime-formed-2", status: "formed", label: "修复 Team", @@ -324,7 +324,7 @@ describe("TeamWorkspaceDock", () => { it("协作准备失败时,不应自动展开面板,但应保留提醒入口", async () => { const { container } = await renderDock({ - runtimeTeamState: { + teamDispatchPreviewState: { requestId: "runtime-failed-1", status: "failed", label: "修复 Team", @@ -354,7 +354,7 @@ describe("TeamWorkspaceDock", () => { const { container } = await renderDock({ placement: "inline", onActivateWorkbench, - runtimeTeamState: { + teamDispatchPreviewState: { requestId: "runtime-formed-launcher-1", status: "formed", label: "修复 Team", @@ -680,7 +680,7 @@ describe("TeamWorkspaceDock", () => { it("本轮协作方案已就绪时,应在空态 Dock 展示成员摘要", async () => { const { container } = await renderDock({ - runtimeTeamState: { + teamDispatchPreviewState: { requestId: "runtime-formed-1", status: "formed", label: "修复 Team", diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.tsx index 9834faac5..b9d58d695 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.tsx @@ -470,7 +470,7 @@ interface TeamWorkspaceDockProps { selectedTeamLabel?: string | null; selectedTeamSummary?: string | null; selectedTeamRoles?: TeamRoleDefinition[] | null; - runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null; + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; } interface InlinePanelLayout { @@ -508,8 +508,9 @@ export function TeamWorkspaceDock({ selectedTeamLabel, selectedTeamSummary, selectedTeamRoles = [], - runtimeTeamState = null, + teamDispatchPreviewState = null, }: TeamWorkspaceDockProps) { + const runtimeTeamState = teamDispatchPreviewState; const launcherOnly = typeof onActivateWorkbench === "function"; const hasRealTeamGraph = childSubagentSessions.length > 0 || Boolean(subagentParentContext); @@ -786,7 +787,7 @@ export function TeamWorkspaceDock({ selectedTeamLabel={selectedTeamLabel} selectedTeamSummary={selectedTeamSummary} selectedTeamRoles={selectedTeamRoles} - runtimeTeamState={runtimeTeamState} + teamDispatchPreviewState={runtimeTeamState} /> ) : ( diff --git a/src/components/agent/chat/components/TokenUsageDisplay.tsx b/src/components/agent/chat/components/TokenUsageDisplay.tsx index 7ecbf0225..9e5f40178 100644 --- a/src/components/agent/chat/components/TokenUsageDisplay.tsx +++ b/src/components/agent/chat/components/TokenUsageDisplay.tsx @@ -8,7 +8,7 @@ import React from "react"; import styled from "styled-components"; import { Coins } from "lucide-react"; -import type { TokenUsage } from "@/lib/api/agentStream"; +import type { AgentTokenUsage as TokenUsage } from "@/lib/api/agentProtocol"; const UsageContainer = styled.div` display: inline-flex; diff --git a/src/components/agent/chat/components/ToolCallDisplay.test.tsx b/src/components/agent/chat/components/ToolCallDisplay.test.tsx index 72b94fe5b..c52513b39 100644 --- a/src/components/agent/chat/components/ToolCallDisplay.test.tsx +++ b/src/components/agent/chat/components/ToolCallDisplay.test.tsx @@ -2,7 +2,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ToolCallDisplay, ToolCallList } from "./ToolCallDisplay"; -import type { ToolCallState } from "@/lib/api/agentStream"; +import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; vi.mock("@tauri-apps/plugin-shell", () => ({ open: vi.fn().mockResolvedValue(undefined), @@ -251,6 +251,154 @@ describe("ToolCallDisplay", () => { expect(container.textContent).toContain("Copy"); }); + it("站点能力工具结果应展示自动保存结果与脚本来源", () => { + const { container } = renderTool({ + id: "tool-site-run-1", + name: "lime_site_run", + arguments: JSON.stringify({ + adapter_name: "github/search", + args: { query: "mcp" }, + }), + status: "completed", + result: { + success: true, + output: JSON.stringify({ + ok: true, + adapter: "github/search", + data: { items: [{ title: "modelcontextprotocol/servers" }] }, + }), + metadata: { + tool_family: "site", + adapter_name: "github/search", + saved_content: { + content_id: "content-1", + project_id: "project-1", + title: "GitHub MCP 搜索结果", + }, + saved_project_id: "project-1", + saved_by: "context_project", + adapter_source_kind: "server_synced", + adapter_source_version: "2026-03-25", + }, + }, + startTime: new Date("2026-03-25T12:10:00.000Z"), + endTime: new Date("2026-03-25T12:10:01.000Z"), + }); + + expect(container.textContent).toContain("已执行 github/search"); + + act(() => { + const toggle = container.querySelector( + 'button[title="查看结果"]', + ) as HTMLButtonElement | null; + toggle?.click(); + }); + + expect(container.textContent).toContain( + "结果已自动保存到项目 project-1:GitHub MCP 搜索结果 · 来自当前项目上下文", + ); + expect(container.textContent).toContain("脚本来源:服务端脚本 · 2026-03-25"); + }); + + it("站点能力工具结果应支持直接打开已保存内容", () => { + const onOpenSavedSiteContent = vi.fn(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ container, root }); + + act(() => { + const toggle = container.querySelector( + 'button[title="查看结果"]', + ) as HTMLButtonElement | null; + toggle?.click(); + }); + + act(() => { + const openButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("打开已保存内容"), + ) as HTMLButtonElement | undefined; + openButton?.click(); + }); + + expect(onOpenSavedSiteContent).toHaveBeenCalledWith({ + projectId: "project-open-1", + contentId: "content-open-1", + title: "Lime 搜索结果", + }); + }); + + it("站点能力工具失败时应展示未保存原因", () => { + const { container } = renderTool({ + id: "tool-site-run-2", + name: "lime_site_run", + arguments: JSON.stringify({ + adapter_name: "zhihu/search", + args: { query: "lime" }, + }), + status: "failed", + result: { + success: false, + error: "执行失败", + output: "", + metadata: { + tool_family: "site", + adapter_name: "zhihu/search", + save_skipped_project_id: "project-2", + save_skipped_by: "context_project", + save_error_message: "数据库写入失败", + }, + }, + startTime: new Date("2026-03-25T12:12:00.000Z"), + endTime: new Date("2026-03-25T12:12:03.000Z"), + }); + + act(() => { + const toggle = container.querySelector( + 'button[title="查看结果"]', + ) as HTMLButtonElement | null; + toggle?.click(); + }); + + expect(container.textContent).toContain( + "执行失败,未保存到项目 project-2 · 来自当前项目上下文", + ); + expect(container.textContent).toContain("自动保存失败:数据库写入失败"); + }); + it("应为浏览器、委派、任务输出与交互类工具生成具体动作句", () => { const container = document.createElement("div"); document.body.appendChild(container); @@ -324,4 +472,25 @@ describe("ToolCallDisplay", () => { expect(container.textContent).toContain("已列出 src/**/*.tsx"); expect(container.textContent).toContain("等待输入 需要继续吗?"); }); + + it("写文件工具应通过 artifact protocol 解析嵌套产物路径", () => { + const { container } = renderTool({ + id: "tool-write-nested-1", + name: "write_file", + arguments: JSON.stringify({ + payload: { + artifact_paths: ["social-posts\\final.md"], + }, + }), + status: "completed", + result: { + success: true, + output: "# 最终稿", + }, + startTime: new Date("2026-03-25T09:00:00.000Z"), + endTime: new Date("2026-03-25T09:00:01.000Z"), + }); + + expect(container.textContent).toContain("已写入 final.md"); + }); }); diff --git a/src/components/agent/chat/components/ToolCallDisplay.tsx b/src/components/agent/chat/components/ToolCallDisplay.tsx index fcbb875a7..d9b559dd2 100644 --- a/src/components/agent/chat/components/ToolCallDisplay.tsx +++ b/src/components/agent/chat/components/ToolCallDisplay.tsx @@ -20,7 +20,11 @@ import { ExternalLink, } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { ToolCallState, ToolResultImage } from "@/lib/api/agentStream"; +import type { + AgentToolCallState as ToolCallState, + AgentToolResultImage as ToolResultImage, +} from "@/lib/api/agentProtocol"; +import type { SiteSavedContentTarget } from "../types"; import { MarkdownRenderer } from "./MarkdownRenderer"; import { SearchResultPreviewList } from "./SearchResultPreviewList"; import { @@ -233,6 +237,180 @@ const normalizeToolResultMetadata = ( return Object.fromEntries(Object.entries(rawMetadata)); }; +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function readFirstNonEmptyString( + candidates: Array | null | undefined>, + keys: string[], +): string | undefined { + for (const candidate of candidates) { + if (!candidate) continue; + for (const key of keys) { + const value = candidate[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + } + return undefined; +} + +interface SiteToolResultSummary { + savedContent?: { + contentId?: string; + projectId?: string; + title?: string; + }; + savedProjectId?: string; + savedBy?: string; + saveSkippedProjectId?: string; + saveSkippedBy?: string; + saveErrorMessage?: string; + adapterSourceKind?: string; + adapterSourceVersion?: string; +} + +interface ToolResultNotice { + key: string; + text: string; + tone: "neutral" | "success" | "warning" | "error"; +} + +function resolveSiteSavedContentTarget( + summary: SiteToolResultSummary | null, +): SiteSavedContentTarget | null { + if (!summary?.savedContent?.contentId) { + return null; + } + + const projectId = + summary.savedContent.projectId?.trim() || summary.savedProjectId?.trim(); + if (!projectId) { + return null; + } + + return { + projectId, + contentId: summary.savedContent.contentId, + title: summary.savedContent.title, + }; +} + +function normalizeSiteToolResultSummary( + rawMetadata: unknown, +): SiteToolResultSummary | null { + const metadata = normalizeToolResultMetadata(rawMetadata); + if (!metadata) { + return null; + } + + const metadataResult = asRecord(metadata.result); + const savedContentRecord = + asRecord(metadata.saved_content) || asRecord(metadataResult?.saved_content); + const candidates = [metadata, metadataResult, savedContentRecord]; + const toolFamily = readFirstNonEmptyString(candidates, [ + "tool_family", + "toolFamily", + ]); + const savedProjectId = readFirstNonEmptyString(candidates, [ + "saved_project_id", + "savedProjectId", + ]); + const saveSkippedProjectId = readFirstNonEmptyString(candidates, [ + "save_skipped_project_id", + "saveSkippedProjectId", + ]); + const saveErrorMessage = readFirstNonEmptyString(candidates, [ + "save_error_message", + "saveErrorMessage", + ]); + const adapterSourceKind = readFirstNonEmptyString(candidates, [ + "adapter_source_kind", + "adapterSourceKind", + ]); + const adapterSourceVersion = readFirstNonEmptyString(candidates, [ + "adapter_source_version", + "adapterSourceVersion", + ]); + + const hasSavedContent = + !!savedContentRecord && + [ + savedContentRecord.content_id, + savedContentRecord.contentId, + savedContentRecord.project_id, + savedContentRecord.projectId, + savedContentRecord.title, + ].some((value) => typeof value === "string" && value.trim()); + + const isSiteTool = + toolFamily === "site" || + hasSavedContent || + !!savedProjectId || + !!saveSkippedProjectId || + !!saveErrorMessage || + !!adapterSourceKind; + + if (!isSiteTool) { + return null; + } + + return { + savedContent: hasSavedContent + ? { + contentId: readFirstNonEmptyString([savedContentRecord], [ + "content_id", + "contentId", + ]), + projectId: readFirstNonEmptyString([savedContentRecord], [ + "project_id", + "projectId", + ]), + title: readFirstNonEmptyString([savedContentRecord], ["title"]), + } + : undefined, + savedProjectId, + savedBy: readFirstNonEmptyString(candidates, ["saved_by", "savedBy"]), + saveSkippedProjectId, + saveSkippedBy: readFirstNonEmptyString(candidates, [ + "save_skipped_by", + "saveSkippedBy", + ]), + saveErrorMessage, + adapterSourceKind, + adapterSourceVersion, + }; +} + +function resolveSiteProjectSourceLabel(source?: string): string | null { + if (source === "context_project") { + return "来自当前项目上下文"; + } + if (source === "explicit_project") { + return "来自显式项目参数"; + } + return null; +} + +function resolveSiteAdapterSourceLabel(summary: SiteToolResultSummary): string | null { + if (summary.adapterSourceKind === "server_synced") { + return summary.adapterSourceVersion + ? `服务端脚本 · ${summary.adapterSourceVersion}` + : "服务端脚本"; + } + if (summary.adapterSourceKind === "bundled") { + return summary.adapterSourceVersion + ? `内置脚本 · ${summary.adapterSourceVersion}` + : "内置脚本"; + } + return null; +} + // ============ 可展开面板组件 ============ interface ExpandablePanelProps { @@ -472,6 +650,7 @@ interface ToolCallDisplayProps { isMessageStreaming?: boolean; /** 文件点击回调 - 用于打开右边栏显示文件内容 */ onFileClick?: (fileName: string, content: string) => void; + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void; grouped?: boolean; groupMarker?: string; } @@ -481,6 +660,7 @@ export const ToolCallDisplay: React.FC = ({ defaultExpanded = false, isMessageStreaming = false, onFileClick, + onOpenSavedSiteContent, grouped = false, groupMarker = "•", }) => { @@ -529,6 +709,14 @@ export const ToolCallDisplay: React.FC = ({ () => normalizeToolResultMetadata(toolCall.result?.metadata), [toolCall.result?.metadata], ); + const siteResultSummary = useMemo( + () => normalizeSiteToolResultSummary(toolCall.result?.metadata), + [toolCall.result?.metadata], + ); + const savedSiteContentTarget = useMemo( + () => resolveSiteSavedContentTarget(siteResultSummary), + [siteResultSummary], + ); const resultText = useMemo(() => { const rawText = toolCall.result?.error || toolCall.result?.output || ""; const normalized = extractLimeToolMetadataBlock(rawText).text; @@ -578,6 +766,65 @@ export const ToolCallDisplay: React.FC = ({ return items; }, [resultMetadata]); + const siteResultNotices = useMemo(() => { + if (!siteResultSummary) return [] as ToolResultNotice[]; + + const notices: ToolResultNotice[] = []; + const savedProjectId = + siteResultSummary.savedProjectId || siteResultSummary.savedContent?.projectId; + const savedSourceLabel = resolveSiteProjectSourceLabel(siteResultSummary.savedBy); + + if (siteResultSummary.savedContent?.title) { + let text = `结果已自动保存${ + savedProjectId ? `到项目 ${savedProjectId}` : "" + }:${siteResultSummary.savedContent.title}`; + if (savedSourceLabel) { + text = `${text} · ${savedSourceLabel}`; + } + notices.push({ + key: "site-save-success", + text, + tone: "success", + }); + } + + if (siteResultSummary.saveSkippedProjectId) { + let text = + toolCall.status === "failed" + ? `执行失败,未保存到项目 ${siteResultSummary.saveSkippedProjectId}` + : `本次结果未保存到项目 ${siteResultSummary.saveSkippedProjectId}`; + const skippedSourceLabel = resolveSiteProjectSourceLabel( + siteResultSummary.saveSkippedBy, + ); + if (skippedSourceLabel) { + text = `${text} · ${skippedSourceLabel}`; + } + notices.push({ + key: "site-save-skipped", + text, + tone: siteResultSummary.saveErrorMessage ? "warning" : "neutral", + }); + } + + if (siteResultSummary.saveErrorMessage) { + notices.push({ + key: "site-save-error", + text: `自动保存失败:${siteResultSummary.saveErrorMessage}`, + tone: "error", + }); + } + + const adapterSourceLabel = resolveSiteAdapterSourceLabel(siteResultSummary); + if (adapterSourceLabel) { + notices.push({ + key: "site-adapter-source", + text: `脚本来源:${adapterSourceLabel}`, + tone: "neutral", + }); + } + + return notices; + }, [siteResultSummary, toolCall.status]); const resultPath = useMemo(() => { if (!resultMetadata) return undefined; if ( @@ -688,6 +935,12 @@ export const ToolCallDisplay: React.FC = ({ } }, [fileContent, onFileClick, openableFilePath]); + const handleOpenSavedSiteContent = useCallback(() => { + if (savedSiteContentTarget && onOpenSavedSiteContent) { + onOpenSavedSiteContent(savedSiteContentTarget); + } + }, [onOpenSavedSiteContent, savedSiteContentTarget]); + const handleToggleExpanded = useCallback(() => { hasUserToggledExpandedRef.current = true; setIsExpanded((prev) => !prev); @@ -844,6 +1097,34 @@ export const ToolCallDisplay: React.FC = ({ ))}
) : null} + {siteResultNotices.length > 0 ? ( +
+ {siteResultNotices.map((notice) => ( +
+ {notice.text} +
+ ))} +
+ ) : null} + {savedSiteContentTarget && onOpenSavedSiteContent ? ( +
+ +
+ ) : null} {resultPath ? (
{resultPath.label}: {resultPath.value} @@ -886,12 +1167,14 @@ interface ToolCallListProps { isMessageStreaming?: boolean; /** 文件点击回调 - 用于打开右边栏显示文件内容 */ onFileClick?: (fileName: string, content: string) => void; + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void; } export const ToolCallList: React.FC = ({ toolCalls, isMessageStreaming = false, onFileClick, + onOpenSavedSiteContent, }) => { if (!toolCalls || toolCalls.length === 0) return null; @@ -970,6 +1253,7 @@ export const ToolCallList: React.FC = ({ toolCall={group.item} isMessageStreaming={isMessageStreaming} onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} /> ); } @@ -982,6 +1266,7 @@ export const ToolCallList: React.FC = ({ toolCall={group.items[0]!} isMessageStreaming={isMessageStreaming} onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} /> ); } @@ -992,6 +1277,7 @@ export const ToolCallList: React.FC = ({ toolCalls={group.items} isMessageStreaming={isMessageStreaming} onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} /> ); } @@ -1002,6 +1288,7 @@ export const ToolCallList: React.FC = ({ toolCalls={group.items} isMessageStreaming={isMessageStreaming} onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} /> ); })} @@ -1013,10 +1300,12 @@ function WorkToolCallGroup({ toolCalls, isMessageStreaming, onFileClick, + onOpenSavedSiteContent, }: { toolCalls: ToolCallState[]; isMessageStreaming: boolean; onFileClick?: (fileName: string, content: string) => void; + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void; }) { const hasRunning = toolCalls.some((item) => item.status === "running"); const hasFailed = toolCalls.some((item) => item.status === "failed"); @@ -1064,6 +1353,7 @@ function WorkToolCallGroup({ toolCall={toolCall} isMessageStreaming={isMessageStreaming} onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} grouped={true} groupMarker={index === 0 ? "└" : "·"} /> @@ -1078,10 +1368,12 @@ function SearchToolCallGroup({ toolCalls, isMessageStreaming, onFileClick, + onOpenSavedSiteContent, }: { toolCalls: ToolCallState[]; isMessageStreaming: boolean; onFileClick?: (fileName: string, content: string) => void; + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void; }) { const [expanded, setExpanded] = useState(true); const semanticSummaries = summarizeSearchQuerySemantics( @@ -1138,6 +1430,7 @@ function SearchToolCallGroup({ toolCall={toolCall} isMessageStreaming={isMessageStreaming} onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} grouped={true} groupMarker={index === 0 ? "└" : "·"} /> diff --git a/src/components/agent/chat/components/themeWorkbenchWorkflowData.test.ts b/src/components/agent/chat/components/themeWorkbenchWorkflowData.test.ts new file mode 100644 index 000000000..a12036f61 --- /dev/null +++ b/src/components/agent/chat/components/themeWorkbenchWorkflowData.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + buildThemeWorkbenchActivityLogGroups, + parseThemeWorkbenchRunMetadataSummary, +} from "./themeWorkbenchWorkflowData"; + +describe("themeWorkbenchWorkflowData", () => { + it("应通过 artifact protocol 统一解析运行元数据里的产物路径", () => { + expect( + parseThemeWorkbenchRunMetadataSummary( + JSON.stringify({ + workflow: "social", + execution_id: "exec-1", + version_id: "version-1", + stages: ["write_mode"], + artifact_paths: [" social-posts/demo.md "], + artifactPath: "social-posts\\demo-cover.png", + filePath: "social-posts/summary.md", + }), + ), + ).toEqual({ + workflow: "social", + executionId: "exec-1", + versionId: "version-1", + stages: ["write_mode"], + artifactPaths: [ + "social-posts/summary.md", + "social-posts/demo-cover.png", + "social-posts/demo.md", + ], + }); + }); + + it("应合并同一运行日志中的产物路径并保持规范化", () => { + expect( + buildThemeWorkbenchActivityLogGroups([ + { + id: "log-1", + name: "排版优化", + status: "running", + timeLabel: "10:30", + runId: "run-1", + artifactPaths: [" social-posts/demo.md "], + }, + { + id: "log-2", + name: "排版优化", + status: "completed", + timeLabel: "10:31", + runId: "run-1", + artifactPaths: ["social-posts/demo.md", "social-posts/demo-cover.png"], + }, + ])[0]?.artifactPaths, + ).toEqual(["social-posts/demo.md", "social-posts/demo-cover.png"]); + }); +}); diff --git a/src/components/agent/chat/components/themeWorkbenchWorkflowData.ts b/src/components/agent/chat/components/themeWorkbenchWorkflowData.ts index 885d53884..572390d6d 100644 --- a/src/components/agent/chat/components/themeWorkbenchWorkflowData.ts +++ b/src/components/agent/chat/components/themeWorkbenchWorkflowData.ts @@ -1,3 +1,4 @@ +import { extractArtifactProtocolPathsFromRecord } from "@/lib/artifact-protocol"; import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace"; export interface ThemeWorkbenchCreationTaskEvent { @@ -173,7 +174,7 @@ export function parseThemeWorkbenchRunMetadataSummary( executionId: readString(parsed.execution_id), versionId: readString(parsed.version_id), stages: readStringArray(parsed.stages), - artifactPaths: readStringArray(parsed.artifact_paths), + artifactPaths: extractArtifactProtocolPathsFromRecord(parsed), }; } catch { return fallback; diff --git a/src/components/agent/chat/homeShellEntry.ts b/src/components/agent/chat/homeShellEntry.ts index 73c6104a6..812fa370e 100644 --- a/src/components/agent/chat/homeShellEntry.ts +++ b/src/components/agent/chat/homeShellEntry.ts @@ -6,8 +6,10 @@ import type { ChatToolPreferences } from "./utils/chatToolPreferences"; export interface AgentChatWorkspaceBootstrap { projectId?: string; + contentId?: string; initialUserPrompt?: string; initialUserImages?: MessageImage[]; + initialRequestMetadata?: Record; theme?: string; initialCreationMode?: CreationMode; openBrowserAssistOnMount?: boolean; @@ -17,6 +19,8 @@ export interface AgentChatWorkspaceBootstrap { export interface HomeShellEnterWorkspacePayload { prompt?: string; images?: MessageImage[]; + contentId?: string; + initialRequestMetadata?: Record; openBrowserAssistOnMount?: boolean; toolPreferences?: ChatToolPreferences; themeOverride?: string; @@ -84,10 +88,12 @@ export function resolveHomeShellWorkspaceEntry( const nextNewChatAt = now(); const shared = { projectId: projectId ?? undefined, + contentId: payload.contentId, theme: targetTheme, initialCreationMode: creationMode, initialUserPrompt: payload.prompt, initialUserImages: payload.images, + initialRequestMetadata: payload.initialRequestMetadata, openBrowserAssistOnMount, newChatAt: nextNewChatAt, } satisfies AgentPageParams; @@ -100,8 +106,10 @@ export function resolveHomeShellWorkspaceEntry( navigationParams: buildClawAgentParams(shared), workspaceBootstrap: { projectId: projectId ?? undefined, + contentId: payload.contentId, initialUserPrompt: payload.prompt, initialUserImages: payload.images, + initialRequestMetadata: payload.initialRequestMetadata, theme: targetTheme, initialCreationMode: creationMode, openBrowserAssistOnMount, diff --git a/src/components/agent/chat/hooks/agentChatHistory.test.ts b/src/components/agent/chat/hooks/agentChatHistory.test.ts index 7b8740082..f7ea86dff 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.test.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.test.ts @@ -18,17 +18,17 @@ describe("agentChatHistory", () => { role: "user", timestamp: 1710000200, content: [ - { type: "input_text", text: "[Image #1]" }, + { type: "input_text", text: "[Image #1]" } as never, { type: "input_image", image_url: "data:image/png;base64,aGVsbG8=", - }, + } as never, ], }, { role: "assistant", timestamp: 1710000201, - content: [{ type: "output_text", text: "已收到图片" }], + content: [{ type: "output_text", text: "已收到图片" } as never], }, ], }; @@ -70,7 +70,7 @@ describe("agentChatHistory", () => { content: [ { type: "thinking", thinking: "先理解主题" } as never, { type: "thinking", thinking: ",再组织结构。\n" } as never, - { type: "output_text", text: "下面是整理好的 Prompt。" }, + { type: "output_text", text: "下面是整理好的 Prompt。" } as never, ], }, ], diff --git a/src/components/agent/chat/hooks/agentChatHistory.ts b/src/components/agent/chat/hooks/agentChatHistory.ts index d335a18d1..9a9fa78a6 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.ts @@ -1,6 +1,7 @@ -import type { ContextTraceStep } from "@/lib/api/agentStream"; +import type { AgentContextTraceStep as ContextTraceStep } from "@/lib/api/agentProtocol"; import type { Message, MessageImage, ContentPart } from "../types"; import type { AsterSessionDetail } from "@/lib/api/agentRuntime"; +import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; import { mergeArtifacts } from "../utils/messageArtifacts"; import { extractLimeToolMetadataBlock, @@ -370,10 +371,7 @@ const messageArtifactsSignature = ( if (!artifacts || artifacts.length === 0) return ""; return artifacts .map((artifact) => { - const filePath = - typeof artifact.meta.filePath === "string" - ? artifact.meta.filePath - : ""; + const filePath = resolveArtifactProtocolFilePath(artifact); return [ artifact.id, artifact.type, diff --git a/src/components/agent/chat/hooks/agentChatToolResult.ts b/src/components/agent/chat/hooks/agentChatToolResult.ts index a7d129ffe..010c2822d 100644 --- a/src/components/agent/chat/hooks/agentChatToolResult.ts +++ b/src/components/agent/chat/hooks/agentChatToolResult.ts @@ -1,4 +1,4 @@ -import type { ToolResultImage } from "@/lib/api/agentStream"; +import type { AgentToolResultImage as ToolResultImage } from "@/lib/api/agentProtocol"; import type { MessageImage } from "../types"; import { LIME_TOOL_METADATA_BEGIN, diff --git a/src/components/agent/chat/hooks/agentRuntimeAdapter.ts b/src/components/agent/chat/hooks/agentRuntimeAdapter.ts index f51fe0311..a4cfc73ec 100644 --- a/src/components/agent/chat/hooks/agentRuntimeAdapter.ts +++ b/src/components/agent/chat/hooks/agentRuntimeAdapter.ts @@ -1,5 +1,10 @@ import { safeListen } from "@/lib/dev-bridge"; import type { UnlistenFn } from "@tauri-apps/api/event"; +import { + createSubmitTurnRequestFromAgentOp, + type AgentEvent, + type AgentOp, +} from "@/lib/api/agentProtocol"; import { compactAgentRuntimeSession, createAgentRuntimeSession, @@ -18,34 +23,11 @@ import { submitAgentRuntimeTurn, updateAgentRuntimeSession, type AsterExecutionStrategy, - type AsterProviderConfig, - type AgentSearchMode, type AsterSessionDetail, type AsterSessionInfo, - type AutoContinueRequestPayload, - type ImageInput, } from "@/lib/api/agentRuntime"; -import type { StreamEvent } from "@/lib/api/agentStream"; import type { ActionRequiredScope } from "../types"; -export interface AgentRuntimeTurnRequest { - message: string; - sessionId: string; - eventName: string; - workspaceId: string; - turnId?: string; - images?: ImageInput[]; - providerConfig?: AsterProviderConfig; - executionStrategy?: AsterExecutionStrategy; - webSearch?: boolean; - searchMode?: AgentSearchMode; - autoContinue?: AutoContinueRequestPayload; - systemPrompt?: string; - metadata?: Record; - queueIfBusy?: boolean; - queuedTurnId?: string; -} - export interface AgentRuntimeActionResponse { sessionId: string; requestId: string; @@ -78,7 +60,7 @@ export interface AgentRuntimeAdapter { sessionId: string, executionStrategy: AsterExecutionStrategy, ): Promise; - submitTurn(request: AgentRuntimeTurnRequest): Promise; + submitOp(op: AgentOp): Promise; compactSession(sessionId: string, eventName: string): Promise; interruptTurn(sessionId: string): Promise; resumeThread(sessionId: string): Promise; @@ -87,11 +69,11 @@ export interface AgentRuntimeAdapter { respondToAction(request: AgentRuntimeActionResponse): Promise; listenToTurnEvents( eventName: string, - handler: (event: { payload: StreamEvent | unknown }) => void, + handler: (event: { payload: AgentEvent | unknown }) => void, ): Promise; listenToTeamEvents( eventName: string, - handler: (event: { payload: StreamEvent | unknown }) => void, + handler: (event: { payload: AgentEvent | unknown }) => void, ): Promise; } @@ -132,26 +114,14 @@ export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = { execution_strategy: executionStrategy, }); }, - async submitTurn(request) { - await submitAgentRuntimeTurn({ - message: request.message, - session_id: request.sessionId, - event_name: request.eventName, - workspace_id: request.workspaceId, - turn_id: request.turnId, - images: request.images, - turn_config: { - provider_config: request.providerConfig, - execution_strategy: request.executionStrategy, - web_search: request.webSearch, - search_mode: request.searchMode, - auto_continue: request.autoContinue, - system_prompt: request.systemPrompt, - metadata: request.metadata, - }, - queue_if_busy: request.queueIfBusy, - queued_turn_id: request.queuedTurnId, - }); + async submitOp(op) { + switch (op.type) { + case "user_input": + await submitAgentRuntimeTurn(createSubmitTurnRequestFromAgentOp(op)); + return; + default: + throw new Error(`当前 runtime adapter 尚不支持 AgentOp: ${op.type}`); + } }, async compactSession(sessionId, eventName) { await compactAgentRuntimeSession({ @@ -203,9 +173,9 @@ export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = { }); }, async listenToTurnEvents(eventName, handler) { - return safeListen(eventName, handler); + return safeListen(eventName, handler); }, async listenToTeamEvents(eventName, handler) { - return safeListen(eventName, handler); + return safeListen(eventName, handler); }, }; diff --git a/src/components/agent/chat/hooks/agentStreamEventProcessor.ts b/src/components/agent/chat/hooks/agentStreamEventProcessor.ts index db8d93a8e..787c1db63 100644 --- a/src/components/agent/chat/hooks/agentStreamEventProcessor.ts +++ b/src/components/agent/chat/hooks/agentStreamEventProcessor.ts @@ -1,14 +1,18 @@ import { toast } from "sonner"; import type { Dispatch, SetStateAction } from "react"; -import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; import type { - StreamEventActionRequired, - StreamEventArtifactSnapshot, - StreamEventContextTrace, - StreamEventToolEnd, - StreamEventToolStart, -} from "@/lib/api/agentStream"; + AgentEventActionRequired, + AgentEventArtifactSnapshot, + AgentEventContextTrace, + AgentEventToolEnd, + AgentEventToolStart, +} from "@/lib/api/agentProtocol"; +import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; import type { Artifact } from "@/lib/artifact/types"; +import { + extractArtifactProtocolPaths, + extractArtifactProtocolPathsFromValue, +} from "@/lib/artifact-protocol"; import type { ActionRequired, Message, WriteArtifactContext } from "../types"; import { activityLogger } from "@/components/content-creator/utils/activityLogger"; import { @@ -27,7 +31,6 @@ import { } from "./agentChatToolResult"; import { buildArtifactFromWrite, - extractArtifactPathsFromMetadata, findMessageArtifact, upsertMessageArtifact, } from "../utils/messageArtifacts"; @@ -135,19 +138,9 @@ function extractToolArgPath( return undefined; } - for (const key of [ - "path", - "file_path", - "filePath", - "target_path", - "targetPath", - "output_path", - "outputPath", - ]) { - const value = toolArgs[key]; - if (typeof value === "string" && value.trim()) { - return value.trim(); - } + const protocolPath = extractArtifactProtocolPathsFromValue(toolArgs)[0]; + if (protocolPath) { + return protocolPath; } return extractPatchPath(extractPatchText(toolArgs)); @@ -267,7 +260,7 @@ export function handleToolStartEvent({ }: BaseProcessorContext & ArtifactWriteOptions & ToolTrackingContext & { - data: StreamEventToolStart; + data: AgentEventToolStart; setPendingActions: Dispatch>; }) { const startedAt = Date.now(); @@ -423,7 +416,7 @@ export function handleToolEndEvent({ }: BaseProcessorContext & ArtifactWriteOptions & ToolTrackingContext & { - data: StreamEventToolEnd; + data: AgentEventToolEnd; }) { const normalizedResult = normalizeIncomingToolResult(data.result) || data.result; const isSuccess = isToolResultSuccessful(normalizedResult); @@ -500,7 +493,7 @@ export function handleToolEndEvent({ }), ); - const artifactPaths = extractArtifactPathsFromMetadata( + const artifactPaths = extractArtifactProtocolPaths( normalizedResult.metadata, ); if (artifactPaths.length === 0) { @@ -555,7 +548,7 @@ export function handleArtifactSnapshotEvent({ assistantMsgId, }: BaseProcessorContext & ArtifactWriteOptions & { - data: StreamEventArtifactSnapshot; + data: AgentEventArtifactSnapshot; }) { const artifactPath = data.artifact.filePath; if (!artifactPath) { @@ -616,7 +609,7 @@ export function handleActionRequiredEvent({ activeSessionId, resolvedWorkspaceId, }: BaseProcessorContext & { - data: StreamEventActionRequired; + data: AgentEventActionRequired; actionLoggedKeys: Set; effectiveExecutionStrategy: AsterExecutionStrategy; runtime: AgentRuntimeAdapter; @@ -704,7 +697,7 @@ export function handleContextTraceEvent({ setMessages, assistantMsgId, }: BaseProcessorContext & { - data: StreamEventContextTrace; + data: AgentEventContextTrace; }) { if (!Array.isArray(data.steps) || data.steps.length === 0) { return; diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts index 592c777fd..3898f00d1 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts @@ -1,14 +1,15 @@ import { toast } from "sonner"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import type { - AsterExecutionStrategy, - QueuedTurnSnapshot, -} from "@/lib/api/agentRuntime"; -import type { + AgentEvent, AgentThreadItem, AgentThreadTurn, - StreamEvent, -} from "@/lib/api/agentStream"; +} from "@/lib/api/agentProtocol"; +import type { + AsterExecutionStrategy, + AsterSessionExecutionRuntime, + QueuedTurnSnapshot, +} from "@/lib/api/agentRuntime"; import { activityLogger } from "@/components/content-creator/utils/activityLogger"; import type { ActionRequired, Message } from "../types"; import { appendTextToParts } from "./agentChatHistory"; @@ -33,6 +34,10 @@ import { buildFailedAgentRuntimeStatus, formatAgentRuntimeStatusSummary, } from "../utils/agentRuntimeStatus"; +import { + applyModelChangeExecutionRuntime, + applyTurnContextExecutionRuntime, +} from "../utils/sessionExecutionRuntime"; type MessageParts = NonNullable; @@ -69,13 +74,13 @@ interface StreamLifecycleCallbacks { } interface HandleTurnStreamEventOptions { - data: StreamEvent; + data: AgentEvent; requestState: StreamRequestState; callbacks: StreamLifecycleCallbacks; observer?: StreamObserver; eventName: string; - optimisticTurnId: string; - optimisticItemId: string; + pendingTurnKey: string; + pendingItemKey: string; assistantMsgId: string; activeSessionId: string; resolvedWorkspaceId: string; @@ -96,6 +101,9 @@ interface HandleTurnStreamEventOptions { setThreadItems: Dispatch>; setThreadTurns: Dispatch>; setCurrentTurnId: Dispatch>; + setExecutionRuntime: Dispatch< + SetStateAction + >; } function finishRequestLog( @@ -127,8 +135,8 @@ export function handleTurnStreamEvent({ callbacks, observer, eventName, - optimisticTurnId, - optimisticItemId, + pendingTurnKey, + pendingItemKey, assistantMsgId, activeSessionId, resolvedWorkspaceId, @@ -145,6 +153,7 @@ export function handleTurnStreamEvent({ setThreadItems, setThreadTurns, setCurrentTurnId, + setExecutionRuntime, }: HandleTurnStreamEventOptions): void { const { activateStream, @@ -167,7 +176,7 @@ export function handleTurnStreamEvent({ setThreadTurns((prev) => { const runningTurn = - prev.find((turn) => turn.id === optimisticTurnId) || + prev.find((turn) => turn.id === pendingTurnKey) || [...prev] .reverse() .find( @@ -189,15 +198,15 @@ export function handleTurnStreamEvent({ }); setThreadItems((prev) => { - const optimisticItem = prev.find((item) => item.id === optimisticItemId); - if (!optimisticItem || optimisticItem.type !== "turn_summary") { + const pendingItem = prev.find((item) => item.id === pendingItemKey); + if (!pendingItem || pendingItem.type !== "turn_summary") { return prev; } return upsertThreadItemState(prev, { - ...optimisticItem, + ...pendingItem, status: "failed", - completed_at: optimisticItem.completed_at || failedAt, + completed_at: pendingItem.completed_at || failedAt, updated_at: failedAt, text: formatAgentRuntimeStatusSummary(failedRuntimeStatus), }); @@ -248,26 +257,26 @@ export function handleTurnStreamEvent({ setCurrentTurnId(data.turn.id); setThreadTurns((prev) => upsertThreadTurnState( - removeThreadTurnState(prev, optimisticTurnId), + removeThreadTurnState(prev, pendingTurnKey), data.turn, ), ); setThreadItems((prev) => { - const optimisticItem = prev.find((item) => item.id === optimisticItemId); - if (!optimisticItem) { + const pendingItem = prev.find((item) => item.id === pendingItemKey); + if (!pendingItem) { return prev; } return upsertThreadItemState( - removeThreadItemState(prev, optimisticItemId), + removeThreadItemState(prev, pendingItemKey), { - ...optimisticItem, + ...pendingItem, thread_id: data.turn.thread_id, turn_id: data.turn.id, updated_at: data.turn.updated_at || data.turn.started_at || - optimisticItem.updated_at, + pendingItem.updated_at, }, ); }); @@ -279,7 +288,7 @@ export function handleTurnStreamEvent({ activateStream(); setThreadItems((prev) => upsertThreadItemState( - removeThreadItemState(prev, optimisticItemId), + removeThreadItemState(prev, pendingItemKey), data.item, ), ); @@ -291,7 +300,7 @@ export function handleTurnStreamEvent({ clearOptimisticItem(); setThreadTurns((prev) => upsertThreadTurnState( - removeThreadTurnState(prev, optimisticTurnId), + removeThreadTurnState(prev, pendingTurnKey), data.turn, ), ); @@ -312,6 +321,20 @@ export function handleTurnStreamEvent({ ); break; + case "turn_context": + activateStream(); + setExecutionRuntime((current) => + applyTurnContextExecutionRuntime(current, data), + ); + break; + + case "model_change": + activateStream(); + setExecutionRuntime((current) => + applyModelChangeExecutionRuntime(current, data), + ); + break; + case "thinking_delta": activateStream(); setMessages((prev) => diff --git a/src/components/agent/chat/hooks/skillCommand.test.ts b/src/components/agent/chat/hooks/skillCommand.test.ts index 2a9187bd6..74af2de1e 100644 --- a/src/components/agent/chat/hooks/skillCommand.test.ts +++ b/src/components/agent/chat/hooks/skillCommand.test.ts @@ -5,12 +5,12 @@ import type { Message } from "../types"; const { mockSafeListen, - mockParseStreamEvent, + mockParseAgentEvent, mockListExecutableSkills, mockExecuteSkill, } = vi.hoisted(() => ({ mockSafeListen: vi.fn(), - mockParseStreamEvent: vi.fn((payload: unknown) => payload), + mockParseAgentEvent: vi.fn((payload: unknown) => payload), mockListExecutableSkills: vi.fn(), mockExecuteSkill: vi.fn(), })); @@ -19,9 +19,16 @@ vi.mock("@/lib/dev-bridge", () => ({ safeListen: mockSafeListen, })); -vi.mock("@/lib/api/agentStream", () => ({ - parseStreamEvent: mockParseStreamEvent, -})); +vi.mock("@/lib/api/agentProtocol", async () => { + const actual = + await vi.importActual( + "@/lib/api/agentProtocol", + ); + return { + ...actual, + parseAgentEvent: mockParseAgentEvent, + }; +}); vi.mock("@/lib/api/skill-execution", () => ({ skillExecutionApi: { @@ -59,7 +66,7 @@ function buildBaseMessage(id = "assistant-1"): Message { beforeEach(() => { vi.clearAllMocks(); - mockParseStreamEvent.mockImplementation((payload: unknown) => payload); + mockParseAgentEvent.mockImplementation((payload: unknown) => payload); mockListExecutableSkills.mockResolvedValue([ { name: "social_post_with_cover", diff --git a/src/components/agent/chat/hooks/skillCommand.ts b/src/components/agent/chat/hooks/skillCommand.ts index 3e8286702..a9636b046 100644 --- a/src/components/agent/chat/hooks/skillCommand.ts +++ b/src/components/agent/chat/hooks/skillCommand.ts @@ -1,7 +1,7 @@ import type { Dispatch, SetStateAction } from "react"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { safeListen } from "@/lib/dev-bridge"; -import { parseStreamEvent, type StreamEvent } from "@/lib/api/agentStream"; +import { parseAgentEvent, type AgentEvent } from "@/lib/api/agentProtocol"; import { skillExecutionApi, type ExecutableSkillInfo, @@ -421,8 +421,8 @@ export async function tryExecuteSlashSkillCommand( }); stepUnlisteners.push(stepStartUl); - skillUnlisten = await safeListen(eventName, ({ payload }) => { - const streamEvent = parseStreamEvent(payload as unknown); + skillUnlisten = await safeListen(eventName, ({ payload }) => { + const streamEvent = parseAgentEvent(payload as unknown); if (!streamEvent) return; switch (streamEvent.type) { diff --git a/src/components/agent/chat/hooks/useAgentSession.ts b/src/components/agent/chat/hooks/useAgentSession.ts index ba4ef5c51..a6d8a2ae3 100644 --- a/src/components/agent/chat/hooks/useAgentSession.ts +++ b/src/components/agent/chat/hooks/useAgentSession.ts @@ -9,6 +9,7 @@ import { import { toast } from "sonner"; import type { AsterExecutionStrategy, + AsterSessionExecutionRuntime, AsterSubagentParentContext, AsterSubagentSessionInfo, AgentRuntimeThreadReadModel, @@ -47,6 +48,10 @@ import { mergeThreadItems, mergeThreadTurns, } from "../utils/threadTimelineView"; +import { + createExecutionRuntimeFromSessionDetail, + createSessionModelPreferenceFromExecutionRuntime, +} from "../utils/sessionExecutionRuntime"; interface UseAgentSessionOptions { runtime: AgentRuntimeAdapter; @@ -152,6 +157,8 @@ export function useAgentSession(options: UseAgentSessionOptions) { const [queuedTurns, setQueuedTurns] = useState([]); const [threadRead, setThreadRead] = useState(null); + const [executionRuntime, setExecutionRuntime] = + useState(null); const [todoItems, setTodoItems] = useState([]); const [childSubagentSessions, setChildSubagentSessions] = useState< AsterSubagentSessionInfo[] @@ -264,6 +271,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setCurrentTurnId(null); setQueuedTurns([]); setThreadRead(null); + setExecutionRuntime(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -300,6 +308,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setCurrentTurnId(scopedCurrentTurnId); setQueuedTurns([]); setThreadRead(null); + setExecutionRuntime(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -571,9 +580,22 @@ export function useAgentSession(options: UseAgentSessionOptions) { }, []); const applyRuntimeReadModel = useCallback( - (detail: Awaited>) => { + ( + detail: Awaited>, + options?: { preserveExecutionRuntimeOnMissingDetail?: boolean }, + ) => { + const nextExecutionRuntime = createExecutionRuntimeFromSessionDetail(detail); setQueuedTurns(normalizeQueuedTurnSnapshots(detail.queued_turns)); setThreadRead(detail.thread_read ?? null); + setExecutionRuntime((current) => { + if ( + options?.preserveExecutionRuntimeOnMissingDetail && + !nextExecutionRuntime + ) { + return current; + } + return nextExecutionRuntime; + }); setTodoItems(detail.todo_items ?? []); setChildSubagentSessions(detail.child_subagent_sessions ?? []); setSubagentParentContext(detail.subagent_parent_context ?? null); @@ -603,11 +625,16 @@ export function useAgentSession(options: UseAgentSessionOptions) { const nextItems = shouldPreserveExistingTimeline ? mergeThreadItems(threadItemsRef.current, incomingItems) : incomingItems; + const shouldPreserveExecutionRuntimeOnMissingDetail = + shouldPreserveExistingTimeline && !options?.syncSessionId; setMessages(nextMessages); setThreadTurns(nextTurns); setThreadItems(nextItems); - applyRuntimeReadModel(detail); + applyRuntimeReadModel(detail, { + preserveExecutionRuntimeOnMissingDetail: + shouldPreserveExecutionRuntimeOnMissingDetail, + }); setCurrentTurnId( nextTurns.length > 0 ? nextTurns[nextTurns.length - 1]?.id || null @@ -651,13 +678,23 @@ export function useAgentSession(options: UseAgentSessionOptions) { workspaceId, }); const detail = await runtime.getSession(topicId); - const topicPreference = loadSessionModelPreference(topicId); + const runtimePreference = + createSessionModelPreferenceFromExecutionRuntime( + detail.execution_runtime, + ); + const topicPreference = + runtimePreference || loadSessionModelPreference(topicId); applySessionDetail(topicId, detail, { syncSessionId: true }); logAgentDebug("useAgentSession", "switchTopic.success", { durationMs: Date.now() - startedAt, itemsCount: detail.items?.length ?? 0, messagesCount: detail.messages.length, + modelPreferenceSource: runtimePreference + ? "execution_runtime" + : topicPreference + ? "session_storage" + : null, queuedTurnsCount: detail.queued_turns?.length ?? 0, topicId, turnsCount: detail.turns?.length ?? 0, @@ -687,6 +724,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setCurrentTurnId(null); setQueuedTurns([]); setThreadRead(null); + setExecutionRuntime(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -702,6 +740,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setCurrentTurnId(null); setQueuedTurns([]); setThreadRead(null); + setExecutionRuntime(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -938,6 +977,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setCurrentTurnId(null); setQueuedTurns([]); setThreadRead(null); + setExecutionRuntime(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -1070,6 +1110,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { setCurrentTurnId(null); setQueuedTurns([]); setThreadRead(null); + setExecutionRuntime(null); setTodoItems([]); setChildSubagentSessions([]); setSubagentParentContext(null); @@ -1202,6 +1243,8 @@ export function useAgentSession(options: UseAgentSessionOptions) { subagentParentContext, queuedTurns, threadRead, + executionRuntime, + setExecutionRuntime, setQueuedTurns, topics, setTopics, diff --git a/src/components/agent/chat/hooks/useAgentStream.ts b/src/components/agent/chat/hooks/useAgentStream.ts index 46948e93f..7936ae719 100644 --- a/src/components/agent/chat/hooks/useAgentStream.ts +++ b/src/components/agent/chat/hooks/useAgentStream.ts @@ -10,14 +10,15 @@ import { import { toast } from "sonner"; import type { AsterExecutionStrategy, + AsterSessionExecutionRuntime, AutoContinueRequestPayload, QueuedTurnSnapshot, } from "@/lib/api/agentRuntime"; import { - parseStreamEvent, + parseAgentEvent, type AgentThreadItem, type AgentThreadTurn, -} from "@/lib/api/agentStream"; +} from "@/lib/api/agentProtocol"; import type { ActionRequired, Message, MessageImage } from "../types"; import { activityLogger } from "@/components/content-creator/utils/activityLogger"; import { @@ -60,6 +61,18 @@ function buildQueuedMessagePreview(content: string): string { return compact.length > preview.length ? `${preview}...` : preview; } +function normalizeRuntimeIdentifier(value?: string | null): string { + return value?.trim().toLowerCase() || ""; +} + +function createPendingTurnKey() { + return `pending-turn:${crypto.randomUUID()}`; +} + +function createPendingItemKey(pendingTurnKey: string) { + return `pending-item:${pendingTurnKey}`; +} + function appendThinkingToParts( parts: NonNullable, textDelta: string, @@ -107,10 +120,14 @@ interface UseAgentStreamOptions { setThreadItems: Dispatch>; setThreadTurns: Dispatch>; setCurrentTurnId: Dispatch>; + setExecutionRuntime: Dispatch< + SetStateAction + >; queuedTurns: QueuedTurnSnapshot[]; setQueuedTurns: Dispatch>; setPendingActions: Dispatch>; refreshSessionReadModel: (targetSessionId?: string) => Promise; + executionRuntime: AsterSessionExecutionRuntime | null; } export function useAgentStream(options: UseAgentStreamOptions) { @@ -133,10 +150,12 @@ export function useAgentStream(options: UseAgentStreamOptions) { setThreadItems, setThreadTurns, setCurrentTurnId, + setExecutionRuntime, queuedTurns, setQueuedTurns, setPendingActions, refreshSessionReadModel, + executionRuntime, } = options; const [isSending, setIsSending] = useState(false); @@ -145,8 +164,8 @@ export function useAgentStream(options: UseAgentStreamOptions) { assistantMsgId: string; eventName: string; sessionId: string; - optimisticTurnId?: string; - optimisticItemId?: string; + pendingTurnKey?: string; + pendingItemKey?: string; } | null>(null); useEffect(() => { @@ -165,8 +184,8 @@ export function useAgentStream(options: UseAgentStreamOptions) { assistantMsgId: string; eventName: string; sessionId: string; - optimisticTurnId?: string; - optimisticItemId?: string; + pendingTurnKey?: string; + pendingItemKey?: string; } | null, ) => { activeStreamRef.current = nextActive; @@ -231,6 +250,21 @@ export function useAgentStream(options: UseAgentStreamOptions) { executionStrategyOverride || executionStrategy; const effectiveProviderType = providerTypeRef.current; const effectiveModel = modelOverride?.trim() || modelRef.current; + const runtimeProviderSelector = + executionRuntime?.provider_selector?.trim() || + executionRuntime?.provider_name?.trim() || + null; + const runtimeModelName = executionRuntime?.model_name?.trim() || null; + const shouldSubmitProviderPreference = + !runtimeProviderSelector || + normalizeRuntimeIdentifier(runtimeProviderSelector) !== + normalizeRuntimeIdentifier(effectiveProviderType); + const shouldSubmitModelPreference = + Boolean(modelOverride?.trim()) || + shouldSubmitProviderPreference || + !runtimeModelName || + normalizeRuntimeIdentifier(runtimeModelName) !== + normalizeRuntimeIdentifier(effectiveModel); const observer = options?.observer; const requestMetadata = options?.requestMetadata; const messagePurpose = options?.purpose; @@ -333,8 +367,8 @@ export function useAgentStream(options: UseAgentStreamOptions) { activeStreamRef.current?.assistantMsgId || assistantMsgId, eventName: skillEventName, sessionId: sessionIdForStop, - optimisticTurnId: activeStreamRef.current?.optimisticTurnId, - optimisticItemId: activeStreamRef.current?.optimisticItemId, + pendingTurnKey: activeStreamRef.current?.pendingTurnKey, + pendingItemKey: activeStreamRef.current?.pendingItemKey, }); }, isExecutionCancelled: () => @@ -362,8 +396,9 @@ export function useAgentStream(options: UseAgentStreamOptions) { }; let streamActivated = false; const optimisticStartedAt = assistantMsg.timestamp.toISOString(); - const optimisticTurnId = crypto.randomUUID(); - const optimisticItemId = `turn-summary:${optimisticTurnId}`; + const pendingTurnKey = createPendingTurnKey(); + const pendingItemKey = createPendingItemKey(pendingTurnKey); + const requestTurnId = crypto.randomUUID(); const optimisticThreadId = sessionIdRef.current || `local-thread:${assistantMsgId}`; const toolLogIdByToolId = new Map(); @@ -416,15 +451,15 @@ export function useAgentStream(options: UseAgentStreamOptions) { if (expectingQueue) { return; } - setThreadItems((prev) => removeThreadItemState(prev, optimisticItemId)); + setThreadItems((prev) => removeThreadItemState(prev, pendingItemKey)); }; const clearOptimisticTurn = () => { if (expectingQueue) { return; } - setThreadTurns((prev) => removeThreadTurnState(prev, optimisticTurnId)); - setCurrentTurnId((prev) => (prev === optimisticTurnId ? null : prev)); + setThreadTurns((prev) => removeThreadTurnState(prev, pendingTurnKey)); + setCurrentTurnId((prev) => (prev === pendingTurnKey ? null : prev)); }; const markOptimisticFailure = (errorMessage: string) => { @@ -436,7 +471,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { const failedRuntimeStatus = buildFailedAgentRuntimeStatus(errorMessage); setThreadTurns((prev) => { - const currentTurn = prev.find((turn) => turn.id === optimisticTurnId); + const currentTurn = prev.find((turn) => turn.id === pendingTurnKey); if (!currentTurn) { return prev; } @@ -451,7 +486,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { }); setThreadItems((prev) => { - const currentItem = prev.find((item) => item.id === optimisticItemId); + const currentItem = prev.find((item) => item.id === pendingItemKey); if (!currentItem || currentItem.type !== "turn_summary") { return prev; } @@ -480,7 +515,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { if (!expectingQueue) { setThreadTurns((prev) => upsertThreadTurnState(prev, { - id: optimisticTurnId, + id: pendingTurnKey, thread_id: optimisticThreadId, prompt_text: content, status: "running", @@ -491,9 +526,9 @@ export function useAgentStream(options: UseAgentStreamOptions) { ); setThreadItems((prev) => upsertThreadItemState(prev, { - id: optimisticItemId, + id: pendingItemKey, thread_id: optimisticThreadId, - turn_id: optimisticTurnId, + turn_id: pendingTurnKey, sequence: 0, status: "in_progress", started_at: optimisticStartedAt, @@ -502,7 +537,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { text: formatAgentRuntimeStatusSummary(assistantMsg.runtimeStatus), }), ); - setCurrentTurnId(optimisticTurnId); + setCurrentTurnId(pendingTurnKey); } const eventName = `aster_stream_${assistantMsgId}`; @@ -528,8 +563,8 @@ export function useAgentStream(options: UseAgentStreamOptions) { assistantMsgId, eventName, sessionId: activeSessionId, - optimisticTurnId, - optimisticItemId, + pendingTurnKey, + pendingItemKey, }); setMessages((prev) => prev.map((msg) => @@ -547,7 +582,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { activateStream(); setThreadTurns((prev) => upsertThreadTurnState(prev, { - id: optimisticTurnId, + id: pendingTurnKey, thread_id: activeSessionId, prompt_text: content, status: "running", @@ -558,9 +593,9 @@ export function useAgentStream(options: UseAgentStreamOptions) { ); setThreadItems((prev) => upsertThreadItemState(prev, { - id: optimisticItemId, + id: pendingItemKey, thread_id: activeSessionId, - turn_id: optimisticTurnId, + turn_id: pendingTurnKey, sequence: 0, status: "in_progress", started_at: optimisticStartedAt, @@ -597,7 +632,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { unlisten = await runtime.listenToTurnEvents( eventName, (event: { payload: unknown }) => { - const data = parseStreamEvent(event.payload); + const data = parseAgentEvent(event.payload); if (!data) { return; } @@ -621,8 +656,8 @@ export function useAgentStream(options: UseAgentStreamOptions) { }, observer, eventName, - optimisticTurnId, - optimisticItemId, + pendingTurnKey, + pendingItemKey, assistantMsgId, activeSessionId, resolvedWorkspaceId, @@ -639,6 +674,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { setThreadItems, setThreadTurns, setCurrentTurnId, + setExecutionRuntime, }); }, ); @@ -653,24 +689,27 @@ export function useAgentStream(options: UseAgentStreamOptions) { })) : undefined; - const providerConfig = { - provider_id: effectiveProviderType, - provider_name: mapProviderName(effectiveProviderType), - model_name: effectiveModel, - }; - - await runtime.submitTurn({ - message: content, + await runtime.submitOp({ + type: "user_input", + text: content, sessionId: activeSessionId, eventName, workspaceId: resolvedWorkspaceId, - turnId: optimisticTurnId, + turnId: requestTurnId, images: imagesToSend, - providerConfig, - executionStrategy: effectiveExecutionStrategy, - webSearch, - searchMode: webSearch ? "allowed" : "disabled", - autoContinue, + preferences: { + providerPreference: shouldSubmitProviderPreference + ? effectiveProviderType + : undefined, + modelPreference: shouldSubmitModelPreference + ? effectiveModel + : undefined, + thinking: _thinking, + executionStrategy: effectiveExecutionStrategy, + webSearch, + searchMode: webSearch ? "allowed" : "disabled", + autoContinue, + }, systemPrompt, metadata: requestMetadata, queueIfBusy: true, @@ -734,9 +773,11 @@ export function useAgentStream(options: UseAgentStreamOptions) { providerTypeRef, queuedTurns.length, runtime, + executionRuntime, sessionIdRef, setActiveStream, setCurrentTurnId, + setExecutionRuntime, setMessages, setPendingActions, setQueuedTurns, @@ -770,17 +811,17 @@ export function useAgentStream(options: UseAgentStreamOptions) { setQueuedTurns([]); if (activeStream?.assistantMsgId) { - if (activeStream.optimisticItemId) { + if (activeStream.pendingItemKey) { setThreadItems((prev) => - removeThreadItemState(prev, activeStream.optimisticItemId!), + removeThreadItemState(prev, activeStream.pendingItemKey!), ); } - if (activeStream.optimisticTurnId) { + if (activeStream.pendingTurnKey) { setThreadTurns((prev) => - removeThreadTurnState(prev, activeStream.optimisticTurnId!), + removeThreadTurnState(prev, activeStream.pendingTurnKey!), ); setCurrentTurnId((prev) => - prev === activeStream.optimisticTurnId ? null : prev, + prev === activeStream.pendingTurnKey ? null : prev, ); } setMessages((prev) => @@ -884,7 +925,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { try { unlisten = await runtime.listenToTurnEvents(eventName, (event) => { - const data = parseStreamEvent(event.payload); + const data = parseAgentEvent(event.payload); if (!data) { return; } diff --git a/src/components/agent/chat/hooks/useArtifactAutoPreviewSync.ts b/src/components/agent/chat/hooks/useArtifactAutoPreviewSync.ts index d7c1b6688..aabe5e75e 100644 --- a/src/components/agent/chat/hooks/useArtifactAutoPreviewSync.ts +++ b/src/components/agent/chat/hooks/useArtifactAutoPreviewSync.ts @@ -1,12 +1,12 @@ import { useEffect } from "react"; import type { Artifact } from "@/lib/artifact/types"; +import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; import type { ArtifactWriteMetadata, WriteArtifactContext, } from "../types"; import { buildArtifactFromWrite, - resolveArtifactFilePath, resolveArtifactWritePhase, } from "../utils/messageArtifacts"; @@ -46,7 +46,7 @@ export function shouldAutoSyncArtifactPreview(artifact: Artifact | null): boolea return false; } - const artifactPath = resolveArtifactFilePath(artifact); + const artifactPath = resolveArtifactProtocolFilePath(artifact); if (!artifactPath.trim()) { return false; } @@ -77,7 +77,7 @@ export function mergePreviewContentIntoArtifact( const nextContent = typeof preview.content === "string" ? preview.content : artifact.content; - const nextPath = preview.path?.trim() || resolveArtifactFilePath(artifact); + const nextPath = preview.path?.trim() || resolveArtifactProtocolFilePath(artifact); const currentContent = artifact.content; if (!nextContent.trim() && currentContent.trim()) { @@ -92,7 +92,10 @@ export function mergePreviewContentIntoArtifact( return null; } - if (nextContent === currentContent && nextPath === resolveArtifactFilePath(artifact)) { + if ( + nextContent === currentContent && + nextPath === resolveArtifactProtocolFilePath(artifact) + ) { return null; } @@ -160,7 +163,7 @@ export function useArtifactAutoPreviewSync({ return; } - const artifactPath = resolveArtifactFilePath(artifact); + const artifactPath = resolveArtifactProtocolFilePath(artifact); if (!artifactPath.trim()) { return; } diff --git a/src/components/agent/chat/hooks/useArtifactDisplayState.ts b/src/components/agent/chat/hooks/useArtifactDisplayState.ts index be6e63c04..c75b577f0 100644 --- a/src/components/agent/chat/hooks/useArtifactDisplayState.ts +++ b/src/components/agent/chat/hooks/useArtifactDisplayState.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Artifact } from "@/lib/artifact/types"; +import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; import { resolveArtifactWritePhase } from "../utils/messageArtifacts"; export type ArtifactDisplayMode = @@ -80,16 +81,6 @@ function hasRenderableArtifactContent(artifact: Artifact | null | undefined): bo return Boolean(artifact?.content.trim()); } -function resolveArtifactPath(artifact: Pick): string { - if (typeof artifact.meta.filePath === "string" && artifact.meta.filePath.trim()) { - return artifact.meta.filePath.trim(); - } - if (typeof artifact.meta.filename === "string" && artifact.meta.filename.trim()) { - return artifact.meta.filename.trim(); - } - return artifact.title; -} - function resolveArtifactDisplayName(path: string): string { const normalized = path.replace(/\\/g, "/"); const segments = normalized.split("/"); @@ -138,7 +129,7 @@ function buildOverlayState( phase: ArtifactOverlayPhase, options: { isSlowTransition: boolean }, ): ArtifactDisplayOverlayState { - const filePath = resolveArtifactPath(artifact); + const filePath = resolveArtifactProtocolFilePath(artifact); const displayName = resolveArtifactDisplayName(filePath); switch (phase) { diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index c9aa09f46..5b08084aa 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -19,7 +19,7 @@ const { mockPromoteAgentRuntimeQueuedTurn, mockRemoveAgentRuntimeQueuedTurn, mockRespondAgentRuntimeAction, - mockParseStreamEvent, + mockParseAgentEvent, mockSafeListen, mockToast, mockParseSkillSlashCommand, @@ -41,7 +41,7 @@ const { mockPromoteAgentRuntimeQueuedTurn: vi.fn(), mockRemoveAgentRuntimeQueuedTurn: vi.fn(), mockRespondAgentRuntimeAction: vi.fn(), - mockParseStreamEvent: vi.fn((payload: unknown) => payload), + mockParseAgentEvent: vi.fn((payload: unknown) => payload), mockSafeListen: vi.fn(), mockToast: { success: vi.fn(), @@ -74,9 +74,16 @@ vi.mock("@/lib/api/agentRuntime", () => ({ respondAgentRuntimeAction: mockRespondAgentRuntimeAction, })); -vi.mock("@/lib/api/agentStream", () => ({ - parseStreamEvent: mockParseStreamEvent, -})); +vi.mock("@/lib/api/agentProtocol", async () => { + const actual = + await vi.importActual( + "@/lib/api/agentProtocol", + ); + return { + ...actual, + parseAgentEvent: mockParseAgentEvent, + }; +}); vi.mock("@/lib/dev-bridge", () => ({ safeListen: mockSafeListen, @@ -243,7 +250,7 @@ beforeEach(() => { mockPromoteAgentRuntimeQueuedTurn.mockReset(); mockRemoveAgentRuntimeQueuedTurn.mockReset(); mockRespondAgentRuntimeAction.mockReset(); - mockParseStreamEvent.mockReset(); + mockParseAgentEvent.mockReset(); mockSafeListen.mockReset(); mockParseSkillSlashCommand.mockReset(); mockTryExecuteSlashSkillCommand.mockReset(); @@ -272,7 +279,7 @@ beforeEach(() => { mockPromoteAgentRuntimeQueuedTurn.mockResolvedValue(true); mockRemoveAgentRuntimeQueuedTurn.mockResolvedValue(true); mockRespondAgentRuntimeAction.mockResolvedValue(undefined); - mockParseStreamEvent.mockImplementation((payload: unknown) => payload); + mockParseAgentEvent.mockImplementation((payload: unknown) => payload); mockSafeListen.mockResolvedValue(() => {}); mockParseSkillSlashCommand.mockReturnValue(null); mockTryExecuteSlashSkillCommand.mockResolvedValue(false); @@ -1527,14 +1534,14 @@ describe("useAsterAgentChat thread timeline", () => { .sendMessage("帮我先开始处理", [], false, false, false, "react"); }); - const optimisticTurnId = harness.getValue().currentTurnId; - expect(optimisticTurnId).toBeTruthy(); + const pendingTurnKey = harness.getValue().currentTurnId; + expect(pendingTurnKey).toContain("pending-turn:"); expect(harness.getValue().turns).toHaveLength(1); - expect(harness.getValue().turns[0]?.id).toBe(optimisticTurnId); + expect(harness.getValue().turns[0]?.id).toBe(pendingTurnKey); expect(harness.getValue().turns[0]?.status).toBe("running"); expect(harness.getValue().threadItems).toHaveLength(1); expect(harness.getValue().threadItems[0]?.id).toBe( - `turn-summary:${optimisticTurnId}`, + `pending-item:${pendingTurnKey}`, ); expect(harness.getValue().threadItems[0]?.type).toBe("turn_summary"); expect(harness.getValue().threadItems[0]?.status).toBe("in_progress"); @@ -1559,7 +1566,7 @@ describe("useAsterAgentChat thread timeline", () => { expect(harness.getValue().turns[0]?.id).toBe("turn-real-1"); expect(harness.getValue().threadItems).toEqual([ expect.objectContaining({ - id: `turn-summary:${optimisticTurnId}`, + id: `pending-item:${pendingTurnKey}`, type: "turn_summary", status: "in_progress", turn_id: "turn-real-1", @@ -1755,7 +1762,7 @@ describe("useAsterAgentChat thread timeline", () => { ]); expect(harness.getValue().threadItems).toEqual([ expect.objectContaining({ - id: expect.stringContaining("turn-summary:"), + id: expect.stringContaining("pending-item:"), type: "turn_summary", status: "failed", turn_id: "turn-stream-error-1", @@ -3303,6 +3310,63 @@ describe("useAsterAgentChat action_required 渲染链路", () => { } }); + it("write_file 工具启动时应递归识别嵌套参数中的协议路径", async () => { + const workspaceId = "ws-artifact-tool-start-nested"; + seedSession(workspaceId, "session-artifact-tool-start-nested"); + const onWriteFile = vi.fn(); + const harness = mountHook(workspaceId, { onWriteFile }); + const stream = captureTurnStream(); + + try { + await flushEffects(); + + await act(async () => { + await harness + .getValue() + .sendMessage("生成嵌套文稿", [], false, false, false, "react"); + }); + + act(() => { + stream.emit({ + type: "tool_start", + tool_id: "tool-write-nested-1", + tool_name: "write_file", + arguments: JSON.stringify({ + payload: { + filePath: "notes/nested.md", + content: "# Nested\n\nbody", + }, + }), + }); + }); + + const assistantMessage = [...harness.getValue().messages] + .reverse() + .find((msg) => msg.role === "assistant"); + + expect(assistantMessage?.artifacts?.[0]).toMatchObject({ + title: "nested.md", + content: "", + status: "streaming", + meta: expect.objectContaining({ + filePath: "notes/nested.md", + writePhase: "preparing", + source: "tool_start", + }), + }); + expect(onWriteFile).toHaveBeenCalledWith( + "", + "notes/nested.md", + expect.objectContaining({ + source: "tool_start", + status: "streaming", + }), + ); + } finally { + harness.unmount(); + } + }); + it("apply_patch 工具启动时应立即暴露目标文件,避免工作台空白等待", async () => { const workspaceId = "ws-artifact-apply-patch"; seedSession(workspaceId, "session-artifact-apply-patch"); @@ -3982,6 +4046,100 @@ describe("useAsterAgentChat 偏好持久化", () => { } }); + it("切换话题时应优先从 execution_runtime 恢复 provider/model", async () => { + const workspaceId = "ws-topic-runtime-priority"; + const topicId = "topic-runtime-priority"; + localStorage.setItem( + `agent_pref_provider_${workspaceId}`, + JSON.stringify("deepseek"), + ); + localStorage.setItem( + `agent_pref_model_${workspaceId}`, + JSON.stringify("deepseek-chat"), + ); + mockGetAgentRuntimeSession.mockResolvedValue({ + id: topicId, + messages: [], + execution_strategy: "react", + execution_runtime: { + session_id: topicId, + provider_selector: "openai", + provider_name: "openai", + model_name: "gpt-5.4-mini", + source: "session", + }, + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + + await act(async () => { + await harness.getValue().switchTopic(topicId); + }); + await flushEffects(); + + const value = harness.getValue(); + expect(value.providerType).toBe("openai"); + expect(value.model).toBe("gpt-5.4-mini"); + expect( + JSON.parse( + localStorage.getItem( + `agent_topic_model_pref_${workspaceId}_${topicId}`, + ) || "null", + ), + ).toEqual({ + providerType: "openai", + model: "gpt-5.4-mini", + }); + } finally { + harness.unmount(); + } + }); + + it("切换话题时 execution_runtime 缺失应回退本地 session preference", async () => { + const workspaceId = "ws-topic-runtime-fallback"; + const topicId = "topic-runtime-fallback"; + localStorage.setItem( + `agent_pref_provider_${workspaceId}`, + JSON.stringify("openai"), + ); + localStorage.setItem( + `agent_pref_model_${workspaceId}`, + JSON.stringify("gpt-5.4-mini"), + ); + localStorage.setItem( + `agent_topic_model_pref_${workspaceId}_${topicId}`, + JSON.stringify({ + providerType: "gemini", + model: "gemini-2.5-pro", + }), + ); + mockGetAgentRuntimeSession.mockResolvedValue({ + id: topicId, + messages: [], + execution_strategy: "react", + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + + await act(async () => { + await harness.getValue().switchTopic(topicId); + }); + await flushEffects(); + + const value = harness.getValue(); + expect(value.providerType).toBe("gemini"); + expect(value.model).toBe("gemini-2.5-pro"); + } finally { + harness.unmount(); + } + }); + it("切换话题时应保留工具调用历史并恢复 elicitation 回答文本", async () => { const workspaceId = "ws-history-hydrate"; const now = Math.floor(Date.now() / 1000); @@ -4515,7 +4673,7 @@ describe("useAsterAgentChat 兼容接口", () => { } }); - it("发送请求时应透传 provider_id,避免 custom provider 类型丢失", async () => { + it("发送请求时应透传 provider 偏好,避免 custom provider 类型丢失", async () => { const harness = mountHook("ws-provider-id"); const providerId = "custom-a32774c6-6fd0-433b-8b81-e95340e08793"; const model = "gpt-5.3-codex"; @@ -4535,12 +4693,12 @@ describe("useAsterAgentChat 兼容接口", () => { expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1); expect( mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config - ?.provider_config, - ).toMatchObject({ - provider_id: providerId, - provider_name: providerId, - model_name: model, - }); + ?.provider_preference, + ).toBe(providerId); + expect( + mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config + ?.model_preference, + ).toBe(model); } finally { harness.unmount(); } @@ -4570,11 +4728,199 @@ describe("useAsterAgentChat 兼容接口", () => { expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1); expect( mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config - ?.provider_config, - ).toMatchObject({ - provider_id: selectedProvider, + ?.provider_preference, + ).toBe(selectedProvider); + expect( + mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config + ?.model_preference, + ).toBe(selectedModel); + } finally { + harness.unmount(); + } + }); + + it("已有 executionRuntime 且 provider/model 未变化时不应重复提交偏好", async () => { + const workspaceId = "ws-runtime-model-reuse"; + const selectedProvider = "openai"; + const selectedModel = "gpt-5.4-mini"; + localStorage.setItem( + `agent_pref_provider_${workspaceId}`, + JSON.stringify(selectedProvider), + ); + localStorage.setItem( + `agent_pref_model_${workspaceId}`, + JSON.stringify(selectedModel), + ); + mockGetAgentRuntimeSession.mockResolvedValue({ + id: "topic-runtime-model-reuse", + created_at: Date.now(), + updated_at: Date.now(), + execution_strategy: "react", + execution_runtime: { + session_id: "topic-runtime-model-reuse", + provider_selector: selectedProvider, + provider_name: "openai", model_name: selectedModel, + source: "session", + }, + messages: [], + turns: [], + items: [], + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().switchTopic("topic-runtime-model-reuse"); }); + + await act(async () => { + await harness + .getValue() + .sendMessage("继续沿用当前模型处理", [], false, false, false, "react"); + }); + + expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1); + expect( + mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config + ?.provider_preference, + ).toBeUndefined(); + expect( + mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config + ?.model_preference, + ).toBeUndefined(); + } finally { + harness.unmount(); + } + }); + + it("同 provider 切模型时应只提交 model 偏好", async () => { + const workspaceId = "ws-runtime-model-switch-same-provider"; + const selectedProvider = "openai"; + const currentModel = "gpt-5.4-mini"; + const nextModel = "gpt-5.4"; + localStorage.setItem( + `agent_pref_provider_${workspaceId}`, + JSON.stringify(selectedProvider), + ); + localStorage.setItem( + `agent_pref_model_${workspaceId}`, + JSON.stringify(currentModel), + ); + mockGetAgentRuntimeSession.mockResolvedValue({ + id: "topic-runtime-model-switch-same-provider", + created_at: Date.now(), + updated_at: Date.now(), + execution_strategy: "react", + execution_runtime: { + session_id: "topic-runtime-model-switch-same-provider", + provider_selector: selectedProvider, + provider_name: "openai", + model_name: currentModel, + source: "session", + }, + messages: [], + turns: [], + items: [], + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().switchTopic("topic-runtime-model-switch-same-provider"); + }); + + act(() => { + harness.getValue().setModel(nextModel); + }); + await flushEffects(); + + await act(async () => { + await harness + .getValue() + .sendMessage("切换到同 provider 的另一个模型", [], false, false, false, "react"); + }); + + expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1); + expect( + mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config + ?.provider_preference, + ).toBeUndefined(); + expect( + mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]?.turn_config + ?.model_preference, + ).toBe(nextModel); + } finally { + harness.unmount(); + } + }); + + it("流式 turn_context / model_change 应更新 executionRuntime,并在结束后仅保留 last runtime", async () => { + const stream = captureTurnStream(); + const harness = mountHook("ws-execution-runtime"); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().triggerAIGuide("请输出结构化结果"); + }); + + await act(async () => { + stream.emit({ + type: "turn_context", + session_id: "created-session", + thread_id: "created-session", + turn_id: "turn-runtime-1", + output_schema_runtime: { + source: "turn", + strategy: "native", + providerName: "openai", + modelName: "gpt-5.4", + }, + }); + }); + + expect(harness.getValue().executionRuntime).toMatchObject({ + session_id: "created-session", + source: "turn_context", + provider_name: "openai", + model_name: "gpt-5.4", + }); + expect(harness.getValue().activeExecutionRuntime).toMatchObject({ + model_name: "gpt-5.4", + }); + + await act(async () => { + stream.emit({ + type: "model_change", + model: "gpt-5.4-mini", + mode: "responses", + }); + }); + + expect(harness.getValue().executionRuntime).toMatchObject({ + source: "model_change", + model_name: "gpt-5.4-mini", + mode: "responses", + }); + expect(harness.getValue().activeExecutionRuntime).toMatchObject({ + model_name: "gpt-5.4-mini", + }); + + await act(async () => { + stream.emit({ + type: "final_done", + }); + }); + + expect(harness.getValue().executionRuntime).toMatchObject({ + model_name: "gpt-5.4-mini", + }); + expect(harness.getValue().activeExecutionRuntime).toBeNull(); } finally { harness.unmount(); } diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index d11281a6b..0d754b04c 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -5,10 +5,10 @@ * useAsterAgentChat -> useAgentContext / useAgentSession / useAgentTools / useAgentStream */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; -import { parseStreamEvent } from "@/lib/api/agentStream"; +import { parseAgentEvent } from "@/lib/api/agentProtocol"; import { logAgentDebug } from "@/lib/agentDebug"; import { executeCodexSlashCommand, @@ -27,6 +27,7 @@ import { type SendMessageFn, type UseAsterAgentChatOptions, } from "./agentChatShared"; +import type { AsterSessionExecutionRuntime } from "@/lib/api/agentRuntime"; export type { Topic } from "./agentChatShared"; @@ -123,10 +124,12 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { setThreadItems: session.setThreadItems, setThreadTurns: session.setThreadTurns, setCurrentTurnId: session.setCurrentTurnId, + setExecutionRuntime: session.setExecutionRuntime, queuedTurns: session.queuedTurns, setQueuedTurns: session.setQueuedTurns, setPendingActions: tools.setPendingActions, refreshSessionReadModel: session.refreshSessionReadModel, + executionRuntime: session.executionRuntime, }); const setChatMessages = session.setMessages; const clearChatMessages = session.clearMessages; @@ -244,6 +247,14 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { session.sessionId && session.topics.some((topic) => topic.id === session.sessionId), ); + const activeExecutionRuntime = useMemo< + AsterSessionExecutionRuntime | null + >(() => { + const threadStatus = session.threadRead?.status; + const shouldPreferRuntime = + stream.isSending || threadStatus === "running" || threadStatus === "queued"; + return shouldPreferRuntime ? session.executionRuntime : null; + }, [session.executionRuntime, session.threadRead?.status, stream.isSending]); const currentSessionId = session.sessionId; const refreshActiveSessionDetail = session.refreshSessionDetail; @@ -369,7 +380,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { const unlisten = await runtime.listenToTeamEvents( eventName, (event) => { - const data = parseStreamEvent(event.payload); + const data = parseAgentEvent(event.payload); if (disposed || data?.type !== "subagent_status_changed") { return; } @@ -522,6 +533,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { subagentParentContext: session.subagentParentContext, queuedTurns: session.queuedTurns, threadRead: session.threadRead, + executionRuntime: session.executionRuntime, + activeExecutionRuntime, isSending: stream.isSending, sendMessage, compactSession: stream.compactSession, diff --git a/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx b/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx index 406d1d7ab..fa2570f96 100644 --- a/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx +++ b/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx @@ -36,29 +36,11 @@ function renderHook(props?: Partial) { const root = createRoot(container); const defaultProps: HookProps = { - activeTheme: "general", projectId: "project-1", sessionId: "session-1", selectedTeam: createSelectedTeam(), subagentEnabled: true, hasRealTeamGraph: false, - generateRuntimeTeam: vi.fn( - async ( - _options: Parameters>[0], - ): Promise => ({ - id: "ephemeral-1", - source: "ephemeral", - label: "临时 Team", - description: "自动生成", - roles: [ - { - id: "member-1", - label: "执行者", - summary: "执行当前任务", - }, - ], - }), - ), createRequestId: () => "request-1", now: () => 1_710_000_000_000, }; @@ -117,77 +99,28 @@ describe("useRuntimeTeamFormation", () => { vi.clearAllMocks(); }); - it("发送前满足条件时应进入 forming 并在生成完成后切到 formed", async () => { - let resolveRuntimeTeam: - | ((team: TeamDefinition) => void) - | null = null; - const generateRuntimeTeam = vi.fn( - async ( - _options: Parameters>[0], - ): Promise => - new Promise((resolve) => { - resolveRuntimeTeam = resolve; - }), - ); - const { render, getValue } = renderHook({ - generateRuntimeTeam, - }); + it("发送前满足条件且已选 Team 时,应直接生成基于蓝图的 formed 状态", async () => { + const { render, getValue } = renderHook(); await render(); const resultRef: { current: RuntimeTeamPreparationResult } = { current: null, }; - let pending: - | Promise - | null = null; await act(async () => { - pending = getValue().prepareRuntimeTeamBeforeSend({ - input: "请拆成两个子任务", - providerType: "openai", - model: "gpt-4.1", - executionStrategy: "react", + resultRef.current = await getValue().prepareRuntimeTeamBeforeSend({ + input: "请把这个需求从多个角度拆解成子任务,分别调研竞品方案和技术实现路径,然后给出综合分析报告", }); }); - expect(getValue().runtimeTeamState?.status).toBe("forming"); - expect(generateRuntimeTeam).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: "project-1", - providerType: "openai", - model: "gpt-4.1", - input: "请拆成两个子任务", - activeTheme: "general", - }), - ); - - await act(async () => { - resolveRuntimeTeam?.({ - id: "ephemeral-1", - source: "ephemeral", - label: "自动 Team", - description: "自动生成", - roles: [ - { - id: "role-1", - label: "执行者", - summary: "负责执行", - }, - ], - }); - resultRef.current = await pending; - }); - expect(resultRef.current?.status).toBe("formed"); - expect(getValue().runtimeTeamState?.status).toBe("formed"); - expect(getValue().runtimeTeamState?.label).toBe("自动 Team"); + expect(resultRef.current?.label).toBe("研究协作组"); + expect("runtimeTeamState" in getValue()).toBe(false); }); - it("发送前 Team 生成失败时应返回 failed 状态", async () => { + it("未选择 Team 时,不应再触发发送前自动编队", async () => { const { render, getValue } = renderHook({ - generateRuntimeTeam: vi.fn(async () => { - throw new Error("模型规划失败"); - }), + selectedTeam: null, }); await render(); @@ -197,118 +130,102 @@ describe("useRuntimeTeamFormation", () => { }; await act(async () => { resultRef.current = await getValue().prepareRuntimeTeamBeforeSend({ - input: "请拆解任务", - providerType: "openai", - model: "gpt-4.1", - executionStrategy: "react", + input: "请从多个维度拆解这个复杂需求,先做竞品调研,再做技术方案评估,最后输出可行性分析报告", }); }); - expect(resultRef.current?.status).toBe("failed"); - expect(resultRef.current?.errorMessage).toBe("模型规划失败"); - expect(getValue().runtimeTeamState?.status).toBe("failed"); + expect(resultRef.current).toBeNull(); + expect("runtimeTeamState" in getValue()).toBe(false); }); - it("subagent 关闭且没有真实 team 图时,应清空 runtime team 状态", async () => { + it("hook 不再把 prepared team 挂成会话级本地状态", async () => { const { render, getValue } = renderHook(); await render(); - act(() => { - void getValue().triggerRuntimeTeamFormation({ - input: "请先规划 team", - providerType: "openai", - model: "gpt-4.1", - executionStrategy: "react", + const firstResultRef: { current: RuntimeTeamPreparationResult } = { + current: null, + }; + await act(async () => { + firstResultRef.current = await getValue().prepareRuntimeTeamBeforeSend({ + input: "请先规划一个多角色协作的 team 来处理这个跨部门需求,需要调研、开发、测试三个角色分别推进各自负责的子任务", + subagentEnabled: true, }); }); - expect(getValue().runtimeTeamState?.status).toBe("forming"); - - await render({ - subagentEnabled: false, - hasRealTeamGraph: false, - }); - - expect(getValue().runtimeTeamState).toBeNull(); + expect(firstResultRef.current?.status).toBe("formed"); + expect("runtimeTeamState" in getValue()).toBe(false); }); - it("本轮不走 Team 且没有真实 team 图时,应清空上一轮 runtime team 状态", async () => { + it("本轮不走 Team 时,应只返回 null 而不保留上一轮残留状态", async () => { const { render, getValue } = renderHook(); await render(); await act(async () => { await getValue().prepareRuntimeTeamBeforeSend({ - input: "请拆分成多个角色执行", - providerType: "openai", - model: "gpt-4.1", - executionStrategy: "react", + input: "请拆分成多个角色来协作执行这个跨领域的综合分析任务,需要数据分析师、行业研究员和报告撰写员分别负责各自模块", }); }); - expect(getValue().runtimeTeamState?.status).toBe("formed"); + expect("runtimeTeamState" in getValue()).toBe(false); + const secondResultRef: { current: RuntimeTeamPreparationResult } = { + current: null, + }; await act(async () => { - await getValue().prepareRuntimeTeamBeforeSend({ + secondResultRef.current = await getValue().prepareRuntimeTeamBeforeSend({ input: "请直接润色这段文案", - providerType: "openai", - model: "gpt-4.1", - executionStrategy: "react", purpose: "content_review", }); }); - expect(getValue().runtimeTeamState).toBeNull(); + expect(secondResultRef.current).toBeNull(); + expect("runtimeTeamState" in getValue()).toBe(false); }); - it("session 切换时应重置 runtime team 状态", async () => { + it("clearRuntimeTeamState 保留为空操作兼容壳", async () => { const { render, getValue } = renderHook(); await render(); - act(() => { - void getValue().triggerRuntimeTeamFormation({ - input: "请先规划 team", - providerType: "openai", - model: "gpt-4.1", - executionStrategy: "react", - }); + await act(async () => { + getValue().clearRuntimeTeamState(); }); - expect(getValue().runtimeTeamState?.status).toBe("forming"); - - await render({ - sessionId: "session-2", - }); - - expect(getValue().runtimeTeamState).toBeNull(); + expect("runtimeTeamState" in getValue()).toBe(false); }); }); describe("shouldPrepareRuntimeTeamBeforeSend", () => { - it("仅在 subagent 开启、存在项目、非 purpose 且输入非空时返回 true", () => { + it("输入足够长且非简单生成类请求时返回 true", () => { expect( shouldPrepareRuntimeTeamBeforeSend({ subagentEnabled: true, projectId: "project-1", - input: "请拆解任务", + input: "请从多个角度拆解这个需求,调研竞品方案,然后分别给出技术实现方案和产品设计方案,最后做交叉验证", }), ).toBe(true); + }); + it("subagent 关闭时返回 false", () => { expect( shouldPrepareRuntimeTeamBeforeSend({ subagentEnabled: false, projectId: "project-1", - input: "请拆解任务", + input: "请从多个角度拆解这个需求,调研竞品方案,然后分别给出技术实现方案和产品设计方案", }), ).toBe(false); + }); + it("无项目时返回 false", () => { expect( shouldPrepareRuntimeTeamBeforeSend({ subagentEnabled: true, projectId: "", - input: "请拆解任务", + input: "请从多个角度拆解这个需求,调研竞品方案,然后分别给出技术实现方案和产品设计方案", }), ).toBe(false); + }); + it("空白输入时返回 false", () => { expect( shouldPrepareRuntimeTeamBeforeSend({ subagentEnabled: true, @@ -316,13 +233,43 @@ describe("shouldPrepareRuntimeTeamBeforeSend", () => { input: " ", }), ).toBe(false); + }); + it("带 purpose 时返回 false", () => { + expect( + shouldPrepareRuntimeTeamBeforeSend({ + subagentEnabled: true, + projectId: "project-1", + input: "请从多个角度拆解这个需求,调研竞品方案,然后分别给出技术实现方案和产品设计方案", + purpose: "content_review", + }), + ).toBe(false); + }); + + it("短输入时返回 false(少于 40 字符)", () => { expect( shouldPrepareRuntimeTeamBeforeSend({ subagentEnabled: true, projectId: "project-1", input: "请拆解任务", - purpose: "content_review", + }), + ).toBe(false); + }); + + it("简单内容生成类请求返回 false", () => { + expect( + shouldPrepareRuntimeTeamBeforeSend({ + subagentEnabled: true, + projectId: "project-1", + input: "生成一份关于人工智能在医疗领域应用的详细报告,需要包含市场分析、技术趋势、政策法规、典型案例和未来展望五个部分", + }), + ).toBe(false); + + expect( + shouldPrepareRuntimeTeamBeforeSend({ + subagentEnabled: true, + projectId: "project-1", + input: "帮我写一篇关于可持续发展目标的深度分析报告,要求涵盖经济、环境和社会三个维度的详细分析以及各国实践案例", }), ).toBe(false); }); diff --git a/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts b/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts index 48815ca88..7352481df 100644 --- a/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts +++ b/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts @@ -1,43 +1,29 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; import type { HandleSendOptions } from "./handleSendTypes"; import { createRuntimeFormationStateFromTeam, type TeamWorkspaceRuntimeFormationState, } from "../teamWorkspaceRuntime"; -import { generateEphemeralTeamWithModel } from "../utils/teamAutoGeneration"; import type { TeamDefinition } from "../utils/teamDefinitions"; -interface TriggerRuntimeTeamFormationParams { +interface PrepareRuntimeTeamBeforeSendParams { input: string; - providerType: string; - model: string; - executionStrategy?: "react" | "code_orchestrated" | "auto"; -} - -interface PrepareRuntimeTeamBeforeSendParams - extends TriggerRuntimeTeamFormationParams { purpose?: HandleSendOptions["purpose"]; subagentEnabled?: boolean; } interface UseRuntimeTeamFormationOptions { - activeTheme: string; projectId?: string | null; sessionId?: string | null; selectedTeam?: TeamDefinition | null; subagentEnabled: boolean; hasRealTeamGraph: boolean; - generateRuntimeTeam?: typeof generateEphemeralTeamWithModel; createRequestId?: () => string; now?: () => number; } export interface UseRuntimeTeamFormationResult { - runtimeTeamState: TeamWorkspaceRuntimeFormationState | null; clearRuntimeTeamState: () => void; - triggerRuntimeTeamFormation: ( - params: TriggerRuntimeTeamFormationParams, - ) => Promise; prepareRuntimeTeamBeforeSend: ( params: PrepareRuntimeTeamBeforeSendParams, ) => Promise; @@ -53,114 +39,61 @@ export function shouldPrepareRuntimeTeamBeforeSend(params: { input: string; purpose?: HandleSendOptions["purpose"]; }): boolean { - return ( - params.subagentEnabled && - !params.purpose && - Boolean(params.projectId) && - params.input.trim().length > 0 - ); + if (!params.subagentEnabled || params.purpose || !params.projectId) { + return false; + } + + const trimmed = params.input.trim(); + if (trimmed.length === 0) { + return false; + } + + // 短输入(少于 40 字符)大概率是简单请求,不需要团队协作 + if (trimmed.length < 40) { + return false; + } + + // 检测是否为简单内容生成类请求(生成提纲、写报告等),这类任务单 agent 即可完成 + const simpleGenerationPatterns = [ + /^(请|帮我|帮忙)?(生成|写|起草|草拟|撰写|创建|制作|输出)/, + /^(generate|write|create|draft|make)\b/i, + ]; + if (simpleGenerationPatterns.some((pattern) => pattern.test(trimmed))) { + return false; + } + + return true; } export function useRuntimeTeamFormation({ - activeTheme, projectId, - sessionId, selectedTeam, subagentEnabled, - hasRealTeamGraph, - generateRuntimeTeam = generateEphemeralTeamWithModel, createRequestId = defaultCreateRequestId, now = () => Date.now(), }: UseRuntimeTeamFormationOptions): UseRuntimeTeamFormationResult { - const [runtimeTeamState, setRuntimeTeamState] = - useState(null); - const runtimeTeamRequestIdRef = useRef(null); - const clearRuntimeTeamState = useCallback(() => { - runtimeTeamRequestIdRef.current = null; - setRuntimeTeamState(null); + return; }, []); - const triggerRuntimeTeamFormation = useCallback( - async ({ - input, - providerType, - model, - executionStrategy, - }: TriggerRuntimeTeamFormationParams) => { - const normalizedInput = input.trim(); - if (!projectId || !normalizedInput) { - return null; - } + const formRuntimeTeamState = useCallback(() => { + if (!selectedTeam) { + return null; + } - const requestId = createRequestId(); - runtimeTeamRequestIdRef.current = requestId; - const formingState = createRuntimeFormationStateFromTeam({ - requestId, - status: "forming", - blueprintTeam: selectedTeam ?? null, - updatedAt: now(), - }); - setRuntimeTeamState(formingState); - - try { - const runtimeTeam = await generateRuntimeTeam({ - workspaceId: projectId, - providerType, - model, - executionStrategy, - activeTheme, - input: normalizedInput, - blueprintTeam: selectedTeam ?? null, - }); - - if (runtimeTeamRequestIdRef.current !== requestId) { - return null; - } - - const formedState = createRuntimeFormationStateFromTeam({ - requestId, - status: "formed", - runtimeTeam, - blueprintTeam: selectedTeam ?? null, - updatedAt: now(), - }); - setRuntimeTeamState(formedState); - return formedState; - } catch (error) { - if (runtimeTeamRequestIdRef.current !== requestId) { - return null; - } - - const errorMessage = - error instanceof Error ? error.message : "Team 生成失败"; - const failedState = createRuntimeFormationStateFromTeam({ - requestId, - status: "failed", - blueprintTeam: selectedTeam ?? null, - errorMessage, - updatedAt: now(), - }); - setRuntimeTeamState(failedState); - return failedState; - } - }, - [ - activeTheme, - createRequestId, - generateRuntimeTeam, - now, - projectId, - selectedTeam, - ], - ); + const formedState = createRuntimeFormationStateFromTeam({ + requestId: createRequestId(), + status: "formed", + runtimeTeam: selectedTeam, + blueprintTeam: selectedTeam, + updatedAt: now(), + }); + return formedState; + }, [createRequestId, now, selectedTeam]); const prepareRuntimeTeamBeforeSend = useCallback( ({ input, - providerType, - model, - executionStrategy, purpose, subagentEnabled: subagentEnabledOverride, }: PrepareRuntimeTeamBeforeSendParams) => { @@ -172,45 +105,19 @@ export function useRuntimeTeamFormation({ projectId, input, purpose, - }) + }) && + selectedTeam ) { - return triggerRuntimeTeamFormation({ - input, - providerType, - model, - executionStrategy, - }); - } - - if (!hasRealTeamGraph) { - clearRuntimeTeamState(); + return Promise.resolve(formRuntimeTeamState()); } return Promise.resolve(null); }, - [ - clearRuntimeTeamState, - hasRealTeamGraph, - projectId, - subagentEnabled, - triggerRuntimeTeamFormation, - ], + [formRuntimeTeamState, projectId, selectedTeam, subagentEnabled], ); - useEffect(() => { - clearRuntimeTeamState(); - }, [clearRuntimeTeamState, sessionId]); - - useEffect(() => { - if (!subagentEnabled && !hasRealTeamGraph) { - clearRuntimeTeamState(); - } - }, [clearRuntimeTeamState, hasRealTeamGraph, subagentEnabled]); - return { - runtimeTeamState, clearRuntimeTeamState, - triggerRuntimeTeamFormation, prepareRuntimeTeamBeforeSend, }; } diff --git a/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.test.tsx b/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.test.tsx index e2a89018d..da19bb902 100644 --- a/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.test.tsx +++ b/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.test.tsx @@ -6,20 +6,20 @@ import { useTeamWorkspaceRuntime } from "./useTeamWorkspaceRuntime"; const { mockSafeListen, - mockParseStreamEvent, + mockParseAgentEvent, } = vi.hoisted(() => ({ mockSafeListen: vi.fn(), - mockParseStreamEvent: vi.fn((payload: unknown) => payload), + mockParseAgentEvent: vi.fn((payload: unknown) => payload), })); -vi.mock("@/lib/api/agentStream", async () => { +vi.mock("@/lib/api/agentProtocol", async () => { const actual = - await vi.importActual( - "@/lib/api/agentStream", + await vi.importActual( + "@/lib/api/agentProtocol", ); return { ...actual, - parseStreamEvent: mockParseStreamEvent, + parseAgentEvent: mockParseAgentEvent, }; }); @@ -88,7 +88,7 @@ describe("useTeamWorkspaceRuntime", () => { ).IS_REACT_ACT_ENVIRONMENT = true; vi.useFakeTimers(); latestValue = null; - mockParseStreamEvent.mockImplementation((payload: unknown) => payload); + mockParseAgentEvent.mockImplementation((payload: unknown) => payload); mockSafeListen.mockImplementation( async (_eventName: string, _handler: (event: { payload: unknown }) => void) => () => {}, diff --git a/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.ts b/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.ts index bb1b77103..6c0230047 100644 --- a/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.ts +++ b/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - parseStreamEvent, - type StreamEvent, - type StreamRuntimeStatusPayload, - type ToolExecutionResult, -} from "@/lib/api/agentStream"; + type AgentRuntimeStatusPayload, + type AgentToolExecutionResult as ToolExecutionResult, + parseAgentEvent, + type AgentEvent, +} from "@/lib/api/agentProtocol"; import type { AsterSubagentParentContext, AsterSubagentSessionInfo, @@ -147,7 +147,7 @@ function buildThinkingDraftEntry(sessionId: string, draft?: string) { function buildRuntimeStatusEntry( sessionId: string, - status: StreamRuntimeStatusPayload, + status: AgentRuntimeStatusPayload, ) { const waiting = status.metadata?.team_phase === "queued" || @@ -164,7 +164,7 @@ function buildRuntimeStatusEntry( }); } -function buildRuntimeStatusMetadataPatch(status: StreamRuntimeStatusPayload) { +function buildRuntimeStatusMetadataPatch(status: AgentRuntimeStatusPayload) { return { teamPhase: status.metadata?.team_phase, teamParallelBudget: status.metadata?.team_parallel_budget, @@ -408,7 +408,7 @@ function resolveFinalRuntimeStatus(params: { function projectRuntimeStreamEvent(params: { sessionId: string; session: TeamWorkspaceRuntimeSessionSnapshot; - event: StreamEvent; + event: AgentEvent; currentRuntime?: TeamWorkspaceLiveRuntimeState; streamState?: SessionLiveStreamState; toolNameById?: Record; @@ -926,7 +926,7 @@ export function useTeamWorkspaceRuntime( const subscribe = async () => { for (const eventName of eventNames) { const unlisten = await safeListen(eventName, (event) => { - const data = parseStreamEvent(event.payload); + const data = parseAgentEvent(event.payload); if (disposed || data?.type !== "subagent_status_changed") { return; } @@ -1039,7 +1039,7 @@ export function useTeamWorkspaceRuntime( for (const sessionId of sessionIds) { const eventName = `agent_subagent_stream:${sessionId}`; const unlisten = await safeListen(eventName, (event) => { - const data = parseStreamEvent(event.payload); + const data = parseAgentEvent(event.payload); if (disposed || !data) { return; } diff --git a/src/components/agent/chat/hooks/useThemeContextWorkspace.test.tsx b/src/components/agent/chat/hooks/useThemeContextWorkspace.test.tsx index 3d21735e0..ec7d459ef 100644 --- a/src/components/agent/chat/hooks/useThemeContextWorkspace.test.tsx +++ b/src/components/agent/chat/hooks/useThemeContextWorkspace.test.tsx @@ -412,4 +412,55 @@ describe("useThemeContextWorkspace", () => { "social-posts/demo.publish-pack.json", ]); }); + + it("应递归提取嵌套协议对象中的产物路径", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + + const messages = [ + { + id: "msg-4", + role: "assistant", + content: "", + timestamp: "2026-03-05T10:50:00.000Z", + toolCalls: [ + { + id: "tool-4", + name: "typesetting", + status: "completed", + arguments: JSON.stringify({ + payload: { + filePath: "social-posts/draft.md", + }, + }), + result: { + success: true, + output: JSON.stringify({ + result: { + absolute_path: "/tmp/social-posts/final.md", + }, + }), + }, + startTime: "2026-03-05T10:50:00.000Z", + endTime: "2026-03-05T10:50:01.000Z", + }, + ], + }, + ] as unknown as Message[]; + + let snapshot: ThemeContextWorkspaceState | null = null; + mountProbe({ + projectId: "project-artifact-nested-path", + activeTheme: "social-media", + messages, + onSnapshot: (value) => { + snapshot = value; + }, + }); + await flushEffects(12); + + expect(snapshot!.activityLogs[0]?.artifactPaths).toEqual([ + "social-posts/draft.md", + "/tmp/social-posts/final.md", + ]); + }); }); diff --git a/src/components/agent/chat/hooks/useThemeContextWorkspace.ts b/src/components/agent/chat/hooks/useThemeContextWorkspace.ts index e9d351bef..83c0bf5a5 100644 --- a/src/components/agent/chat/hooks/useThemeContextWorkspace.ts +++ b/src/components/agent/chat/hooks/useThemeContextWorkspace.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getContent, listContents, type ContentListItem } from "@/lib/api/project"; +import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol"; import { normalizeProjectId } from "../utils/topicProjectResolution"; import { useMaterials } from "@/hooks/useMaterials"; import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt"; @@ -270,67 +271,6 @@ function formatDurationLabel( )}s`; } -const TOOL_ARTIFACT_KEYWORDS = [ - "path", - "file", - "filename", - "artifact", - "output", - "target", - "destination", -]; - -function isLikelyArtifactPath(value: string): boolean { - const normalized = value.trim(); - if (!normalized || normalized.length > 260) { - return false; - } - if (normalized.includes("\n")) { - return false; - } - const lower = normalized.toLowerCase(); - if ( - lower.startsWith("http://") || - lower.startsWith("https://") || - lower.startsWith("data:") - ) { - return false; - } - return /[\\/]/.test(normalized) || /\.[a-z0-9]{1,10}$/i.test(normalized); -} - -function collectArtifactPathFromValue(value: unknown, bucket: Set): void { - if (typeof value === "string") { - const candidate = value.trim(); - if (isLikelyArtifactPath(candidate)) { - bucket.add(candidate); - } - return; - } - if (Array.isArray(value)) { - value.forEach((item) => collectArtifactPathFromValue(item, bucket)); - return; - } - if (!value || typeof value !== "object") { - return; - } - - const record = value as Record; - Object.entries(record).forEach(([key, nestedValue]) => { - const lowerKey = key.toLowerCase(); - const shouldCollectDirectly = TOOL_ARTIFACT_KEYWORDS.some((keyword) => - lowerKey.includes(keyword), - ); - if (shouldCollectDirectly) { - collectArtifactPathFromValue(nestedValue, bucket); - return; - } - if (nestedValue && typeof nestedValue === "object") { - collectArtifactPathFromValue(nestedValue, bucket); - } - }); -} - function tryParseJson(raw?: string): unknown { if (!raw || typeof raw !== "string") { return null; @@ -354,11 +294,11 @@ function extractToolCallArtifactPaths( const parsedArgs = tryParseJson(argumentsRaw); const parsedOutput = tryParseJson(outputRaw); - if (parsedArgs) { - collectArtifactPathFromValue(parsedArgs, bucket); + for (const path of extractArtifactProtocolPathsFromValue(parsedArgs)) { + bucket.add(path); } - if (parsedOutput) { - collectArtifactPathFromValue(parsedOutput, bucket); + for (const path of extractArtifactProtocolPathsFromValue(parsedOutput)) { + bucket.add(path); } return Array.from(bucket); diff --git a/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.test.tsx b/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.test.tsx index b4f92bc63..9b835f718 100644 --- a/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.test.tsx +++ b/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.test.tsx @@ -127,4 +127,84 @@ describe("useThemeScopedChatToolPreferences", () => { harness.unmount(); } }); + + it("收到 runtime 快照时应优先回灌工具偏好", () => { + saveChatToolPreferences( + { webSearch: false, thinking: false, task: true, subagent: false }, + "general", + ); + + const harness = mountHook("general"); + + try { + act(() => { + harness.getValue().syncChatToolPreferencesSource("general", { + webSearch: true, + thinking: true, + task: false, + subagent: true, + }); + }); + + expect(harness.getValue().chatToolPreferences).toEqual({ + webSearch: true, + thinking: true, + task: false, + subagent: true, + }); + expect( + JSON.parse( + localStorage.getItem("lime.chat.tool_preferences.general.v3") || + "null", + ), + ).toEqual({ + webSearch: true, + thinking: true, + task: false, + subagent: true, + }); + } finally { + harness.unmount(); + } + }); + + it("相同 runtime 快照不应覆盖用户手动切换的偏好", () => { + const harness = mountHook("general"); + + try { + act(() => { + harness.getValue().syncChatToolPreferencesSource("general", { + webSearch: true, + thinking: false, + task: false, + subagent: false, + }); + }); + act(() => { + harness.getValue().setChatToolPreferences({ + webSearch: false, + thinking: true, + task: true, + subagent: true, + }); + }); + act(() => { + harness.getValue().syncChatToolPreferencesSource("general", { + webSearch: true, + thinking: false, + task: false, + subagent: false, + }); + }); + + expect(harness.getValue().chatToolPreferences).toEqual({ + webSearch: false, + thinking: true, + task: true, + subagent: true, + }); + } finally { + harness.unmount(); + } + }); }); diff --git a/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.ts b/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.ts index 9964995f3..49a56a288 100644 --- a/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.ts +++ b/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.ts @@ -1,35 +1,60 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { loadChatToolPreferences, saveChatToolPreferences, type ChatToolPreferences, } from "../utils/chatToolPreferences"; +function serializePreferenceSource( + preferences?: ChatToolPreferences | null, +): string { + if (!preferences) { + return "storage"; + } + + return JSON.stringify([ + preferences.webSearch, + preferences.thinking, + preferences.task, + preferences.subagent, + ]); +} + export function useThemeScopedChatToolPreferences(activeTheme: string) { const [chatToolPreferences, setChatToolPreferences] = useState(() => loadChatToolPreferences(activeTheme)); - const [chatToolPreferencesTheme, setChatToolPreferencesTheme] = - useState(activeTheme); + const lastHydratedSourceRef = useRef( + `${activeTheme}:${serializePreferenceSource(null)}`, + ); + + const syncChatToolPreferencesSource = useCallback( + (theme: string, runtimePreferences?: ChatToolPreferences | null) => { + const nextSourceKey = `${theme}:${serializePreferenceSource( + runtimePreferences, + )}`; + if (lastHydratedSourceRef.current === nextSourceKey) { + return; + } + + setChatToolPreferences( + runtimePreferences ?? loadChatToolPreferences(theme), + ); + lastHydratedSourceRef.current = nextSourceKey; + }, + [], + ); useEffect(() => { - if (chatToolPreferencesTheme === activeTheme) { - return; - } - - setChatToolPreferences(loadChatToolPreferences(activeTheme)); - setChatToolPreferencesTheme(activeTheme); - }, [activeTheme, chatToolPreferencesTheme]); + syncChatToolPreferencesSource(activeTheme); + }, [activeTheme, syncChatToolPreferencesSource]); useEffect(() => { - if (chatToolPreferencesTheme !== activeTheme) { - return; - } - saveChatToolPreferences(chatToolPreferences, activeTheme); - }, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]); + }, [activeTheme, chatToolPreferences]); return { chatToolPreferences, setChatToolPreferences, + syncChatToolPreferencesSource, }; } diff --git a/src/components/agent/chat/hooks/useTrayModelShortcuts.ts b/src/components/agent/chat/hooks/useTrayModelShortcuts.ts index d1d74a8e9..c49f5ab49 100644 --- a/src/components/agent/chat/hooks/useTrayModelShortcuts.ts +++ b/src/components/agent/chat/hooks/useTrayModelShortcuts.ts @@ -20,7 +20,7 @@ import { loadConfiguredProviders, type ConfiguredProvider, } from "@/hooks/useConfiguredProviders"; -import { buildProviderModelsFromRegistry } from "@/hooks/useProviderModels"; +import { buildProviderModelsFromRegistry } from "@/lib/model/providerModelsCatalog"; import { filterModelsByTheme } from "../utils/modelThemePolicy"; import { getProviderModelCompatibilityIssue } from "../utils/providerModelCompatibility"; diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 00e9e5e81..ef832ba90 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -13,7 +13,6 @@ const { mockUseTopicBranchBoard, mockUseTeamWorkspaceRuntime, mockUseCompatSubagentRuntime, - mockGenerateEphemeralTeamWithModel, mockGetProject, mockGetDefaultProject, mockGetOrCreateDefaultProject, @@ -58,7 +57,6 @@ const { mockUseTopicBranchBoard: vi.fn(), mockUseTeamWorkspaceRuntime: vi.fn(), mockUseCompatSubagentRuntime: vi.fn(), - mockGenerateEphemeralTeamWithModel: vi.fn(), mockGetProject: vi.fn(), mockGetDefaultProject: vi.fn(), mockGetOrCreateDefaultProject: vi.fn(), @@ -173,10 +171,6 @@ vi.mock("./hooks", () => ({ useCompatSubagentRuntime: mockUseCompatSubagentRuntime, })); -vi.mock("./utils/teamAutoGeneration", () => ({ - generateEphemeralTeamWithModel: mockGenerateEphemeralTeamWithModel, -})); - vi.mock("./hooks/useSessionFiles", () => ({ useSessionFiles: () => ({ saveFile: vi.fn(async () => undefined), @@ -429,14 +423,14 @@ vi.mock("./components/TeamWorkspaceDock", () => ({ withBottomOverlay, shellVisible, childSubagentSessions, - runtimeTeamState, + teamDispatchPreviewState, onActivateWorkbench, }: { placement?: "floating" | "inline"; withBottomOverlay?: boolean; shellVisible?: boolean; childSubagentSessions?: Array<{ id: string }>; - runtimeTeamState?: { + teamDispatchPreviewState?: { status?: string; members?: Array<{ id: string }>; } | null; @@ -448,8 +442,8 @@ vi.mock("./components/TeamWorkspaceDock", () => ({ data-with-bottom-overlay={withBottomOverlay ? "true" : "false"} data-shell-visible={shellVisible ? "true" : "false"} data-child-count={String(childSubagentSessions?.length ?? 0)} - data-runtime-status={runtimeTeamState?.status || ""} - data-runtime-member-count={String(runtimeTeamState?.members?.length ?? 0)} + data-runtime-status={teamDispatchPreviewState?.status || ""} + data-runtime-member-count={String(teamDispatchPreviewState?.members?.length ?? 0)} > {onActivateWorkbench ? (
+ ) : null + } items={items} embedded loading={loading} + onSecondaryStatusAction={ + onOpenAutomationJob + ? (item) => { + const skill = skills.find((candidate) => candidate.id === item.key); + if (skill?.automationStatus) { + void onOpenAutomationJob(skill); + } + } + : undefined + } onAction={(item) => { const skill = skills.find((candidate) => candidate.id === item.key); if (skill) { diff --git a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx index 9cefa3c43..4a44a2301 100644 --- a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx +++ b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx @@ -6,18 +6,23 @@ import { ServiceSkillLaunchDialog } from "./ServiceSkillLaunchDialog"; import type { ServiceSkillHomeItem } from "./types"; vi.mock("@/components/ui/dialog", () => ({ - Dialog: ({ - open, - children, - }: { - open: boolean; - children: React.ReactNode; - }) => (open ?
{children}
: null), - DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, - DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, - DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, - DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), })); vi.mock("@/components/ui/button", () => ({ @@ -32,7 +37,9 @@ vi.mock("@/components/ui/button", () => ({ })); vi.mock("@/components/ui/input", () => ({ - Input: (props: React.InputHTMLAttributes) => , + Input: (props: React.InputHTMLAttributes) => ( + + ), })); vi.mock("@/components/ui/textarea", () => ({ @@ -45,7 +52,9 @@ vi.mock("@/components/ui/label", () => ({ Label: ({ children, ...props - }: React.LabelHTMLAttributes) => , + }: React.LabelHTMLAttributes) => ( + + ), })); const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; @@ -87,6 +96,7 @@ const MOCK_SKILL: ServiceSkillHomeItem = { runnerTone: "emerald", runnerDescription: "客户端起步版可直接进入工作区执行。", actionLabel: "填写参数", + automationStatus: null, }; beforeEach(() => { @@ -160,10 +170,9 @@ function setFormValue( const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); descriptor?.set?.call(element, value); element.dispatchEvent( - new Event( - element instanceof HTMLSelectElement ? "change" : "input", - { bubbles: true }, - ), + new Event(element instanceof HTMLSelectElement ? "change" : "input", { + bubbles: true, + }), ); } @@ -262,4 +271,73 @@ describe("ServiceSkillLaunchDialog", () => { }, ); }); + + it("云端托管技能应显示云端运行文案且不暴露本地自动化入口", async () => { + const onLaunch = vi.fn(); + const onCreateAutomation = vi.fn(); + + renderDialog({ + skill: { + ...MOCK_SKILL, + id: "cloud-video-dubbing", + title: "云端视频配音", + executionLocation: "cloud_required", + defaultExecutorBinding: "cloud_scene", + runnerLabel: "云端托管执行", + runnerTone: "slate", + runnerDescription: "提交到 OEM 云端执行,结果由服务端异步返回。", + actionLabel: "提交云端", + }, + onLaunch, + onCreateAutomation, + }); + + await flushEffects(); + + expect(document.body.textContent).toContain("提交云端运行"); + expect(document.body.textContent).toContain( + "不会进入本地工作区,也不会创建本地自动化草稿", + ); + expect( + document.body.querySelector( + '[data-testid="service-skill-enter-workspace"]', + ), + ).toBeNull(); + expect( + document.body.querySelector( + '[data-testid="service-skill-create-automation"]', + ), + ).toBeNull(); + + const referenceInput = document.body.querySelector( + '[data-testid="service-skill-slot-reference_video"]', + ) as HTMLInputElement | null; + const launchButton = document.body.querySelector( + '[data-testid="service-skill-launch"]', + ) as HTMLButtonElement | null; + + act(() => { + if (referenceInput) { + setFormValue(referenceInput, "https://example.com/cloud-video"); + } + }); + + await flushEffects(); + + act(() => { + launchButton?.click(); + }); + + expect(onLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + id: "cloud-video-dubbing", + executionLocation: "cloud_required", + }), + { + reference_video: "https://example.com/cloud-video", + platform: "douyin", + }, + ); + expect(onCreateAutomation).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx index 0d556dac9..bfd28c1c9 100644 --- a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx +++ b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx @@ -72,9 +72,7 @@ function renderFieldControl(params: { "ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2", )} > - + {(slot.options ?? []).map((option) => (
@@ -193,7 +203,8 @@ export function ServiceSkillLaunchDialog({ {!validation.valid ? (
- 还缺少必填参数:{validation.missing.map((slot) => slot.label).join("、")} + 还缺少必填参数: + {validation.missing.map((slot) => slot.label).join("、")}
) : null} @@ -234,7 +245,7 @@ export function ServiceSkillLaunchDialog({ void onLaunch(skill, slotValues); }} > - {canCreateAutomation ? "创建任务并进入工作区" : "进入工作区"} + {primaryActionLabel} diff --git a/src/components/agent/chat/service-skills/automationLinkStorage.test.ts b/src/components/agent/chat/service-skills/automationLinkStorage.test.ts new file mode 100644 index 000000000..231bcd035 --- /dev/null +++ b/src/components/agent/chat/service-skills/automationLinkStorage.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AutomationJobRecord } from "@/lib/api/automation"; +import { + buildServiceSkillAutomationStatusMap, + listServiceSkillAutomationLinks, + recordServiceSkillAutomationLink, + subscribeServiceSkillAutomationLinksChanged, +} from "./automationLinkStorage"; + +function buildJob( + overrides: Partial = {}, +): AutomationJobRecord { + return { + id: "automation-job-1", + name: "每日趋势摘要|定时执行", + description: "服务型技能创建的本地任务", + enabled: true, + workspace_id: "project-1", + execution_mode: "skill", + schedule: { + kind: "cron", + expr: "0 9 * * *", + tz: "Asia/Shanghai", + }, + payload: { + kind: "agent_turn", + prompt: "prompt", + system_prompt: null, + web_search: false, + }, + delivery: { + mode: "none", + channel: null, + target: null, + best_effort: true, + output_schema: "text", + output_format: "text", + }, + timeout_secs: null, + max_retries: 2, + next_run_at: "2026-03-24T09:00:00.000Z", + last_status: "success", + last_error: null, + last_run_at: "2026-03-23T09:00:00.000Z", + last_finished_at: "2026-03-23T09:00:10.000Z", + running_started_at: null, + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: null, + last_delivery: null, + created_at: "2026-03-22T09:00:00.000Z", + updated_at: "2026-03-23T09:00:10.000Z", + ...overrides, + }; +} + +describe("automationLinkStorage", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("记录关联后应能读回最后一次 skill -> job 绑定", () => { + recordServiceSkillAutomationLink({ + skillId: "daily-trend-briefing", + jobId: "automation-job-1", + jobName: "每日趋势摘要|定时执行", + linkedAt: 1, + }); + recordServiceSkillAutomationLink({ + skillId: "daily-trend-briefing", + jobId: "automation-job-2", + jobName: "每日趋势摘要|持续执行", + linkedAt: 2, + }); + + expect(listServiceSkillAutomationLinks()).toEqual([ + { + skillId: "daily-trend-briefing", + jobId: "automation-job-2", + jobName: "每日趋势摘要|持续执行", + linkedAt: 2, + }, + ]); + }); + + it("变更关联时应广播事件", () => { + const callback = vi.fn(); + const unsubscribe = subscribeServiceSkillAutomationLinksChanged(callback); + + try { + recordServiceSkillAutomationLink({ + skillId: "daily-trend-briefing", + jobId: "automation-job-1", + jobName: "每日趋势摘要|定时执行", + }); + + expect(callback).toHaveBeenCalledTimes(1); + } finally { + unsubscribe(); + } + }); + + it("应把关联 job 汇总成首页可显示的状态摘要", () => { + recordServiceSkillAutomationLink({ + skillId: "daily-trend-briefing", + jobId: "automation-job-1", + jobName: "每日趋势摘要|定时执行", + }); + + const statusMap = buildServiceSkillAutomationStatusMap([buildJob()]); + + expect(statusMap["daily-trend-briefing"]).toEqual( + expect.objectContaining({ + jobId: "automation-job-1", + statusLabel: "成功", + tone: "emerald", + }), + ); + expect(statusMap["daily-trend-briefing"]?.detail).toContain("下次"); + }); +}); diff --git a/src/components/agent/chat/service-skills/automationLinkStorage.ts b/src/components/agent/chat/service-skills/automationLinkStorage.ts new file mode 100644 index 000000000..13af3b9a7 --- /dev/null +++ b/src/components/agent/chat/service-skills/automationLinkStorage.ts @@ -0,0 +1,296 @@ +import type { AutomationJobRecord } from "@/lib/api/automation"; +import type { + ServiceSkillAutomationLinkRecord, + ServiceSkillAutomationStatus, +} from "./types"; + +const SERVICE_SKILL_AUTOMATION_LINKS_STORAGE_KEY = + "lime:service-skill-automation-links:v1"; +export const SERVICE_SKILL_AUTOMATION_LINKS_CHANGED_EVENT = + "lime:service-skill-automation-links-changed"; + +function hasWindow(): boolean { + return typeof window !== "undefined"; +} + +function isValidAutomationLinkRecord( + value: unknown, +): value is ServiceSkillAutomationLinkRecord { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Partial; + return ( + typeof record.skillId === "string" && + record.skillId.length > 0 && + typeof record.jobId === "string" && + record.jobId.length > 0 && + typeof record.jobName === "string" && + record.jobName.length > 0 && + typeof record.linkedAt === "number" && + Number.isFinite(record.linkedAt) + ); +} + +function emitAutomationLinksChanged(): void { + if (!hasWindow()) { + return; + } + + window.dispatchEvent( + new CustomEvent(SERVICE_SKILL_AUTOMATION_LINKS_CHANGED_EVENT, { + detail: { + timestamp: Date.now(), + }, + }), + ); +} + +function persistAutomationLinks( + records: ServiceSkillAutomationLinkRecord[], +): ServiceSkillAutomationLinkRecord[] { + if (!hasWindow()) { + return records; + } + + try { + window.localStorage.setItem( + SERVICE_SKILL_AUTOMATION_LINKS_STORAGE_KEY, + JSON.stringify(records), + ); + } catch { + // ignore write errors + } + + emitAutomationLinksChanged(); + return records; +} + +function formatAutomationTime(value?: string | null): string | null { + if (!value) { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); +} + +function resolveStatusLabel(status?: string | null): string { + switch (status) { + case "queued": + return "排队中"; + case "success": + return "成功"; + case "running": + return "运行中"; + case "waiting_for_human": + return "等待人工处理"; + case "human_controlling": + return "人工接管中"; + case "agent_resuming": + return "恢复给 Agent"; + case "error": + return "失败"; + case "timeout": + return "超时"; + default: + return "待执行"; + } +} + +function resolveStatusTone( + status?: string | null, +): ServiceSkillAutomationStatus["tone"] { + if (status === "success") { + return "emerald"; + } + if ( + status === "queued" || + status === "running" || + status === "agent_resuming" + ) { + return "sky"; + } + if ( + status === "waiting_for_human" || + status === "human_controlling" || + status === "timeout" + ) { + return "amber"; + } + if (status === "error") { + return "amber"; + } + return "slate"; +} + +function resolveStatusDetail(job: AutomationJobRecord): string | null { + if (job.running_started_at) { + const startedAt = formatAutomationTime(job.running_started_at); + return startedAt ? `开始于 ${startedAt}` : null; + } + + if (job.auto_disabled_until) { + const resumeAt = formatAutomationTime(job.auto_disabled_until); + return resumeAt ? `冷却至 ${resumeAt}` : "当前处于冷却期"; + } + + if (job.last_status === "success") { + const nextRunAt = formatAutomationTime(job.next_run_at); + const finishedAt = formatAutomationTime(job.last_finished_at); + if (nextRunAt) { + return `下次 ${nextRunAt}`; + } + if (finishedAt) { + return `完成于 ${finishedAt}`; + } + return null; + } + + if (job.last_status === "error" || job.last_status === "timeout") { + const finishedAt = + formatAutomationTime(job.last_finished_at) ?? + formatAutomationTime(job.updated_at); + if (finishedAt) { + return `最近一次 ${finishedAt}`; + } + return null; + } + + if (job.next_run_at) { + const nextRunAt = formatAutomationTime(job.next_run_at); + return nextRunAt ? `下次 ${nextRunAt}` : null; + } + + if (!job.enabled) { + return "任务已停用"; + } + + return null; +} + +export function listServiceSkillAutomationLinks(): ServiceSkillAutomationLinkRecord[] { + if (!hasWindow()) { + return []; + } + + try { + const raw = window.localStorage.getItem( + SERVICE_SKILL_AUTOMATION_LINKS_STORAGE_KEY, + ); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter(isValidAutomationLinkRecord) + .sort((left, right) => right.linkedAt - left.linkedAt); + } catch { + return []; + } +} + +export function recordServiceSkillAutomationLink( + input: Omit & { + linkedAt?: number; + }, +): ServiceSkillAutomationLinkRecord[] { + const nextRecord: ServiceSkillAutomationLinkRecord = { + skillId: input.skillId, + jobId: input.jobId, + jobName: input.jobName, + linkedAt: input.linkedAt ?? Date.now(), + }; + + const nextRecords = [ + nextRecord, + ...listServiceSkillAutomationLinks().filter( + (record) => record.skillId !== nextRecord.skillId, + ), + ]; + + return persistAutomationLinks(nextRecords); +} + +export function subscribeServiceSkillAutomationLinksChanged( + callback: () => void, +): () => void { + if (!hasWindow()) { + return () => undefined; + } + + const customEventHandler = () => { + callback(); + }; + + const storageHandler = (event: StorageEvent) => { + if (event.key !== SERVICE_SKILL_AUTOMATION_LINKS_STORAGE_KEY) { + return; + } + callback(); + }; + + window.addEventListener( + SERVICE_SKILL_AUTOMATION_LINKS_CHANGED_EVENT, + customEventHandler, + ); + window.addEventListener("storage", storageHandler); + + return () => { + window.removeEventListener( + SERVICE_SKILL_AUTOMATION_LINKS_CHANGED_EVENT, + customEventHandler, + ); + window.removeEventListener("storage", storageHandler); + }; +} + +export function buildServiceSkillAutomationStatusMap( + jobs: AutomationJobRecord[], +): Record { + const jobsById = new Map(jobs.map((job) => [job.id, job])); + + return listServiceSkillAutomationLinks().reduce< + Record + >((result, link) => { + const job = jobsById.get(link.jobId); + if (!job) { + return result; + } + + const statusLabel = job.auto_disabled_until + ? "冷却中" + : resolveStatusLabel(job.running_started_at ? "running" : job.last_status); + + result[link.skillId] = { + jobId: job.id, + jobName: job.name || link.jobName, + statusLabel, + tone: resolveStatusTone( + job.auto_disabled_until + ? "timeout" + : job.running_started_at + ? "running" + : job.last_status, + ), + detail: resolveStatusDetail(job), + }; + return result; + }, {}); +} diff --git a/src/components/agent/chat/service-skills/types.ts b/src/components/agent/chat/service-skills/types.ts index 32d34bb8b..4618cd02f 100644 --- a/src/components/agent/chat/service-skills/types.ts +++ b/src/components/agent/chat/service-skills/types.ts @@ -1,5 +1,6 @@ import type { ServiceSkillCatalog, + ServiceSkillArtifactKind, ServiceSkillExecutionLocation, ServiceSkillExecutorBinding, ServiceSkillItem, @@ -13,6 +14,7 @@ import type { export type { ServiceSkillCatalog, + ServiceSkillArtifactKind, ServiceSkillExecutionLocation, ServiceSkillExecutorBinding, ServiceSkillItem, @@ -28,6 +30,21 @@ export type ServiceSkillTone = "slate" | "sky" | "emerald" | "amber"; export type ServiceSkillSlotValues = Record; +export interface ServiceSkillAutomationStatus { + jobId: string; + jobName: string; + statusLabel: string; + tone: ServiceSkillTone; + detail: string | null; +} + +export interface ServiceSkillAutomationLinkRecord { + skillId: string; + jobId: string; + jobName: string; + linkedAt: number; +} + export interface ServiceSkillHomeItem extends ServiceSkillItem { badge: string; recentUsedAt: number | null; @@ -36,6 +53,16 @@ export interface ServiceSkillHomeItem extends ServiceSkillItem { runnerTone: ServiceSkillTone; runnerDescription: string; actionLabel: string; + automationStatus: ServiceSkillAutomationStatus | null; +} + +export interface ServiceSkillCatalogMeta { + tenantId: string; + version: string; + syncedAt: string; + itemCount: number; + sourceLabel: string; + isSeeded: boolean; } export interface ServiceSkillUsageRecord { diff --git a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx new file mode 100644 index 000000000..5c320d2d2 --- /dev/null +++ b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx @@ -0,0 +1,256 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearServiceSkillCatalogCache, + getSeededServiceSkillCatalog, + saveServiceSkillCatalog, + type ServiceSkillCatalog, +} from "@/lib/api/serviceSkills"; +import { recordServiceSkillAutomationLink } from "./automationLinkStorage"; +import { useServiceSkills } from "./useServiceSkills"; + +interface HookHarness { + getValue: () => ReturnType; + unmount: () => void; +} + +function buildRemoteCatalog(): ServiceSkillCatalog { + const seeded = getSeededServiceSkillCatalog(); + return { + version: "tenant-2026-03-24", + tenantId: "tenant-demo", + syncedAt: "2026-03-24T12:00:00.000Z", + items: [ + { + ...seeded.items[0]!, + id: "tenant-daily-briefing", + title: "租户日报摘要", + summary: "远端同步后的目录项", + version: "tenant-2026-03-24", + }, + ], + }; +} + +function mountHook(): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent() { + hookValue = useServiceSkills(); + return null; + } + + act(() => { + root.render(); + }); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + + return { + promise, + resolve, + reject, + }; +} + +async function flushEffects(times = 3) { + for (let index = 0; index < times; index += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +describe("useServiceSkills", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + delete window.__LIME_OEM_CLOUD__; + delete window.__LIME_SESSION_TOKEN__; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("目录更新事件后应刷新服务型技能列表", async () => { + const harness = mountHook(); + + try { + await flushEffects(); + + expect(harness.getValue().skills[0]?.id).toBe( + "carousel-post-replication", + ); + expect(harness.getValue().catalogMeta).toEqual( + expect.objectContaining({ + tenantId: "local-seeded", + sourceLabel: "本地 Seeded 目录", + isSeeded: true, + }), + ); + + act(() => { + recordServiceSkillAutomationLink({ + skillId: "carousel-post-replication", + jobId: "automation-job-daily-brief", + jobName: "每日线索巡检", + }); + }); + + await flushEffects(); + + expect( + harness + .getValue() + .skills.find((skill) => skill.id === "carousel-post-replication") + ?.automationStatus, + ).toEqual( + expect.objectContaining({ + jobId: "automation-job-daily-brief", + jobName: "每日线索巡检", + statusLabel: "成功", + }), + ); + expect( + harness + .getValue() + .skills.find((skill) => skill.id === "carousel-post-replication") + ?.automationStatus?.detail, + ).toContain("下次"); + + act(() => { + saveServiceSkillCatalog(buildRemoteCatalog(), "bootstrap_sync"); + }); + + await flushEffects(); + + expect(harness.getValue().skills).toHaveLength(1); + expect(harness.getValue().skills[0]?.id).toBe("tenant-daily-briefing"); + expect(harness.getValue().skills[0]?.badge).toBe("云目录"); + expect(harness.getValue().catalogMeta).toEqual( + expect.objectContaining({ + tenantId: "tenant-demo", + version: "tenant-2026-03-24", + sourceLabel: "租户云目录", + isSeeded: false, + }), + ); + + act(() => { + clearServiceSkillCatalogCache(); + }); + + await flushEffects(); + + expect(harness.getValue().skills[0]?.id).toBe( + "carousel-post-replication", + ); + expect(harness.getValue().catalogMeta).toEqual( + expect.objectContaining({ + tenantId: "local-seeded", + sourceLabel: "本地 Seeded 目录", + isSeeded: true, + }), + ); + } finally { + harness.unmount(); + } + }); + + it("有 OEM 会话时应先显示本地目录再后台刷新远端目录", async () => { + window.__LIME_OEM_CLOUD__ = { + baseUrl: "https://oem.example.com", + tenantId: "tenant-demo", + }; + window.__LIME_SESSION_TOKEN__ = "session-token-demo"; + + const responseDeferred = createDeferred<{ + ok: boolean; + json: () => Promise; + }>(); + const fetchMock = vi.fn(() => responseDeferred.promise); + vi.stubGlobal("fetch", fetchMock); + + const harness = mountHook(); + + try { + await flushEffects(4); + + expect(fetchMock).toHaveBeenCalledWith( + "https://oem.example.com/api/v1/public/tenants/tenant-demo/client/service-skills", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Accept: "application/json", + Authorization: "Bearer session-token-demo", + }), + }), + ); + expect(harness.getValue().catalogMeta).toEqual( + expect.objectContaining({ + tenantId: "local-seeded", + sourceLabel: "本地 Seeded 目录", + isSeeded: true, + }), + ); + + responseDeferred.resolve({ + ok: true, + json: async () => ({ + code: 200, + message: "success", + data: buildRemoteCatalog(), + }), + }); + + await flushEffects(4); + + expect(harness.getValue().skills).toHaveLength(1); + expect(harness.getValue().skills[0]?.id).toBe("tenant-daily-briefing"); + expect(harness.getValue().catalogMeta).toEqual( + expect.objectContaining({ + tenantId: "tenant-demo", + version: "tenant-2026-03-24", + sourceLabel: "租户云目录", + isSeeded: false, + }), + ); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/service-skills/useServiceSkills.ts b/src/components/agent/chat/service-skills/useServiceSkills.ts index b1ae79ded..04df157c2 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.ts +++ b/src/components/agent/chat/service-skills/useServiceSkills.ts @@ -1,8 +1,22 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { listServiceSkills } from "@/lib/api/serviceSkills"; +import { getAutomationJobs } from "@/lib/api/automation"; +import { + getServiceSkillCatalog, + isSeededServiceSkillCatalog, + refreshServiceSkillCatalogFromRemote, + subscribeServiceSkillCatalogChanged, +} from "@/lib/api/serviceSkills"; +import { + buildServiceSkillAutomationStatusMap, + listServiceSkillAutomationLinks, + subscribeServiceSkillAutomationLinksChanged, +} from "./automationLinkStorage"; import { getServiceSkillUsageMap, recordServiceSkillUsage } from "./storage"; import type { RecordServiceSkillUsageInput, + ServiceSkillAutomationStatus, + ServiceSkillCatalog, + ServiceSkillCatalogMeta, ServiceSkillHomeItem, ServiceSkillItem, ServiceSkillRunnerType, @@ -27,12 +41,40 @@ const RUNNER_DESCRIPTIONS: Record = { managed: "当前先进入工作区生成首版跟踪方案,后续再接本地持续任务。", }; -const ACTION_LABELS: Record = { +const LOCAL_ACTION_LABELS: Record = { instant: "填写参数", scheduled: "先做方案", managed: "先定指标", }; +function getRunnerLabel(item: ServiceSkillItem): string { + if (item.executionLocation === "cloud_required") { + return "云端托管执行"; + } + return RUNNER_LABELS[item.runnerType]; +} + +function getRunnerTone(item: ServiceSkillItem): ServiceSkillTone { + if (item.executionLocation === "cloud_required") { + return "slate"; + } + return RUNNER_TONES[item.runnerType]; +} + +function getRunnerDescription(item: ServiceSkillItem): string { + if (item.executionLocation === "cloud_required") { + return "提交到 OEM 云端执行,结果由服务端异步返回。"; + } + return RUNNER_DESCRIPTIONS[item.runnerType]; +} + +function getActionLabel(item: ServiceSkillItem): string { + if (item.executionLocation === "cloud_required") { + return "提交云端"; + } + return LOCAL_ACTION_LABELS[item.runnerType]; +} + function getSkillBadge(item: ServiceSkillItem, isRecent: boolean): string { if (isRecent) { return "最近使用"; @@ -43,7 +85,10 @@ function getSkillBadge(item: ServiceSkillItem, isRecent: boolean): string { return "本地技能"; } -function buildHomeItems(items: ServiceSkillItem[]): ServiceSkillHomeItem[] { +function buildHomeItems( + items: ServiceSkillItem[], + automationStatusMap: Record, +): ServiceSkillHomeItem[] { const usageMap = getServiceSkillUsageMap(); const mapped: Array = items.map( (item, index) => { @@ -56,10 +101,11 @@ function buildHomeItems(items: ServiceSkillItem[]): ServiceSkillHomeItem[] { badge: getSkillBadge(item, isRecent), recentUsedAt, isRecent, - runnerLabel: RUNNER_LABELS[item.runnerType], - runnerTone: RUNNER_TONES[item.runnerType], - runnerDescription: RUNNER_DESCRIPTIONS[item.runnerType], - actionLabel: ACTION_LABELS[item.runnerType], + runnerLabel: getRunnerLabel(item), + runnerTone: getRunnerTone(item), + runnerDescription: getRunnerDescription(item), + actionLabel: getActionLabel(item), + automationStatus: automationStatusMap[item.id] ?? null, _sortIndex: index, }; }, @@ -82,8 +128,24 @@ function buildHomeItems(items: ServiceSkillItem[]): ServiceSkillHomeItem[] { .map(({ _sortIndex, ...item }) => item); } +function buildCatalogMeta( + catalog: ServiceSkillCatalog, +): ServiceSkillCatalogMeta { + const isSeeded = isSeededServiceSkillCatalog(catalog); + + return { + tenantId: catalog.tenantId, + version: catalog.version, + syncedAt: catalog.syncedAt, + itemCount: catalog.items.length, + sourceLabel: isSeeded ? "本地 Seeded 目录" : "租户云目录", + isSeeded, + }; +} + interface UseServiceSkillsResult { skills: ServiceSkillHomeItem[]; + catalogMeta: ServiceSkillCatalogMeta | null; isLoading: boolean; error: string | null; refresh: () => Promise; @@ -92,35 +154,120 @@ interface UseServiceSkillsResult { export function useServiceSkills(enabled = true): UseServiceSkillsResult { const [items, setItems] = useState([]); + const [automationStatusMap, setAutomationStatusMap] = useState< + Record + >({}); + const [catalogMeta, setCatalogMeta] = useState( + null, + ); const [isLoading, setIsLoading] = useState(enabled); const [error, setError] = useState(null); const [usageVersion, setUsageVersion] = useState(0); + const [automationLinkCount, setAutomationLinkCount] = useState(0); + + const applyCatalogSnapshot = useCallback(async (catalog: ServiceSkillCatalog) => { + const automationLinks = listServiceSkillAutomationLinks(); + let automationStatuses: Record = {}; + + if (automationLinks.length > 0) { + try { + automationStatuses = buildServiceSkillAutomationStatusMap( + await getAutomationJobs(), + ); + } catch { + automationStatuses = {}; + } + } + + setItems(catalog.items.filter((item) => item.source === "cloud_catalog")); + setAutomationLinkCount(automationLinks.length); + setAutomationStatusMap(automationStatuses); + setCatalogMeta(buildCatalogMeta(catalog)); + }, []); + + const loadCurrentCatalog = useCallback(async () => { + const catalog = await getServiceSkillCatalog(); + await applyCatalogSnapshot(catalog); + return catalog; + }, [applyCatalogSnapshot]); const refresh = useCallback(async () => { if (!enabled) { setItems([]); + setAutomationStatusMap({}); + setAutomationLinkCount(0); + setCatalogMeta(null); setError(null); setIsLoading(false); return; } setIsLoading(true); + let currentCatalog: ServiceSkillCatalog | null = null; + try { - const nextItems = await listServiceSkills(); - setItems(nextItems); + currentCatalog = await loadCurrentCatalog(); setError(null); + setIsLoading(false); + + try { + const remoteCatalog = await refreshServiceSkillCatalogFromRemote(); + if (remoteCatalog) { + await applyCatalogSnapshot(remoteCatalog); + } + } catch (reason) { + if (!currentCatalog) { + throw reason; + } + } } catch (reason) { setItems([]); + setAutomationStatusMap({}); + setAutomationLinkCount(0); + setCatalogMeta(null); setError(reason instanceof Error ? reason.message : String(reason)); } finally { setIsLoading(false); } - }, [enabled]); + }, [applyCatalogSnapshot, enabled, loadCurrentCatalog]); useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { + if (!enabled) { + return; + } + + const unsubscribeCatalog = subscribeServiceSkillCatalogChanged(() => { + void loadCurrentCatalog(); + }); + const unsubscribeAutomationLinks = + subscribeServiceSkillAutomationLinksChanged(() => { + void loadCurrentCatalog(); + }); + + return () => { + unsubscribeCatalog(); + unsubscribeAutomationLinks(); + }; + }, [enabled, loadCurrentCatalog]); + + useEffect(() => { + if (!enabled || automationLinkCount === 0) { + return; + } + + const timer = window.setInterval(() => { + void refresh(); + }, 15_000); + + return () => { + window.clearInterval(timer); + }; + }, [automationLinkCount, enabled, refresh]); + const recordUsageAndRefresh = useCallback( (input: RecordServiceSkillUsageInput) => { recordServiceSkillUsage(input); @@ -131,11 +278,12 @@ export function useServiceSkills(enabled = true): UseServiceSkillsResult { const skills = useMemo(() => { void usageVersion; - return buildHomeItems(items); - }, [items, usageVersion]); + return buildHomeItems(items, automationStatusMap); + }, [items, usageVersion, automationStatusMap]); return { skills, + catalogMeta, isLoading, error, refresh, diff --git a/src/components/agent/chat/service-skills/workspaceLaunch.test.ts b/src/components/agent/chat/service-skills/workspaceLaunch.test.ts new file mode 100644 index 000000000..d5cd14b93 --- /dev/null +++ b/src/components/agent/chat/service-skills/workspaceLaunch.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { buildServiceSkillWorkspaceSeed } from "./workspaceLaunch"; +import type { ServiceSkillItem } from "@/lib/api/serviceSkills"; + +function createSkill( + overrides: Partial = {}, +): ServiceSkillItem { + return { + id: "daily-trend-briefing", + title: "每日趋势摘要", + summary: "围绕指定平台输出趋势摘要。", + category: "社媒运营", + outputHint: "趋势摘要", + source: "cloud_catalog", + runnerType: "scheduled", + defaultExecutorBinding: "automation_job", + executionLocation: "client_default", + themeTarget: "social-media", + version: "seed-v1", + slotSchema: [], + ...overrides, + }; +} + +describe("service skill workspace launch", () => { + it("内容创作类服务型技能应生成内容种子与 artifact metadata", () => { + const seed = buildServiceSkillWorkspaceSeed( + createSkill({ + defaultArtifactKind: "analysis", + }), + ); + + expect(seed).toEqual({ + title: "每日趋势摘要", + contentType: "post", + requestMetadata: { + artifact: { + artifact_mode: "draft", + artifact_kind: "analysis", + workbench_surface: "right_panel", + }, + }, + metadata: { + source: "service_skill", + serviceSkill: { + id: "daily-trend-briefing", + title: "每日趋势摘要", + runnerType: "scheduled", + executionLocation: "client_default", + themeTarget: "social-media", + artifactKind: "analysis", + }, + }, + }); + }); + + it("非内容创作主题不应强制生成内容种子", () => { + expect( + buildServiceSkillWorkspaceSeed( + createSkill({ + themeTarget: "general", + defaultArtifactKind: "brief", + }), + ), + ).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/service-skills/workspaceLaunch.ts b/src/components/agent/chat/service-skills/workspaceLaunch.ts new file mode 100644 index 000000000..fa9379c3f --- /dev/null +++ b/src/components/agent/chat/service-skills/workspaceLaunch.ts @@ -0,0 +1,56 @@ +import type { ContentType, ProjectType } from "@/lib/api/project"; +import { getDefaultContentTypeForProject } from "@/lib/api/project"; +import type { ServiceSkillArtifactKind, ServiceSkillItem } from "@/lib/api/serviceSkills"; +import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt"; + +export interface ServiceSkillWorkspaceSeed { + title: string; + contentType: ContentType; + requestMetadata?: Record; + metadata?: Record; +} + +function resolveServiceSkillArtifactRequestMetadata( + artifactKind?: ServiceSkillArtifactKind, +): Record | undefined { + if (!artifactKind) { + return undefined; + } + + return { + artifact: { + artifact_mode: "draft", + artifact_kind: artifactKind, + workbench_surface: "right_panel", + }, + }; +} + +export function buildServiceSkillWorkspaceSeed( + skill: ServiceSkillItem, + fallbackTheme?: string | null, +): ServiceSkillWorkspaceSeed | null { + const targetTheme = skill.themeTarget ?? fallbackTheme ?? null; + if (!targetTheme || !isContentCreationTheme(targetTheme)) { + return null; + } + + return { + title: skill.title.trim() || "服务型技能交付物", + contentType: getDefaultContentTypeForProject(targetTheme as ProjectType), + requestMetadata: resolveServiceSkillArtifactRequestMetadata( + skill.defaultArtifactKind, + ), + metadata: { + source: "service_skill", + serviceSkill: { + id: skill.id, + title: skill.title, + runnerType: skill.runnerType, + executionLocation: skill.executionLocation, + themeTarget: targetTheme, + artifactKind: skill.defaultArtifactKind ?? null, + }, + }, + }; +} diff --git a/src/components/agent/chat/teamWorkspaceRuntime.ts b/src/components/agent/chat/teamWorkspaceRuntime.ts index e73c4b4d8..54a63308b 100644 --- a/src/components/agent/chat/teamWorkspaceRuntime.ts +++ b/src/components/agent/chat/teamWorkspaceRuntime.ts @@ -1,4 +1,4 @@ -import type { AgentThreadItem } from "@/lib/api/agentStream"; +import type { AgentThreadItem } from "@/lib/api/agentProtocol"; import type { AsterSubagentParentContext, AsterSubagentSessionInfo, diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index d21948472..db414abdc 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -1,8 +1,8 @@ import type { - ContextTraceStep, - ToolCallState, - TokenUsage, -} from "@/lib/api/agentStream"; + AgentContextTraceStep as ContextTraceStep, + AgentToolCallState as ToolCallState, + AgentTokenUsage as TokenUsage, +} from "@/lib/api/agentProtocol"; import type { Artifact, ArtifactStatus } from "@/lib/artifact/types"; import { safeInvoke } from "@/lib/dev-bridge"; @@ -10,7 +10,7 @@ export type { AgentThreadItem, AgentThreadItemStatus, AgentThreadTurn, -} from "@/lib/api/agentStream"; +} from "@/lib/api/agentProtocol"; export interface MessageImage { data: string; @@ -45,6 +45,12 @@ export type BrowserPreflightState = | "failed" | "degraded"; +export interface SiteSavedContentTarget { + projectId: string; + contentId: string; + title?: string; +} + // ============ 权限确认相关类型 ============ export interface ActionRequiredScope { diff --git a/src/components/agent/chat/utils/agentRuntimeStatus.ts b/src/components/agent/chat/utils/agentRuntimeStatus.ts index d94e50190..d7515be4a 100644 --- a/src/components/agent/chat/utils/agentRuntimeStatus.ts +++ b/src/components/agent/chat/utils/agentRuntimeStatus.ts @@ -1,5 +1,5 @@ import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; -import type { ContextTraceStep } from "@/lib/api/agentStream"; +import type { AgentContextTraceStep as ContextTraceStep } from "@/lib/api/agentProtocol"; import type { AgentRuntimeStatus } from "../types"; function buildExecutionLabel(strategy: AsterExecutionStrategy): string { diff --git a/src/components/agent/chat/utils/agentThreadGrouping.test.ts b/src/components/agent/chat/utils/agentThreadGrouping.test.ts index 6b5a74d01..c2bfef995 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.test.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.test.ts @@ -120,6 +120,52 @@ describe("agentThreadGrouping", () => { ]); }); + it("应通过 artifact protocol 识别嵌套参数中的文件路径", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("tool-file-1", 1), + type: "tool_call", + tool_name: "write_file", + arguments: { + payload: { + filePath: "articles/nested-draft.md", + }, + }, + }, + ]; + + const model = buildAgentThreadDisplayModel(items); + + expect(model.groups.map((group) => group.kind)).toEqual(["file"]); + expect(model.groups[0]?.previewLines).toEqual(["nested-draft.md"]); + }); + + it("应通过 filesystem event protocol 识别目录与输出文件位置线索", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("tool-dir-1", 1), + type: "tool_call", + tool_name: "list_directory", + arguments: { + directory: "workspace\\reports", + }, + }, + { + ...createBaseItem("tool-output-1", 2), + type: "tool_call", + tool_name: "bash", + metadata: { + output_file: "workspace\\logs\\run.log", + }, + }, + ]; + + const model = buildAgentThreadDisplayModel(items); + + expect(model.groups.map((group) => group.kind)).toEqual(["file"]); + expect(model.groups[0]?.previewLines).toEqual(["reports", "run.log"]); + }); + it("思考块应保留在真实时序中,而不是整体前置", () => { const items: AgentThreadItem[] = [ { diff --git a/src/components/agent/chat/utils/agentThreadGrouping.ts b/src/components/agent/chat/utils/agentThreadGrouping.ts index 6f2405e52..5a9dfe4be 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.ts @@ -1,4 +1,9 @@ import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; +import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol"; +import { + extractFilesystemEventLocationHintsFromValue, + extractFilesystemEventPathsFromValue, +} from "@/lib/filesystem-event-protocol"; import type { AgentThreadItem, AgentThreadItemStatus } from "../types"; import { resolveInternalImageTaskDisplayName } from "./internalImagePlaceholder"; import { resolveToolDisplayLabel } from "./toolDisplayInfo"; @@ -174,11 +179,19 @@ function resolvePathFromItem(item: AgentThreadItem): string | null { return null; } - const metadata = asRecord(item.metadata); - const args = asRecord(item.arguments); + const protocolPath = + extractArtifactProtocolPathsFromValue(item.arguments)[0] || + extractArtifactProtocolPathsFromValue(item.metadata)[0]; + if (protocolPath) { + return protocolPath; + } + return ( - readString(args, ["path", "file_path", "filePath", "directory", "cwd"]) || - readString(metadata, ["path", "file_path", "filePath", "output_file", "offload_file"]) + extractFilesystemEventPathsFromValue(item.arguments)[0] || + extractFilesystemEventPathsFromValue(item.metadata)[0] || + extractFilesystemEventLocationHintsFromValue(item.arguments)[0] || + extractFilesystemEventLocationHintsFromValue(item.metadata)[0] || + null ); } diff --git a/src/components/agent/chat/utils/artifactTimelineNavigation.test.ts b/src/components/agent/chat/utils/artifactTimelineNavigation.test.ts new file mode 100644 index 000000000..418df7516 --- /dev/null +++ b/src/components/agent/chat/utils/artifactTimelineNavigation.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + buildArtifactTimelineLinkIndex, + resolveTimelineArtifactNavigation, +} from "./artifactTimelineNavigation"; +import type { AgentThreadItem } from "../types"; +import type { Artifact } from "@/lib/artifact/types"; + +function createFileArtifactItem( + overrides: Partial> = {}, +): Extract { + return { + id: "item-1", + thread_id: "thread-1", + turn_id: "turn-1", + sequence: 3, + status: "completed", + started_at: "2026-03-25T10:00:00Z", + completed_at: "2026-03-25T10:00:01Z", + updated_at: "2026-03-25T10:00:01Z", + type: "file_artifact", + path: ".lime/artifacts/thread-1/demo.artifact.json", + source: "artifact_snapshot", + content: JSON.stringify({ + schemaVersion: "artifact_document.v1", + artifactId: "artifact-document:demo", + kind: "analysis", + title: "季度总结", + status: "ready", + language: "zh-CN", + blocks: [ + { id: "hero-1", type: "hero_summary", summary: "摘要" }, + { id: "body-1", type: "rich_text", markdown: "正文" }, + ], + sources: [], + metadata: { + currentVersionDiff: { + changedBlocks: [ + { + blockId: "body-1", + changeType: "updated", + }, + ], + }, + }, + }), + metadata: { + artifact_id: "artifact-document:demo", + }, + ...overrides, + }; +} + +function createArtifact(): Artifact { + const content = JSON.stringify({ + schemaVersion: "artifact_document.v1", + artifactId: "artifact-document:demo", + kind: "analysis", + title: "季度总结", + status: "ready", + language: "zh-CN", + blocks: [ + { id: "hero-1", type: "hero_summary", summary: "摘要" }, + { id: "body-1", type: "rich_text", markdown: "正文" }, + ], + sources: [], + metadata: {}, + }); + + return { + id: "artifact-1", + type: "document", + title: "demo.artifact.json", + content, + status: "complete", + meta: { + filePath: ".lime/artifacts/thread-1/demo.artifact.json", + filename: "demo.artifact.json", + language: "json", + }, + position: { start: 0, end: content.length }, + createdAt: 1, + updatedAt: 1, + }; +} + +describe("artifactTimelineNavigation", () => { + it("应优先从 timeline file_artifact 的 diff 中提取 block 目标", () => { + const navigation = resolveTimelineArtifactNavigation(createFileArtifactItem()); + + expect(navigation?.rootTarget.filePath).toBe( + ".lime/artifacts/thread-1/demo.artifact.json", + ); + expect(navigation?.blockTargets).toEqual([ + expect.objectContaining({ + timelineItemId: "item-1", + blockId: "body-1", + }), + ]); + }); + + it("构建 workbench 侧索引时应按 blockId 回灌 timeline 关联", () => { + const linkIndex = buildArtifactTimelineLinkIndex({ + artifact: createArtifact(), + items: [ + createFileArtifactItem(), + createFileArtifactItem({ + id: "item-2", + sequence: 5, + updated_at: "2026-03-25T10:00:03Z", + metadata: { + artifact_id: "artifact-document:demo", + artifact_block_id: "hero-1", + }, + }), + ], + }); + + expect(linkIndex["body-1"]).toEqual([ + expect.objectContaining({ + itemId: "item-1", + blockId: "body-1", + }), + ]); + expect(linkIndex["hero-1"]).toEqual([ + expect.objectContaining({ + itemId: "item-2", + blockId: "hero-1", + }), + ]); + }); +}); diff --git a/src/components/agent/chat/utils/artifactTimelineNavigation.ts b/src/components/agent/chat/utils/artifactTimelineNavigation.ts new file mode 100644 index 000000000..73bae5ac2 --- /dev/null +++ b/src/components/agent/chat/utils/artifactTimelineNavigation.ts @@ -0,0 +1,249 @@ +import { + resolveArtifactDocumentCurrentVersionDiff, + resolveArtifactDocumentSourceLinks, + type ArtifactDocumentV1, +} from "@/lib/artifact-document"; +import { + resolveArtifactProtocolDocumentPayload, + resolveArtifactProtocolFilePath, +} from "@/lib/artifact-protocol"; +import type { Artifact } from "@/lib/artifact/types"; +import type { AgentThreadItem } from "../types"; + +export interface ArtifactTimelineOpenTarget { + artifactId?: string; + filePath: string; + content: string; + timelineItemId: string; + blockId?: string; +} + +export interface ArtifactTimelineLink { + itemId: string; + blockId: string; + label: string; + filePath: string; + artifactId?: string; + updatedAt?: string; + sequence: number; +} + +interface ResolvedTimelineArtifactNavigation { + rootTarget: ArtifactTimelineOpenTarget; + blockTargets: ArtifactTimelineOpenTarget[]; + artifactDocumentId?: string; + title?: string; + updatedAt?: string; + sequence: number; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function normalizeText(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizePath(path: string): string { + return path.replace(/\\/g, "/").trim(); +} + +function readString( + record: Record | null, + keys: string[], +): string | undefined { + for (const key of keys) { + const value = normalizeText(record?.[key]); + if (value) { + return value; + } + } + + return undefined; +} + +function readStringArray( + record: Record | null, + keys: string[], +): string[] { + const values: string[] = []; + + for (const key of keys) { + const rawValue = record?.[key]; + if (Array.isArray(rawValue)) { + for (const item of rawValue) { + const normalized = normalizeText(item); + if (normalized) { + values.push(normalized); + } + } + continue; + } + + const normalized = normalizeText(rawValue); + if (normalized) { + values.push(normalized); + } + } + + return values; +} + +function dedupeStrings(values: string[]): string[] { + return Array.from( + new Set(values.map((value) => value.trim()).filter(Boolean)), + ); +} + +function buildStepLabel(sequence: number): string { + return `步骤 ${String(sequence).padStart(2, "0")}`; +} + +function resolveDocumentBlockIds( + document: ArtifactDocumentV1 | null, + includeSourceLinks: boolean, +): string[] { + if (!document) { + return []; + } + + const diffBlockIds = + resolveArtifactDocumentCurrentVersionDiff(document)?.changedBlocks + .map((block) => normalizeText(block.blockId)) + .filter((blockId): blockId is string => Boolean(blockId)) || []; + if (diffBlockIds.length > 0) { + return dedupeStrings(diffBlockIds); + } + + if (!includeSourceLinks) { + return []; + } + + return dedupeStrings( + resolveArtifactDocumentSourceLinks(document) + .map((link) => normalizeText(link.blockId)) + .filter((blockId): blockId is string => Boolean(blockId)), + ); +} + +export function resolveTimelineArtifactNavigation( + item: AgentThreadItem, + options?: { + includeSourceLinks?: boolean; + }, +): ResolvedTimelineArtifactNavigation | null { + if (item.type !== "file_artifact") { + return null; + } + + const metadata = asRecord(item.metadata); + const artifactId = readString(metadata, ["artifact_id", "artifactId"]); + const filePath = normalizePath(item.path); + const content = typeof item.content === "string" ? item.content : ""; + const document = resolveArtifactProtocolDocumentPayload({ + content, + metadata: metadata || undefined, + }); + const explicitBlockIds = readStringArray(metadata, [ + "artifact_block_id", + "artifactBlockId", + "block_id", + "blockId", + "target_block_id", + "targetBlockId", + "artifact_target_block_id", + "artifactTargetBlockId", + ]); + const blockIds = + explicitBlockIds.length > 0 + ? dedupeStrings(explicitBlockIds) + : resolveDocumentBlockIds(document, options?.includeSourceLinks === true); + const rootTarget: ArtifactTimelineOpenTarget = { + artifactId, + filePath, + content, + timelineItemId: item.id, + }; + + return { + rootTarget, + blockTargets: blockIds.map((blockId) => ({ + ...rootTarget, + blockId, + })), + artifactDocumentId: document?.artifactId, + title: document?.title, + updatedAt: item.updated_at, + sequence: item.sequence, + }; +} + +export function buildArtifactTimelineLinkIndex(params: { + artifact: Artifact; + items: AgentThreadItem[]; +}): Record { + const artifactPath = normalizePath(resolveArtifactProtocolFilePath(params.artifact)); + const document = resolveArtifactProtocolDocumentPayload({ + content: params.artifact.content, + metadata: params.artifact.meta, + }); + const artifactIds = new Set( + [ + normalizeText(params.artifact.id), + normalizeText(document?.artifactId), + normalizeText( + asRecord(params.artifact.meta)?.artifactId, + ), + ].filter((value): value is string => Boolean(value)), + ); + const result = new Map(); + + for (const item of params.items) { + const navigation = resolveTimelineArtifactNavigation(item, { + includeSourceLinks: true, + }); + if (!navigation) { + continue; + } + + const matchesByPath = + navigation.rootTarget.filePath.length > 0 && + navigation.rootTarget.filePath === artifactPath; + const matchesByArtifactId = + (navigation.rootTarget.artifactId && + artifactIds.has(navigation.rootTarget.artifactId)) || + (navigation.artifactDocumentId && + artifactIds.has(navigation.artifactDocumentId)); + + if (!matchesByPath && !matchesByArtifactId) { + continue; + } + + for (const target of navigation.blockTargets) { + if (!target.blockId) { + continue; + } + const current = result.get(target.blockId) || []; + current.push({ + itemId: navigation.rootTarget.timelineItemId, + blockId: target.blockId, + label: navigation.title || buildStepLabel(navigation.sequence), + filePath: navigation.rootTarget.filePath, + artifactId: navigation.rootTarget.artifactId, + updatedAt: navigation.updatedAt, + sequence: navigation.sequence, + }); + result.set(target.blockId, current); + } + } + + return Object.fromEntries( + Array.from(result.entries()).map(([blockId, links]) => [ + blockId, + [...links].sort((left, right) => right.sequence - left.sequence), + ]), + ); +} diff --git a/src/components/agent/chat/utils/browserAssistSession.ts b/src/components/agent/chat/utils/browserAssistSession.ts index 6b9767082..abd077b9b 100644 --- a/src/components/agent/chat/utils/browserAssistSession.ts +++ b/src/components/agent/chat/utils/browserAssistSession.ts @@ -1,4 +1,4 @@ -import type { ToolCallState } from "@/lib/api/agentStream"; +import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; import type { Artifact } from "@/lib/artifact/types"; import type { BrowserAssistSessionState, diff --git a/src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts new file mode 100644 index 000000000..ed7ed4159 --- /dev/null +++ b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders"; +import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; +import { resolveClawWorkspaceProviderSelection } from "./clawWorkspaceProviderSelection"; + +const { + mockLoadConfiguredProviders, + mockGetModelRegistry, + mockGetProviderAliasConfig, + mockFetchProviderModelsAuto, + mockFilterModelsByTheme, +} = vi.hoisted(() => ({ + mockLoadConfiguredProviders: vi.fn(), + mockGetModelRegistry: vi.fn(), + mockGetProviderAliasConfig: vi.fn(), + mockFetchProviderModelsAuto: vi.fn(), + mockFilterModelsByTheme: vi.fn(), +})); + +vi.mock("@/hooks/useConfiguredProviders", () => ({ + loadConfiguredProviders: mockLoadConfiguredProviders, +})); + +vi.mock("@/lib/api/modelRegistry", () => ({ + modelRegistryApi: { + getModelRegistry: mockGetModelRegistry, + getProviderAliasConfig: mockGetProviderAliasConfig, + fetchProviderModelsAuto: mockFetchProviderModelsAuto, + }, +})); + +vi.mock("./modelThemePolicy", () => ({ + filterModelsByTheme: mockFilterModelsByTheme, +})); + +function createProvider( + overrides: Partial = {}, +): ConfiguredProvider { + return { + key: "custom-social-provider", + label: "Custom Social Provider", + registryId: "custom-social-provider", + fallbackRegistryId: "openai", + type: "openai", + ...overrides, + }; +} + +function createModel( + id: string, + overrides: Partial = {}, +): EnhancedModelMetadata { + return { + id, + display_name: id, + provider_id: "custom-social-provider", + provider_name: "Custom Social Provider", + family: null, + tier: "pro", + capabilities: { + vision: false, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: false, + }, + pricing: null, + limits: { + context_length: null, + max_output_tokens: null, + requests_per_minute: null, + tokens_per_minute: null, + }, + status: "active", + release_date: null, + is_latest: false, + description: null, + source: "local", + created_at: 0, + updated_at: 0, + ...overrides, + }; +} + +describe("resolveClawWorkspaceProviderSelection", () => { + beforeEach(() => { + mockLoadConfiguredProviders.mockResolvedValue([]); + mockGetModelRegistry.mockResolvedValue([]); + mockGetProviderAliasConfig.mockResolvedValue(null); + mockFetchProviderModelsAuto.mockResolvedValue({ + models: [], + source: "LocalFallback", + error: null, + }); + mockFilterModelsByTheme.mockImplementation( + (_theme: string | undefined, models: EnhancedModelMetadata[]) => ({ + models, + usedFallback: false, + filteredOutCount: 0, + policyName: "mock", + }), + ); + }); + + it("本地注册表存在模型时应直接选中,不再触发 provider API fallback", async () => { + mockLoadConfiguredProviders.mockResolvedValueOnce([ + createProvider(), + createProvider({ + key: "backup-provider", + label: "Backup Provider", + registryId: "backup-provider", + }), + ]); + mockGetModelRegistry.mockResolvedValueOnce([ + createModel("social-model-1", { + is_latest: true, + }), + ]); + + const result = await resolveClawWorkspaceProviderSelection({ + currentProviderType: "custom-social-provider", + currentModel: "legacy-model", + theme: "social-media", + }); + + expect(result).toEqual({ + providerType: "custom-social-provider", + model: "social-model-1", + }); + expect(mockFetchProviderModelsAuto).not.toHaveBeenCalled(); + }); + + it("本地注册表无模型时应回退到后端 provider API 结果", async () => { + mockLoadConfiguredProviders.mockResolvedValueOnce([ + createProvider(), + ]); + mockFetchProviderModelsAuto.mockResolvedValueOnce({ + models: [ + createModel("social-model-api", { + source: "custom", + is_latest: true, + }), + ], + source: "Api", + error: null, + }); + + const result = await resolveClawWorkspaceProviderSelection({ + currentProviderType: "custom-social-provider", + currentModel: null, + theme: "social-media", + }); + + expect(result).toEqual({ + providerType: "custom-social-provider", + model: "social-model-api", + }); + expect(mockFetchProviderModelsAuto).toHaveBeenCalledWith( + "custom-social-provider", + ); + }); +}); diff --git a/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts index c5c16ca1f..83c6b9457 100644 --- a/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts +++ b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts @@ -2,7 +2,12 @@ import { loadConfiguredProviders, type ConfiguredProvider, } from "@/hooks/useConfiguredProviders"; -import { loadProviderModels } from "@/hooks/useProviderModels"; +import { modelRegistryApi } from "@/lib/api/modelRegistry"; +import { + getAliasConfigKey, + isAliasProvider, +} from "@/lib/constants/providerMappings"; +import { buildProviderModelsFromRegistry } from "@/lib/model/providerModelsCatalog"; import { type EnhancedModelMetadata } from "@/lib/types/modelRegistry"; import { filterModelsByTheme } from "./modelThemePolicy"; import { resolveProviderModelCompatibility } from "./providerModelCompatibility"; @@ -83,6 +88,52 @@ function resolvePreferredModelId( return candidateModels[0]?.id ?? null; } +async function loadProviderModelsForSelection( + provider: ConfiguredProvider, +): Promise { + const aliasConfigPromise = isAliasProvider(provider.key) + ? modelRegistryApi.getProviderAliasConfig(getAliasConfigKey(provider.key)) + : Promise.resolve(null); + const [registryModels, aliasConfig] = await Promise.all([ + modelRegistryApi.getModelRegistry(), + aliasConfigPromise, + ]); + const localResult = buildProviderModelsFromRegistry( + provider, + registryModels, + aliasConfig, + ); + + if (localResult.models.length > 0 || isAliasProvider(provider.key)) { + return localResult.models; + } + + try { + const apiResult = await modelRegistryApi.fetchProviderModelsAuto(provider.key); + if (!apiResult.models.length) { + return localResult.models; + } + + const existingModelIds = new Set( + localResult.models.map((model) => normalizeValue(model.id)), + ); + + return [ + ...localResult.models, + ...apiResult.models.filter((model) => { + const normalizedModelId = normalizeValue(model.id); + if (existingModelIds.has(normalizedModelId)) { + return false; + } + existingModelIds.add(normalizedModelId); + return true; + }), + ]; + } catch { + return localResult.models; + } +} + export async function resolveClawWorkspaceProviderSelection( input: ResolveClawWorkspaceProviderSelectionInput, ): Promise { @@ -106,7 +157,7 @@ export async function resolveClawWorkspaceProviderSelection( : configuredProviders; for (const provider of orderedProviders) { - const providerModels = await loadProviderModels(provider); + const providerModels = await loadProviderModelsForSelection(provider); const preferredModel = resolvePreferredModelId( provider, providerModels, diff --git a/src/components/agent/chat/utils/harnessRequestMetadata.test.ts b/src/components/agent/chat/utils/harnessRequestMetadata.test.ts index 48a081005..838145006 100644 --- a/src/components/agent/chat/utils/harnessRequestMetadata.test.ts +++ b/src/components/agent/chat/utils/harnessRequestMetadata.test.ts @@ -13,12 +13,12 @@ describe("harnessRequestMetadata", () => { legacy_flag: true, }, theme: "general", - creationMode: "guided", - chatMode: "general", - webSearchEnabled: true, - thinkingEnabled: false, - taskModeEnabled: true, - subagentModeEnabled: false, + preferences: { + webSearch: true, + thinking: false, + task: true, + subagent: false, + }, sessionMode: "default", browserAssistProfileKey: "general_browser_assist", preferredTeamPresetId: "code-triage-team", @@ -44,10 +44,12 @@ describe("harnessRequestMetadata", () => { trace_id: "trace-1", legacy_flag: true, theme: "general", - creation_mode: "guided", - chat_mode: "general", - web_search_enabled: true, - task_mode_enabled: true, + preferences: { + web_search: true, + thinking: false, + task: true, + subagent: false, + }, preferred_team_preset_id: "code-triage-team", selected_team_id: "custom-team-1", selected_team_source: "custom", @@ -69,17 +71,86 @@ describe("harnessRequestMetadata", () => { profile_key: "general_browser_assist", }), }); + expect(metadata.creation_mode).toBeUndefined(); + expect(metadata.chat_mode).toBeUndefined(); + expect(metadata.web_search_enabled).toBeUndefined(); + expect(metadata.task_mode_enabled).toBeUndefined(); + }); + + it("应清理 base 中遗留的平铺状态字段", () => { + const metadata = buildHarnessRequestMetadata({ + base: { + creation_mode: "guided", + chat_mode: "general", + web_search_enabled: false, + thinking_enabled: false, + task_mode_enabled: false, + subagent_mode_enabled: false, + creationMode: "hybrid", + chatMode: "creator", + webSearchEnabled: true, + thinkingEnabled: true, + taskModeEnabled: true, + subagentModeEnabled: true, + turn_team_decision: "team_prepared", + turn_team_reason: "runtime_team_prepared", + turn_team_blueprint: { + label: "旧 Team 蓝图", + }, + turnTeamDecision: "single_agent", + turnTeamReason: "single_agent_direct", + turnTeamBlueprint: { + label: "legacy team blueprint", + }, + }, + theme: "document", + preferences: { + webSearch: true, + thinking: true, + task: false, + subagent: false, + }, + sessionMode: "default", + }); + + expect(metadata).toMatchObject({ + theme: "document", + preferences: { + web_search: true, + thinking: true, + task: false, + subagent: false, + }, + }); + expect(metadata.creation_mode).toBeUndefined(); + expect(metadata.chat_mode).toBeUndefined(); + expect(metadata.web_search_enabled).toBeUndefined(); + expect(metadata.thinking_enabled).toBeUndefined(); + expect(metadata.task_mode_enabled).toBeUndefined(); + expect(metadata.subagent_mode_enabled).toBeUndefined(); + expect(metadata.creationMode).toBeUndefined(); + expect(metadata.chatMode).toBeUndefined(); + expect(metadata.webSearchEnabled).toBeUndefined(); + expect(metadata.thinkingEnabled).toBeUndefined(); + expect(metadata.taskModeEnabled).toBeUndefined(); + expect(metadata.subagentModeEnabled).toBeUndefined(); + expect(metadata.turn_team_decision).toBeUndefined(); + expect(metadata.turn_team_reason).toBeUndefined(); + expect(metadata.turn_team_blueprint).toBeUndefined(); + expect(metadata.turnTeamDecision).toBeUndefined(); + expect(metadata.turnTeamReason).toBeUndefined(); + expect(metadata.turnTeamBlueprint).toBeUndefined(); }); it("默认会话模式不应写入 gate_key", () => { const metadata = buildHarnessRequestMetadata({ theme: "document", - creationMode: "fast", - chatMode: "agent", - webSearchEnabled: false, - thinkingEnabled: true, - taskModeEnabled: false, - subagentModeEnabled: false, + preferences: { + webSearch: false, + thinking: true, + task: false, + subagent: false, + }, sessionMode: "default", gateKey: "topic_select", }); @@ -87,15 +158,31 @@ describe("harnessRequestMetadata", () => { expect(metadata.gate_key).toBeUndefined(); }); + it("应透传当前发送用途,供后端统一决策运行时行为", () => { + const metadata = buildHarnessRequestMetadata({ + theme: "document", + turnPurpose: "content_review", + preferences: { + webSearch: false, + thinking: true, + task: false, + subagent: false, + }, + sessionMode: "theme_workbench", + }); + + expect(metadata.turn_purpose).toBe("content_review"); + }); + it("应保留 Team 角色的 profileId、roleKey 与 skillIds", () => { const metadata = buildHarnessRequestMetadata({ theme: "general", - creationMode: "guided", - chatMode: "agent", - webSearchEnabled: false, - thinkingEnabled: true, - taskModeEnabled: true, - subagentModeEnabled: true, + preferences: { + webSearch: false, + thinking: true, + task: true, + subagent: true, + }, sessionMode: "default", selectedTeamRoles: [ { @@ -121,73 +208,32 @@ describe("harnessRequestMetadata", () => { ]); }); - it("应写入当前 Team 决策与预编队蓝图", () => { + it("不应再写入旧 turn_team compat 字段", () => { const metadata = buildHarnessRequestMetadata({ theme: "general", - creationMode: "guided", - chatMode: "agent", - webSearchEnabled: false, - thinkingEnabled: true, - taskModeEnabled: true, - subagentModeEnabled: true, - sessionMode: "default", - turnTeamDecision: "team_prepared", - turnTeamReason: "runtime_team_prepared", - turnTeamBlueprint: { - label: "当前调试 Team", - description: "先分析,再实现,最后验证。", - roles: [ - { - id: "explorer", - label: "分析", - summary: "负责定位问题。", - profileId: "code-explorer", - roleKey: "explorer", - skillIds: ["repo-exploration"], - }, - { - id: "executor", - label: "执行", - summary: "负责提交修复。", - }, - ], + preferences: { + webSearch: false, + thinking: true, + task: true, + subagent: true, }, + sessionMode: "default", }); - expect(metadata).toMatchObject({ - turn_team_decision: "team_prepared", - turn_team_reason: "runtime_team_prepared", - turn_team_blueprint: { - label: "当前调试 Team", - description: "先分析,再实现,最后验证。", - roles: [ - { - id: "explorer", - label: "分析", - summary: "负责定位问题。", - profile_id: "code-explorer", - role_key: "explorer", - skill_ids: ["repo-exploration"], - }, - { - id: "executor", - label: "执行", - summary: "负责提交修复。", - }, - ], - }, - }); + expect(metadata.turn_team_decision).toBeUndefined(); + expect(metadata.turn_team_reason).toBeUndefined(); + expect(metadata.turn_team_blueprint).toBeUndefined(); }); it("需要人工确认的浏览器任务应标记 user step", () => { const metadata = buildHarnessRequestMetadata({ theme: "general", - creationMode: "hybrid", - chatMode: "general", - webSearchEnabled: true, - thinkingEnabled: true, - taskModeEnabled: true, - subagentModeEnabled: true, + preferences: { + webSearch: true, + thinking: true, + task: true, + subagent: true, + }, sessionMode: "theme_workbench", gateKey: "publish_confirm", browserRequirement: "required_with_user_step", diff --git a/src/components/agent/chat/utils/harnessRequestMetadata.ts b/src/components/agent/chat/utils/harnessRequestMetadata.ts index 2596ebff0..14bf4be2f 100644 --- a/src/components/agent/chat/utils/harnessRequestMetadata.ts +++ b/src/components/agent/chat/utils/harnessRequestMetadata.ts @@ -1,24 +1,16 @@ -import type { CreationMode } from "../components/types"; -import type { BrowserTaskRequirement } from "../types"; +import type { BrowserTaskRequirement, Message } from "../types"; import type { TeamRoleDefinition, TeamDefinitionSource } from "./teamDefinitions"; -export type HarnessTurnTeamDecision = "single_agent" | "team_prepared"; - -export interface HarnessTurnTeamBlueprint { - label?: string | null; - description?: string | null; - roles?: TeamRoleDefinition[] | null; -} - export interface BuildHarnessRequestMetadataOptions { base?: Record; theme: string; - creationMode: CreationMode; - chatMode: "agent" | "general" | "creator"; - webSearchEnabled: boolean; - thinkingEnabled: boolean; - taskModeEnabled: boolean; - subagentModeEnabled: boolean; + turnPurpose?: Message["purpose"] | null; + preferences: { + webSearch: boolean; + thinking: boolean; + task: boolean; + subagent: boolean; + }; sessionMode: "default" | "theme_workbench"; gateKey?: string | null; runTitle?: string | null; @@ -33,9 +25,6 @@ export interface BuildHarnessRequestMetadataOptions { selectedTeamLabel?: string | null; selectedTeamSummary?: string | null; selectedTeamRoles?: TeamRoleDefinition[] | null; - turnTeamDecision?: HarnessTurnTeamDecision | null; - turnTeamReason?: string | null; - turnTeamBlueprint?: HarnessTurnTeamBlueprint | null; } export function extractExistingHarnessMetadata( @@ -53,18 +42,41 @@ export function extractExistingHarnessMetadata( return harnessValue as Record; } +const LEGACY_HARNESS_STATE_KEYS = [ + "creation_mode", + "creationMode", + "chat_mode", + "chatMode", + "web_search_enabled", + "webSearchEnabled", + "thinking_enabled", + "thinkingEnabled", + "task_mode_enabled", + "taskModeEnabled", + "subagent_mode_enabled", + "subagentModeEnabled", + "turn_team_decision", + "turnTeamDecision", + "turn_team_reason", + "turnTeamReason", + "turn_team_blueprint", + "turnTeamBlueprint", +] as const; + +function clearLegacyHarnessStateFields(metadata: Record): void { + LEGACY_HARNESS_STATE_KEYS.forEach((key) => { + delete metadata[key]; + }); +} + export function buildHarnessRequestMetadata( options: BuildHarnessRequestMetadataOptions, ): Record { const { base, theme, - creationMode, - chatMode, - webSearchEnabled, - thinkingEnabled, - taskModeEnabled, - subagentModeEnabled, + turnPurpose, + preferences, sessionMode, gateKey, runTitle, @@ -79,9 +91,6 @@ export function buildHarnessRequestMetadata( selectedTeamLabel, selectedTeamSummary, selectedTeamRoles, - turnTeamDecision, - turnTeamReason, - turnTeamBlueprint, } = options; const serializeTeamRoles = (roles?: TeamRoleDefinition[] | null) => @@ -99,15 +108,16 @@ export function buildHarnessRequestMetadata( })) : undefined; - return { + const metadata: Record = { ...(base || {}), theme, - creation_mode: creationMode, - chat_mode: chatMode, - web_search_enabled: webSearchEnabled, - thinking_enabled: thinkingEnabled, - task_mode_enabled: taskModeEnabled, - subagent_mode_enabled: subagentModeEnabled, + turn_purpose: turnPurpose || undefined, + preferences: { + web_search: preferences.webSearch, + thinking: preferences.thinking, + task: preferences.task, + subagent: preferences.subagent, + }, session_mode: sessionMode, gate_key: sessionMode === "theme_workbench" ? gateKey || undefined : undefined, @@ -119,19 +129,6 @@ export function buildHarnessRequestMetadata( selected_team_label: selectedTeamLabel || undefined, selected_team_summary: selectedTeamSummary || undefined, selected_team_roles: serializeTeamRoles(selectedTeamRoles), - turn_team_decision: turnTeamDecision || undefined, - turn_team_reason: turnTeamReason || undefined, - turn_team_blueprint: - turnTeamBlueprint && - (turnTeamBlueprint.label?.trim() || - turnTeamBlueprint.description?.trim() || - (turnTeamBlueprint.roles?.length || 0) > 0) - ? { - label: turnTeamBlueprint.label?.trim() || undefined, - description: turnTeamBlueprint.description?.trim() || undefined, - roles: serializeTeamRoles(turnTeamBlueprint.roles), - } - : undefined, browser_requirement: browserRequirement || undefined, browser_requirement_reason: browserRequirementReason || undefined, browser_launch_url: browserLaunchUrl || undefined, @@ -147,4 +144,7 @@ export function buildHarnessRequestMetadata( } : undefined, }; + + clearLegacyHarnessStateFields(metadata); + return metadata; } diff --git a/src/components/agent/chat/utils/harnessState.test.ts b/src/components/agent/chat/utils/harnessState.test.ts index 42c0ff2f1..94389cb28 100644 --- a/src/components/agent/chat/utils/harnessState.test.ts +++ b/src/components/agent/chat/utils/harnessState.test.ts @@ -151,6 +151,51 @@ describe("deriveHarnessSessionState", () => { expect(state.outputSignals[0]?.content).toContain("https://example.com/xinhua"); }); + it("应通过 artifact protocol 提取工具输出中的嵌套产物路径", () => { + const messages = [ + createMessage({ + toolCalls: [ + { + id: "tool-artifact-1", + name: "write_file", + arguments: JSON.stringify({ + payload: { + filePath: "workspace/draft.md", + }, + }), + status: "completed", + result: { + success: true, + metadata: { + payload: { + artifactPath: "workspace\\cover.png", + }, + }, + output: JSON.stringify({ + result: { + absolute_path: "/tmp/workspace/final.md", + }, + }), + }, + startTime: new Date("2026-03-13T12:00:00.000Z"), + endTime: new Date("2026-03-13T12:00:03.000Z"), + }, + ], + }), + ]; + + const state = deriveHarnessSessionState(messages, []); + + expect(state.outputSignals[0]).toMatchObject({ + title: "产物已写入", + artifactPath: "workspace/cover.png", + }); + expect(state.recentFileEvents[0]).toMatchObject({ + path: "workspace/draft.md", + action: "write", + }); + }); + it("仅有 turn_summary 时也应为 harness 提供计划摘要兜底", () => { const messages = [createMessage()]; const items: AgentThreadItem[] = [ diff --git a/src/components/agent/chat/utils/harnessState.ts b/src/components/agent/chat/utils/harnessState.ts index 64971582a..b9ab2da9c 100644 --- a/src/components/agent/chat/utils/harnessState.ts +++ b/src/components/agent/chat/utils/harnessState.ts @@ -1,9 +1,14 @@ import type { + AgentContextTraceStep as ContextTraceStep, AgentThreadItem, - ContextTraceStep, - ToolCallState, -} from "@/lib/api/agentStream"; + AgentToolCallState as ToolCallState, +} from "@/lib/api/agentProtocol"; import type { ArtifactStatus } from "@/lib/artifact/types"; +import { + extractArtifactProtocolPathsFromValue, + resolveArtifactProtocolFilePath, +} from "@/lib/artifact-protocol"; +import { extractFilesystemEventPathsFromValue } from "@/lib/filesystem-event-protocol"; import type { ActionRequired, AgentRuntimeStatus, Message } from "../types"; import { resolveToolDisplayLabel } from "./toolDisplayInfo"; import { @@ -446,14 +451,7 @@ function extractActiveFileWrites( continue; } - const path = - typeof artifact.meta.filePath === "string" && - artifact.meta.filePath.trim() - ? artifact.meta.filePath.trim() - : typeof artifact.meta.filename === "string" && - artifact.meta.filename.trim() - ? artifact.meta.filename.trim() - : artifact.title; + const path = resolveArtifactProtocolFilePath(artifact); if (!path) { continue; } @@ -580,56 +578,6 @@ function fileNameFromPath(path: string): string { return segments[segments.length - 1] || path; } -function pickFirstPath(value: unknown): string | undefined { - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - - if (Array.isArray(value)) { - for (const item of value) { - if (typeof item === "string" && item.trim()) { - return item.trim(); - } - } - } - - return undefined; -} - -function extractPathFromRecord( - record: Record | null, -): string | undefined { - if (!record) { - return undefined; - } - - for (const key of [ - "path", - "file_path", - "filePath", - "file_name", - "fileName", - "filename", - "target_path", - "targetPath", - "output_path", - "outputPath", - "absolute_path", - "absolutePath", - "new_path", - "newPath", - "paths", - "files", - ]) { - const value = pickFirstPath(record[key]); - if (value) { - return value; - } - } - - return undefined; -} - function extractContentFromRecord( record: Record | null, ): string | undefined { @@ -739,12 +687,7 @@ function resolveFileKind( function extractArtifactPath(toolCall: ToolCallState): string | undefined { const output = toolCall.result?.output; - const parsed = asRecord(parseJsonValue(output)); - return ( - normalizeString(parsed?.absolute_path) || - normalizeString(parsed?.path) || - normalizeString(parsed?.output_path) - ); + return extractArtifactProtocolPathsFromValue(parseJsonValue(output))[0]; } function extractOutputSignal( @@ -766,7 +709,7 @@ function extractOutputSignal( output, ); const artifactPath = - normalizeString(metadata?.path) || extractArtifactPath(toolCall); + extractArtifactProtocolPathsFromValue(metadata)[0] || extractArtifactPath(toolCall); const exitCode = normalizeNumber(metadata?.exit_code) || parseNumberFromText(/^退出码:\s*(-?\d+)$/m, output) || @@ -939,7 +882,12 @@ function extractFileEventFromToolCall( const args = asRecord(parseJsonValue(toolCall.arguments)); const metadata = extractMetadata(toolCall); - const path = extractPathFromRecord(args) || extractPathFromRecord(metadata); + const path = + extractArtifactProtocolPathsFromValue(args)[0] || + extractArtifactProtocolPathsFromValue(metadata)[0] || + // 非 artifact 协议文件工具仍可能只暴露通用文件参数,这里保留事件级 fallback。 + extractFilesystemEventPathsFromValue(args)[0] || + extractFilesystemEventPathsFromValue(metadata)[0]; if (!path) { return null; } @@ -1147,8 +1095,7 @@ function pickItemPath(item: AgentThreadItem): string | undefined { } if (item.type === "tool_call") { - const metadata = asRecord(item.metadata); - return extractPathFromRecord(metadata); + return extractArtifactProtocolPathsFromValue(item.metadata)[0]; } return undefined; diff --git a/src/components/agent/chat/utils/messageArtifacts.test.ts b/src/components/agent/chat/utils/messageArtifacts.test.ts new file mode 100644 index 000000000..e0221ef6a --- /dev/null +++ b/src/components/agent/chat/utils/messageArtifacts.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import type { Artifact } from "@/lib/artifact/types"; +import { ARTIFACT_DOCUMENT_SCHEMA_VERSION } from "@/lib/artifact-document"; +import { buildArtifactFromWrite } from "./messageArtifacts"; + +function createArtifact(overrides: Partial = {}): Artifact { + const content = overrides.content ?? ""; + return { + id: overrides.id ?? "artifact-1", + type: overrides.type ?? "document", + title: overrides.title ?? "report.artifact.json", + content, + status: overrides.status ?? "streaming", + meta: { + filePath: + overrides.meta?.filePath ?? + ".lime/artifacts/thread-1/report.artifact.json", + filename: overrides.meta?.filename ?? "report.artifact.json", + ...overrides.meta, + }, + position: overrides.position ?? { start: 0, end: content.length }, + createdAt: overrides.createdAt ?? 1, + updatedAt: overrides.updatedAt ?? 1, + error: overrides.error, + }; +} + +describe("messageArtifacts.buildArtifactFromWrite", () => { + it("应从 artifactDocument metadata 直接构建结构化文档 artifact", () => { + const artifactDocument = { + schemaVersion: ARTIFACT_DOCUMENT_SCHEMA_VERSION, + artifactId: "artifact-doc-1", + kind: "analysis", + title: "自动落盘报告", + status: "ready", + language: "zh-CN", + summary: "这是结构化摘要。", + blocks: [ + { + id: "hero-1", + type: "hero_summary", + summary: "这是结构化摘要。", + }, + ], + sources: [], + metadata: { + theme: "knowledge", + }, + }; + + const artifact = buildArtifactFromWrite({ + filePath: ".lime/artifacts/thread-1/report.artifact.json", + content: "", + context: { + artifactId: "artifact-snapshot-1", + source: "artifact_snapshot", + sourceMessageId: "assistant-1", + status: "streaming", + metadata: { + complete: true, + artifactSchema: ARTIFACT_DOCUMENT_SCHEMA_VERSION, + artifactDocument, + }, + }, + }); + + expect(artifact.type).toBe("document"); + expect(artifact.meta.language).toBe("json"); + expect(artifact.meta.previewText).toBe("这是结构化摘要。"); + expect(artifact.meta.artifactTitle).toBe("自动落盘报告"); + expect(artifact.meta.artifactKind).toBe("analysis"); + expect(artifact.meta.artifactDocument).toMatchObject({ + artifactId: "artifact-doc-1", + kind: "analysis", + title: "自动落盘报告", + summary: "这是结构化摘要。", + }); + }); + + it("metadata 仅保留 schema 时应复用已有 artifactDocument", () => { + const previousArtifact = createArtifact({ + meta: { + filePath: ".lime/artifacts/thread-1/report.artifact.json", + filename: "report.artifact.json", + artifactSchema: ARTIFACT_DOCUMENT_SCHEMA_VERSION, + artifactDocument: { + schemaVersion: ARTIFACT_DOCUMENT_SCHEMA_VERSION, + artifactId: "artifact-doc-2", + kind: "report", + title: "已有结构化文档", + status: "ready", + language: "zh-CN", + summary: "沿用已有结构摘要", + blocks: [ + { + id: "hero-1", + type: "hero_summary", + summary: "沿用已有结构摘要", + }, + ], + sources: [], + metadata: {}, + }, + }, + }); + + const artifact = buildArtifactFromWrite({ + filePath: ".lime/artifacts/thread-1/report.artifact.json", + content: "", + context: { + artifact: previousArtifact, + source: "artifact_snapshot", + sourceMessageId: "assistant-2", + status: "complete", + metadata: { + artifactSchema: ARTIFACT_DOCUMENT_SCHEMA_VERSION, + complete: true, + }, + }, + }); + + expect(artifact.meta.artifactTitle).toBe("已有结构化文档"); + expect(artifact.meta.previewText).toBe("沿用已有结构摘要"); + expect(artifact.meta.artifactDocument).toMatchObject({ + artifactId: "artifact-doc-2", + kind: "report", + title: "已有结构化文档", + summary: "沿用已有结构摘要", + }); + }); +}); diff --git a/src/components/agent/chat/utils/messageArtifacts.ts b/src/components/agent/chat/utils/messageArtifacts.ts index ef2872ee4..c11d45478 100644 --- a/src/components/agent/chat/utils/messageArtifacts.ts +++ b/src/components/agent/chat/utils/messageArtifacts.ts @@ -4,6 +4,12 @@ import type { ArtifactStatus, ArtifactType, } from "@/lib/artifact/types"; +import { + hasArtifactProtocolDocumentMetadata, + resolveArtifactProtocolDocumentPayload, + resolveArtifactProtocolFilePath, + resolveArtifactProtocolPreviewText, +} from "@/lib/artifact-protocol"; import type { ArtifactWriteMetadata, ArtifactWritePhase, @@ -52,6 +58,8 @@ const CODE_LANGUAGE_BY_EXTENSION: Record = { }; const ARTIFACT_TYPE_ALIASES: Record = { + artifact_document: "document", + artifact_document_v1: "document", code: "code", document: "document", draft: "document", @@ -184,10 +192,7 @@ export function findMessageArtifact( return false; } - const artifactPath = - typeof artifact.meta.filePath === "string" - ? normalizePath(artifact.meta.filePath) - : null; + const artifactPath = normalizePath(resolveArtifactProtocolFilePath(artifact)); return artifactPath === normalizedPath; }); } @@ -195,7 +200,18 @@ export function findMessageArtifact( export function resolveArtifactTypeFromFile( filePath: string, metadata?: Record, + content?: string, ): ArtifactType { + if ( + resolveArtifactProtocolDocumentPayload({ + content, + metadata, + }) || + hasArtifactProtocolDocumentMetadata(metadata) + ) { + return "document"; + } + const explicitType = readStringValue(metadata, "artifact_type") || readStringValue(metadata, "type") || @@ -259,24 +275,6 @@ export function resolveDefaultArtifactViewMode( return "source"; } -export function resolveArtifactFilePath( - artifact: Pick, -): string { - if ( - typeof artifact.meta.filePath === "string" && - artifact.meta.filePath.trim() - ) { - return artifact.meta.filePath.trim(); - } - if ( - typeof artifact.meta.filename === "string" && - artifact.meta.filename.trim() - ) { - return artifact.meta.filename.trim(); - } - return artifact.title; -} - export interface BuildArtifactInput { filePath: string; content: string; @@ -292,31 +290,59 @@ export function buildArtifactFromWrite({ const title = fileNameFromPath(normalizedPath); const now = Date.now(); const metadata = context?.metadata; - const type = resolveArtifactTypeFromFile(normalizedPath, metadata); - const language = - type === "code" || type === "document" - ? resolveArtifactLanguageFromFile(normalizedPath) - : undefined; - const previewText = - typeof content === "string" && content.trim() - ? normalizePreviewText(content) - : undefined; const existingMeta = context?.artifact?.meta && typeof context.artifact.meta === "object" ? (context.artifact.meta as ArtifactWriteMetadata) : undefined; + const existingArtifactDocument = resolveArtifactProtocolDocumentPayload({ + metadata: existingMeta, + }); + const artifactDocument = resolveArtifactProtocolDocumentPayload({ + content, + metadata, + previous: existingArtifactDocument, + }); + const type = resolveArtifactTypeFromFile(normalizedPath, metadata, content); + const language = + artifactDocument + ? "json" + : type === "code" || type === "document" + ? resolveArtifactLanguageFromFile(normalizedPath) + : undefined; + const previewCandidate = + artifactDocument + ? resolveArtifactProtocolPreviewText(artifactDocument) + : typeof metadata?.previewText === "string" && metadata.previewText.trim() + ? metadata.previewText + : typeof content === "string" && content.trim() + ? content + : undefined; + const previewText = previewCandidate + ? normalizePreviewText(previewCandidate) + : undefined; const baseMeta: ArtifactMeta = { ...(existingMeta || {}), ...(metadata || {}), ...(language ? { language } : {}), ...(previewText && - !(typeof metadata?.previewText === "string" && metadata.previewText.trim()) + (!( + typeof metadata?.previewText === "string" && metadata.previewText.trim() + ) || + Boolean(artifactDocument)) ? { previewText } : {}), filePath: normalizedPath, filename: title, source: context?.source, sourceMessageId: context?.sourceMessageId, + ...(artifactDocument + ? { + artifactSchema: artifactDocument.schemaVersion, + artifactKind: artifactDocument.kind, + artifactDocument, + artifactTitle: artifactDocument.title, + } + : {}), }; return { @@ -397,42 +423,6 @@ export function updateMessageArtifactsStatus( }; } -export function extractArtifactPathsFromMetadata( - metadata?: Record, -): string[] { - if (!metadata) { - return []; - } - - const collected = new Set(); - const appendPath = (value: unknown) => { - if (typeof value !== "string") { - return; - } - const normalized = normalizePath(value); - if (normalized) { - collected.add(normalized); - } - }; - - const appendArray = (value: unknown) => { - if (!Array.isArray(value)) { - return; - } - for (const item of value) { - appendPath(item); - } - }; - - appendArray(metadata.artifact_paths); - appendArray(metadata.paths); - appendPath(metadata.artifact_path); - appendPath(metadata.path); - appendPath(metadata.source_file_name); - - return Array.from(collected); -} - export function mergeArtifacts(artifacts: Artifact[]): Artifact[] { const merged = new Map(); diff --git a/src/components/agent/chat/utils/sessionExecutionRuntime.test.ts b/src/components/agent/chat/utils/sessionExecutionRuntime.test.ts new file mode 100644 index 000000000..10586c12f --- /dev/null +++ b/src/components/agent/chat/utils/sessionExecutionRuntime.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { + applyModelChangeExecutionRuntime, + getExecutionRuntimeDisplayLabel, + applyTurnContextExecutionRuntime, + createChatToolPreferencesFromExecutionRuntime, + createSessionModelPreferenceFromExecutionRuntime, + getExecutionRuntimeProviderLabel, + getExecutionRuntimeSummaryLabel, + getOutputSchemaRuntimeLabel, +} from "./sessionExecutionRuntime"; + +describe("sessionExecutionRuntime", () => { + it("应根据 turn_context 事件同步 output schema runtime", () => { + const runtime = applyTurnContextExecutionRuntime(null, { + type: "turn_context", + session_id: "session-1", + thread_id: "thread-1", + turn_id: "turn-1", + output_schema_runtime: { + source: "turn", + strategy: "native", + providerName: "openai", + modelName: "gpt-5.4", + }, + }); + + expect(runtime).toMatchObject({ + session_id: "session-1", + source: "turn_context", + provider_name: "openai", + model_name: "gpt-5.4", + latest_turn_id: "turn-1", + latest_turn_status: "running", + }); + }); + + it("应在 model_change 后保留 provider 与 output schema,并更新模型", () => { + const fromTurnContext = applyTurnContextExecutionRuntime(null, { + type: "turn_context", + session_id: "session-1", + thread_id: "thread-1", + turn_id: "turn-1", + output_schema_runtime: { + source: "session", + strategy: "final_output_tool", + providerName: "openai", + modelName: "gpt-5.4", + }, + }); + + const runtime = applyModelChangeExecutionRuntime(fromTurnContext, { + type: "model_change", + model: "gpt-5.4-mini", + mode: "responses", + }); + + expect(runtime).toMatchObject({ + session_id: "session-1", + source: "model_change", + provider_name: "openai", + model_name: "gpt-5.4-mini", + mode: "responses", + }); + expect(runtime?.output_schema_runtime?.strategy).toBe("final_output_tool"); + }); + + it("应产出可读的 provider 与 schema 标签", () => { + const runtime = { + session_id: "session-2", + provider_selector: "openai", + provider_name: "openai", + model_name: "gpt-5.4", + source: "runtime_snapshot" as const, + output_schema_runtime: { + source: "turn" as const, + strategy: "native" as const, + providerName: "openai", + modelName: "gpt-5.4", + }, + }; + + expect(getExecutionRuntimeProviderLabel(runtime)).toBe("OpenAI"); + expect(getExecutionRuntimeSummaryLabel(runtime)).toBe( + "执行模型 OpenAI · gpt-5.4", + ); + expect(getExecutionRuntimeDisplayLabel(runtime)).toBe( + "最近执行模型 OpenAI · gpt-5.4", + ); + expect(getExecutionRuntimeDisplayLabel(runtime, { active: true })).toBe( + "实际执行模型 OpenAI · gpt-5.4", + ); + expect(getOutputSchemaRuntimeLabel(runtime.output_schema_runtime)).toBe( + "Native schema · turn contract", + ); + }); + + it("应优先使用 provider_selector 还原会话模型偏好", () => { + expect( + createSessionModelPreferenceFromExecutionRuntime({ + provider_selector: "custom-provider-id", + provider_name: "openai", + model_name: "gpt-5.4", + }), + ).toEqual({ + providerType: "custom-provider-id", + model: "gpt-5.4", + }); + }); + + it("缺少 provider 或 model 时不应生成会话模型偏好", () => { + expect( + createSessionModelPreferenceFromExecutionRuntime({ + provider_selector: "openai", + model_name: null, + }), + ).toBeNull(); + expect( + createSessionModelPreferenceFromExecutionRuntime({ + provider_selector: null, + provider_name: null, + model_name: "gpt-5.4", + }), + ).toBeNull(); + }); + + it("应从 execution runtime 提取最近工具偏好", () => { + expect( + createChatToolPreferencesFromExecutionRuntime({ + recent_preferences: { + webSearch: true, + thinking: true, + task: false, + subagent: true, + }, + }), + ).toEqual({ + webSearch: true, + thinking: true, + task: false, + subagent: true, + }); + }); +}); diff --git a/src/components/agent/chat/utils/sessionExecutionRuntime.ts b/src/components/agent/chat/utils/sessionExecutionRuntime.ts new file mode 100644 index 000000000..f2d09cf32 --- /dev/null +++ b/src/components/agent/chat/utils/sessionExecutionRuntime.ts @@ -0,0 +1,223 @@ +import { getProviderLabel } from "@/lib/constants/providerMappings"; +import type { + AsterSessionExecutionRuntime, + AsterSessionExecutionRuntimePreferences, + AsterSessionExecutionRuntimeSource, + AsterTurnOutputSchemaRuntime, +} from "@/lib/api/agentExecutionRuntime"; +import type { AsterSessionDetail } from "@/lib/api/agentRuntime"; +import type { + AgentEventModelChange, + AgentEventTurnContext, +} from "@/lib/api/agentProtocol"; +import type { SessionModelPreference } from "../hooks/agentChatShared"; +import type { ChatToolPreferences } from "./chatToolPreferences"; + +function mergeExecutionRuntime( + current: AsterSessionExecutionRuntime | null, + updates: Partial, + source: AsterSessionExecutionRuntimeSource, +): AsterSessionExecutionRuntime | null { + const sessionId = updates.session_id || current?.session_id; + const providerSelector = + updates.provider_selector ?? current?.provider_selector ?? null; + const providerName = updates.provider_name ?? current?.provider_name ?? null; + const modelName = updates.model_name ?? current?.model_name ?? null; + const executionStrategy = + updates.execution_strategy ?? current?.execution_strategy ?? null; + const outputSchemaRuntime = + updates.output_schema_runtime ?? current?.output_schema_runtime ?? null; + const recentPreferences = + updates.recent_preferences ?? current?.recent_preferences ?? null; + const mode = updates.mode ?? current?.mode ?? null; + const latestTurnId = updates.latest_turn_id ?? current?.latest_turn_id ?? null; + const latestTurnStatus = + updates.latest_turn_status ?? current?.latest_turn_status ?? null; + + if (!sessionId) { + return null; + } + + if ( + !providerSelector && + !providerName && + !modelName && + !outputSchemaRuntime && + !executionStrategy && + !recentPreferences + ) { + return null; + } + + return { + session_id: sessionId, + provider_selector: providerSelector, + provider_name: providerName, + model_name: modelName, + execution_strategy: executionStrategy, + output_schema_runtime: outputSchemaRuntime, + recent_preferences: recentPreferences, + source, + mode, + latest_turn_id: latestTurnId, + latest_turn_status: latestTurnStatus, + }; +} + +export function createExecutionRuntimeFromSessionDetail( + detail?: Pick | null, +): AsterSessionExecutionRuntime | null { + return detail?.execution_runtime || null; +} + +export function createSessionModelPreferenceFromExecutionRuntime( + runtime?: Pick< + AsterSessionExecutionRuntime, + "provider_selector" | "provider_name" | "model_name" + > | null, +): SessionModelPreference | null { + const providerType = + runtime?.provider_selector?.trim() || + runtime?.provider_name?.trim() || + null; + const model = runtime?.model_name?.trim() || null; + + if (!providerType || !model) { + return null; + } + + return { + providerType, + model, + }; +} + +function normalizeRecentPreferenceBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +export function createChatToolPreferencesFromExecutionRuntime( + runtime?: Pick | null, +): ChatToolPreferences | null { + const preferences = runtime?.recent_preferences as + | AsterSessionExecutionRuntimePreferences + | null + | undefined; + if (!preferences) { + return null; + } + + const webSearch = normalizeRecentPreferenceBoolean(preferences.webSearch); + const thinking = normalizeRecentPreferenceBoolean(preferences.thinking); + const task = normalizeRecentPreferenceBoolean(preferences.task); + const subagent = normalizeRecentPreferenceBoolean(preferences.subagent); + + if ( + webSearch === null && + thinking === null && + task === null && + subagent === null + ) { + return null; + } + + return { + webSearch: webSearch ?? false, + thinking: thinking ?? false, + task: task ?? false, + subagent: subagent ?? false, + }; +} + +export function applyTurnContextExecutionRuntime( + current: AsterSessionExecutionRuntime | null, + event: AgentEventTurnContext, +): AsterSessionExecutionRuntime | null { + const outputSchemaRuntime = event.output_schema_runtime || null; + return mergeExecutionRuntime( + current, + { + session_id: event.session_id, + output_schema_runtime: outputSchemaRuntime, + provider_name: outputSchemaRuntime?.providerName ?? undefined, + model_name: outputSchemaRuntime?.modelName ?? undefined, + latest_turn_id: event.turn_id, + latest_turn_status: "running", + }, + "turn_context", + ); +} + +export function applyModelChangeExecutionRuntime( + current: AsterSessionExecutionRuntime | null, + event: AgentEventModelChange, +): AsterSessionExecutionRuntime | null { + return mergeExecutionRuntime( + current, + { + model_name: event.model, + mode: event.mode, + latest_turn_status: current?.latest_turn_status || "running", + }, + "model_change", + ); +} + +export function getExecutionRuntimeProviderLabel( + runtime?: AsterSessionExecutionRuntime | null, +): string | null { + const providerKey = + runtime?.provider_selector?.trim() || + runtime?.provider_name?.trim() || + null; + if (!providerKey) { + return null; + } + return getProviderLabel(providerKey); +} + +export function getOutputSchemaRuntimeLabel( + runtime?: AsterTurnOutputSchemaRuntime | null, +): string | null { + if (!runtime) { + return null; + } + + const strategyLabel = + runtime.strategy === "native" ? "Native schema" : "Final output tool"; + const sourceLabel = + runtime.source === "turn" ? "turn contract" : "session contract"; + return `${strategyLabel} · ${sourceLabel}`; +} + +export function getExecutionRuntimeSummaryLabel( + runtime?: AsterSessionExecutionRuntime | null, +): string | null { + const providerLabel = getExecutionRuntimeProviderLabel(runtime); + const modelLabel = runtime?.model_name?.trim() || null; + if (providerLabel && modelLabel) { + return `执行模型 ${providerLabel} · ${modelLabel}`; + } + if (modelLabel) { + return `执行模型 ${modelLabel}`; + } + if (providerLabel) { + return `执行提供方 ${providerLabel}`; + } + return null; +} + +export function getExecutionRuntimeDisplayLabel( + runtime?: AsterSessionExecutionRuntime | null, + options?: { active?: boolean }, +): string | null { + const summaryLabel = getExecutionRuntimeSummaryLabel(runtime); + if (!summaryLabel) { + return null; + } + + return summaryLabel.replace( + /^执行/, + options?.active ? "实际执行" : "最近执行", + ); +} diff --git a/src/components/agent/chat/utils/streamDiagnostics.ts b/src/components/agent/chat/utils/streamDiagnostics.ts index 487985385..dbb7f3e39 100644 --- a/src/components/agent/chat/utils/streamDiagnostics.ts +++ b/src/components/agent/chat/utils/streamDiagnostics.ts @@ -1,4 +1,4 @@ -import type { StreamEvent } from "@/lib/api/agentStream"; +import type { AgentEvent } from "@/lib/api/agentProtocol"; import { updateCrashContext } from "@/lib/crashReporting"; const EVENT_PUBLISH_INTERVAL = 20; @@ -119,7 +119,7 @@ export function createStreamDiagnosticsReporter(componentName: string) { publish(true); }, - record(event: StreamEvent) { + record(event: AgentEvent) { if (!snapshot) return; snapshot.totalEvents += 1; snapshot.lastEventAt = nowIso(); diff --git a/src/components/agent/chat/utils/teamAutoGeneration.ts b/src/components/agent/chat/utils/teamAutoGeneration.ts deleted file mode 100644 index 5dcb95ba4..000000000 --- a/src/components/agent/chat/utils/teamAutoGeneration.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { safeListen } from "@/lib/dev-bridge"; -import { - createAgentRuntimeSession, - deleteAgentRuntimeSession, - getAgentRuntimeSession, - initAsterAgent, - submitAgentRuntimeTurn, - type AsterExecutionStrategy, -} from "@/lib/api/agentRuntime"; -import { parseStreamEvent } from "@/lib/api/agentStream"; -import { mapProviderName } from "../hooks/agentChatCoreUtils"; -import { - createTeamDefinitionId, - normalizeTeamDefinition, - type TeamDefinition, - type TeamRoleDefinition, -} from "./teamDefinitions"; - -interface GenerateTeamWithModelOptions { - workspaceId: string; - providerType: string; - model: string; - executionStrategy?: AsterExecutionStrategy; - activeTheme?: string; - input: string; - blueprintTeam?: TeamDefinition | null; - timeoutMs?: number; -} - -interface GeneratedTeamPayload { - label?: string; - description?: string; - roles?: Array<{ - id?: string; - label?: string; - summary?: string; - profileId?: string; - roleKey?: string; - skillIds?: string[]; - }>; - team?: GeneratedTeamPayload; -} - -function buildTeamGenerationPrompt( - input: string, - activeTheme?: string, - blueprintTeam?: TeamDefinition | null, -): string { - const blueprintPayload = blueprintTeam - ? { - label: blueprintTeam.label, - description: blueprintTeam.description, - roles: blueprintTeam.roles.map((role) => ({ - id: role.id, - label: role.label, - summary: role.summary, - profileId: role.profileId, - roleKey: role.roleKey, - skillIds: role.skillIds ?? [], - })), - } - : null; - - return [ - "请根据下面的任务,为 GUI Team 模式生成一份“当前协作方案”。", - "要求:", - "1. 仅输出 JSON,不要 Markdown,不要代码块,不要额外解释。", - "2. 生成 2~4 个角色,角色名称与职责用中文。", - "3. label 要像产品里可直接展示的团队名称,不要出现“本轮 Team”这类内部说法。", - "4. roleKey 优先从以下集合中选择:explorer、executor、verifier、researcher、planner、reviewer、writer。", - "5. profileId 仅可使用以下值:code-explorer、code-executor、code-verifier、research-analyst、doc-writer、content-ideator、content-reviewer。", - "6. skillIds 仅可使用以下值:repo-exploration、bounded-implementation、verification-report、source-grounding、structured-writing。", - "7. 输出结构必须满足:", - JSON.stringify( - { - label: "当前协作名称", - description: "一句话描述适用场景", - roles: [ - { - id: "role-id", - label: "角色名称", - summary: "角色职责", - profileId: "code-explorer", - roleKey: "explorer", - skillIds: ["repo-exploration", "source-grounding"], - }, - ], - }, - null, - 2, - ), - `当前主题:${activeTheme?.trim() || "general"}`, - blueprintPayload - ? "8. 如果下面提供了“参考蓝图 Team”,请把它视为偏好与约束来源;你可以按当前任务动态调整角色结构,但不要完全脱离参考蓝图。" - : "8. 如果没有参考蓝图,请直接围绕当前任务组织最合适的当前协作方案。", - blueprintPayload - ? `参考蓝图 Team:${JSON.stringify(blueprintPayload, null, 2)}` - : "参考蓝图 Team:无", - "如果任务本身不复杂,也仍然请输出一个最轻量可用的两角色 Team。", - `任务描述:${input.trim()}`, - ].join("\n"); -} - -function extractAssistantText(detail: Awaited>): string { - const assistantMessages = [...detail.messages] - .filter((message) => message.role === "assistant") - .sort((left, right) => right.timestamp - left.timestamp); - - for (const message of assistantMessages) { - const text = message.content - .map((item) => item.text || item.output || item.error || "") - .join("\n") - .trim(); - if (text) { - return text; - } - } - - return ""; -} - -function extractJsonObject(raw: string): string { - const trimmed = raw.trim(); - const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); - const candidate = codeFenceMatch?.[1]?.trim() || trimmed; - const start = candidate.indexOf("{"); - const end = candidate.lastIndexOf("}"); - if (start === -1 || end === -1 || end <= start) { - throw new Error("模型未返回可解析的 JSON"); - } - return candidate.slice(start, end + 1); -} - -function parseGeneratedTeam( - raw: string, - activeTheme?: string, -): TeamDefinition { - const json = extractJsonObject(raw); - const parsed = JSON.parse(json) as GeneratedTeamPayload; - const payload = parsed.team && typeof parsed.team === "object" ? parsed.team : parsed; - const normalizedRoles: TeamRoleDefinition[] | undefined = payload.roles?.map( - (role) => ({ - id: role.id?.trim() || "", - label: role.label?.trim() || "", - summary: role.summary?.trim() || "", - profileId: role.profileId?.trim() || undefined, - roleKey: role.roleKey?.trim() || undefined, - skillIds: role.skillIds?.map((skillId) => skillId.trim()).filter(Boolean) || [], - }), - ); - const normalized = normalizeTeamDefinition({ - id: createTeamDefinitionId("ephemeral-team"), - source: "ephemeral", - label: payload.label, - description: payload.description, - theme: activeTheme?.trim() || undefined, - roles: normalizedRoles, - }); - - if (!normalized) { - throw new Error("模型返回的 Team 结构不完整"); - } - - return { - ...normalized, - source: "ephemeral", - }; -} - -export async function generateEphemeralTeamWithModel( - options: GenerateTeamWithModelOptions, -): Promise { - const { - workspaceId, - providerType, - model, - executionStrategy = "react", - activeTheme, - input, - blueprintTeam, - timeoutMs = 45_000, - } = options; - const trimmedInput = input.trim(); - if (!workspaceId.trim()) { - throw new Error("缺少 workspace,暂时无法生成 Team"); - } - if (!providerType.trim() || !model.trim()) { - throw new Error("请先选择可用模型,再生成 Team"); - } - if (!trimmedInput) { - throw new Error("请先输入任务描述,再生成 Team"); - } - - await initAsterAgent(); - const resolvedExecutionStrategy = - executionStrategy === "code_orchestrated" ? "react" : executionStrategy; - const sessionId = await createAgentRuntimeSession( - workspaceId, - "Team 规划", - resolvedExecutionStrategy, - ); - const eventName = `agent_team_draft:${sessionId}:${Date.now()}`; - const unlistenRef: { current: (() => void) | null } = { current: null }; - - try { - const completion = new Promise((resolve, reject) => { - let settled = false; - const timer = globalThis.setTimeout(() => { - settle(() => reject(new Error("生成 Team 超时,请稍后重试"))); - }, timeoutMs); - const settle = (callback: () => void) => { - if (settled) { - return; - } - settled = true; - globalThis.clearTimeout(timer); - callback(); - }; - - void (async () => { - try { - unlistenRef.current = await safeListen(eventName, async (event) => { - const parsed = parseStreamEvent(event.payload); - if (!parsed) { - return; - } - - if (parsed.type === "turn_failed" || parsed.type === "error") { - settle(() => - reject( - new Error( - parsed.type === "turn_failed" - ? parsed.turn.error_message?.trim() || - "Team 生成失败" - : parsed.message.trim() || "Team 生成失败", - ), - ), - ); - return; - } - - if ( - parsed.type !== "turn_completed" && - parsed.type !== "final_done" - ) { - return; - } - - try { - const detail = await getAgentRuntimeSession(sessionId); - const responseText = extractAssistantText(detail); - const generatedTeam = parseGeneratedTeam(responseText, activeTheme); - settle(() => resolve(generatedTeam)); - } catch (error) { - settle(() => - reject( - error instanceof Error - ? error - : new Error("解析 Team 结果失败"), - ), - ); - } - }); - - await submitAgentRuntimeTurn({ - message: buildTeamGenerationPrompt( - trimmedInput, - activeTheme, - blueprintTeam, - ), - session_id: sessionId, - event_name: eventName, - workspace_id: workspaceId, - turn_config: { - provider_config: { - provider_id: providerType, - provider_name: mapProviderName(providerType), - model_name: model, - }, - execution_strategy: resolvedExecutionStrategy, - web_search: false, - search_mode: "disabled", - system_prompt: - "你是 GUI Team 规划器。当前唯一任务是输出结构化 JSON Team 定义,不要调用工具,不要输出解释。", - metadata: { - source: "team_auto_generation", - auto_team: true, - theme: activeTheme || "general", - }, - }, - }); - } catch (error) { - settle(() => - reject( - error instanceof Error - ? error - : new Error("提交 Team 生成任务失败"), - ), - ); - } - })(); - }); - - return await completion; - } finally { - try { - unlistenRef.current?.(); - } catch { - // ignore cleanup failure - } - try { - await deleteAgentRuntimeSession(sessionId); - } catch { - // ignore cleanup failure - } - } -} diff --git a/src/components/agent/chat/utils/toolDisplayInfo.ts b/src/components/agent/chat/utils/toolDisplayInfo.ts index 78ccbc344..ff8145c16 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.ts @@ -12,7 +12,8 @@ import { Wrench, type LucideIcon, } from "lucide-react"; -import type { ToolCallState } from "@/lib/api/agentStream"; +import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; +import { extractArtifactProtocolPathsFromValue } from "@/lib/artifact-protocol"; export type ToolCallStatus = ToolCallState["status"]; export type ToolCallFamily = @@ -866,6 +867,70 @@ const EXACT_TOOL_CONFIGS = new Map([ actionKey: "task", }, ], + [ + "limesitelist", + { + family: "list", + label: "站点能力目录", + verb: "浏览", + icon: Globe, + groupTitle: "站点", + actionKey: "list", + actions: { + failed: "浏览失败", + completed: "已浏览", + running: "浏览中", + }, + }, + ], + [ + "limesitesearch", + { + family: "search", + label: "站点能力搜索", + verb: "搜索", + icon: Search, + groupTitle: "站点", + actionKey: "search", + actions: { + failed: "搜索失败", + completed: "已搜索", + running: "搜索中", + }, + }, + ], + [ + "limesiteinfo", + { + family: "read", + label: "站点能力详情", + verb: "查看", + icon: Globe, + groupTitle: "站点", + actionKey: "read", + actions: { + failed: "查看失败", + completed: "已查看", + running: "查看中", + }, + }, + ], + [ + "limesiterun", + { + family: "generic", + label: "站点能力执行", + verb: "执行", + icon: Globe, + groupTitle: "站点", + actionKey: "generic", + actions: { + failed: "执行失败", + completed: "已执行", + running: "执行中", + }, + }, + ], ]); const BROWSER_TOOL_MATCHERS: Array<{ @@ -1173,7 +1238,7 @@ const resolveToolArgumentPreview = ( }; const getFileName = (filePath: string): string => { - const parts = filePath.split("/"); + const parts = filePath.split(/[/\\]/); return parts[parts.length - 1] || filePath; }; @@ -1206,8 +1271,7 @@ export const parseToolCallArguments = ( export const resolveToolFilePath = ( args: Record, ): string | null => { - const path = args.path || args.file_path || args.filePath; - return path ? String(path) : null; + return extractArtifactProtocolPathsFromValue(args)[0] ?? null; }; export const isBrowserToolName = (name: string): boolean => @@ -1355,6 +1419,25 @@ export const resolveToolPrimarySubject = ( ]); } + if ( + normalizedName === "limesiterun" || + normalizedName === "limesiteinfo" + ) { + return ( + resolveToolArgumentPreview(args, [ + "adapter_name", + "name", + "query", + "repo", + "url", + ]) || "站点适配器" + ); + } + + if (normalizedName === "limesitesearch") { + return resolveToolArgumentPreview(args, ["query", "q"]) || "站点能力"; + } + if (normalizedName === "ask" || normalizedName === "requestuserinput") { return resolveToolArgumentPreview(args, [ "question", @@ -1561,10 +1644,16 @@ export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => { const running = toolCalls.some((item) => item.status === "running"); if (info.family === "search") { + if (info.groupTitle === "站点") { + return running ? "站点搜索中" : failed ? "站点搜索失败" : "已搜索站点能力"; + } return running ? "搜索中" : failed ? "搜索失败" : "已搜索"; } if (["read", "list"].includes(info.family)) { + if (info.groupTitle === "站点") { + return running ? "站点浏览中" : failed ? "站点浏览失败" : "已浏览站点能力"; + } return running ? "探索中" : failed ? "探索失败" : "已探索"; } @@ -1645,7 +1734,15 @@ export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => { ? `图像分析失败 ${toolCalls.length} 项` : running ? `图像分析中 ${toolCalls.length} 项` - : `已分析 ${toolCalls.length} 项图像`; + : `已分析 ${toolCalls.length} 项图像`; + } + + if (info.groupTitle === "站点") { + return failed + ? `站点操作失败 ${toolCalls.length} 项` + : running + ? `站点操作中 ${toolCalls.length} 项` + : `已完成 ${toolCalls.length} 项站点操作`; } return failed diff --git a/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx new file mode 100644 index 000000000..1da0b4237 --- /dev/null +++ b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx @@ -0,0 +1,669 @@ +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 { ArtifactWorkbenchShell } from "./ArtifactWorkbenchShell"; +import { + areLightweightRenderersRegistered, + registerLightweightRenderers, +} from "@/components/artifact/renderers"; +import type { Artifact } from "@/lib/artifact/types"; +import type { AgentThreadItem } from "../types"; + +vi.mock("@/components/content-creator/canvas/document/editor", async () => { + const ReactModule = await import("react"); + + const MockNotionEditor = ReactModule.forwardRef< + { flushContent: () => string }, + { + content: string; + onCommit: (content: string) => void; + onSave: (latestContent?: string) => void; + onCancel: () => void; + } + >(({ content, onCommit, onSave, onCancel }, ref) => { + const [value, setValue] = ReactModule.useState(content); + + ReactModule.useEffect(() => { + setValue(content); + }, [content]); + + ReactModule.useImperativeHandle( + ref, + () => ({ + flushContent: () => value, + }), + [value], + ); + + return ( +
+