From deceacc69974c2510592f2a56153feb744f447ca Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 29 Mar 2026 14:15:34 +0800 Subject: [PATCH] chore: release v0.98.0 --- AGENTS.md | 2 + RELEASE_NOTES.md | 36 +- docs/aiprompts/README.md | 4 + docs/aiprompts/commands.md | 15 +- docs/aiprompts/governance.md | 7 + docs/aiprompts/overview.md | 16 +- docs/aiprompts/playwright-e2e.md | 32 +- docs/aiprompts/quality-workflow.md | 15 +- docs/aiprompts/site-adapter-standard.md | 381 +++++ docs/aiprompts/skill-standard.md | 442 +++++ .../site-adapter-source-integration.md | 512 ++++++ extensions/lime-chrome/README.md | 16 +- extensions/lime-chrome/background.js | 74 +- extensions/lime-chrome/popup.js | 2 +- package.json | 2 +- scripts/chrome-bridge-e2e.mjs | 252 ++- scripts/start-web-bridge-dev.mjs | 84 +- src-tauri/Cargo.lock | 62 +- src-tauri/Cargo.toml | 8 +- src-tauri/crates/agent/src/event_converter.rs | 60 +- src-tauri/crates/agent/src/lib.rs | 6 +- .../agent/src/session_execution_runtime.rs | 213 ++- .../crates/agent/src/text_normalization.rs | 73 + .../crates/agent/src/turn_input_envelope.rs | 44 +- src-tauri/crates/core/src/config/types.rs | 19 + src-tauri/crates/server/src/chrome_bridge.rs | 195 +++ .../site-adapters/bundled/index.json | 108 ++ .../bundled/scripts/linux-do-categories.js | 48 + .../bundled/scripts/linux-do-hot.js | 63 + .../bundled/scripts/smzdm-search.js | 59 + .../bundled/scripts/yahoo-finance-quote.js | 134 ++ src-tauri/src/agent/aster_agent.rs | 7 + src-tauri/src/app/runner.rs | 3 + .../command_api/session_api.rs | 8 + src-tauri/src/commands/aster_agent_cmd/dto.rs | 18 + .../commands/aster_agent_cmd/reply_runtime.rs | 20 +- .../run_metadata/request_metadata.rs | 27 + .../commands/aster_agent_cmd/runtime_turn.rs | 77 + .../aster_agent_cmd/subagent_runtime.rs | 4 + .../src/commands/aster_agent_cmd/tests.rs | 66 + .../tool_runtime/site_tools.rs | 6 + src-tauri/src/commands/external_tools_cmd.rs | 6 +- src-tauri/src/commands/site_capability_cmd.rs | 50 +- src-tauri/src/commands/webview_cmd.rs | 13 +- src-tauri/src/dev_bridge.rs | 89 +- .../dev_bridge/dispatcher/browser/bridge.rs | 12 + .../src/dev_bridge/dispatcher/browser/site.rs | 15 + src-tauri/src/services/README.md | 1 + .../services/automation_service/executor.rs | 76 +- .../src/services/automation_service/mod.rs | 323 ++-- .../src/services/browser_connector_service.rs | 257 ++- src-tauri/src/services/mod.rs | 1 + .../services/site_adapter_import_service.rs | 1450 +++++++++++++++++ .../src/services/site_adapter_registry.rs | 388 ++++- .../src/services/site_capability_service.rs | 515 +++++- src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- .../imported-real-world-bundle.yaml | 287 ++++ src/App.tsx | 668 +------- src/components/AppPageContent.tsx | 377 +++++ src/components/AppSidebar.test.tsx | 15 + src/components/AppSidebar.tsx | 2 +- src/components/agent/AgentSkillsPanel.tsx | 12 +- .../agent/chat/AgentChatHomeShell.test.tsx | 168 +- .../agent/chat/AgentChatHomeShell.tsx | 205 ++- .../agent/chat/AgentChatWorkspace.tsx | 284 +++- .../agent/chat/agentChatWorkspaceContract.ts | 2 + .../agent/chat/commands/executor.ts | 8 +- .../agent/chat/commands/formatter.ts | 2 +- .../chat/components/A2UITaskCard.test.tsx | 45 +- .../agent/chat/components/A2UITaskCard.tsx | 36 +- .../chat/components/AgentRuntimeStrip.tsx | 16 +- .../components/AgentThreadTimeline.test.tsx | 306 +++- .../chat/components/AgentThreadTimeline.tsx | 555 +++++-- .../AgentThreadTimelineArtifactCard.test.tsx | 160 ++ .../AgentThreadTimelineArtifactCard.tsx | 327 ++++ .../components/CanvasWorkbenchLayout.test.tsx | 28 +- .../chat/components/CanvasWorkbenchLayout.tsx | 93 +- .../ChatModelSelector.integration.test.tsx | 7 + .../agent/chat/components/EmptyState.test.tsx | 102 +- .../agent/chat/components/EmptyState.tsx | 166 +- .../EmptyStateComposerPanel.test.tsx | 159 +- .../components/EmptyStateComposerPanel.tsx | 1226 +++++--------- .../agent/chat/components/EmptyStateHero.tsx | 69 +- .../components/HarnessStatusPanel.test.tsx | 4 +- .../components/CharacterMention.test.tsx | 30 +- .../Inputbar/components/CharacterMention.tsx | 60 +- .../components/CharacterMentionPanel.tsx | 90 +- .../components/InputbarAccessModeSelect.tsx | 54 + .../components/InputbarComposerSection.tsx | 95 +- .../Inputbar/components/InputbarCore.test.tsx | 165 +- .../Inputbar/components/InputbarCore.tsx | 245 ++- .../InputbarExecutionStrategySelect.tsx | 81 +- .../components/InputbarModelExtra.tsx | 31 +- .../components/InputbarOverlayShell.test.tsx | 19 +- .../components/InputbarOverlayShell.tsx | 30 +- .../Inputbar/components/InputbarTools.tsx | 175 +- .../components/SkillSelector.test.tsx | 109 +- .../Inputbar/components/SkillSelector.tsx | 284 +++- .../components/SkillSelectorPanel.tsx | 228 --- .../Inputbar/components/TeamSelector.tsx | 13 +- .../components/TeamSelectorPanel.test.tsx | 30 +- .../Inputbar/components/TeamSelectorPanel.tsx | 19 +- .../components/characterMentionPanelLoader.ts | 9 + .../Inputbar/components/skillQuery.test.ts | 41 + .../Inputbar/components/skillQuery.ts | 50 + .../components/skillSelectionBindings.ts | 79 + .../components/skillSelectionDisplay.test.ts | 50 + .../components/skillSelectionDisplay.ts | 38 + .../components/useIdleModulePreload.ts | 13 + .../hooks/useA2UISubmissionNotice.test.tsx | 117 ++ .../Inputbar/hooks/useA2UISubmissionNotice.ts | 28 +- .../Inputbar/hooks/useActiveSkill.ts | 23 +- .../Inputbar/hooks/useInputbarController.ts | 23 +- .../Inputbar/hooks/useInputbarDictation.ts | 240 +++ .../Inputbar/hooks/useInputbarToolState.ts | 39 +- .../chat/components/Inputbar/index.test.tsx | 149 +- .../agent/chat/components/Inputbar/index.tsx | 54 +- .../agent/chat/components/Inputbar/styles.ts | 510 ++++-- .../chat/components/MarkdownRenderer.test.tsx | 217 ++- .../chat/components/MarkdownRenderer.tsx | 870 +++++++--- .../chat/components/MessageList.test.tsx | 225 ++- .../agent/chat/components/MessageList.tsx | 318 +--- .../components/StableProcessingNotice.tsx | 9 - .../components/StreamingRenderer.test.tsx | 259 ++- .../chat/components/StreamingRenderer.tsx | 1039 ++++++++---- .../components/ThemeWorkbenchSidebar.test.tsx | 104 +- .../chat/components/ToolCallDisplay.test.tsx | 51 +- .../agent/chat/components/ToolCallDisplay.tsx | 38 +- .../components/emptyStateSurfaceTokens.ts | 14 +- .../agent/chat/homeShellEntry.test.ts | 65 + src/components/agent/chat/homeShellEntry.ts | 26 +- .../chat/hooks/agentChatSendMessage.test.ts | 145 ++ .../agent/chat/hooks/agentChatSendMessage.ts | 99 ++ .../agent/chat/hooks/agentChatStorage.ts | 60 + .../chat/hooks/agentRuntimeAdapter.test.ts | 56 + .../agent/chat/hooks/agentRuntimeAdapter.ts | 17 +- .../chat/hooks/agentSessionRefresh.test.ts | 114 ++ .../agent/chat/hooks/agentSessionRefresh.ts | 111 ++ .../chat/hooks/agentSessionState.test.ts | 185 +++ .../agent/chat/hooks/agentSessionState.ts | 179 ++ .../chat/hooks/agentStreamCompaction.test.ts | 238 +++ .../agent/chat/hooks/agentStreamCompaction.ts | 148 ++ .../chat/hooks/agentStreamEventProcessor.ts | 5 +- .../chat/hooks/agentStreamFlowControl.test.ts | 232 +++ .../chat/hooks/agentStreamFlowControl.ts | 219 +++ .../agentStreamPreparedSendDispatch.test.ts | 1 + .../hooks/agentStreamPreparedSendEnv.test.ts | 55 + .../chat/hooks/agentStreamPreparedSendEnv.ts | 18 + .../chat/hooks/agentStreamRuntimeHandler.ts | 50 +- .../agent/chat/hooks/agentStreamSend.test.ts | 48 + .../agent/chat/hooks/agentStreamSend.ts | 57 + .../hooks/agentStreamSubmitExecution.test.ts | 5 + .../chat/hooks/agentStreamSubmitExecution.ts | 10 +- .../agentStreamUserInputSubmission.test.ts | 9 + .../hooks/agentStreamUserInputSubmission.ts | 1 + .../agent/chat/hooks/skillCommand.ts | 4 - .../useAgentChatStateSnapshotDebug.test.tsx | 152 ++ .../hooks/useAgentChatStateSnapshotDebug.ts | 64 + .../agent/chat/hooks/useAgentContext.ts | 98 ++ .../hooks/useAgentRuntimeSyncEffects.test.tsx | 288 ++++ .../chat/hooks/useAgentRuntimeSyncEffects.ts | 164 ++ .../agent/chat/hooks/useAgentSession.ts | 422 ++--- .../agent/chat/hooks/useAgentStream.ts | 477 ++---- .../hooks/useAgentStreamController.test.tsx | 166 ++ .../chat/hooks/useAgentStreamController.ts | 92 ++ .../agent/chat/hooks/useAgentTools.ts | 8 +- .../chat/hooks/useAgentTopicSnapshot.test.tsx | 170 ++ .../agent/chat/hooks/useAgentTopicSnapshot.ts | 119 ++ .../hooks/useArtifactDisplayState.test.ts | 36 + .../chat/hooks/useArtifactDisplayState.ts | 21 + .../chat/hooks/useAsterAgentChat.test.tsx | 230 ++- .../agent/chat/hooks/useAsterAgentChat.ts | 372 +---- .../agent/chat/hooks/useLimeSkills.test.tsx | 73 + .../agent/chat/hooks/useLimeSkills.ts | 20 +- .../hooks/useSelectedTeamPreference.test.tsx | 2 + .../chat/hooks/useStableProcessingNotice.ts | 86 + .../chat/hooks/useTeamWorkspaceRuntime.ts | 24 +- src/components/agent/chat/index.test.tsx | 668 +++++++- src/components/agent/chat/index.tsx | 6 + .../ServiceSkillHomePanel.test.tsx | 288 ++-- .../service-skills/ServiceSkillHomePanel.tsx | 483 +++++- .../ServiceSkillLaunchDialog.test.tsx | 236 ++- .../ServiceSkillLaunchDialog.tsx | 280 +++- .../service-skills/automationDraft.test.ts | 2 +- .../chat/service-skills/automationDraft.ts | 2 +- .../service-skills/promptComposer.test.ts | 5 +- .../chat/service-skills/promptComposer.ts | 16 +- .../service-skills/siteCapabilityBinding.ts | 3 + .../chat/service-skills/skillPresentation.ts | 205 +++ .../agent/chat/service-skills/types.ts | 16 + .../service-skills/useServiceSkills.test.tsx | 183 ++- .../chat/service-skills/useServiceSkills.ts | 167 +- .../chat/service-skills/workspaceLaunch.ts | 2 +- src/components/agent/chat/styles/index.ts | 113 +- src/components/agent/chat/types.ts | 25 + .../agent/chat/utils/accessModeRuntime.ts | 58 + .../chat/utils/actionRequestA2UI.test.ts | 97 +- .../agent/chat/utils/actionRequestA2UI.ts | 52 +- .../utils/actionRequestGovernance.test.ts | 79 + .../chat/utils/actionRequestGovernance.ts | 111 ++ .../chat/utils/agentThreadGrouping.test.ts | 57 +- .../agent/chat/utils/agentThreadGrouping.ts | 149 +- .../chat/utils/buildUserInputSubmitOp.test.ts | 6 + .../chat/utils/buildUserInputSubmitOp.ts | 7 + .../agent/chat/utils/chatToolPreferences.ts | 22 + .../chat/utils/generalAgentPrompt.test.ts | 5 +- .../agent/chat/utils/generalAgentPrompt.ts | 25 +- .../chat/utils/harnessRequestMetadata.ts | 4 + .../agent/chat/utils/harnessState.test.ts | 4 +- .../utils/legacyQuestionnaireA2UI.test.ts | 197 ++- .../chat/utils/legacyQuestionnaireA2UI.ts | 702 +++++++- .../agent/chat/utils/messageArtifacts.test.ts | 43 +- .../agent/chat/utils/messageArtifacts.ts | 37 +- .../chat/utils/progressivePendingA2UI.ts | 434 +++++ .../chat/utils/sessionExecutionRuntime.ts | 35 +- .../agent/chat/utils/skillFailure.ts | 2 +- .../chat/utils/submitOpRuntimeCompaction.ts | 15 + .../chat/utils/threadTimelineView.test.ts | 32 + .../agent/chat/utils/threadTimelineView.ts | 32 +- .../workspace/ArtifactWorkbenchShell.test.tsx | 117 +- .../chat/workspace/ArtifactWorkbenchShell.tsx | 29 +- .../ServiceSkillExecutionCard.test.tsx | 118 ++ .../workspace/ServiceSkillExecutionCard.tsx | 88 + .../chat/workspace/WorkspaceChatContent.tsx | 25 +- .../workspace/WorkspaceConversationScene.tsx | 26 + .../chat/workspace/WorkspaceMainArea.test.tsx | 132 ++ .../chat/workspace/WorkspaceMainArea.tsx | 12 +- .../WorkspacePendingA2UIDialog.test.tsx | 168 ++ .../workspace/WorkspacePendingA2UIDialog.tsx | 93 ++ .../agent/chat/workspace/WorkspaceStyles.tsx | 40 +- .../workspace/artifactWorkbenchDocument.tsx | 1 - .../agent/chat/workspace/chatSurfaceProps.ts | 8 +- .../useThemeWorkbenchSidebarPresentation.tsx | 4 + .../useWorkspaceA2UIRuntime.test.tsx | 146 +- .../chat/workspace/useWorkspaceA2UIRuntime.ts | 168 +- .../useWorkspaceA2UISubmitActions.ts | 24 +- ...seWorkspaceArtifactPreviewActions.test.tsx | 200 +++ .../useWorkspaceArtifactPreviewActions.ts | 23 +- ...eWorkspaceArtifactViewModeControl.test.tsx | 162 ++ .../useWorkspaceArtifactViewModeControl.ts | 94 ++ .../useWorkspaceBrowserAssistRuntime.test.tsx | 233 +++ .../useWorkspaceBrowserAssistRuntime.ts | 615 +++++-- .../useWorkspaceCanvasLayoutRuntime.test.tsx | 29 + .../useWorkspaceCanvasLayoutRuntime.ts | 38 + ...WorkspaceCanvasMessageSyncRuntime.test.tsx | 83 + .../useWorkspaceCanvasMessageSyncRuntime.ts | 13 +- .../useWorkspaceCanvasPreviewPresentation.tsx | 2 - .../useWorkspaceCanvasTaskFileSync.test.tsx | 88 + .../useWorkspaceCanvasTaskFileSync.ts | 18 +- .../useWorkspaceContextHarnessRuntime.ts | 98 +- .../useWorkspaceConversationSceneRuntime.tsx | 45 +- ...eWorkspaceHarnessInventoryRuntime.test.tsx | 21 + .../useWorkspaceHarnessInventoryRuntime.ts | 29 +- .../useWorkspaceInputbarSceneRuntime.tsx | 6 + .../workspace/useWorkspaceResetRuntime.ts | 11 +- .../useWorkspaceSendActions.test.tsx | 28 + .../chat/workspace/useWorkspaceSendActions.ts | 5 + ...WorkspaceServiceSkillEntryActions.test.tsx | 94 +- .../useWorkspaceServiceSkillEntryActions.ts | 155 +- ...useWorkspaceThemeWorkbenchShellRuntime.tsx | 7 +- .../useWorkspaceWriteFileAction.test.tsx | 183 +++ .../workspace/useWorkspaceWriteFileAction.ts | 62 +- .../chat/workspace/workbenchPreview.test.tsx | 50 + .../agent/chat/workspace/workbenchPreview.tsx | 5 - .../workspace/workbenchPreviewHelpers.tsx | 2 - .../chat/workspace/workspaceSendHelpers.ts | 4 + src/components/api-server/ApiServerPage.tsx | 25 +- .../artifact/ArtifactRenderer.ui.test.tsx | 5 + .../renderers/ArtifactDocumentRenderer.tsx | 2 +- src/components/artifact/renderers/index.ts | 5 +- src/components/content-creator/README.md | 138 +- .../content-creator/a2ui/A2uiSurface.tsx | 31 + src/components/content-creator/a2ui/README.md | 47 +- .../content-creator/a2ui/adapter.tsx | 20 + .../a2ui/catalog/basic/childList.ts | 87 + .../components}/A2UIFormControls.test.tsx | 45 +- .../basic/components}/A2UILayout.test.tsx | 90 +- .../catalog/basic/components/AudioPlayer.tsx | 37 + .../a2ui/catalog/basic/components/Button.tsx | 121 ++ .../basic/components}/Card.tsx | 22 +- .../basic/components}/CheckBox.tsx | 23 +- .../catalog/basic/components/ChildList.tsx | 54 + .../basic/components}/ChoicePicker.tsx | 23 +- .../a2ui/catalog/basic/components/Column.tsx | 63 + .../basic/components/DateTimeInput.tsx | 95 ++ .../basic/components}/Divider.tsx | 11 +- .../a2ui/catalog/basic/components/Icon.tsx | 107 ++ .../a2ui/catalog/basic/components/Image.tsx | 50 + .../a2ui/catalog/basic/components/List.tsx | 65 + .../a2ui/catalog/basic/components/Modal.tsx | 67 + .../a2ui/catalog/basic/components/Row.tsx | 104 ++ .../basic/components}/Slider.tsx | 32 +- .../a2ui/catalog/basic/components/Tabs.tsx | 95 ++ .../a2ui/catalog/basic/components/Text.tsx | 144 ++ .../basic/components}/TextField.tsx | 25 +- .../a2ui/catalog/basic/components/Video.tsx | 32 + .../a2ui/catalog/basic/index.ts | 69 + .../a2ui/catalog/basic/utils.ts | 36 + .../content-creator/a2ui/catalog/index.ts | 3 + .../catalog/minimal/components/Button.tsx | 1 + .../catalog/minimal/components/ChildList.tsx | 1 + .../catalog/minimal/components/Column.tsx | 1 + .../a2ui/catalog/minimal/components/Row.tsx | 1 + .../a2ui/catalog/minimal/components/Text.tsx | 1 + .../catalog/minimal/components/TextField.tsx | 1 + .../a2ui/catalog/minimal/index.ts | 23 + .../a2ui/components/A2UIRenderer.test.tsx | 155 +- .../a2ui/components/ComponentRenderer.tsx | 122 +- .../content-creator/a2ui/components/README.md | 56 +- .../a2ui/components/display/Button.tsx | 82 - .../a2ui/components/display/Text.tsx | 27 - .../a2ui/components/display/index.ts | 6 - .../a2ui/components/form/index.ts | 8 - .../content-creator/a2ui/components/index.tsx | 34 +- .../a2ui/components/layout/Column.tsx | 62 - .../a2ui/components/layout/Row.tsx | 62 - .../a2ui/components/layout/index.ts | 8 - .../content-creator/a2ui/dataModel.ts | 182 +++ src/components/content-creator/a2ui/index.ts | 5 + .../content-creator/a2ui/layoutTokens.ts | 3 +- .../content-creator/a2ui/parser.test.ts | 96 ++ src/components/content-creator/a2ui/parser.ts | 101 +- .../content-creator/a2ui/protocol.ts | 648 ++++++++ .../content-creator/a2ui/rendererTokens.ts | 24 +- .../a2ui/taskCardPrimitives.tsx | 77 +- .../content-creator/a2ui/taskCardTokens.ts | 9 +- .../content-creator/a2ui/taskFormTokens.ts | 26 +- src/components/content-creator/a2ui/types.ts | 37 +- .../agents/AgentScheduler.test.ts | 15 +- .../canvas/document/DocumentCanvas.tsx | 66 +- .../image-gen/ImageGenPage.test.tsx | 10 +- .../image-gen/hooks/useImageSearch.test.tsx | 221 ++- .../image-gen/hooks/useImageSearch.ts | 105 +- .../image-gen/tabs/MyGalleryTab.test.tsx | 10 +- .../image-gen/tabs/MyGalleryTab.tsx | 4 +- src/components/input-kit/BaseComposer.tsx | 6 +- .../steps/VoiceShortcutTestStep.tsx | 9 +- src/components/openclaw/OpenClawPage.test.tsx | 1 + .../plugins/PluginInstallDialog.tsx | 25 +- src/components/plugins/PluginManager.tsx | 7 +- .../dialogs/MaterialPreviewDialog.tsx | 4 +- .../projects/dialogs/PersonaDialog.tsx | 12 +- .../api-key/ProviderModelList.tsx | 32 +- .../credential-forms/AntigravityForm.tsx | 11 +- .../credential-forms/ClaudeFormStandalone.tsx | 11 +- .../credential-forms/ClaudeOAuthForm.tsx | 11 +- .../credential-forms/CodexForm.tsx | 11 +- .../credential-forms/GeminiForm.tsx | 15 +- .../credential-forms/GeminiFormStandalone.tsx | 15 +- .../settings-v2/agent/skills/index.tsx | 2 +- .../general/appearance/index.test.tsx | 1 + .../settings-v2/general/appearance/index.tsx | 24 +- .../general/hotkeys/index.test.tsx | 21 +- .../automation/AutomationJobDialog.test.tsx | 129 +- .../system/automation/AutomationJobDialog.tsx | 822 ++++------ .../system/automation/index.test.tsx | 87 +- .../settings-v2/system/automation/index.tsx | 290 +--- .../system/automation/serviceSkillContext.ts | 2 +- .../system/chrome-relay/index.test.tsx | 80 + .../settings-v2/system/chrome-relay/index.tsx | 187 ++- .../system/developer/index.test.tsx | 122 +- .../settings-v2/system/developer/index.tsx | 296 +++- .../system/web-search/index.test.tsx | 29 +- .../skills/SkillsWorkspacePage.test.tsx | 306 ++++ src/components/skills/SkillsWorkspacePage.tsx | 1004 ++++++++++++ src/components/skills/index.ts | 1 + src/components/ui/sonner.tsx | 9 +- .../panels/WorkbenchCreateEntryHome.tsx | 70 +- .../panels/WorkbenchRightRail.test.tsx | 15 +- .../panels/useWorkbenchRightRailImageTasks.ts | 17 +- .../BrowserSiteAdapterPanel.tsx | 1 + src/hooks/useAppNavigation.test.tsx | 132 ++ src/hooks/useAppNavigation.ts | 123 ++ src/hooks/useAppShellLayout.test.ts | 78 + src/hooks/useAppShellLayout.ts | 46 + src/hooks/useAppStartupEffects.ts | 97 ++ src/hooks/useDeveloperFeatureFlags.ts | 50 + src/hooks/useOemCloudAccess.test.tsx | 8 + src/hooks/useOemCloudAccess.ts | 7 + src/hooks/useProjectContext.ts | 13 +- src/hooks/useSkillCatalogBootstrap.ts | 12 + src/hooks/useSkills.test.tsx | 19 +- src/hooks/useSkills.ts | 16 +- src/i18n/withI18nPatch.test.tsx | 9 +- src/lib/api/agent.test.ts | 48 + src/lib/api/agentExecutionRuntime.ts | 14 + src/lib/api/agentProtocol.test.ts | 40 +- src/lib/api/agentProtocol.ts | 21 +- src/lib/api/agentRuntime.ts | 112 ++ src/lib/api/agentRuntimeEvents.test.ts | 105 ++ src/lib/api/agentRuntimeEvents.ts | 40 + src/lib/api/agentTextNormalization.ts | 40 + src/lib/api/appConfig.ts | 31 + src/lib/api/appConfigTypes.ts | 5 + src/lib/api/fileSystem.test.ts | 18 +- src/lib/api/fileSystem.ts | 5 + src/lib/api/imageSearch.ts | 85 + src/lib/api/notification.test.ts | 15 +- src/lib/api/oemCloudControlPlane.ts | 2 + src/lib/api/personas.ts | 16 + src/lib/api/plugins.test.ts | 36 +- src/lib/api/plugins.ts | 31 +- src/lib/api/projectContext.ts | 14 + src/lib/api/providerAuthEvents.test.ts | 67 + src/lib/api/providerAuthEvents.ts | 48 + src/lib/api/serviceSkills.test.ts | 68 +- src/lib/api/serviceSkills.ts | 448 ++++- src/lib/api/skillCatalog.test.ts | 153 ++ src/lib/api/skillCatalog.ts | 1097 +++++++++++++ src/lib/api/voiceShortcutEvents.test.ts | 37 + src/lib/api/voiceShortcutEvents.ts | 26 + src/lib/artifact/hooks/useArtifactParser.ts | 15 - src/lib/artifact/hooks/useDebouncedValue.ts | 60 +- src/lib/artifact/parser.ts | 2 - src/lib/artifact/types.ts | 8 +- src/lib/dev-bridge/http-client.test.ts | 92 ++ src/lib/dev-bridge/http-client.ts | 146 ++ src/lib/dev-bridge/index.ts | 1 + src/lib/dev-bridge/mockPriorityCommands.ts | 1 + src/lib/dev-bridge/safeInvoke.test.ts | 58 +- src/lib/dev-bridge/safeInvoke.ts | 11 + src/lib/developerFeatures.ts | 20 + src/lib/governance/agentCommandCatalog.json | 14 +- src/lib/governance/legacySurfaceCatalog.json | 142 ++ .../governance/legacySurfaceCatalog.test.ts | 92 ++ src/lib/navigation/sidebarNav.ts | 17 +- src/lib/oemCloudDesktopAuth.ts | 2 + src/lib/skillCatalogBootstrap.ts | 108 ++ src/lib/tauri-mock/core.test.ts | 38 +- src/lib/tauri-mock/core.ts | 385 ++++- src/lib/tauri-mock/plugin-shell.ts | 4 +- src/lib/webview-api.ts | 121 +- src/pages/smart-input.tsx | 15 +- src/types/page.ts | 14 + 435 files changed, 35673 insertions(+), 8535 deletions(-) create mode 100644 docs/aiprompts/site-adapter-standard.md create mode 100644 docs/aiprompts/skill-standard.md create mode 100644 docs/research/site-adapter-source-integration.md create mode 100644 src-tauri/crates/agent/src/text_normalization.rs create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/linux-do-categories.js create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/linux-do-hot.js create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/smzdm-search.js create mode 100644 src-tauri/resources/site-adapters/bundled/scripts/yahoo-finance-quote.js create mode 100644 src-tauri/src/services/site_adapter_import_service.rs create mode 100644 src-tauri/tests/fixtures/site-adapters/imported-real-world-bundle.yaml create mode 100644 src/components/AppPageContent.tsx create mode 100644 src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx create mode 100644 src/components/agent/chat/components/AgentThreadTimelineArtifactCard.tsx create mode 100644 src/components/agent/chat/components/Inputbar/components/InputbarAccessModeSelect.tsx delete mode 100644 src/components/agent/chat/components/Inputbar/components/SkillSelectorPanel.tsx create mode 100644 src/components/agent/chat/components/Inputbar/components/characterMentionPanelLoader.ts create mode 100644 src/components/agent/chat/components/Inputbar/components/skillQuery.test.ts create mode 100644 src/components/agent/chat/components/Inputbar/components/skillQuery.ts create mode 100644 src/components/agent/chat/components/Inputbar/components/skillSelectionBindings.ts create mode 100644 src/components/agent/chat/components/Inputbar/components/skillSelectionDisplay.test.ts create mode 100644 src/components/agent/chat/components/Inputbar/components/skillSelectionDisplay.ts create mode 100644 src/components/agent/chat/components/Inputbar/components/useIdleModulePreload.ts create mode 100644 src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.test.tsx create mode 100644 src/components/agent/chat/components/Inputbar/hooks/useInputbarDictation.ts create mode 100644 src/components/agent/chat/hooks/agentChatSendMessage.test.ts create mode 100644 src/components/agent/chat/hooks/agentChatSendMessage.ts create mode 100644 src/components/agent/chat/hooks/agentRuntimeAdapter.test.ts create mode 100644 src/components/agent/chat/hooks/agentSessionRefresh.test.ts create mode 100644 src/components/agent/chat/hooks/agentSessionRefresh.ts create mode 100644 src/components/agent/chat/hooks/agentSessionState.test.ts create mode 100644 src/components/agent/chat/hooks/agentSessionState.ts create mode 100644 src/components/agent/chat/hooks/agentStreamCompaction.test.ts create mode 100644 src/components/agent/chat/hooks/agentStreamCompaction.ts create mode 100644 src/components/agent/chat/hooks/agentStreamFlowControl.test.ts create mode 100644 src/components/agent/chat/hooks/agentStreamFlowControl.ts create mode 100644 src/components/agent/chat/hooks/agentStreamPreparedSendEnv.test.ts create mode 100644 src/components/agent/chat/hooks/agentStreamSend.test.ts create mode 100644 src/components/agent/chat/hooks/agentStreamSend.ts create mode 100644 src/components/agent/chat/hooks/useAgentChatStateSnapshotDebug.test.tsx create mode 100644 src/components/agent/chat/hooks/useAgentChatStateSnapshotDebug.ts create mode 100644 src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.test.tsx create mode 100644 src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.ts create mode 100644 src/components/agent/chat/hooks/useAgentStreamController.test.tsx create mode 100644 src/components/agent/chat/hooks/useAgentStreamController.ts create mode 100644 src/components/agent/chat/hooks/useAgentTopicSnapshot.test.tsx create mode 100644 src/components/agent/chat/hooks/useAgentTopicSnapshot.ts create mode 100644 src/components/agent/chat/hooks/useStableProcessingNotice.ts create mode 100644 src/components/agent/chat/service-skills/skillPresentation.ts create mode 100644 src/components/agent/chat/utils/accessModeRuntime.ts create mode 100644 src/components/agent/chat/utils/actionRequestGovernance.test.ts create mode 100644 src/components/agent/chat/utils/actionRequestGovernance.ts create mode 100644 src/components/agent/chat/utils/progressivePendingA2UI.ts create mode 100644 src/components/agent/chat/workspace/ServiceSkillExecutionCard.test.tsx create mode 100644 src/components/agent/chat/workspace/ServiceSkillExecutionCard.tsx create mode 100644 src/components/agent/chat/workspace/WorkspaceMainArea.test.tsx create mode 100644 src/components/agent/chat/workspace/WorkspacePendingA2UIDialog.test.tsx create mode 100644 src/components/agent/chat/workspace/WorkspacePendingA2UIDialog.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceArtifactViewModeControl.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceArtifactViewModeControl.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceBrowserAssistRuntime.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasMessageSyncRuntime.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasTaskFileSync.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceWriteFileAction.test.tsx create mode 100644 src/components/agent/chat/workspace/workbenchPreview.test.tsx create mode 100644 src/components/content-creator/a2ui/A2uiSurface.tsx create mode 100644 src/components/content-creator/a2ui/adapter.tsx create mode 100644 src/components/content-creator/a2ui/catalog/basic/childList.ts rename src/components/content-creator/a2ui/{components/form => catalog/basic/components}/A2UIFormControls.test.tsx (77%) rename src/components/content-creator/a2ui/{components/layout => catalog/basic/components}/A2UILayout.test.tsx (61%) create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/AudioPlayer.tsx create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/Button.tsx rename src/components/content-creator/a2ui/{components/layout => catalog/basic/components}/Card.tsx (69%) rename src/components/content-creator/a2ui/{components/form => catalog/basic/components}/CheckBox.tsx (55%) create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/ChildList.tsx rename src/components/content-creator/a2ui/{components/form => catalog/basic/components}/ChoicePicker.tsx (85%) create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/Column.tsx create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/DateTimeInput.tsx rename src/components/content-creator/a2ui/{components/layout => catalog/basic/components}/Divider.tsx (71%) create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/Icon.tsx create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/Image.tsx create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/List.tsx create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/Modal.tsx create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/Row.tsx rename src/components/content-creator/a2ui/{components/form => catalog/basic/components}/Slider.tsx (62%) create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/Tabs.tsx create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/Text.tsx rename src/components/content-creator/a2ui/{components/form => catalog/basic/components}/TextField.tsx (78%) create mode 100644 src/components/content-creator/a2ui/catalog/basic/components/Video.tsx create mode 100644 src/components/content-creator/a2ui/catalog/basic/index.ts create mode 100644 src/components/content-creator/a2ui/catalog/basic/utils.ts create mode 100644 src/components/content-creator/a2ui/catalog/index.ts create mode 100644 src/components/content-creator/a2ui/catalog/minimal/components/Button.tsx create mode 100644 src/components/content-creator/a2ui/catalog/minimal/components/ChildList.tsx create mode 100644 src/components/content-creator/a2ui/catalog/minimal/components/Column.tsx create mode 100644 src/components/content-creator/a2ui/catalog/minimal/components/Row.tsx create mode 100644 src/components/content-creator/a2ui/catalog/minimal/components/Text.tsx create mode 100644 src/components/content-creator/a2ui/catalog/minimal/components/TextField.tsx create mode 100644 src/components/content-creator/a2ui/catalog/minimal/index.ts delete mode 100644 src/components/content-creator/a2ui/components/display/Button.tsx delete mode 100644 src/components/content-creator/a2ui/components/display/Text.tsx delete mode 100644 src/components/content-creator/a2ui/components/display/index.ts delete mode 100644 src/components/content-creator/a2ui/components/form/index.ts delete mode 100644 src/components/content-creator/a2ui/components/layout/Column.tsx delete mode 100644 src/components/content-creator/a2ui/components/layout/Row.tsx delete mode 100644 src/components/content-creator/a2ui/components/layout/index.ts create mode 100644 src/components/content-creator/a2ui/dataModel.ts create mode 100644 src/components/content-creator/a2ui/parser.test.ts create mode 100644 src/components/content-creator/a2ui/protocol.ts create mode 100644 src/components/skills/SkillsWorkspacePage.test.tsx create mode 100644 src/components/skills/SkillsWorkspacePage.tsx create mode 100644 src/hooks/useAppNavigation.test.tsx create mode 100644 src/hooks/useAppNavigation.ts create mode 100644 src/hooks/useAppShellLayout.test.ts create mode 100644 src/hooks/useAppShellLayout.ts create mode 100644 src/hooks/useAppStartupEffects.ts create mode 100644 src/hooks/useDeveloperFeatureFlags.ts create mode 100644 src/hooks/useSkillCatalogBootstrap.ts create mode 100644 src/lib/api/agentRuntimeEvents.test.ts create mode 100644 src/lib/api/agentRuntimeEvents.ts create mode 100644 src/lib/api/agentTextNormalization.ts create mode 100644 src/lib/api/imageSearch.ts create mode 100644 src/lib/api/projectContext.ts create mode 100644 src/lib/api/providerAuthEvents.test.ts create mode 100644 src/lib/api/providerAuthEvents.ts create mode 100644 src/lib/api/skillCatalog.test.ts create mode 100644 src/lib/api/skillCatalog.ts create mode 100644 src/lib/api/voiceShortcutEvents.test.ts create mode 100644 src/lib/api/voiceShortcutEvents.ts create mode 100644 src/lib/dev-bridge/http-client.test.ts create mode 100644 src/lib/developerFeatures.ts create mode 100644 src/lib/skillCatalogBootstrap.ts diff --git a/AGENTS.md b/AGENTS.md index 3c351d2db..94d8b99d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,8 @@ - **架构概览**:`docs/aiprompts/overview.md` - **工程质量**:`docs/aiprompts/quality-workflow.md` - **治理收口**:`docs/aiprompts/governance.md` +- **技能标准**:`docs/aiprompts/skill-standard.md` +- **站点适配器标准**:`docs/aiprompts/site-adapter-standard.md` - **UI 规范**:`docs/aiprompts/design-language.md` - **Tauri 命令边界**:`docs/aiprompts/commands.md` - **凭证与路径**:`docs/aiprompts/credential-pool.md` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7404fa782..2efdb6a0f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,45 +1,41 @@ -## Lime v0.97.0 +## Lime v0.98.0 ### ✨ 主要更新 - **Chrome Relay / Browser Connector 进入 current 主线**:新增浏览器连接器命令与后端服务,设置页补齐 Chrome Relay 安装、连接状态和目录展示,扩展弹窗与清单同步更新 - **Agent Chat 工作台与时间线继续收口**:`ArtifactWorkbenchShell`、`AgentThreadTimeline`、`CanvasWorkbenchLayout`、`MessageList` 等核心区域完成进一步瘦身,移除了旧的 `ProjectSelector`、`TaskFiles`、`TimelineInlineItem` 等遗留表面 -- **A2UI 与工作台运行时稳定性修复**:修正 legacy 问卷场景下 `useWorkspaceA2UIRuntime` 的自循环更新,补齐 Action Request/A2UI 预览和画布布局回归,减少自动引导与写文件链路的测试噪音 -- **测试执行与发布流程显著稳定化**:Vitest 默认切到智能分批 + 单 fork 模式,收敛 mock/info 日志风暴;macOS release workflow 现在会在构建前显式探测 Tauri CLI native binding,缺失时自动清理 `node_modules` 并重装,规避 `@tauri-apps/cli-darwin-*` 丢失导致的发布失败 -- **命令边界与文档同步更新**:浏览器连接器命令、GUI smoke 路径与质量流程文档已与当前实现保持一致,避免前端调用、Rust 注册、mock 与文档再度漂移 +- **A2UI、Service Skill 与工作台运行时稳定性继续加固**:修正 legacy 问卷场景下 `useWorkspaceA2UIRuntime` 的自循环更新,补齐 Action Request / A2UI 预览与画布布局回归,并继续收敛 `site adapter`、`Browser Assist`、`current content` 写回主链 +- **站点适配器与运行时网关进一步收口**:`site_*` 能力统一回挂到现役 `agentRuntime` 网关,减少业务层散落命令名;配合 `Service Skill` 启动、运行与保存链路,前端命令边界和契约口径保持一致 +- **测试执行与发布流程继续稳定化**:Vitest 默认切到智能分批 + 单 fork 模式,收敛 mock/info 日志风暴;macOS release workflow 会在构建前显式探测 Tauri CLI native binding,缺失时自动清理 `node_modules` 并重装,规避 `@tauri-apps/cli-darwin-*` 丢失导致的发布失败;本轮也修复了 DevBridge SSE 路由的 axum handler 编译断点,避免发布前 Rust 校验被阻塞 ### ⚠️ 兼容性说明 -- 本次仍然使用同一个版本号 `v0.97.0`,但 release/tag 会重指向 `main` 上的最新提交,用于覆盖之前的同版发布 -- 正式发布仍由 `v*` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文 +- 正式发布由 `v0.98.0` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文 - GUI 冒烟依赖本机可启动 headless Tauri、`DevBridge`、默认 workspace 和系统 Chrome;目标环境如果缺少对应条件,Browser Runtime/站点适配器相关能力会被降级 -- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.22.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 +- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.23.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 ### 🔗 依赖同步 -- 应用版本已同步提升到 `v0.97.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock` -- 当前仓库声明的 `aster-rust` 依赖仍为 `v0.22.0`;本地覆盖仓库已核对为干净 `v0.22.0` 状态 -- `src-tauri/Cargo.lock` 已随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `0.97.0` 对齐 +- 应用版本已同步提升到 `v0.98.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock` +- 当前仓库声明的 `aster-rust` 依赖已同步到 `v0.23.0`;本地覆盖仓库也应保持在干净 `v0.23.0` 状态 +- `src-tauri/Cargo.lock` 已随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `0.98.0` 对齐 ### 🧪 测试 -- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all` +- 发布前执行:`CARGO_TARGET_DIR="src-tauri/target-release-v0.98.0" cargo test --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`CARGO_TARGET_DIR="src-tauri/target-release-v0.98.0" cargo clippy --manifest-path src-tauri/Cargo.toml` - 发布前执行:`npm run lint` -- 发布前执行:`npm run typecheck` -- 发布前执行:`npm test` -- 发布前执行:`npm run test:contracts` - 发布前执行:`npm run verify:app-version` +- 发布前执行:`npm run test:contracts` - 发布前执行:`npm run verify:gui-smoke` -- 验证结果:上述命令已在当前工作区全部通过,`verify:gui-smoke` 已验证 `DevBridge`、默认 workspace、Browser Runtime 与 site adapter catalog 主路径 -- 备注:`cargo clippy` 当前通过,但仍保留 3 条既有 warning,未在本次同版发布中额外扩范围消除 +- 验证结果:上述命令已在当前工作区通过;Rust 校验使用独立 `CARGO_TARGET_DIR` 完成,以避免与本机其他 Cargo 进程争抢默认构建目录;GUI smoke 已验证 `DevBridge`、默认 workspace、Browser Runtime 与 site adapter catalog 主路径 ### 📝 文档 -- 发布说明已切换到当前这次 `v0.97.0` 同版重发内容,供 GitHub Release 直接读取 +- 发布说明已切换到当前这次 `v0.98.0` 正式发布内容,供 GitHub Release 直接读取 - 工程质量、命令边界与 Playwright / GUI 冒烟文档已同步更新到最新实现 --- -**完整变更**: `v0.97.0` 同版重发,对齐 `main` 最新提交 +**完整变更**: `v0.97.0` -> `v0.98.0` diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index a1696232e..9b43073d9 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -17,6 +17,8 @@ - `overview.md` - 项目架构总览与模块分层 - `governance.md` - 新旧并存治理、迁移收口、禁止回流 - `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁 +- `skill-standard.md` - 统一技能标准、skill / adapter / runtime binding 边界 +- `site-adapter-standard.md` - 站点适配器标准、来源导入边界、运行时收敛规则 - `project-heatmap.md` - 仓库热力图与治理候选分析 - `limecore-collaboration-entry.md` - 跨仓库联动入口 - `../tech/harness/README.md` - Lime Harness Engineering 总入口与实施蓝图 @@ -53,6 +55,8 @@ - **改 UI / 页面结构**:先读 `design-language.md`,再看 `quality-workflow.md` - **改 Tauri 命令 / Bridge / mock**:先读 `commands.md`,再看 `quality-workflow.md` +- **改 Claw 技能 / Service Skill / 统一 Skills 标准**:先读 `skill-standard.md` +- **改站点适配器 / 导入外部 adapter**:先读 `site-adapter-standard.md`,再看 `quality-workflow.md` - **改 Workspace / GUI 壳 / 主路径**:先读 `workspace.md`、`quality-workflow.md`、`playwright-e2e.md` - **做迁移 / 收口 / 去兼容层**:先读 `governance.md` - **改 Provider / 凭证加载 / Token 刷新**:先读 `providers.md`、`credential-pool.md` diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 8d17e95a4..1b2cbcfd9 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -33,6 +33,7 @@ - `get_browser_connector_install_status_cmd` - `install_browser_connector_extension_cmd` - `open_browser_extensions_page_cmd` +- `disconnect_browser_connector_session` 这些命令属于当前设置主路径,不应再在页面组件里散落裸 `invoke`。 @@ -157,9 +158,10 @@ npm run verify:local 如果命令边界改动影响会话运行时恢复语义,例如: -- `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_preferences / recent_team_selection` -- `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` -- 话题切换时的 provider/model、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛 +- `agent_runtime_submit_turn.turn_config` 新增或调整 `approval_policy / sandbox_policy` +- `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_access_mode / recent_preferences / recent_team_selection` +- `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` +- 话题切换时的 provider/model、权限 accessMode、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛 除了契约检查,还应补对应 Hook / UI 稳定回归,确认切换话题后模型选择器恢复的是会话 runtime,而不是陈旧本地缓存。 @@ -201,7 +203,8 @@ npm run verify:local 以下是仓库当前已经明确收敛的几个方向: - **Agent / Codex 主命令**:继续收敛到 `agent_runtime_*` -- **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写 +- **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_access_mode`、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写 +- **会话权限主链**:`agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` 是正式 turn context 权限协议;`getSession` 返回的 `execution_runtime.recent_access_mode` 负责承接会话最近一次 accessMode。当前端已命中同一 steady-state 权限时,不应继续依赖 `harness.access_mode` 作为唯一事实源 - **运行时交接导出主链**:继续收敛到 `agent_runtime_export_handoff_bundle`;前端统一通过 `src/lib/api/agentRuntime.ts` 网关进入,当前 GUI 入口位于 `HarnessStatusPanel` - **运行时证据导出主链**:继续收敛到 `agent_runtime_export_evidence_pack`,用于把 runtime / timeline / artifacts 打包成最小问题证据 - **运行时 replay 样本主链**:继续收敛到 `agent_runtime_export_replay_case`,复用 handoff bundle + evidence pack 生成 `input / expected / grader / evidence-links` @@ -221,9 +224,11 @@ npm run verify:local 补充约定: -- **站点能力主链**:继续收敛到 `site_list_adapters / site_recommend_adapters / site_search_adapters / site_get_adapter_info / site_run_adapter` +- **站点能力主链**:继续收敛到 `site_list_adapters / site_recommend_adapters / site_search_adapters / site_get_adapter_info / site_get_adapter_launch_readiness / site_get_adapter_catalog_status / site_import_adapter_yaml_bundle / site_run_adapter` +- **站点适配器导入主链**:`site_import_adapter_yaml_bundle` 只负责把外部 YAML 来源编译为 Lime 标准并写入 `imported` 目录,不允许带入第二套 runtime、daemon 或自动唤醒浏览器链路 - **站点 Agent 工具主链**:继续收敛到 `lime_site_list / lime_site_recommend / lime_site_search / lime_site_info / lime_site_run` - **站点结果沉淀主线**:`site_run_adapter` / `lime_site_run` 优先透传 `content_id` 写回当前主稿;只有缺少 `content_id` 时,才回退到 `project_id` 新建结果文档 +- **Claw 站点直跑门禁主链**:`site_get_adapter_launch_readiness` 只负责检测“是否存在已附着的真实浏览器会话 + 目标站点上下文”;`site_run_adapter.require_attached_session = true` 时,后端必须拒绝 managed/default fallback,不能后台偷偷起 Chrome - **站点运行失败语义**:`SiteAdapterRunResult` 至少统一输出 `auth_required / no_matching_context / adapter_runtime_error`,并在前端与 Agent 结果里保留 `report_hint` - **浏览器资料 / 环境预设主链**:`list/save/archive/restore_browser_profile_cmd` 与 `list/save/archive/restore_browser_environment_preset_cmd` 已进入真实 DevBridge 主路径;浏览器模式下不应再默认放进 `mockPriorityCommands`,仅在 DevBridge 不可用时才允许回落 `defaultMocks` diff --git a/docs/aiprompts/governance.md b/docs/aiprompts/governance.md index 141a4b2e1..469ab8acb 100644 --- a/docs/aiprompts/governance.md +++ b/docs/aiprompts/governance.md @@ -143,6 +143,13 @@ npm run test:contracts **不是鼓励走新路,而是先封住老路。** +这同样适用于已经删除的旧 UI 壳或旧组件路径: + +- 删除旧文件后,仍应在治理目录册里补 import / 文本守卫,防止后续 AI 或人工把旧路径重新接回主链 +- 如果已经把重复 UI 的扁平 props 收口为共享契约,也应补对应文本 / 正则守卫,防止父层透传和子层接口一起长回旧面 +- 如果共享契约还依赖单独的构造器或归一化 helper,应继续限制只有事实源边界能调用它,不要让运行时代码到处重新拼装 +- 如果多个页面或面板展示的是同一份状态,也要把状态文案收敛到共享 helper,不要让首页、下拉面板、状态徽标各自重新命名 + ### 6. 主链路和旁路一起治理 如果只迁: diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index 9af494411..2b7c29fbf 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -22,6 +22,14 @@ Lime 是一个以创作为中心的本地优先 AI Agent 交互工作台,基 在 Lime 中,Skills 处于比 MCP 更贴近产品的一层:它不是底层原语,而是将领域经验、交互方式和执行流程打包后的编排单元。 +对 Lime 来说,Skills 还必须继续区分: + +- `skill`:产品入口与业务语义 +- `adapter / tool`:底层能力工件 +- `runtime binding`:最终执行绑定 + +统一技能标准见 [skill-standard.md](skill-standard.md),站点工件子标准见 [site-adapter-standard.md](site-adapter-standard.md)。 + ## 项目结构 ``` @@ -53,7 +61,7 @@ lime/ | `workspace/` | 工作区与项目边界,承载文件、会话与配置上下文 | | `components/agent/` | Agent 对话主入口,负责会话、流式事件与交互 | | `components/content-creator/` | 主题化创作工作台与画布联动 | -| `skills/` | 技能加载、标准校验与经验编排能力 | +| `skills/` | 技能加载、标准校验与经验编排能力;统一遵循 `skill-standard.md` | | `lib/artifact/` | Artifact 解析、状态与轻量渲染器 | | `memory / style / personas` | 项目记忆、风格策略与人设沉淀 | @@ -109,6 +117,10 @@ lime/ | `lib/artifact/` | Artifact 状态与解析 | | `pages/` | 独立窗口与页面入口 | +补充约束: + +- `开发者中心 -> 处理工作台与信息收集` 由 `config.developer.workspace_harness_enabled` 控制,默认关闭;关闭时通用对话不应显示“处理工作台”入口,也不应继续触发对应运行态信息收集链路。 + ## 数据流 ``` @@ -211,6 +223,7 @@ lime/ ### 产品与工作台 - [workspace.md](workspace.md) - Workspace 边界与工作区设计 - [content-creator.md](content-creator.md) - 主题化创作工作台 +- [skill-standard.md](skill-standard.md) - 统一技能标准、目录与运行边界 - [../../src-tauri/src/skills/README.md](../../src-tauri/src/skills/README.md) - Skills 标准与集成 - [terminal.md](terminal.md) - 终端能力 - [mcp.md](mcp.md) - MCP 服务器 @@ -230,6 +243,7 @@ lime/ ### 配置、服务与数据 - [commands.md](commands.md) - Tauri 命令 +- [site-adapter-standard.md](site-adapter-standard.md) - 站点适配器标准与外部来源接入边界 - [services.md](services.md) - 业务服务 - [database.md](database.md) - 数据库层 - [performance-profiling.md](performance-profiling.md) - 性能分析与火焰图 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index 38b82bf7c..f3ee3c940 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -142,14 +142,33 @@ npm run test:contracts 7. 如工作台模式开启自动保存,再确认执行成功后保存态文案与打开入口正常 8. 打开控制台并确认浏览器资料 / 环境预设读取没有落回 web mock,尤其不应出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd` +### Claw 站点技能直跑门禁验证 + +1. 在 `Claw` 首页打开一个站点型技能弹窗 +2. 如果当前没有附着真实浏览器会话,确认主按钮保持禁用,并出现“需要浏览器工作台 / 重新检测会话”的门禁提示 +3. 点击 `去浏览器工作台`,确认只发生页面跳转,不会后台偷偷拉起 Chrome +4. 在浏览器工作台附着到真实浏览器并打开目标站点后,回到 `Claw` 再次打开同一技能 +5. 确认此时主按钮变为可执行,点击后进入 `Claw` 工作区 +6. 确认消息流顶部出现独立的站点技能执行卡,状态依次体现 `执行中 / 已完成` 或明确阻断原因,而不是伪装成普通聊天消息 + +### 开发者页站点来源导入验证 + +1. 进入 `设置 -> 开发者` +2. 在 `站点脚本目录联调` 区块找到 `外部来源 YAML 导入` +3. 粘贴一份仅包含 Lime 支持子集的 YAML 来源,点击 `导入到 Lime 标准` +4. 验证摘要区来源切换为 `外部导入`,且适配器列表出现新导入名称 +5. 再点击 `清空站点目录缓存`,确认来源恢复为 `应用内置`,列表回退到 bundled 目录 +6. 导入与清理全过程都不应出现后台自动拉起浏览器、自动唤醒 Chrome 或常驻浏览器控制进程 + ### 连接器页验证 1. 进入 `设置 -> 连接器` -2. 确认首页能看到“我的浏览器”“macOS 连接器/系统连接器”“高级控制”三块主区域 +2. macOS 下确认首页能看到“我的浏览器”“macOS 连接器”“高级控制”三块主区域;Windows 与其他非 macOS 平台默认不应出现系统连接器卡片 3. 点击“展开高级控制”,确认 `总览 / Profile / 桥接 / 后端 / 调试` 页签可切换 4. 如当前环境允许目录选择,点击“选择目录并安装”或“同步更新扩展”,确认安装目录最终落到固定子目录 `Lime Browser Connector` 5. 点击“复制配置”,确认剪贴板内容包含 `serverUrl / bridgeKey / profileKey` -6. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”可成功唤起浏览器扩展管理页 +6. 如当前环境已有 observer 连接,再确认“断开已连接扩展”能把页面状态回退到等待连接 +7. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”可成功唤起浏览器扩展管理页 ### 话题模型恢复验证 @@ -159,6 +178,15 @@ npm run test:contracts 4. 验证模型选择器恢复的是该话题最近一次 session runtime,而不是陈旧的 localStorage 默认值 5. 如页面暴露运行时摘要条,再确认 provider/model 文案与选择器一致 +### 话题权限恢复验证 + +1. 进入同一工作区中的两个话题 +2. 在话题 A 选择 `只读`,在话题 B 选择 `当前工作区` 或 `完全访问` +3. 在两个话题之间来回切换,必要时刷新页面后再切回 +4. 验证输入框权限选择器恢复的是该话题最近一次 accessMode,而不是工作区级默认值 +5. 如页面暴露运行时摘要、调试面板或开发日志,继续确认恢复依据是当前话题最近一次 `execution_runtime.recent_access_mode` +6. 再立即发送一条消息,确认本轮会沿用该 accessMode 对应的正式权限策略,而不是只在 metadata 里残留旧的 `harness.access_mode` + ### 话题工具偏好恢复验证 1. 进入同一工作区中的两个话题 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 81d7fc6a4..58233cb7a 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -164,11 +164,12 @@ npm run bridge:health -- --timeout-ms 120000 高频场景: - 修改 `safeInvoke` / `invoke` -- 修改 `agent_runtime_update_session` 或会话 provider/model / recent_preferences / recent_team_selection 恢复语义 -- 修改 `execution_runtime.recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑 -- 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_run_adapter` +- 修改 `agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` +- 修改 `agent_runtime_update_session` 或会话 provider/model / recent_access_mode / recent_preferences / recent_team_selection 恢复语义 +- 修改 `execution_runtime.recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.access_mode / harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑 +- 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_get_adapter_launch_readiness`、`site_import_adapter_yaml_bundle`、`site_run_adapter` - 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级 -- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器和扩展安装状态 +- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、扩展安装状态或主动断开扩展连接 - 修改 `src/lib/dev-bridge/` - 修改 `src/lib/tauri-mock/` - 修改 `src-tauri/src/app/runner.rs` @@ -212,10 +213,14 @@ npm run bridge:health -- --timeout-ms 120000 - 如果这次改动把 `theme / session_mode` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: - session 已有 `execution_runtime.recent_theme / recent_session_mode` 时,前端不会重复提交相同 `harness.theme / harness.session_mode` - 切换到新 theme 或 `theme_workbench` 但 runtime 尚未同步时,前端仍会保留显式 `theme / session_mode` +- 如果这次改动把 `accessMode` steady-state 从“只写 harness metadata”收敛到正式 turn context 与 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: + - turn 提交始终携带正式 `approval_policy / sandbox_policy` + - session 已有 `execution_runtime.recent_access_mode` 时,切换话题会恢复对应 accessMode,而不是回退到工作区默认值 + - execution_runtime 缺失但本地 shadow 已命中时,前端仍会回填 `recent_access_mode` 到 session - 如果这次改动把 `gate_key / run_title` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: - session 已有 `execution_runtime.recent_gate_key / recent_run_title` 时,前端不会重复提交相同 `harness.gate_key / harness.run_title` - 切换到新的 Theme Workbench gate 或运行标题、但 runtime 尚未同步时,前端仍会保留显式 `gate_key / run_title` -- 如果这次改动影响浏览器工作台里的站点采集链路,例如推荐区、资料自动选择、`report_hint` 展示、`lime_site_recommend`,或“优先写回当前 `content_id` 而不是新建资源文档”的主线收敛,除了契约检查,还应补对应 `*.test.tsx` 回归并执行 `verify:gui-smoke`。 +- 如果这次改动影响浏览器工作台里的站点采集链路,例如推荐区、资料自动选择、`site_get_adapter_launch_readiness` 门禁、`report_hint` 展示、`lime_site_recommend`,或“优先写回当前 `content_id` 而不是新建资源文档”的主线收敛,除了契约检查,还应补对应 `*.test.tsx` 回归并执行 `verify:gui-smoke`。 - 如果这次改动影响浏览器资料 / 环境预设的真实来源,还应补一次浏览器模式实测,确认控制台不再出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd`。 - 如果这次改动影响设置页“连接器”主路径或 Chrome 扩展导出链路,除了 `test:contracts`,还应补对应设置页回归,并在 GUI smoke 或 Playwright 续测里确认连接器页能打开、目录可选、扩展状态可读。 - 如果这次改动影响 `agent_runtime_export_handoff_bundle`、`agent_runtime_export_evidence_pack`、`agent_runtime_export_analysis_handoff`、`agent_runtime_export_review_decision_template`、`agent_runtime_save_review_decision` 或 `agent_runtime_export_replay_case` 这条 Harness 导出 / 审核主链,除了契约检查,还应至少补: diff --git a/docs/aiprompts/site-adapter-standard.md b/docs/aiprompts/site-adapter-standard.md new file mode 100644 index 000000000..8503a6650 --- /dev/null +++ b/docs/aiprompts/site-adapter-standard.md @@ -0,0 +1,381 @@ +# Lime 站点适配器标准 + +## 这份文档回答什么 + +本文件定义 Lime 仓库中站点适配器能力的唯一工程标准,主要回答: + +- Lime 自己认可的站点适配器标准长什么样 +- 外部来源为什么不能直接成为 Lime 的事实源 +- 站点适配器应该如何接入、执行、校验与治理 +- 如何避免因为接入外部适配器库而把 Lime 做成“万国牌” + +它是 **站点适配器能力的工程标准文档**,不是某个外部项目的引入说明书。 + +如果讨论的是 Skills 总模型、`skill / adapter / runtime binding` 总边界,先读 [skill-standard.md](skill-standard.md),再回到本文。 + +## 第一原则 + +**Lime 有自己的标准。来源可以有多个,但标准只能有一个。** + +对 Lime 来说: + +- 可以有多个 adapter 来源 + - 仓库内手写 + - 服务端下发 + - 外部项目导入 +- 但 Lime 内部只能存在一个继续演进的适配器标准 + +从现在开始,站点适配器能力的唯一事实源应收敛到: + +> `Lime Site Adapter Spec` + +外部项目只能提供“原料”,不能提供 Lime 的运行时标准、协议标准或状态标准。 + +换句话说: + +- 可以接更多来源 +- 不能接更多“标准” +- 不能让来源格式反过来定义 Lime 的产品边界 + +如果某个外部来源和 Lime 标准冲突,优先保留 Lime 标准,而不是为了兼容把 Lime 改成第二套产品。 + +## 什么时候先读 + +出现以下任一情况时,先读本文件,再决定是否写代码: + +- 想新增一个站点适配器 +- 想从外部项目导入适配器 +- 想扩展站点适配器字段、参数类型或执行语义 +- 想调整 `site_*` 命令族的输入输出结构 +- 想新增第二套站点执行引擎、pipeline 或 bridge +- 发现站点采集能力开始出现多套定义、多套错误语义或多套运行时 + +如果问题已经上升为“业务 skill 如何引用 adapter、服务端如何下发统一技能目录、用户入口应该如何表达”,先回到 [skill-standard.md](skill-standard.md)。 + +## 非目标 + +本标准明确不负责以下目标: + +- 定义另一套浏览器 runtime +- 为外部项目保留原生执行模型 +- 支持所有外部适配器语义的 100% 兼容 +- 为了“接得更多”而放松 Lime 的产品边界 + +尤其不要把“支持更多站点”误解成: + +- 再引一套 daemon +- 再引一套浏览器扩展协议 +- 再引一套站点 pipeline runtime + +## 标准分层 + +Lime 的站点适配器能力必须分成三层: + +### 1. 来源层 + +作用: + +- 提供原始 adapter 定义 +- 可以来自仓库内、服务端或外部项目 + +特点: + +- 不直接参与 Lime 执行 +- 不直接决定 Lime 错误语义 +- 不直接决定 Lime 前端展示模型 + +### 2. 编译层 + +作用: + +- 把来源层 adapter 转换为 Lime 标准 +- 做字段收敛、语义校验、步骤白名单检查 + +特点: + +- 是外部来源进入 Lime 的唯一入口 +- 是站点适配器治理边界 +- 负责拒绝不符合 Lime 标准的来源 adapter + +### 3. 执行层 + +作用: + +- 执行已经被编译为 Lime 标准的适配器 + +特点: + +- 只能使用 Lime 当前浏览器执行主链 +- 不允许外部来源自带执行内核绕过 Lime runtime + +## Lime Site Adapter Spec v1 + +Lime 内部适配器标准至少包含以下字段语义: + +- `name` + - 唯一标识,推荐 `site/name` 形式 +- `domain` + - 目标站点主域名 +- `description` + - 面向用户和开发者可读的说明 +- `read_only` + - 是否只读 +- `capabilities` + - 当前适配器暴露的能力标签 +- `args` + - 已归一化后的参数定义 +- `example` + - 最小可运行示例 +- `auth_hint` + - 登录态或上下文要求 +- `entry` + - 入口 URL 规则 +- `script` + - Lime 当前唯一执行脚本 +- `source_kind` + - 来源类型,例如 `bundled` / `server_synced` / `imported` +- `source_version` + - 来源版本号或快照标识 + +现有实现的主承载结构为: + +- `src-tauri/src/services/site_adapter_registry.rs` 中的 `SiteAdapterSpec` + +如果未来字段扩展,仍然必须收敛到 Lime 自己的标准模型,而不是向外部项目的原始结构靠拢。 + +## 标准优先级 + +站点适配器相关决策的优先级固定如下: + +1. Lime 产品边界 +2. Lime Site Adapter Spec +3. Lime 当前浏览器运行时主链 +4. 外部来源可提供的原始 adapter 定义 + +这意味着: + +- 外部来源只能被编译、裁剪、白名单化后进入 Lime +- 不能为了保留来源格式的完整性,引入第二套 runtime、协议或错误语义 +- 不能因为“某来源支持某能力”就直接判定 Lime 也应该支持 + +## 命名标准 + +适配器唯一标识统一使用: + +- `site/name` + +示例: + +- `reddit/hot` +- `zhihu/hot` +- `github/search` + +禁止: + +- 让来源项目自己的内部 ID 成为 Lime 对外主标识 +- 同时维护多套命名规则 + +## 参数标准 + +参数定义必须先归一化,再进入 Lime 主链。 + +`v1` 建议先稳定在最小集合: + +- `string` +- `integer` + +每个参数至少应有: + +- `name` +- `description` +- `required` +- `arg_type` +- `example` + +不要在第一阶段为了兼容外部来源而引入复杂参数系统。 + +## 执行标准 + +### 1. 运行时只能走 Lime 主链 + +站点适配器的真实执行,只允许走 Lime 当前运行时路径: + +- `managed_cdp` +- `existing_session` + +禁止: + +- 直接调用外部项目自带 daemon 作为 Lime 主执行链 +- 直接让外部项目控制 Chrome / Chromium 生命周期 +- 在 Lime 内部并行保留第二套 site adapter runtime + +### 2. 当前执行模型以 script 为唯一主格式 + +对 Lime 来说,站点适配器当前主格式是: + +- 归一化 manifest +- 归一化 script +- 通过现有 runtime 执行 script + +如果外部来源使用: + +- YAML pipeline +- 自定义表达式系统 +- 特定 bridge 协议 + +都必须先编译为 Lime 现有 script 模型。 + +不要直接把外部 pipeline engine 搬进 Lime。 + +### 3. 步骤兼容必须采用白名单 + +对外部 adapter 的语义兼容,必须采用白名单,而不是黑名单。 + +`v1` 推荐只允许: + +- `navigate` +- `evaluate` +- `map` +- `filter` +- `limit` +- `sort` + +`v1` 明确不允许: + +- `intercept` +- `tap` +- `Desktop` 模式 +- 依赖浏览器扩展上下文的动作 +- 依赖 daemon 会话协议的动作 +- 隐式启动、唤醒、关闭浏览器的动作 + +对不支持步骤,必须在导入阶段直接失败。 + +## 错误语义标准 + +Lime 的适配器错误语义必须统一,不能跟着来源项目漂移。 + +至少保持以下错误类型继续收敛: + +- `auth_required` +- `no_matching_context` +- `adapter_runtime_error` +- `site_unreachable` +- `internal_error` + +错误信息可以引用来源 adapter 的上下文,但错误分类、前端提示和结果结构必须以 Lime 为准。 + +## 产品边界标准 + +这是站点适配器能力不可突破的边界。 + +### 明确禁止 + +- 无任务时后台自动执行适配器 +- 自动启动浏览器 +- 自动唤醒浏览器 +- 自动连接外部 daemon +- 常驻后台的浏览器控制进程 +- 用户未明确发起时预热站点运行时 + +### 只允许 + +- 用户显式发起一次站点任务 +- Lime 在可见状态下执行 +- 用户可感知当前使用的浏览器上下文 +- 执行完成后及时收口 + +如果某个外部来源天然依赖“后台常驻自动化”,它就不能原样进入 Lime 主链。 + +## 外部来源接入规则 + +外部来源接入必须遵守以下顺序: + +1. 先盘点来源能力 +2. 再定义支持子集 +3. 再做编译器 +4. 再导入白名单 adapter +5. 最后才允许进入 Lime 主链 + +禁止: + +- 未经过编译层直接执行来源 adapter +- 全量导入来源仓库的全部 adapter +- 把来源项目的 runtime 一并当成捷径接入 + +## 外部适配器来源 的定位 + +`外部适配器来源` 在 Lime 中的定位必须被明确限定为: + +- **站点适配器来源** + +而不能是: + +- Lime 的浏览器 runtime +- Lime 的适配器事实源 +- Lime 的协议事实源 + +也就是说: + +> Lime 可以借 YAML 来源 的 adapter,但不能把 YAML 来源 变成 Lime。 + +## 治理标准 + +治理时统一沿用仓库的 `current / compat / deprecated / dead` 语言。 + +对站点适配器能力,建议这样判断: + +- `current` + - `Lime Site Adapter Spec` + - 当前 `site_*` 命令族 + - 当前 `managed_cdp / existing_session` 执行主链 +- `compat` + - 仅为迁移期保留的来源转换层 +- `deprecated` + - 已经不再建议新增依赖的旧 adapter 表示格式 +- `dead` + - 已无入口、无引用、无导入计划的旧来源代码 + +任何新的来源项目接入,如果引入了第二套运行时、第二套错误语义或第二套前端结果模型,就说明已经偏离本标准。 + +## 校验与交付 + +修改站点适配器能力时,除了常规工程校验,还应至少回答以下问题: + +1. 这次改动是否仍然收敛到 `Lime Site Adapter Spec` +2. 是否新增了第二套执行主链 +3. 是否破坏了当前 `site_*` 命令族的统一语义 +4. 是否引入了后台自动化副作用 +5. 是否补了站点适配器目录、搜索、推荐、运行的最小验证 + +推荐最小校验: + +```bash +npm run test:contracts +npm run verify:gui-smoke +npm run smoke:site-adapters +``` + +如果只是新增或调整适配器来源规则,也应至少补对应文档和最小 smoke 说明。 + +## 实施建议 + +如果下一步要把外部 adapter 引入 Lime,推荐按以下顺序推进: + +1. 先冻结 `Lime Site Adapter Spec v1` +2. 建立来源导入服务 +3. 建立步骤白名单 +4. 只导入只读、安全、无后台副作用的 adapter +5. 跑通 3 到 5 个站点后再扩面 + +不要一开始就追求: + +- 全量兼容 +- 全量站点导入 +- 全量步骤支持 + +站点适配器能力的目标是 **标准化扩展**,不是 **来源堆砌**。 + +## 一句话版本 + +> Lime 可以吸收外部 adapter 能力,但所有来源都必须先编译成 Lime 标准,再交给 Lime 自己的 runtime 执行。 diff --git a/docs/aiprompts/skill-standard.md b/docs/aiprompts/skill-standard.md new file mode 100644 index 000000000..fd94ff4a6 --- /dev/null +++ b/docs/aiprompts/skill-standard.md @@ -0,0 +1,442 @@ +# Lime Skills 标准 + +## 这份文档回答什么 + +本文件定义 Lime 仓库里 `skill` 能力的统一工程标准,主要回答: + +- Lime 自己认可的 skill 标准长什么样 +- `skill`、`adapter`、`runtime binding` 的边界分别是什么 +- 为什么外部 `SKILL.md` 仓库只能作为说明层参考,不能直接成为 Lime 的正式标准 +- 以后新增 Claw 业务技能、站点技能、提示词技能时,应该如何保持一致 + +它是 **Lime 技能能力的总标准文档**。 + +其中: + +- [site-adapter-standard.md](site-adapter-standard.md) 是站点适配器子标准 +- 本文负责技能总模型、事实源、分发和 UI 表达边界 + +## 第一原则 + +**Lime 有自己的 skills 标准。来源可以多个,但标准只能有一个。** + +对 Lime 来说,可以同时存在: + +- 服务端下发的技能目录 +- 仓库内 seeded 技能目录 +- 外部项目提供的 `SKILL.md` / YAML / adapter 来源 + +但 Lime 内部继续演进的标准只能有一套。 + +从现在开始,技能能力的唯一长期事实源应收敛到: + +> `Lime Skill Spec` + +外部仓库只能提供: + +- 说明层模板 +- 来源层原料 +- 触发语义参考 + +不能直接提供: + +- Lime 的运行时协议 +- Lime 的分发协议 +- Lime 的 UI 表达标准 +- Lime 的自动化与浏览器行为边界 + +## 什么时候先读 + +出现以下任一情况时,先读本文件,再决定是否写代码: + +- 想新增一个 Claw 业务技能 +- 想把站点 adapter 封装成业务 skill +- 想新增 prompt-only 技能或说明型技能 +- 想扩展服务端 `serviceSkillCatalog` / 未来 `skillCatalog` +- 想修改 `ServiceSkillItem`、`ClientServiceSkillCatalog` 或对应 UI 入口 +- 想讨论 skill 与 adapter、Tool Hub、Scene、Claw 的边界 +- 发现仓库里开始出现多套 skill 定义、多套入口术语或多套运行语义 + +如果问题已经缩小到站点适配器字段、脚本、导入和执行,先回到 [site-adapter-standard.md](site-adapter-standard.md)。 + +## 非目标 + +本标准明确不负责以下目标: + +- 定义另一套浏览器 runtime +- 让外部 `SKILL.md` 直接成为 Lime 运行时协议 +- 把 adapter 当成 skill 本体 +- 为了兼容来源而长期维护第二套 skill 协议 +- 在第一阶段把所有既有实现一次性重命名重构完 + +尤其不要把“支持更多技能”误解成: + +- 再造一个平级的 `service skill` 协议 +- 再造一个平级的 `site skill` 协议 +- 再造一个平级的 `prompt package` 协议 + +## 标准分层 + +Lime 的技能标准必须分成四层: + +### 1. 说明层 + +作用: + +- 回答“这是什么技能、何时使用、依赖什么、怎么触发” +- 给用户、模型、运营和后台治理看得懂 + +来源可以参考外部 `SKILL.md` 的优点,例如: + +- `name` +- `description` +- `when to use` +- `setup` +- `examples` + +但说明层不是 Lime 的运行时事实源。 + +### 2. 输入层 + +作用: + +- 定义技能参数、默认值、校验和补参表单 + +当前主承载结构是: + +- `src/lib/api/serviceSkills.ts` 里的 `ServiceSkillItem` +- `slotSchema` +- `readinessRequirements` + +新增技能时,优先补结构化输入字段,不要继续把参数要求散落在 prompt 和按钮文案里。 + +### 3. 运行时层 + +作用: + +- 定义技能最终走哪种执行器 + +当前允许的主执行绑定为: + +- `agent_turn` +- `browser_assist` +- `automation_job` +- `cloud_scene` +- `native_skill` + +运行时层回答的是“怎么执行”,不是“对用户如何命名”。 + +### 4. 分发层 + +作用: + +- 定义技能如何被服务端发布、客户端缓存、bootstrap 注入和独立刷新 + +当前已存在的事实源包括: + +- Lime 本地 seeded catalog +- `client/service-skills` +- `bootstrap.serviceSkillCatalog` + +长期目标应收敛到统一的 `client/skills` 与 `bootstrap.skillCatalog`,但兼容期内允许保留现有 `serviceSkillCatalog` 投影。 + +## 统一对象关系 + +Lime 技能能力必须明确区分三个对象: + +### 1. Skill + +作用: + +- 面向用户和产品表达业务入口 +- 解决“为什么用、何时触发、输出去哪” + +### 2. Adapter / Tool + +作用: + +- 提供底层站点、工具或外部能力的执行工件 +- 解决“怎么访问、参数是什么、脚本怎么跑” + +### 3. Runtime Binding + +作用: + +- 把 skill 绑定到具体执行面 +- 解决“最终交给谁执行” + +必须遵守: + +- adapter 不是 skill +- skill 可以引用 adapter,但 adapter 不能冒充 skill +- 一个 skill 只能有一个主执行绑定 +- 多 adapter 编排不属于普通 site skill,属于后续 scene / orchestration 范畴 + +## Lime Skill Spec v1 + +### 1. 技能分类 + +第一阶段只允许三类技能: + +- `service` +- `site` +- `prompt` + +含义如下: + +- `service` + - 业务交付型技能,通常产出主稿、方案、报告、草案 +- `site` + - 业务语义入口,但底层依赖 adapter / 站点工件执行 +- `prompt` + - 说明型或提示词型技能,强调触发语义和使用约束,不强制要求结构化 runtime + +### 2. 统一信息清单 + +无论哪一类技能,新增时都必须回答以下信息。 + +#### 身份字段 + +- `id` +- `skillKey` +- `version` +- `source` + +#### 展示字段 + +- `title` +- `summary` +- `entryHint` +- `aliases` +- `category` +- `outputHint` + +#### 触发字段 + +- `surfaceScopes` +- `triggerHints` + +说明: + +- 当前结构化模型尚未正式包含 `triggerHints` +- 在结构化字段补齐前,新增技能也必须在服务端模板或伴随文档中写清楚,不允许缺失 + +#### 输入字段 + +- `slotSchema` +- `default values` +- `validation` + +#### 执行字段 + +- `defaultExecutorBinding` +- `executionLocation` +- `readinessRequirements` + +#### 运行时引用字段 + +- `siteCapabilityBinding` +- `promptTemplateKey` +- 未来可扩展的 `toolHubBinding` + +#### 产物字段 + +- `defaultArtifactKind` +- `output destination` + +说明: + +- 产品投影层可以直接提供 `outputDestination` +- 标准摘要层统一收敛到 `skillBundle.metadata.Lime_output_destination` +- 新增技能时必须明确写清结果会回到:当前主稿、资源文档、工作区消息、自动化结果还是云端运行结果 + +#### 说明字段 + +- `usageGuidelines` +- `setupRequirements` +- `examples` + +说明: + +- 这些字段可以先由服务端模板或说明文档承接 +- 长期目标是结构化,而不是永久只写在 README / prompt 里 + +## 执行绑定标准 + +### 1. `agent_turn` + +适用于: + +- Claw 业务技能 +- 结构化 prompt + 当前工作区继续执行 + +要求: + +- 输出是业务结果,不是“进入某个工作台” +- 不能把底层技术入口当成用户动作文案 + +### 2. `browser_assist` + +适用于: + +- 必须依赖真实浏览器登录态或页面上下文的技能 + +要求: + +- 业务 skill 可以引用 adapter +- 不能把“浏览器工作台 / 调试面板”作为主产品语义 +- 不允许隐式后台自动化 + +### 3. `automation_job` + +适用于: + +- 定时或持续跟踪技能 + +要求: + +- 必须说明首轮结果、后续调度、失败处理和结果回流方式 + +### 4. `cloud_scene` + +适用于: + +- 必须由云端托管执行的技能 + +要求: + +- 客户端默认只做目录消费、提交和结果回流 +- 不把普通本地即时技能错误迁成云端必跑 + +## UI 表达标准 + +技能 UI 必须表达业务动作,而不是暴露底层实现。 + +### 1. 卡片与入口 + +每个 skill 卡片至少要能回答: + +- 这是什么 +- 何时用 +- 怎么执行 +- 需要什么依赖 +- 结果去哪 + +### 2. 启动弹窗 + +启动弹窗统一应包含: + +- 技能摘要 +- 补参表单 +- 执行方式说明 +- 依赖条件说明 +- 结果写入位置说明 + +### 3. 文案禁止项 + +禁止把以下内容直接当成主产品术语: + +- 浏览器工作台 +- 调试面板 +- 脚本目录 +- runtime debug +- adapter 执行器 + +这些只能作为实现说明,不能作为用户主动作文案。 + +## 分发与事实源标准 + +### 标准层与产品层的边界 + +当前必须明确区分两件事: + +- `skillBundle` + - 对外对齐 Agent Skills 思路的**标准摘要层** + - 负责表达:`name`、`description`、`license`、`compatibility`、`metadata`、`allowedTools` + - 以及 Lime 运行时真正需要的标准状态:`resourceSummary`、`standardCompliance` +- `ServiceSkillCatalog` / `ClientServiceSkillCatalog` + - Lime 面向 Claw / 工作区 / 启动弹窗的**产品投影层** + - 负责表达:卡片文案、补参表单、执行绑定、结果去向、主题目标、自动化入口等业务语义 + +强约束: + +- 不要把 `ServiceSkillItem` 上的产品展示字段误认为标准本体 +- 也不要把外部 `SKILL.md` 原文直接当成 Lime 客户端协议 +- 标准层与产品层可以共存,但标准层必须有唯一投影:`skillBundle` + +### 当前事实源 + +客户端现状: + +- 本地 seeded skill catalog +- `bootstrap.serviceSkillCatalog` +- `client/service-skills` +- `siteAdapterCatalog` + +服务端现状: + +- `control-plane-svc` 负责客户端技能目录聚合 +- Tool Hub 方向负责 tool / adapter 工件真相源 + +### 长期收敛方向 + +长期收敛规则固定如下: + +1. 统一 skill 目录收敛到 `client/skills` +2. 兼容期保留 `client/service-skills` +3. adapter / tool 工件目录继续独立,不与 skill 目录混用 +4. bootstrap 与独立刷新必须消费同一份目录协议 + +## 外部 `SKILL.md` 参考边界 + +外部 `SKILL.md` 仓库对 Lime 只有三类帮助: + +- 触发语义怎么写更清楚 +- `when to use / setup / examples` 怎么组织更清楚 +- 说明层如何让人和模型都容易理解 + +它不能直接成为: + +- Lime 的目录协议 +- Lime 的执行绑定协议 +- Lime 的客户端 UI 标准 +- Lime 的租户分发标准 + +一句话: + +> 外部 `SKILL.md` 只可借“说明书结构”,不可借“产品标准定义权”。 + +## 新增技能的最低检查单 + +新增一个技能时,至少要回答以下问题: + +1. 它属于 `service / site / prompt` 哪一类 +2. 它的主执行绑定是什么 +3. 它是否依赖 adapter、浏览器、模型、项目或云端运行 +4. 它的结果会写回哪里 +5. 它的用户主动作文案是否仍然是业务语义,而不是底层实现 +6. 它是否继续沿用当前主目录协议,而不是再造平级协议 +7. 如果它引用 adapter,是否仍然遵守 [site-adapter-standard.md](site-adapter-standard.md) + +## 当前主链 + +在统一 `client/skills` 正式落地前,当前新增能力的主链固定如下: + +- 业务技能目录:继续收敛到 `ServiceSkillCatalog` +- 标准摘要层:继续收敛到 `skillBundle` +- 站点工件目录:继续收敛到 `siteAdapterCatalog` +- 业务 skill 引用站点能力:通过 `siteCapabilityBinding.adapterName` + +不要在这个阶段再引入: + +- 平级 `skill.json` 目录协议 +- 平级 Markdown-only 技能协议 +- 平级浏览器技能协议 + +## 相关文档 + +- [overview.md](overview.md) +- [site-adapter-standard.md](site-adapter-standard.md) +- [commands.md](commands.md) +- [quality-workflow.md](quality-workflow.md) +- [limecore-collaboration-entry.md](limecore-collaboration-entry.md) diff --git a/docs/research/site-adapter-source-integration.md b/docs/research/site-adapter-source-integration.md new file mode 100644 index 000000000..61cfb0f87 --- /dev/null +++ b/docs/research/site-adapter-source-integration.md @@ -0,0 +1,512 @@ +# 基于外部站点适配器来源的引入方案 + +> 目标:在不引入后台浏览器自动化副作用的前提下,利用外部站点适配器来源扩充 Lime 的站点覆盖面,同时坚持 Lime 自己的标准。 + +## 一、结论 + +外部站点适配器来源可以接,但只能作为来源层,不能成为 Lime 的第二套产品标准。 + +必须同时满足三条边界: + +- 只引入站点适配器定义,不引入浏览器运行时 +- 只引入可编译的安全子集,不引入完整来源语义 +- Lime 继续以 `managed_cdp / existing_session` 作为唯一浏览器执行事实源 + +如果目标是“更多站点适配器”,这是合理方向。 +如果目标变成“更多浏览器自动化模式”或“多套浏览器控制系统”,这条路不适合 Lime。 + +## 二、为什么不能原样接入 + +### 2.1 外部来源的价值在站点覆盖,而不在浏览器控制 + +外部来源真正有价值的是: + +- 已沉淀的大量站点定义 +- 声明式 YAML adapter 结构 +- 可复用的 pipeline 表达方式 +- 对站点字段抽取的经验 + +它解决的是“站点覆盖面”问题,不是 Lime 缺少的“浏览器内核抽象”问题。 + +### 2.2 原生浏览器链路与 Lime 产品边界冲突 + +很多外部来源自带这些行为: + +- 后台拉起守护进程 +- 自动连接浏览器扩展 +- 连接失败时尝试唤醒 Chrome +- 把 Chrome/Chromium 会话当成默认主链 + +这和 Lime 当前已经明确收紧的产品边界直接冲突: + +- 禁止无任务时后台自运行 +- 禁止用户无感知地拉起浏览器 +- 禁止常驻式浏览器自动化观感 +- 禁止像“后门”一样持续消耗 CPU / 内存 + +因此,外部来源不能以“浏览器运行时方案”的身份进入 Lime。 + +### 2.3 原样并入会把 Lime 做成两套系统 + +Lime 当前已经有自己的主链: + +- 浏览器来源:`system | playwright` +- 上下文接入:`managed_cdp | existing_session` +- 站点适配器注册表 +- 站点适配器执行分发链 + +如果再把外部来源的 `daemon + extension + protocol + page bridge` 整套搬进来,结果就是: + +1. Lime 自己的浏览器控制链 +2. 外部来源自己的浏览器控制链 + +这会直接导致: + +- 事实源分裂 +- 排障复杂度翻倍 +- 用户心智混乱 +- Windows / macOS 资源问题更难治理 + +这不符合 Lime 的产品判断,也不符合仓库治理原则。 + +## 三、应该引入什么,不应该引入什么 + +### 3.1 应该引入 + +建议只引入这些“来源材料”: + +- 外部来源中的 adapter 定义文件 +- adapter 元数据结构 + - `site` + - `name` + - `description` + - `domain` + - `args` + - `columns` + - `pipeline` +- 表达式与步骤设计思路 +- 已验证过的字段映射经验 + +### 3.2 不应该引入 + +明确不引入这些运行时能力: + +- 浏览器桥接实现 +- 守护进程生命周期 +- 浏览器扩展连接模型 +- 后台自动拉起浏览器 +- 自动唤醒 Chrome +- 常驻后台的浏览器控制进程 +- 任何绕过 Lime 当前 browser runtime 的执行主链 + +### 3.3 核心边界 + +一句话定义边界: + +> 外部来源是 Lime 的站点适配器来源,不是 Lime 的浏览器执行内核。 + +## 四、Lime 当前切入点 + +Lime 现有站点适配器主链已经存在,关键边界如下: + +- 站点适配器注册:`src-tauri/src/services/site_adapter_registry.rs` +- 站点适配器执行:`src-tauri/src/services/site_capability_service.rs` +- 前端命令网关:`src/lib/webview-api.ts` +- 浏览器执行路线: + - `existing_session` + - `managed_cdp` + +Lime 当前主格式本质上是: + +- manifest 元数据 +- entry URL 规则 +- script 脚本执行 +- 通过 Lime 现有 runtime 执行 script + +而外部来源通常是: + +- YAML 元数据 +- pipeline 步骤 +- 来源自己的运行时抽象 +- 可选浏览器桥接 + +这两者不是直接兼容关系,因此正确切入点不是“并列保留两套格式”,而是增加一层导入编译边界。 + +## 五、推荐落地方案 + +### 5.1 三层结构 + +建议固定为三层: + +1. 来源层 + - 外部 adapter 定义文件 +2. Lime 编译层 + - 把来源 YAML 编译成 Lime 标准 +3. Lime 执行层 + - 继续使用 Lime 现有 `managed_cdp / existing_session` + +这样可以同时保证: + +- 站点覆盖面扩展 +- 浏览器执行事实源不分裂 +- 不引入后台自动化副作用 +- Lime 标准仍然是唯一事实源 + +### 5.2 第一阶段只支持安全子集 + +第一阶段不要追求完整兼容来源 pipeline。 + +建议只支持以下安全子集: + +- `navigate` +- `evaluate` +- `map` +- `filter` +- `limit` +- `sort` + +第一阶段明确不支持: + +- `tap` +- `intercept` +- `Desktop` 模式 +- 依赖扩展上下文的动作 +- 依赖守护进程协议的动作 +- 自动关闭、拉起、唤醒浏览器的动作 + +这能把复杂度和产品风险都压在可控范围内。 + +### 5.3 编译层职责 + +建议通过独立导入服务承接,例如: + +- `src-tauri/src/services/site_adapter_import_service.rs` + +职责只保留三件事: + +1. 读取来源 YAML +2. 按 Lime 白名单规则校验 +3. 编译为 Lime `SiteAdapterSpec` 所需结构 + +原则是: + +- 不污染现有站点执行链 +- 不把来源原始模型散落到整个仓库 +- 不让来源格式越过编译边界直达运行时 + +## 六、接入路线对比 + +### 方案 A:直接调用外部 CLI + +实现方式: + +- Lime 在用户触发时调用外部命令 +- 读取输出结果 +- 回填到 Lime 的结果链路 + +优点: + +- 接入快 +- 便于短期验证少量站点价值 + +缺点: + +- 仍可能间接触发来源自己的浏览器自动化模型 +- 输出结构和错误语义受外部 CLI 限制 +- 难以对齐 Lime 当前 `managed_cdp / existing_session` +- 长期治理成本高 + +判断: + +- 只适合作为一次性实验 +- 不适合作为 Lime 主方案 + +### 方案 B:只导入来源定义,由 Lime 执行 + +实现方式: + +- Lime 只消费来源 adapter 定义 +- 编译后交给 Lime 现有 runtime 执行 + +优点: + +- 浏览器控制事实源统一 +- 产品边界一致 +- 用户体验一致 +- 可复用 Lime 当前审计、状态、结果链路 + +缺点: + +- 首轮需要实现编译层 +- 需要维护 pipeline 子集映射规则 + +判断: + +- 这是 Lime 应采用的长期主方案 + +--- + +## 七、推荐实施阶段 + +### 阶段 0:只做静态评估 + +目标: + +- 建立 YAML 来源 adapter 能力盘点 +- 标注哪些站点适合迁入 +- 标注哪些步骤当前不支持 + +输出: + +- 站点白名单 +- 不支持步骤清单 +- 优先迁移顺序 + +建议优先挑选的站点特征: + +- 只依赖 `navigate + evaluate + map + limit` +- 不依赖 Chrome extension +- 不依赖桌面应用上下文 +- 只读采集,不带写操作 + +### 阶段 1:做 adapter importer PoC + +目标: + +- 支持读取 YAML 来源 YAML +- 转换为 Lime 内部适配器结构 +- 跑通 3 到 5 个代表性站点 + +建议首批站点: + +- Reddit +- Zhihu +- Yahoo Finance +- Hacker News +- Xueqiu 中不依赖拦截链的只读项 + +阶段完成标准: + +- 站点可被 Lime 列出 +- 可被搜索和推荐 +- 可通过现有站点执行链跑出稳定结果 +- 不引入任何后台自动拉起浏览器行为 + +### 阶段 2:扩展步骤子集 + +目标: + +- 在验证稳定后,再考虑增加更多 pipeline 步骤 + +建议顺序: + +1. `filter` +2. `sort` +3. 更复杂的 `map` 表达式 +4. 更完整的参数类型支持 + +仍然不建议过早支持: + +- `intercept` +- `tap` +- 浏览器扩展相关上下文 + +### 阶段 3:建立上游同步机制 + +目标: + +- 让 Lime 能稳定跟随 YAML 来源 adapter 增长 + +建议做法: + +- 明确一份允许导入的 adapter 白名单 +- 通过脚本做同步与编译 +- 同步时生成变更报告 + +不要做成: + +- 自动无审核拉取上游全部 adapter +- 每次构建时动态扫描外部仓库 + +同步必须可审计、可回滚、可控。 + +--- + +## 八、适配器编译规则建议 + +### 8.1 名称映射 + +建议内部统一命名: + +- `site/name` 形式作为 adapter 唯一标识 +- 例如: + - `reddit/hot` + - `zhihu/hot` + - `xiaohongshu/feed` + +### 8.2 参数映射 + +YAML 来源 常见参数类型先收敛到 Lime 已支持的简单类型: + +- `str` -> `string` +- `int` -> `integer` + +首轮不要扩展复杂参数系统。 + +### 8.3 pipeline 到 script 的映射 + +推荐生成单一脚本执行体,而不是在 Lime 再实现一整套 YAML 来源 pipeline engine。 + +理由: + +- 当前 Lime 站点执行模型本来就是 script 型 +- 可复用现有运行时 +- 更容易与 `existing_session / managed_cdp` 对齐 + +建议做法: + +- `navigate` 映射为脚本前置导航意图 +- `evaluate` 直接转为脚本主体 +- `map/filter/limit/sort` 优先编译到脚本尾部的数据整形 + +也就是说: + +> 不把 YAML 来源 pipeline 原样搬进 Lime,而是把它编译成 Lime 现有 script 执行模型。 + +### 8.4 不支持步骤的处理 + +对于当前不支持的步骤,必须在导入阶段就失败,并给出清晰原因。 + +例如: + +- `adapter uses intercept step` +- `adapter requires extension runtime` +- `adapter requires desktop mode` + +不要进入运行时才失败。 + +--- + +## 九、用户体验与产品边界 + +这是本方案最重要的非功能约束。 + +### 9.1 明确禁止 + +- 无任务时后台自动运行站点适配器 +- 自动启动 Chrome +- 自动唤醒 Chrome +- 自动挂接扩展 +- 常驻 daemon +- 用户未点击执行时预热站点运行时 + +### 9.2 只允许的执行方式 + +只允许: + +- 用户显式点击运行某个适配器 +- 或者用户明确发起某次站点采集任务 + +并且执行过程必须满足: + +- 可见 +- 可中断 +- 有状态反馈 +- 有完成结果 +- 结束即收口 + +### 9.3 UX 文案要求 + +任何未来如果接入 YAML 来源 来源的适配器,都不应对用户暴露“YAML 来源 daemon”“extension reconnect”这类技术词。 + +用户只需要知道: + +- 这是一个站点适配器 +- 需要哪个浏览器上下文 +- 是否需要登录态 +- 执行后会得到什么结果 + +--- + +## 十、风险清单 + +### 10.1 表达式兼容风险 + +YAML 来源 的表达式和 Lime 当前脚本模板体系不完全相同。 +需要一个明确的受支持表达式子集。 + +### 10.2 站点易碎性风险 + +很多站点适配器本质上依赖页面结构、接口字段、前端状态树。 +即使成功导入,也需要接受它们会失效。 + +### 10.3 维护成本风险 + +如果一次性导入过多站点,后续维护成本会很高。 +必须建立白名单,不要全量照搬 55 个站点。 + +### 10.4 写操作风险 + +带有发送、发布、点赞、交互类动作的适配器,不应在第一阶段导入。 +首轮只应覆盖只读采集类适配器。 + +### 10.5 协议漂移风险 + +如果 Lime 自己新做一套 pipeline,又试图兼容 YAML 来源 全量语义,最后会长成第三套事实源。 +必须坚持“导入后编译到 Lime 当前 script 执行模型”。 + +--- + +## 十一、推荐的首轮范围 + +首轮建议只做以下范围: + +- 只读适配器 +- 无扩展依赖 +- 无 daemon 依赖 +- 无桌面应用依赖 +- 无写操作 +- 无登录强依赖,或登录态可复用现有 `existing_session` + +不建议首轮纳入: + +- 小红书 `intercept / tap` 型复杂适配器 +- ChatGPT / Chatwise / Discord-App 这类 Desktop 模式适配器 +- 依赖扩展上下文或浏览器网络拦截的适配器 + +--- + +## 十二、建议的下一步落地顺序 + +1. 建立 YAML 来源 adapter 能力审计脚本 +2. 产出支持步骤白名单 +3. 新增 `site_adapter_import_service` +4. 先导入 3 到 5 个只读 adapter +5. 接入现有 `site_list_adapters / site_search_adapters / site_run_adapter` +6. 补 smoke,证明不会触发后台自动化副作用 +7. 再决定是否扩展更多步骤 + +--- + +## 十三、最终决策 + +最终建议如下: + +- **决策一**:采纳 `外部适配器来源`,但仅作为站点适配器来源 +- **决策二**:不采纳 `外部适配器来源` 的浏览器 runtime +- **决策三**:Lime 继续保持浏览器 runtime 单一事实源 +- **决策四**:先做 importer + 编译层,不做 runtime 并入 +- **决策五**:首轮仅支持只读、安全、无后台副作用的 adapter 子集 + +这条路径同时满足: + +- 更多站点适配器 +- 不引入第二套浏览器内核 +- 不破坏当前产品边界 +- 便于渐进式扩展 + +--- + +## 附:一句话版本 + +> 要引的是 YAML 来源 的“站点适配器库”,不是它的“后台浏览器自动化模式”。 diff --git a/extensions/lime-chrome/README.md b/extensions/lime-chrome/README.md index fcaf87309..ef007e3d2 100644 --- a/extensions/lime-chrome/README.md +++ b/extensions/lime-chrome/README.md @@ -4,12 +4,13 @@ ## 功能 -- Observer 通道自动连接:`/lime-chrome-observer/Lime_Key=...` +- Observer 通道自动连接:`/lime-chrome-observer/?profileKey=...` - 页面信息上报:标题、URL、Markdown - 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `list_tabs` / `go_back` 等 - 弹窗入口:直接打开 Lime 的“连接器”页,并保留高级手动配置 - 自动配置文件:导出后自动写入 `auto_config.json` - 弹窗配置:`serverUrl`、`bridgeKey`、`profileKey`、监控开关、手动抓取 +- 桌面端主动断开:当 Lime 设置页点击“断开已连接扩展”时,扩展会关闭 observer 连接并停止自动重连,直到用户手动重连 ## 安装 @@ -67,12 +68,25 @@ npm run bridge:e2e -- --server ws://127.0.0.1:8787 --key proxy_cast --profile default ``` +默认还会通过 `http://127.0.0.1:3030/invoke` 调用 `disconnect_browser_connector_session`,继续验证桌面端主动断开链路: + +- observer/control 均收到 `force_disconnect` +- `disconnect_browser_connector_session` 返回断开计数 +- 在干净环境下,`get_chrome_bridge_status` 最终归零 + +如果你当前只想验证纯 WebSocket 握手和命令回路,而不依赖 DevBridge invoke,可显式跳过该阶段: + +```bash +npm run bridge:e2e -- --server ws://127.0.0.1:8787 --key proxy_cast --profile default --skip-force-disconnect +``` + 脚本会验证: - observer/control 握手 - 双向心跳 ack - `wait_for_page_info=true` 命令链路(`command_result` + `page_info_update`) - 普通命令链路(`command_result`) +- 桌面端主动断开链路(默认开启) ## 兼容说明 diff --git a/extensions/lime-chrome/background.js b/extensions/lime-chrome/background.js index f955c42d9..72a6a1cb4 100644 --- a/extensions/lime-chrome/background.js +++ b/extensions/lime-chrome/background.js @@ -19,6 +19,7 @@ let activeTabId = null; let monitoringEnabled = true; let latestPageInfo = null; let lastSettings = { ...DEFAULT_SETTINGS }; +const expectedClosedSockets = new WeakSet(); function logInfo(message, payload) { if (payload === undefined) { @@ -62,6 +63,10 @@ function buildObserverUrl(settings) { return `${normalized}/lime-chrome-observer/${encodeURIComponent(bridgeKey)}?profileKey=${profileKey}`; } +function isCapturableUrl(url) { + return /^https?:\/\//i.test(String(url || "").trim()); +} + async function connectObserver(forceReconnect = false) { if (ws && ws.readyState === WebSocket.OPEN && !forceReconnect) { return; @@ -83,15 +88,14 @@ async function connectObserver(forceReconnect = false) { } if (forceReconnect && ws) { - try { - ws.close(); - } catch (_) {} + closeObserverSocket(ws, { shouldReconnect: false }); } logInfo(`连接 observer: ${url}`); - ws = new WebSocket(url); + const socket = new WebSocket(url); + ws = socket; - ws.onopen = () => { + socket.onopen = () => { reconnectAttempts = 0; setConnectionState(true); startHeartbeat(); @@ -99,7 +103,7 @@ async function connectObserver(forceReconnect = false) { triggerPageCapture("ws_open"); }; - ws.onmessage = async (event) => { + socket.onmessage = async (event) => { try { const payload = JSON.parse(event.data); await handleObserverMessage(payload); @@ -108,14 +112,22 @@ async function connectObserver(forceReconnect = false) { } }; - ws.onclose = () => { + socket.onclose = () => { + const shouldReconnect = !expectedClosedSockets.has(socket); + if (ws === socket) { + ws = null; + } setConnectionState(false); clearHeartbeatTimer(); - scheduleReconnect(); + if (shouldReconnect) { + scheduleReconnect(); + } else { + reconnectAttempts = 0; + } broadcastStatus(); }; - ws.onerror = (error) => { + socket.onerror = (error) => { logWarn("WebSocket 错误", error?.message || error); }; } @@ -124,9 +136,7 @@ function disconnectObserver(manual = true) { clearReconnectTimer(); clearHeartbeatTimer(); if (ws) { - try { - ws.close(); - } catch (_) {} + closeObserverSocket(ws, { shouldReconnect: false }); } if (manual) { setConnectionState(false); @@ -134,6 +144,18 @@ function disconnectObserver(manual = true) { } } +function closeObserverSocket(socket, { shouldReconnect }) { + if (!socket) { + return; + } + if (!shouldReconnect) { + expectedClosedSockets.add(socket); + } + try { + socket.close(); + } catch (_) {} +} + function setConnectionState(connected) { isConnected = connected; chrome.action.setBadgeText({ text: connected ? "ON" : "OFF" }); @@ -211,6 +233,11 @@ async function handleObserverMessage(payload) { if (type === "heartbeat_ack" || type === "connection_ack") { return; } + if (type === "force_disconnect") { + logInfo("收到桌面端主动断开指令"); + disconnectObserver(true); + return; + } if (type !== "command" || !payload.data) { return; } @@ -530,6 +557,17 @@ async function triggerPageCapture(reason, retry = 0) { return; } + let tab = null; + try { + tab = await chrome.tabs.get(tabId); + } catch (_) { + tab = null; + } + + if (!tab || !isCapturableUrl(tab.url)) { + return; + } + try { await sendCommandToTab(tabId, { type: "REQUEST_PAGE_CAPTURE", @@ -736,8 +774,16 @@ async function loadAutoConfig() { logWarn("自动配置缺少必要字段", config); } } catch (error) { - // 文件不存在或解析失败时记录错误 - logWarn("加载自动配置失败", error?.message || String(error)); + const message = error?.message || String(error); + if ( + /failed to fetch/i.test(message) || + /not found/i.test(message) || + /networkerror/i.test(message) + ) { + logInfo("未检测到 auto_config.json,继续使用本地设置"); + return; + } + logWarn("加载自动配置失败", message); } } diff --git a/extensions/lime-chrome/popup.js b/extensions/lime-chrome/popup.js index 2a819e4fc..edbbcdc36 100644 --- a/extensions/lime-chrome/popup.js +++ b/extensions/lime-chrome/popup.js @@ -36,7 +36,7 @@ function buildObserverEndpoint(serverUrl, bridgeKey, profileKey) { if (!base || !key) { return "Observer URL: 未配置"; } - return `Observer URL: ${base}/lime-chrome-observer/Lime_Key=${encodeURIComponent(key)}?profileKey=${profile}`; + return `Observer URL: ${base}/lime-chrome-observer/${encodeURIComponent(key)}?profileKey=${profile}`; } function applyStatus(status) { diff --git a/package.json b/package.json index 1c1234c1e..67292b4f1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.97.0", + "version": "0.98.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/scripts/chrome-bridge-e2e.mjs b/scripts/chrome-bridge-e2e.mjs index 9c5281a24..a4e54e9bb 100644 --- a/scripts/chrome-bridge-e2e.mjs +++ b/scripts/chrome-bridge-e2e.mjs @@ -5,9 +5,13 @@ import process from "node:process"; const DEFAULTS = { server: "ws://127.0.0.1:8787", + healthUrl: "http://127.0.0.1:3030/health", + invokeUrl: "http://127.0.0.1:3030/invoke", key: "", profile: "default", timeoutMs: 15000, + intervalMs: 1000, + verifyForceDisconnect: true, }; function parseArgs(argv) { @@ -19,6 +23,16 @@ function parseArgs(argv) { i += 1; continue; } + if (arg === "--health-url" && argv[i + 1]) { + args.healthUrl = argv[i + 1]; + i += 1; + continue; + } + if (arg === "--invoke-url" && argv[i + 1]) { + args.invokeUrl = argv[i + 1]; + i += 1; + continue; + } if (arg === "--key" && argv[i + 1]) { args.key = argv[i + 1]; i += 1; @@ -34,6 +48,15 @@ function parseArgs(argv) { i += 1; continue; } + if (arg === "--interval-ms" && argv[i + 1]) { + args.intervalMs = Number(argv[i + 1]); + i += 1; + continue; + } + if (arg === "--skip-force-disconnect") { + args.verifyForceDisconnect = false; + continue; + } if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); @@ -51,13 +74,18 @@ Lime Chrome Bridge E2E 联调脚本 选项: --server 服务地址,默认 ws://127.0.0.1:8787 + --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health + --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke --key Lime API Key(必填) --profile profileKey,默认 default --timeout-ms 单步超时毫秒,默认 15000 + --interval-ms 状态轮询间隔毫秒,默认 1000 + --skip-force-disconnect 跳过桌面端主动断开验证,仅校验 WebSocket 命令链路 -h, --help 显示帮助 示例: node scripts/chrome-bridge-e2e.mjs --server ws://127.0.0.1:8787 --key proxy_cast --profile default + node scripts/chrome-bridge-e2e.mjs --server ws://127.0.0.1:8787 --key proxy_cast --profile default --skip-force-disconnect `); } @@ -76,6 +104,124 @@ function normalizeServer(server) { .replace(/\/$/, ""); } +function appendProfileKey(url, profileKey) { + const endpoint = new URL(url); + endpoint.searchParams.set("profileKey", profileKey); + return endpoint.toString(); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function invoke(invokeUrl, cmd, args) { + const response = await fetch(invokeUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ cmd, args }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const payload = await response.json(); + if (payload?.error) { + throw new Error(String(payload.error)); + } + + return payload?.result; +} + +async function waitForHealth(options) { + const startedAt = Date.now(); + let lastError = null; + + while (Date.now() - startedAt < options.timeoutMs) { + try { + const response = await fetch(options.healthUrl, { method: "GET" }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + console.log( + `[E2E] DevBridge 已就绪 (${Date.now() - startedAt}ms)${ + payload?.status ? ` status=${payload.status}` : "" + }`, + ); + return; + } catch (error) { + lastError = error; + await sleep(options.intervalMs); + } + } + + const detail = + lastError instanceof Error + ? lastError.message + : String(lastError || "unknown error"); + throw new Error( + `[E2E] DevBridge 未就绪,请先启动 npm run tauri:dev 或 npm run tauri:dev:headless。最后错误: ${detail}`, + ); +} + +function summarizeStatus(status) { + if (!status || typeof status !== "object") { + return "unknown"; + } + + return `observer=${status.observer_count ?? "?"}, control=${ + status.control_count ?? "?" + }, pending=${status.pending_command_count ?? "?"}`; +} + +function isStatusEmpty(status) { + return ( + Number(status?.observer_count || 0) === 0 && + Number(status?.control_count || 0) === 0 && + Number(status?.pending_command_count || 0) === 0 + ); +} + +function hasObserverForProfile(status, profileKey) { + return (status?.observers || []).some( + (observer) => observer?.profile_key === profileKey, + ); +} + +async function getBridgeStatus(invokeUrl) { + return invoke(invokeUrl, "get_chrome_bridge_status"); +} + +async function waitForStatus(invokeUrl, predicate, timeoutMs, intervalMs, desc) { + const startedAt = Date.now(); + let lastStatus = null; + + while (Date.now() - startedAt < timeoutMs) { + lastStatus = await getBridgeStatus(invokeUrl); + if (predicate(lastStatus)) { + return lastStatus; + } + await sleep(intervalMs); + } + + throw new Error( + `[E2E] 等待桥接状态超时(${timeoutMs}ms): ${desc}\n最近状态: ${JSON.stringify( + lastStatus, + null, + 2, + )}`, + ); +} + function toText(data) { if (typeof data === "string") return data; if (Buffer.isBuffer(data)) return data.toString("utf8"); @@ -186,6 +332,9 @@ async function closeClient(client) { } async function main() { + if (typeof fetch !== "function") { + throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+"); + } assertGlobalWebSocket(); const args = parseArgs(process.argv.slice(2)); @@ -196,12 +345,44 @@ async function main() { if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 1000) { throw new Error("--timeout-ms 必须是 >= 1000 的数字"); } + if (!Number.isFinite(args.intervalMs) || args.intervalMs < 100) { + throw new Error("--interval-ms 必须是 >= 100 的数字"); + } const server = normalizeServer(args.server); const key = encodeURIComponent(args.key); - const profile = encodeURIComponent(args.profile || "default"); - const observerUrl = `${server}/lime-chrome-observer/Lime_Key=${key}?profileKey=${profile}`; - const controlUrl = `${server}/lime-chrome-control/Lime_Key=${key}`; + const profile = String(args.profile || "default").trim() || "default"; + let observerBaseUrl = `${server}/lime-chrome-observer/${key}`; + let controlUrl = `${server}/lime-chrome-control/${key}`; + let baselineStatus = null; + if (args.verifyForceDisconnect) { + console.log("[E2E] invoke :", args.invokeUrl); + await waitForHealth(args); + const endpointInfo = await invoke(args.invokeUrl, "get_chrome_bridge_endpoint_info"); + if (typeof endpointInfo?.observer_ws_url === "string" && endpointInfo.observer_ws_url) { + observerBaseUrl = endpointInfo.observer_ws_url; + } + if (typeof endpointInfo?.control_ws_url === "string" && endpointInfo.control_ws_url) { + controlUrl = endpointInfo.control_ws_url; + } + if ( + typeof endpointInfo?.bridge_key === "string" && + endpointInfo.bridge_key && + endpointInfo.bridge_key !== args.key + ) { + console.warn( + `[E2E] 传入 key 与当前运行态 bridge_key 不一致,将以运行态 endpoint 为准: cli=${args.key} runtime=${endpointInfo.bridge_key}`, + ); + } + baselineStatus = await getBridgeStatus(args.invokeUrl); + console.log("[E2E] 基线状态:", summarizeStatus(baselineStatus)); + if (!isStatusEmpty(baselineStatus)) { + console.warn( + "[E2E] 检测到已有桥接连接,本次将校验 force_disconnect 消息和目标 profile 清理,但不强制要求全局状态归零。", + ); + } + } + const observerUrl = appendProfileKey(observerBaseUrl, profile); console.log("[E2E] observer:", observerUrl); console.log("[E2E] control :", controlUrl); @@ -341,6 +522,71 @@ async function main() { ); console.log("[E2E] 非 wait_for_page_info 命令链路通过"); + if (args.verifyForceDisconnect) { + const forceDisconnectObserver = waitForMessage( + observer, + (msg) => msg.type === "force_disconnect", + args.timeoutMs, + "observer 收到 force_disconnect", + ); + const forceDisconnectControl = waitForMessage( + control, + (msg) => msg.type === "force_disconnect", + args.timeoutMs, + "control 收到 force_disconnect", + ); + + const disconnectResult = await invoke( + args.invokeUrl, + "disconnect_browser_connector_session", + { + profileKey: args.profile, + }, + ); + + assert( + Number(disconnectResult?.disconnected_observer_count || 0) >= 1, + `disconnect_browser_connector_session 未断开 observer: ${JSON.stringify( + disconnectResult, + null, + 2, + )}`, + ); + assert( + Number(disconnectResult?.disconnected_control_count || 0) >= 1, + `disconnect_browser_connector_session 未断开 control: ${JSON.stringify( + disconnectResult, + null, + 2, + )}`, + ); + + await Promise.all([forceDisconnectObserver, forceDisconnectControl]); + console.log("[E2E] force_disconnect 消息链路通过"); + + const finalStatus = await waitForStatus( + args.invokeUrl, + (status) => + isStatusEmpty(baselineStatus) + ? isStatusEmpty(status) + : !hasObserverForProfile(status, args.profile), + args.timeoutMs, + args.intervalMs, + isStatusEmpty(baselineStatus) + ? "桥接状态归零" + : `profile=${args.profile} observer 已清理`, + ); + + if (isStatusEmpty(baselineStatus)) { + console.log("[E2E] force_disconnect 后状态归零:", summarizeStatus(finalStatus)); + } else { + console.log( + "[E2E] force_disconnect 后目标 profile 已清理:", + summarizeStatus(finalStatus), + ); + } + } + console.log("\n[E2E] ✅ Chrome Bridge 联调通过"); } finally { await closeClient(control); diff --git a/scripts/start-web-bridge-dev.mjs b/scripts/start-web-bridge-dev.mjs index b2ebcaed9..7ab0e1915 100755 --- a/scripts/start-web-bridge-dev.mjs +++ b/scripts/start-web-bridge-dev.mjs @@ -1,22 +1,88 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import process from 'node:process'; + +const DEV_URL = process.env.LIME_WEB_BRIDGE_URL?.trim() || 'http://127.0.0.1:1420/'; +const DEV_URL_TIMEOUT_MS = 1_500; +const ROOT_MARKERS = ['Lime', '
']; const env = { ...process.env }; delete env.TAURI_ENV_PLATFORM; env.LIME_BROWSER_BRIDGE = '1'; -const child = spawn('npx', ['vite'], { - stdio: 'inherit', - shell: true, - env, -}); +function isLimeDevShell(html) { + return ROOT_MARKERS.some((marker) => html.includes(marker)); +} -child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal); +async function probeExistingDevServer(url) { + if (typeof fetch !== 'function') { + return { reachable: false }; + } + + try { + const response = await fetch(url, { + method: 'GET', + signal: AbortSignal.timeout(DEV_URL_TIMEOUT_MS), + }); + const html = await response.text(); + + return { + reachable: true, + status: response.status, + statusText: response.statusText, + isLimeDevShell: response.ok && isLimeDevShell(html), + }; + } catch { + return { reachable: false }; + } +} + +async function waitForExitSignal() { + await new Promise((resolve) => { + const handleExit = () => resolve(); + process.once('SIGINT', handleExit); + process.once('SIGTERM', handleExit); + }); +} + +function startVite() { + const child = spawn('npx', ['vite'], { + stdio: 'inherit', + shell: true, + env, + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); +} + +async function main() { + const existingServer = await probeExistingDevServer(DEV_URL); + + if (existingServer.reachable) { + if (!existingServer.isLimeDevShell) { + const statusLabel = `${existingServer.status} ${existingServer.statusText}`.trim(); + throw new Error( + `[dev:web-bridge] ${DEV_URL} 已被其他服务占用,且返回内容不是 Lime dev shell(${statusLabel})。请先关闭占用进程后重试。`, + ); + } + + console.log(`[dev:web-bridge] 复用已存在的 Lime dev server: ${DEV_URL}`); + await waitForExitSignal(); return; } - process.exit(code ?? 0); + + startVite(); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index df6f53363..47a3f5902 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -378,7 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.22.0" +version = "0.23.0" dependencies = [ "ahash", "anyhow", @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.22.0" +version = "0.23.0" dependencies = [ "serde", "serde_json", @@ -2417,7 +2417,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.10.1", + "phf 0.8.0", "proc-macro2", "quote", "smallvec", @@ -2433,7 +2433,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.3", + "phf 0.8.0", "smallvec", ] @@ -4374,7 +4374,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.56.0", ] [[package]] @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "0.97.0" +version = "0.98.0" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "0.97.0" +version = "0.98.0" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "0.97.0" +version = "0.98.0" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "0.97.0" +version = "0.98.0" dependencies = [ "async-trait", "lime-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "0.97.0" +version = "0.98.0" dependencies = [ "aster-models", "async-trait", @@ -5307,7 +5307,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "0.97.0" +version = "0.98.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "0.97.0" +version = "0.98.0" dependencies = [ "aes", "axum 0.7.9", @@ -5372,7 +5372,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "0.97.0" +version = "0.98.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "0.97.0" +version = "0.98.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5424,7 +5424,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "0.97.0" +version = "0.98.0" dependencies = [ "async-trait", "lime-core", @@ -5443,7 +5443,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "0.97.0" +version = "0.98.0" dependencies = [ "anyhow", "async-stream", @@ -5498,7 +5498,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "0.97.0" +version = "0.98.0" dependencies = [ "aster-core", "async-stream", @@ -5543,7 +5543,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "0.97.0" +version = "0.98.0" dependencies = [ "axum 0.7.9", "futures", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "0.97.0" +version = "0.98.0" dependencies = [ "anyhow", "aster-core", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "0.97.0" +version = "0.98.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5618,7 +5618,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "0.97.0" +version = "0.98.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "0.97.0" +version = "0.98.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 2.0.2", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.117", @@ -7077,7 +7077,9 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ + "phf_macros 0.8.0", "phf_shared 0.8.0", + "proc-macro-hack", ] [[package]] @@ -7086,9 +7088,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ - "phf_macros 0.10.0", "phf_shared 0.10.0", - "proc-macro-hack", ] [[package]] @@ -7192,12 +7192,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator 0.8.0", + "phf_shared 0.8.0", "proc-macro-hack", "proc-macro2", "quote", @@ -7609,7 +7609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.117", @@ -9108,7 +9108,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 6.0.0", + "dirs 4.0.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 490620bd3..2d9daac0c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.97.0" +version = "0.98.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.22.0" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.22.0" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.23.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.23.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.97.0" +version = "0.98.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 4c26aa4c8..2dac3ec2c 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -21,6 +21,9 @@ pub use crate::protocol::{ AgentTokenUsage as TauriTokenUsage, AgentToolImage as TauriToolImage, AgentToolResult as TauriToolResult, }; +use crate::text_normalization::{ + normalize_legacy_runtime_status_title, normalize_legacy_turn_summary_text, +}; use crate::tool_io_offload::{maybe_offload_tool_arguments, maybe_offload_tool_result_payload}; const JSON_RECURSION_LIMIT: usize = 50; @@ -679,9 +682,9 @@ fn convert_item_status( fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String]) -> String { let mut lines = Vec::new(); - let trimmed_title = title.trim(); + let trimmed_title = normalize_legacy_runtime_status_title(title); if !trimmed_title.is_empty() { - lines.push(trimmed_title.to_string()); + lines.push(trimmed_title); } let trimmed_detail = detail.trim(); @@ -696,7 +699,7 @@ fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String]) } } - lines.join("\n") + normalize_legacy_turn_summary_text(&lines.join("\n")) } fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload { @@ -727,10 +730,9 @@ fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload { content, metadata, }, - ItemRuntimePayload::Reasoning { text } => AgentThreadItemPayload::Reasoning { - text, - summary: None, - }, + ItemRuntimePayload::Reasoning { text, summary } => { + AgentThreadItemPayload::Reasoning { text, summary } + } ItemRuntimePayload::ToolCall { tool_name, arguments, @@ -1440,6 +1442,47 @@ mod tests { } } + #[test] + fn test_convert_item_started_reasoning_runtime_item_preserves_summary() { + let now = chrono::Utc::now(); + let item = ItemRuntime { + id: "reasoning-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 3, + status: ItemStatus::InProgress, + started_at: now, + completed_at: None, + updated_at: now, + payload: ItemRuntimePayload::Reasoning { + text: "先判断任务类型\n\n再决定是否联网".to_string(), + summary: Some(vec![ + "先判断任务类型".to_string(), + "再决定是否联网".to_string(), + ]), + }, + }; + + let events = convert_agent_event(AgentEvent::ItemStarted { item }); + assert_eq!(events.len(), 1); + match &events[0] { + TauriAgentEvent::ItemStarted { item } => match &item.payload { + AgentThreadItemPayload::Reasoning { text, summary } => { + assert_eq!(text, "先判断任务类型\n\n再决定是否联网"); + assert_eq!( + summary.as_ref(), + Some(&vec![ + "先判断任务类型".to_string(), + "再决定是否联网".to_string(), + ]) + ); + } + other => panic!("Unexpected payload: {other:?}"), + }, + other => panic!("Expected ItemStarted event, got {other:?}"), + } + } + #[test] fn test_convert_item_started_file_artifact_runtime_item() { let now = chrono::Utc::now(); @@ -1512,7 +1555,8 @@ mod tests { match &events[0] { TauriAgentEvent::ItemUpdated { item } => match &item.payload { AgentThreadItemPayload::TurnSummary { text } => { - assert!(text.contains("已决定:先规划再输出")); + assert!(text.contains("先规划再输出")); + assert!(!text.contains("已决定:")); assert!(text.contains("当前请求更像计划拆解")); assert!(text.contains("• 检测到计划需求")); } diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index bdf84e64f..ea906f199 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -44,6 +44,7 @@ pub mod subagent_control; pub mod subagent_profiles; pub mod subagent_scheduler; pub mod team_runtime_governor; +mod text_normalization; pub mod tool_io_offload; pub mod tools; pub mod turn_input_envelope; @@ -103,8 +104,9 @@ pub use runtime_queue::{ }; pub use session_execution_runtime::{ build_session_execution_runtime, extract_recent_content_id_from_runtime_snapshot, - persist_session_recent_preferences, persist_session_recent_team_selection, - SessionExecutionRuntime, SessionExecutionRuntimePreferences, + persist_session_recent_access_mode, persist_session_recent_preferences, + persist_session_recent_team_selection, SessionExecutionRuntime, + SessionExecutionRuntimeAccessMode, SessionExecutionRuntimePreferences, SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection, SessionExecutionRuntimeSource, }; diff --git a/src-tauri/crates/agent/src/session_execution_runtime.rs b/src-tauri/crates/agent/src/session_execution_runtime.rs index bde0835d4..18ad84fa1 100644 --- a/src-tauri/crates/agent/src/session_execution_runtime.rs +++ b/src-tauri/crates/agent/src/session_execution_runtime.rs @@ -1,7 +1,9 @@ use crate::session_query::read_session; use crate::session_update::persist_session_extension_data; use aster::session::extension_data::{ExtensionData, ExtensionState}; -use aster::session::{Session, SessionRuntimeSnapshot, TurnOutputSchemaRuntime, TurnStatus}; +use aster::session::{ + Session, SessionRuntimeSnapshot, TurnContextOverride, TurnOutputSchemaRuntime, TurnStatus, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -23,6 +25,92 @@ pub enum SessionExecutionRuntimeSource { ModelChange, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionExecutionRuntimeAccessMode { + ReadOnly, + Current, + FullAccess, +} + +impl ExtensionState for SessionExecutionRuntimeAccessMode { + const EXTENSION_NAME: &'static str = "lime_recent_access_mode"; + const VERSION: &'static str = "v0"; +} + +impl SessionExecutionRuntimeAccessMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::ReadOnly => "read-only", + Self::Current => "current", + Self::FullAccess => "full-access", + } + } + + pub fn approval_policy(&self) -> &'static str { + match self { + Self::FullAccess => "never", + Self::ReadOnly | Self::Current => "on-request", + } + } + + pub fn sandbox_policy(&self) -> &'static str { + match self { + Self::ReadOnly => "read-only", + Self::Current => "workspace-write", + Self::FullAccess => "danger-full-access", + } + } + + pub fn from_access_mode_text(value: Option<&str>) -> Option { + match value.map(str::trim) { + Some("read-only") => Some(Self::ReadOnly), + Some("current") => Some(Self::Current), + Some("full-access") => Some(Self::FullAccess), + _ => None, + } + } + + pub fn from_runtime_policies( + _approval_policy: Option<&str>, + sandbox_policy: Option<&str>, + ) -> Option { + match sandbox_policy.map(str::trim) { + Some("read-only") => Some(Self::ReadOnly), + Some("workspace-write") => Some(Self::Current), + Some("danger-full-access") => Some(Self::FullAccess), + _ => None, + } + } + + fn from_extension_data(extension_data: &ExtensionData) -> Option { + ::from_extension_data(extension_data) + } + + fn from_session(session: &Session) -> Option { + Self::from_extension_data(&session.extension_data) + } + + fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> { + ::to_extension_data(self, extension_data) + .map_err(|error| error.to_string()) + } + + fn into_updated_extension_data(self, session: &Session) -> Result { + let mut extension_data = session.extension_data.clone(); + self.to_extension_data(&mut extension_data)?; + Ok(extension_data) + } + + fn from_turn_context_override(turn_context: &TurnContextOverride) -> Option { + Self::from_runtime_policies( + turn_context.approval_policy.as_deref(), + turn_context.sandbox_policy.as_deref(), + ) + .or_else(|| extract_recent_access_mode_from_metadata(&turn_context.metadata)) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SessionExecutionRuntimePreferences { @@ -212,6 +300,8 @@ pub struct SessionExecutionRuntime { #[serde(skip_serializing_if = "Option::is_none")] pub latest_turn_status: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub recent_access_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub recent_preferences: Option, #[serde(skip_serializing_if = "Option::is_none")] pub recent_team_selection: Option, @@ -341,6 +431,17 @@ fn extract_recent_preferences_from_metadata( }) } +fn extract_recent_access_mode_from_metadata( + metadata: &std::collections::HashMap, +) -> Option { + let harness = metadata.get("harness").and_then(Value::as_object); + let access_mode = harness + .and_then(|value| extract_text_from_object(value, &["access_mode", "accessMode"])) + .or_else(|| extract_text_from_metadata(metadata, &["access_mode", "accessMode"])); + + SessionExecutionRuntimeAccessMode::from_access_mode_text(access_mode.as_deref()) +} + fn extract_recent_team_roles_from_values( values: Vec, ) -> Option> { @@ -479,6 +580,35 @@ pub fn extract_recent_content_id_from_runtime_snapshot( extract_recent_harness_context_from_runtime_snapshot(snapshot).content_id } +fn extract_recent_access_mode_from_runtime_snapshot( + snapshot: &SessionRuntimeSnapshot, +) -> Option { + snapshot + .threads + .iter() + .flat_map(|thread| thread.turns.iter()) + .filter_map(|turn| { + let access_mode = turn + .context_override + .as_ref() + .and_then(SessionExecutionRuntimeAccessMode::from_turn_context_override)?; + Some((turn.updated_at, access_mode)) + }) + .max_by_key(|(updated_at, _)| *updated_at) + .map(|(_, access_mode)| access_mode) +} + +pub async fn persist_session_recent_access_mode( + session_id: &str, + recent_access_mode: SessionExecutionRuntimeAccessMode, +) -> Result<(), String> { + let session = read_session(session_id, false, "读取会话 recent_access_mode 失败").await?; + let extension_data = recent_access_mode.into_updated_extension_data(&session)?; + persist_session_extension_data(session_id, extension_data, "持久化会话 recent_access_mode") + .await?; + Ok(()) +} + pub async fn persist_session_recent_preferences( session_id: &str, preferences: SessionExecutionRuntimePreferences, @@ -547,6 +677,7 @@ pub fn build_session_execution_runtime( mode: None, latest_turn_id: None, latest_turn_status: None, + recent_access_mode: None, recent_preferences: None, recent_team_selection: None, recent_theme: None, @@ -563,6 +694,7 @@ pub fn build_session_execution_runtime( runtime.recent_gate_key = recent_harness_context.gate_key; runtime.recent_run_title = recent_harness_context.run_title; runtime.recent_content_id = recent_harness_context.content_id; + runtime.recent_access_mode = extract_recent_access_mode_from_runtime_snapshot(snapshot); if let Some(latest_turn) = resolve_latest_turn(snapshot) { runtime.latest_turn_id = Some(latest_turn.id.clone()); @@ -596,6 +728,11 @@ pub fn build_session_execution_runtime( } } + if runtime.recent_access_mode.is_none() { + runtime.recent_access_mode = + session.and_then(SessionExecutionRuntimeAccessMode::from_session); + } + if runtime.recent_preferences.is_none() { runtime.recent_preferences = session.and_then(SessionExecutionRuntimePreferences::from_session); @@ -610,6 +747,7 @@ pub fn build_session_execution_runtime( && runtime.provider_name.is_none() && runtime.model_name.is_none() && runtime.output_schema_runtime.is_none() + && runtime.recent_access_mode.is_none() && runtime.recent_preferences.is_none() && runtime.recent_team_selection.is_none() && runtime.recent_theme.is_none() @@ -627,9 +765,9 @@ pub fn build_session_execution_runtime( #[cfg(test)] mod tests { use super::{ - build_session_execution_runtime, SessionExecutionRuntimePreferences, - SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection, - SessionExecutionRuntimeSource, + build_session_execution_runtime, SessionExecutionRuntimeAccessMode, + SessionExecutionRuntimePreferences, SessionExecutionRuntimeRecentTeamRole, + SessionExecutionRuntimeRecentTeamSelection, SessionExecutionRuntimeSource, }; use aster::model::ModelConfig; use aster::session::{ @@ -813,6 +951,73 @@ mod tests { ); } + #[test] + fn keeps_recent_access_mode_from_latest_turn_context_override() { + let now = Utc::now(); + let latest_turn = TurnRuntime { + id: "turn-access".to_string(), + session_id: "session-access".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("hello".to_string()), + error_message: None, + context_override: Some(TurnContextOverride { + approval_policy: Some("never".to_string()), + sandbox_policy: Some("danger-full-access".to_string()), + ..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-access".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-1", + "session-access", + PathBuf::from("/tmp/workspace"), + ), + turns: vec![latest_turn], + items: Vec::new(), + }], + }; + + let runtime = + build_session_execution_runtime("session-access", None, None, Some(&snapshot), None) + .expect("runtime"); + + assert_eq!( + runtime.recent_access_mode, + Some(SessionExecutionRuntimeAccessMode::FullAccess) + ); + } + + #[test] + fn falls_back_to_session_recent_access_mode_when_runtime_snapshot_missing() { + let mut session = Session::default(); + session.id = "session-access-fallback".to_string(); + SessionExecutionRuntimeAccessMode::ReadOnly + .to_extension_data(&mut session.extension_data) + .expect("persist access mode"); + + let runtime = build_session_execution_runtime( + "session-access-fallback", + Some(&session), + Some("react".to_string()), + None, + None, + ) + .expect("runtime"); + + assert_eq!( + runtime.recent_access_mode, + Some(SessionExecutionRuntimeAccessMode::ReadOnly) + ); + } + #[test] fn keeps_recent_team_selection_from_latest_turn_metadata() { let now = Utc::now(); diff --git a/src-tauri/crates/agent/src/text_normalization.rs b/src-tauri/crates/agent/src/text_normalization.rs new file mode 100644 index 000000000..158fbb38d --- /dev/null +++ b/src-tauri/crates/agent/src/text_normalization.rs @@ -0,0 +1,73 @@ +const LEGACY_DECISION_PREFIXES: [&str; 2] = ["已决定:", "已决定:"]; + +pub fn normalize_legacy_runtime_status_title(title: &str) -> String { + let trimmed = title.trim(); + + for prefix in LEGACY_DECISION_PREFIXES { + if let Some(stripped) = trimmed.strip_prefix(prefix) { + return stripped.trim().to_string(); + } + } + + trimmed.to_string() +} + +pub fn normalize_legacy_turn_summary_text(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let mut lines = trimmed.lines(); + let Some(first_line) = lines.next() else { + return String::new(); + }; + + let normalized_first_line = normalize_legacy_runtime_status_title(first_line); + let remaining = lines.collect::>(); + + if remaining.is_empty() { + return normalized_first_line; + } + + if normalized_first_line.is_empty() { + return remaining.join("\n").trim().to_string(); + } + + format!("{normalized_first_line}\n{}", remaining.join("\n")) +} + +#[cfg(test)] +mod tests { + use super::{normalize_legacy_runtime_status_title, normalize_legacy_turn_summary_text}; + + #[test] + fn test_normalize_legacy_runtime_status_title_strips_decision_prefix() { + assert_eq!( + normalize_legacy_runtime_status_title("已决定:先深度思考"), + "先深度思考" + ); + assert_eq!( + normalize_legacy_runtime_status_title("已决定: 直接回答优先"), + "直接回答优先" + ); + assert_eq!( + normalize_legacy_runtime_status_title("直接回答优先"), + "直接回答优先" + ); + } + + #[test] + fn test_normalize_legacy_turn_summary_text_strips_only_first_line_prefix() { + assert_eq!( + normalize_legacy_turn_summary_text( + "已决定:先规划再输出\n当前请求更像计划拆解。\n• 检测到计划需求" + ), + "先规划再输出\n当前请求更像计划拆解。\n• 检测到计划需求" + ); + assert_eq!( + normalize_legacy_turn_summary_text("已决定:直接回答优先"), + "直接回答优先" + ); + } +} diff --git a/src-tauri/crates/agent/src/turn_input_envelope.rs b/src-tauri/crates/agent/src/turn_input_envelope.rs index 6227795ac..4e2324dd5 100644 --- a/src-tauri/crates/agent/src/turn_input_envelope.rs +++ b/src-tauri/crates/agent/src/turn_input_envelope.rs @@ -166,6 +166,8 @@ pub struct TurnInputEnvelope { working_dir: Option, effective_user_message: String, include_context_trace: bool, + approval_policy: Option, + sandbox_policy: Option, turn_context_metadata: Option>, } @@ -196,11 +198,17 @@ impl TurnInputEnvelope { } pub fn turn_context_override(&self) -> Option { - self.merged_turn_context_metadata() - .map(|metadata| TurnContextOverride { - metadata: metadata.into_iter().collect(), - ..TurnContextOverride::default() - }) + let metadata = self.merged_turn_context_metadata(); + if metadata.is_none() && self.approval_policy.is_none() && self.sandbox_policy.is_none() { + return None; + } + + Some(TurnContextOverride { + approval_policy: self.approval_policy.clone(), + sandbox_policy: self.sandbox_policy.clone(), + metadata: metadata.unwrap_or_default().into_iter().collect(), + ..TurnContextOverride::default() + }) } pub fn diagnostics_snapshot(&self) -> TurnDiagnosticsSnapshot { @@ -266,6 +274,8 @@ impl TurnInputEnvelopeBuilder { working_dir: None, effective_user_message: String::new(), include_context_trace: false, + approval_policy: None, + sandbox_policy: None, turn_context_metadata: None, }, } @@ -345,6 +355,16 @@ impl TurnInputEnvelopeBuilder { self } + pub fn set_approval_policy(&mut self, approval_policy: Option) -> &mut Self { + self.envelope.approval_policy = normalize_optional_string(approval_policy); + self + } + + pub fn set_sandbox_policy(&mut self, sandbox_policy: Option) -> &mut Self { + self.envelope.sandbox_policy = normalize_optional_string(sandbox_policy); + self + } + pub fn set_turn_context_metadata_from_value(&mut self, metadata: Option<&Value>) -> &mut Self { self.envelope.turn_context_metadata = match metadata { Some(Value::Object(map)) => Some(map.clone()), @@ -556,4 +576,18 @@ mod tests { })) ); } + + #[test] + fn test_turn_input_envelope_keeps_runtime_access_policies() { + let mut builder = TurnInputEnvelopeBuilder::new("session-4", "workspace-4"); + builder + .set_approval_policy(Some("on-request".to_string())) + .set_sandbox_policy(Some("read-only".to_string())); + + let envelope = builder.build(); + let turn_context = envelope.turn_context_override().expect("turn context"); + + assert_eq!(turn_context.approval_policy.as_deref(), Some("on-request")); + assert_eq!(turn_context.sandbox_policy.as_deref(), Some("read-only")); + } } diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index bc5e5e5d2..2538ec816 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -419,6 +419,9 @@ pub struct Config { /// 用户资料 #[serde(default)] pub user_profile: UserProfile, + /// 开发者功能开关 + #[serde(default)] + pub developer: DeveloperConfig, /// 速率限制配置 #[serde(default)] pub rate_limit: RateLimitSettings, @@ -2168,6 +2171,7 @@ impl Default for Config { voice: VoiceConfig::default(), image_gen: ImageGenConfig::default(), user_profile: UserProfile::default(), + developer: DeveloperConfig::default(), rate_limit: RateLimitSettings::default(), crash_reporting: CrashReportingConfig::default(), conversation: ConversationSettings::default(), @@ -2453,6 +2457,14 @@ pub struct ChatAppearanceConfig { pub append_selected_text_to_recommendation: Option, } +/// 开发者能力配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct DeveloperConfig { + /// 是否允许在工作区启用处理工作台与信息收集 + #[serde(default)] + pub workspace_harness_enabled: bool, +} + /// 记忆管理配置 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct MemoryProfileConfig { @@ -3229,6 +3241,12 @@ mod unit_tests { assert_eq!(parsed, config); } + #[test] + fn test_developer_config_default() { + let config = DeveloperConfig::default(); + assert!(!config.workspace_harness_enabled); + } + #[test] fn test_config_with_experimental() { let config = Config::default(); @@ -3244,6 +3262,7 @@ mod unit_tests { config.experimental.voice_input.shortcut, "CommandOrControl+Shift+V" ); + assert!(!config.developer.workspace_harness_enabled); assert!(config.tool_calling.enabled); assert!(config.tool_calling.dynamic_filtering); assert!(!config.tool_calling.native_input_examples); diff --git a/src-tauri/crates/server/src/chrome_bridge.rs b/src-tauri/crates/server/src/chrome_bridge.rs index fab05ac5b..1ae090544 100644 --- a/src-tauri/crates/server/src/chrome_bridge.rs +++ b/src-tauri/crates/server/src/chrome_bridge.rs @@ -82,6 +82,13 @@ pub struct ChromeBridgeStatusSnapshot { pub pending_commands: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChromeBridgeDisconnectResult { + pub disconnected_observer_count: usize, + pub disconnected_control_count: usize, + pub status: ChromeBridgeStatusSnapshot, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChromeBridgeCommandRequest { #[serde(default)] @@ -284,6 +291,118 @@ impl ChromeBridgeHub { } } + pub async fn disconnect_connections( + &self, + profile_key: Option<&str>, + ) -> ChromeBridgeDisconnectResult { + let normalized_profile = + profile_key.map(|value| normalize_profile_key(Some(value.to_string()))); + let (observer_senders, control_senders, pending) = { + let mut inner = self.inner.lock().await; + + let observer_ids: Vec = inner + .observers + .iter() + .filter_map(|(client_id, conn)| { + let matches_profile = normalized_profile + .as_ref() + .map(|profile| conn.profile_key == *profile) + .unwrap_or(true); + if matches_profile { + Some(client_id.clone()) + } else { + None + } + }) + .collect(); + + let observer_senders = observer_ids + .iter() + .filter_map(|client_id| { + inner + .observers + .get(client_id) + .map(|conn| conn.sender.clone()) + }) + .collect::>(); + + let mut pending = Vec::new(); + for client_id in &observer_ids { + inner.observers.remove(client_id); + pending.extend(take_pending_by_observer( + &mut inner.pending_commands, + client_id, + )); + } + + let control_ids: Vec = if observer_ids.is_empty() { + Vec::new() + } else { + inner.controls.keys().cloned().collect() + }; + + let control_senders = control_ids + .iter() + .filter_map(|client_id| { + inner + .controls + .get(client_id) + .map(|conn| conn.sender.clone()) + }) + .collect::>(); + + for client_id in &control_ids { + inner.controls.remove(client_id); + let pending_ids: Vec = inner + .pending_commands + .iter() + .filter_map(|(request_id, pending)| match &pending.source { + PendingSource::Control { control_client_id } + if control_client_id == client_id => + { + Some(request_id.clone()) + } + _ => None, + }) + .collect(); + for request_id in pending_ids { + inner.pending_commands.remove(&request_id); + } + } + + (observer_senders, control_senders, pending) + }; + + for sender in &observer_senders { + let _ = sender.send( + json!({ + "type": "force_disconnect", + "message": "Lime 已主动断开当前扩展连接。", + }) + .to_string(), + ); + } + + for sender in &control_senders { + let _ = sender.send( + json!({ + "type": "force_disconnect", + "message": "Lime 已主动断开当前控制连接。", + }) + .to_string(), + ); + } + + self.resolve_pending_with_disconnect(pending).await; + let status = self.get_status_snapshot().await; + + ChromeBridgeDisconnectResult { + disconnected_observer_count: observer_senders.len(), + disconnected_control_count: control_senders.len(), + status, + } + } + pub async fn execute_api_command( &self, request: ChromeBridgeCommandRequest, @@ -1147,6 +1266,82 @@ mod tests { assert!(result.error.unwrap_or_default().contains("observer")); } + #[tokio::test] + async fn should_force_disconnect_connections_and_notify_clients() { + let hub = Arc::new(ChromeBridgeHub::new()); + + let (observer_tx, mut observer_rx) = mpsc::unbounded_channel::(); + hub.register_observer( + "observer-a".to_string(), + Some("search_google".to_string()), + None, + observer_tx, + ) + .await; + + let (control_tx, mut control_rx) = mpsc::unbounded_channel::(); + hub.register_control("control-a".to_string(), None, control_tx) + .await; + + let (result_tx, result_rx) = oneshot::channel(); + { + let mut inner = hub.inner.lock().await; + inner.pending_commands.insert( + "req-disconnect".to_string(), + PendingCommand { + request_id: "req-disconnect".to_string(), + source: PendingSource::Api(result_tx), + command: "click".to_string(), + observer_client_id: "observer-a".to_string(), + wait_for_page_info: false, + command_completed: false, + execution_message: None, + created_at: Utc::now(), + expires_at: Instant::now() + Duration::from_secs(30), + }, + ); + } + + let result = hub.disconnect_connections(Some("search_google")).await; + assert_eq!(result.disconnected_observer_count, 1); + assert_eq!(result.disconnected_control_count, 1); + assert_eq!(result.status.observer_count, 0); + assert_eq!(result.status.control_count, 0); + assert_eq!(result.status.pending_command_count, 0); + + let observer_message = observer_rx + .recv() + .await + .expect("observer should receive force_disconnect"); + let control_message = control_rx + .recv() + .await + .expect("control should receive force_disconnect"); + + let observer_payload: Value = + serde_json::from_str(&observer_message).expect("observer message should be valid json"); + let control_payload: Value = + serde_json::from_str(&control_message).expect("control message should be valid json"); + + assert_eq!( + observer_payload.get("type").and_then(Value::as_str), + Some("force_disconnect") + ); + assert_eq!( + control_payload.get("type").and_then(Value::as_str), + Some("force_disconnect") + ); + + let pending_result = result_rx + .await + .expect("pending api command should resolve after disconnect"); + assert!(!pending_result.success); + assert!(pending_result + .error + .unwrap_or_default() + .contains("observer")); + } + #[tokio::test] async fn wait_for_page_info_should_resolve_after_update() { let hub = Arc::new(ChromeBridgeHub::new()); diff --git a/src-tauri/resources/site-adapters/bundled/index.json b/src-tauri/resources/site-adapters/bundled/index.json index 69d81c79f..b09d42039 100644 --- a/src-tauri/resources/site-adapters/bundled/index.json +++ b/src-tauri/resources/site-adapters/bundled/index.json @@ -130,6 +130,114 @@ "script_file": "scripts/github-search.js", "source_version": "2026-03-25" }, + { + "name": "linux-do/categories", + "domain": "linux.do", + "description": "读取 linux.do 分类列表。", + "read_only": true, + "capabilities": ["categories", "community", "research"], + "args": [ + { + "name": "limit", + "description": "返回分类数量上限", + "required": false, + "arg_type": "integer", + "example": 10 + } + ], + "example": "linux-do/categories {\"limit\":10}", + "auth_hint": "请先在浏览器中登录 linux.do,再重试该命令。", + "entry": { + "kind": "fixed_url", + "url": "https://linux.do" + }, + "script_file": "scripts/linux-do-categories.js", + "source_version": "2026-03-28" + }, + { + "name": "linux-do/hot", + "domain": "linux.do", + "description": "读取 linux.do 热门话题。", + "read_only": true, + "capabilities": ["hot", "topics", "community", "research"], + "args": [ + { + "name": "limit", + "description": "返回话题数量上限", + "required": false, + "arg_type": "integer", + "example": 10 + }, + { + "name": "period", + "description": "热门周期,可选 all/daily/weekly/monthly/yearly", + "required": false, + "arg_type": "string", + "example": "weekly" + } + ], + "example": "linux-do/hot {\"period\":\"weekly\",\"limit\":10}", + "auth_hint": "请先在浏览器中登录 linux.do,再重试该命令。", + "entry": { + "kind": "fixed_url", + "url": "https://linux.do" + }, + "script_file": "scripts/linux-do-hot.js", + "source_version": "2026-03-28" + }, + { + "name": "smzdm/search", + "domain": "search.smzdm.com", + "description": "按关键词采集什么值得买搜索结果。", + "read_only": true, + "capabilities": ["search", "shopping", "deals", "research"], + "args": [ + { + "name": "query", + "description": "搜索关键词", + "required": true, + "arg_type": "string", + "example": "Mac mini" + }, + { + "name": "limit", + "description": "返回条目数量上限", + "required": false, + "arg_type": "integer", + "example": 5 + } + ], + "example": "smzdm/search {\"query\":\"Mac mini\",\"limit\":5}", + "entry": { + "kind": "url_template", + "template": "https://search.smzdm.com/?c=home&s={{query|urlencode}}&v=b" + }, + "script_file": "scripts/smzdm-search.js", + "source_version": "2026-03-28" + }, + { + "name": "yahoo-finance/quote", + "domain": "finance.yahoo.com", + "description": "读取 Yahoo Finance 股票行情摘要。", + "read_only": true, + "capabilities": ["quote", "finance", "research"], + "args": [ + { + "name": "symbol", + "description": "股票代码,例如 AAPL、MSFT", + "required": true, + "arg_type": "string", + "example": "AAPL" + } + ], + "example": "yahoo-finance/quote {\"symbol\":\"AAPL\"}", + "entry": { + "kind": "url_template", + "template": "https://finance.yahoo.com/quote/{{symbol|urlencode}}/" + }, + "script_file": "scripts/yahoo-finance-quote.js", + "source_version": "2026-03-28" + }, { "name": "zhihu/hot", "domain": "www.zhihu.com", diff --git a/src-tauri/resources/site-adapters/bundled/scripts/linux-do-categories.js b/src-tauri/resources/site-adapters/bundled/scripts/linux-do-categories.js new file mode 100644 index 000000000..3b3a00620 --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/linux-do-categories.js @@ -0,0 +1,48 @@ +async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + + try { + const response = await fetch("/categories.json", { credentials: "include" }); + if (!response.ok) { + return { + ok: false, + error_code: "auth_required", + error_message: "linux.do 分类列表暂不可用,可能需要先登录。", + }; + } + const payload = await response.json(); + const categories = Array.isArray(payload?.category_list?.categories) + ? payload.category_list.categories + : []; + + const items = helpers.take( + categories.map((category) => ({ + name: String(category?.name || "").trim(), + slug: String(category?.slug || "").trim(), + id: category?.id ?? null, + topics: category?.topic_count ?? 0, + description: String(category?.description_text || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 80), + })), + limit, + ).filter((item) => item.name); + + return { + ok: true, + data: { + items, + count: items.length, + }, + source_url: location.href, + }; + } catch (error) { + return { + ok: false, + error_code: "runtime_error", + error_message: + error instanceof Error ? error.message : "读取 linux.do 分类列表失败。", + }; + } +}; diff --git a/src-tauri/resources/site-adapters/bundled/scripts/linux-do-hot.js b/src-tauri/resources/site-adapters/bundled/scripts/linux-do-hot.js new file mode 100644 index 000000000..8c375f86b --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/linux-do-hot.js @@ -0,0 +1,63 @@ +async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + const allowedPeriods = new Set(["all", "daily", "weekly", "monthly", "yearly"]); + const period = String(args.period || "weekly").trim().toLowerCase(); + const normalizedPeriod = allowedPeriods.has(period) ? period : "weekly"; + + try { + const response = await fetch( + "/top.json?period=" + encodeURIComponent(normalizedPeriod), + { credentials: "include" }, + ); + if (!response.ok) { + return { + ok: false, + error_code: "auth_required", + error_message: "linux.do 热门话题暂不可用,可能需要先登录。", + }; + } + const payload = await response.json(); + const topics = Array.isArray(payload?.topic_list?.topics) + ? payload.topic_list.topics + : []; + const categories = Array.isArray(payload?.topic_list?.categories) + ? payload.topic_list.categories + : Array.isArray(payload?.categories) + ? payload.categories + : []; + const categoryMap = new Map( + categories.map((category) => [category?.id, String(category?.name || "").trim()]), + ); + + const items = helpers.take( + topics.map((topic, index) => ({ + rank: index + 1, + title: String(topic?.title || "").trim(), + replies: Math.max(0, Number(topic?.posts_count || 1) - 1), + views: Number(topic?.views || 0), + likes: Number(topic?.like_count || 0), + category: + categoryMap.get(topic?.category_id) || + String(topic?.category_id || "").trim(), + })), + limit, + ).filter((item) => item.title); + + return { + ok: true, + data: { + period: normalizedPeriod, + items, + count: items.length, + }, + source_url: location.href, + }; + } catch (error) { + return { + ok: false, + error_code: "runtime_error", + error_message: + error instanceof Error ? error.message : "读取 linux.do 热门话题失败。", + }; + } +}; diff --git a/src-tauri/resources/site-adapters/bundled/scripts/smzdm-search.js b/src-tauri/resources/site-adapters/bundled/scripts/smzdm-search.js new file mode 100644 index 000000000..cce56d552 --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/smzdm-search.js @@ -0,0 +1,59 @@ +async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + const rowSelector = "li.feed-row-wide"; + + await helpers.waitFor( + () => + document.querySelectorAll(rowSelector).length > 0 || + /搜索结果|相关好价|什么值得买/i.test(document.body?.textContent || ""), + 12000, + 300, + ); + + const rows = Array.from(document.querySelectorAll(rowSelector)); + const items = helpers.take( + helpers.uniqueBy( + rows + .map((row, index) => { + const titleAnchor = + row.querySelector("h5.feed-block-title > a") || row.querySelector("h5 > a"); + const rawHref = + titleAnchor?.getAttribute("href") || titleAnchor?.href || ""; + const url = helpers.absoluteUrl(rawHref); + const title = ( + titleAnchor?.getAttribute("title") || helpers.text(titleAnchor) + ).trim(); + const price = helpers.text(row.querySelector(".z-highlight")); + const mall = helpers.text( + row.querySelector(".z-feed-foot-r .feed-block-extras span") || + row.querySelector(".z-feed-foot-r span"), + ); + const commentsText = helpers.text( + row.querySelector(".feed-btn-comment"), + ).replace(/[^\d]/g, ""); + return { + rank: index + 1, + title, + url, + price, + mall, + comments: commentsText ? Number(commentsText) : 0, + }; + }) + .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/yahoo-finance-quote.js b/src-tauri/resources/site-adapters/bundled/scripts/yahoo-finance-quote.js new file mode 100644 index 000000000..b4d3b0be6 --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/yahoo-finance-quote.js @@ -0,0 +1,134 @@ +async (args, helpers) => { + const symbol = String(args.symbol || "").trim().toUpperCase(); + if (!symbol) { + return { + ok: false, + error_code: "invalid_args", + error_message: "symbol 不能为空。", + }; + } + + const normalizeText = (value) => { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + return text || null; + }; + const normalizeNumber = (value) => { + if (value === undefined || value === null || value === "") { + return null; + } + const text = String(value).replace(/,/g, "").replace(/%/g, "").trim(); + const number = Number(text); + return Number.isFinite(number) ? number : null; + }; + const buildResult = (item) => ({ + ok: true, + data: { + symbol, + items: [item], + count: 1, + }, + source_url: location.href, + }); + + try { + const chartUrl = + "https://query1.finance.yahoo.com/v8/finance/chart/" + + encodeURIComponent(symbol) + + "?interval=1d&range=1d"; + const response = await fetch(chartUrl, { credentials: "include" }); + if (response.ok) { + const payload = await response.json(); + const chart = payload?.chart?.result?.[0]; + if (chart) { + const meta = chart.meta || {}; + const previousClose = meta.previousClose ?? meta.chartPreviousClose ?? null; + const price = meta.regularMarketPrice ?? null; + const change = + price != null && previousClose != null ? price - previousClose : null; + const changePercent = + change != null && previousClose + ? Number(((change / previousClose) * 100).toFixed(2)) + : null; + return buildResult({ + symbol: meta.symbol || symbol, + name: meta.shortName || meta.longName || symbol, + price: price != null ? Number(price.toFixed(2)) : null, + change: change != null ? Number(change.toFixed(2)) : null, + changePercent, + open: chart.indicators?.quote?.[0]?.open?.[0] ?? null, + high: meta.regularMarketDayHigh ?? null, + low: meta.regularMarketDayLow ?? null, + volume: meta.regularMarketVolume ?? null, + marketCap: meta.marketCap ?? null, + }); + } + } + } catch {} + + await helpers.waitFor( + () => + document.querySelector('[data-testid="qsp-price"]') || + document.querySelector('fin-streamer[data-field="regularMarketPrice"]') || + document.querySelector("h1"), + 12000, + 300, + ); + + const titleText = normalizeText(document.querySelector("h1")?.textContent); + const item = { + symbol, + name: titleText + ? titleText.replace(/\s*\([^)]+\)\s*$/, "").trim() || symbol + : symbol, + price: normalizeNumber( + document.querySelector('[data-testid="qsp-price"]')?.textContent || + document.querySelector('fin-streamer[data-field="regularMarketPrice"]') + ?.textContent, + ), + change: normalizeNumber( + document.querySelector('[data-testid="qsp-price-change"]')?.textContent || + document.querySelector('fin-streamer[data-field="regularMarketChange"]') + ?.textContent, + ), + changePercent: normalizeNumber( + document.querySelector('[data-testid="qsp-price-change-percent"]') + ?.textContent || + document.querySelector( + 'fin-streamer[data-field="regularMarketChangePercent"]', + )?.textContent, + ), + open: normalizeNumber( + document.querySelector('[data-test="OPEN-value"]')?.textContent, + ), + high: normalizeNumber( + document.querySelector('[data-test="DAYS_RANGE-value"]') + ?.textContent?.split(" - ") + ?.at(1), + ), + low: normalizeNumber( + document.querySelector('[data-test="DAYS_RANGE-value"]') + ?.textContent?.split(" - ") + ?.at(0), + ), + volume: normalizeNumber( + document.querySelector('[data-test="TD_VOLUME-value"]')?.textContent, + ), + marketCap: normalizeText( + document.querySelector('[data-test="MARKET_CAP-value"]')?.textContent, + ), + }; + + if (item.price != null || item.name !== symbol) { + return buildResult(item); + } + + return { + ok: true, + data: { + symbol, + items: [], + count: 0, + }, + source_url: location.href, + }; +}; diff --git a/src-tauri/src/agent/aster_agent.rs b/src-tauri/src/agent/aster_agent.rs index dfb5d0759..1d9f08d72 100644 --- a/src-tauri/src/agent/aster_agent.rs +++ b/src-tauri/src/agent/aster_agent.rs @@ -235,6 +235,13 @@ impl AsterAgentWrapper { lime_agent::persist_session_recent_preferences(session_id, preferences).await } + pub async fn persist_session_recent_access_mode( + session_id: &str, + recent_access_mode: lime_agent::SessionExecutionRuntimeAccessMode, + ) -> Result<(), String> { + lime_agent::persist_session_recent_access_mode(session_id, recent_access_mode).await + } + pub async fn persist_session_recent_team_selection( session_id: &str, recent_team_selection: lime_agent::SessionExecutionRuntimeRecentTeamSelection, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 20776654a..236999290 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1290,9 +1290,11 @@ pub fn run() { commands::site_capability_cmd::site_recommend_adapters, commands::site_capability_cmd::site_search_adapters, commands::site_capability_cmd::site_get_adapter_info, + commands::site_capability_cmd::site_get_adapter_launch_readiness, 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_import_adapter_yaml_bundle, commands::site_capability_cmd::site_run_adapter, commands::site_capability_cmd::site_debug_run_adapter, commands::site_capability_cmd::site_save_adapter_result, @@ -1603,6 +1605,7 @@ pub fn run() { commands::webview_cmd::close_chrome_profile_session, commands::webview_cmd::get_chrome_bridge_endpoint_info, commands::webview_cmd::get_chrome_bridge_status, + commands::webview_cmd::disconnect_browser_connector_session, commands::webview_cmd::chrome_bridge_execute_command, commands::webview_cmd::get_browser_backends_status, commands::webview_cmd::get_browser_backend_policy, diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs index 33bcba5c3..06c70c611 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs @@ -99,6 +99,14 @@ pub async fn agent_runtime_update_session( .await?; } + if let Some(recent_access_mode) = request.recent_access_mode { + AsterAgentWrapper::persist_session_recent_access_mode( + &trimmed_session_id, + recent_access_mode, + ) + .await?; + } + if let Some(recent_team_selection) = request.recent_team_selection { AsterAgentWrapper::persist_session_recent_team_selection( &trimmed_session_id, diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index 7eaf82d8d..c5342e3e7 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -71,6 +71,12 @@ pub struct AsterChatRequest { /// 是否偏好 reasoning 变体 #[serde(default, alias = "thinkingEnabled")] pub thinking_enabled: Option, + /// 执行权限审批策略 + #[serde(default, alias = "approvalPolicy")] + pub approval_policy: Option, + /// 执行沙箱策略 + #[serde(default, alias = "sandboxPolicy")] + pub sandbox_policy: Option, /// 项目 ID(可选,用于注入项目上下文到 System Prompt) #[serde(default, alias = "projectId")] pub project_id: Option, @@ -116,6 +122,10 @@ pub struct AgentTurnConfigSnapshot { pub model_preference: Option, #[serde(default, alias = "thinkingEnabled")] pub thinking_enabled: Option, + #[serde(default, alias = "approvalPolicy")] + pub approval_policy: Option, + #[serde(default, alias = "sandboxPolicy")] + pub sandbox_policy: Option, #[serde(default, alias = "executionStrategy")] pub execution_strategy: Option, #[serde(default, alias = "webSearch")] @@ -172,6 +182,12 @@ impl From for AsterChatRequest { thinking_enabled: turn_config .as_ref() .and_then(|config| config.thinking_enabled), + approval_policy: turn_config + .as_ref() + .and_then(|config| config.approval_policy.clone()), + sandbox_policy: turn_config + .as_ref() + .and_then(|config| config.sandbox_policy.clone()), project_id: None, workspace_id: request.workspace_id.unwrap_or_default(), web_search: turn_config.as_ref().and_then(|config| config.web_search), @@ -1595,6 +1611,8 @@ pub struct AgentRuntimeUpdateSessionRequest { pub model_name: Option, #[serde(default, alias = "executionStrategy")] pub execution_strategy: Option, + #[serde(default, alias = "recentAccessMode")] + pub recent_access_mode: Option, #[serde(default, alias = "recentPreferences")] pub recent_preferences: Option, #[serde(default, alias = "recentTeamSelection")] 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 651713943..9b96236db 100644 --- a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs @@ -242,7 +242,7 @@ pub(super) async fn build_turn_runtime_statuses( let decided = if request_tool_policy.requires_web_search() { ( - "已决定:先联网检索".to_string(), + "先联网检索".to_string(), "当前任务已被明确指定为先搜索后答复,会先完成联网核实再继续生成。".to_string(), vec![ "用户明确要求联网搜索".to_string(), @@ -251,7 +251,7 @@ pub(super) async fn build_turn_runtime_statuses( ) } else if news_expansion_needed { ( - "已决定:先联网扩搜".to_string(), + "先联网扩搜".to_string(), "当前输入属于新闻或最新动态综述类请求,会先分批执行多组联网搜索,再基于结果做主题归纳与交叉核实。" .to_string(), vec![ @@ -261,7 +261,7 @@ pub(super) async fn build_turn_runtime_statuses( ) } else if subagent_enabled && message_suggests_subagent(&request.message) { ( - "已决定:优先拆分为多代理".to_string(), + "优先拆分为多代理".to_string(), "用户输入更适合并行分工处理,先按多代理路径组织执行。".to_string(), vec![ "检测到并行/多角度需求".to_string(), @@ -270,7 +270,7 @@ pub(super) async fn build_turn_runtime_statuses( ) } else if task_enabled && message_suggests_task(&request.message) { ( - "已决定:升级为后台任务".to_string(), + "升级为后台任务".to_string(), "用户输入更接近耗时或异步推进场景,优先走后台任务链路。".to_string(), vec![ "检测到排队/持续执行诉求".to_string(), @@ -279,7 +279,7 @@ pub(super) async fn build_turn_runtime_statuses( ) } else if thinking_enabled && reasoning_supported { ( - "已决定:先深度思考".to_string(), + "先深度思考".to_string(), "当前模型支持 reasoning,先做更充分的意图理解与方案判断,再决定是否调用搜索或工具。" .to_string(), vec![ @@ -289,7 +289,7 @@ pub(super) async fn build_turn_runtime_statuses( ) } else if thinking_enabled { ( - "已决定:轻量理解后回答".to_string(), + "轻量理解后回答".to_string(), "当前模型不支持显式 reasoning,先做轻量意图理解,再决定是否需要搜索或其他能力。" .to_string(), vec![ @@ -301,7 +301,7 @@ pub(super) async fn build_turn_runtime_statuses( && message_suggests_live_search(&request.message) { ( - "已决定:先联网核实".to_string(), + "先联网核实".to_string(), "问题包含明显时效性或实时性特征,先搜索核实再回答更稳妥。".to_string(), vec![ "已检测到最新/实时信息需求".to_string(), @@ -310,7 +310,7 @@ pub(super) async fn build_turn_runtime_statuses( ) } else if message_suggests_planning(&request.message) { ( - "已决定:先规划再输出".to_string(), + "先规划再输出".to_string(), "当前请求更像计划或方案拆解,会先整理执行路径和关键步骤。".to_string(), vec![ "检测到计划/拆解需求".to_string(), @@ -319,7 +319,7 @@ pub(super) async fn build_turn_runtime_statuses( ) } else if message_suggests_content_generation(&request.message) { ( - "已决定:先生成草稿".to_string(), + "先生成草稿".to_string(), "当前请求属于内容生成类,优先基于已有上下文生成一版草稿,信息不足时附带假设说明,而非反复追问。".to_string(), vec![ "检测到内容生成需求".to_string(), @@ -328,7 +328,7 @@ pub(super) async fn build_turn_runtime_statuses( ) } else { ( - "已决定:直接回答优先".to_string(), + "直接回答优先".to_string(), "当前请求无需默认升级为搜索或任务,先直接给出结果,必要时再调用工具。".to_string(), vec![ "默认保持直接回答".to_string(), 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 c64caa936..6d411eae5 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 @@ -115,6 +115,8 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields( ("gateKey", "gate_key"), ("run_title", "run_title"), ("runTitle", "run_title"), + ("access_mode", "access_mode"), + ("accessMode", "access_mode"), ("content_id", "content_id"), ("contentId", "content_id"), ("preferred_team_preset_id", "preferred_team_preset_id"), @@ -173,6 +175,15 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields( } } +fn derive_request_access_mode( + request: &AsterChatRequest, +) -> Option { + lime_agent::SessionExecutionRuntimeAccessMode::from_runtime_policies( + request.approval_policy.as_deref(), + request.sandbox_policy.as_deref(), + ) +} + pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base( request: &AsterChatRequest, workspace_id: &str, @@ -200,6 +211,14 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base( "message_length".to_string(), serde_json::json!(request.message.chars().count()), ); + metadata.insert( + "approval_policy".to_string(), + serde_json::json!(request.approval_policy.clone()), + ); + metadata.insert( + "sandbox_policy".to_string(), + serde_json::json!(request.sandbox_policy.clone()), + ); metadata.insert( "web_search_enabled".to_string(), serde_json::json!(request_tool_policy.effective_web_search), @@ -217,6 +236,14 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base( serde_json::json!(auto_continue_metadata), ); extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref()); + if !metadata.contains_key("access_mode") { + if let Some(access_mode) = derive_request_access_mode(request) { + metadata.insert( + "access_mode".to_string(), + serde_json::json!(access_mode.as_str()), + ); + } + } for (target_key, preference_keys, session_value) in [ ( "thinking_enabled", 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 1cade54d8..5673c8259 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -126,6 +126,33 @@ pub(crate) fn resolve_request_web_search_preference_from_sources( }) } +fn resolve_runtime_access_mode_from_request( + request: &AsterChatRequest, +) -> Option { + lime_agent::SessionExecutionRuntimeAccessMode::from_runtime_policies( + request.approval_policy.as_deref(), + request.sandbox_policy.as_deref(), + ) + .or_else(|| { + let access_mode = + extract_harness_string(request.metadata.as_ref(), &["access_mode", "accessMode"]); + lime_agent::SessionExecutionRuntimeAccessMode::from_access_mode_text(access_mode.as_deref()) + }) +} + +fn backfill_runtime_access_policies(request: &mut AsterChatRequest) { + let Some(access_mode) = resolve_runtime_access_mode_from_request(request) else { + return; + }; + + if request.approval_policy.is_none() { + request.approval_policy = Some(access_mode.approval_policy().to_string()); + } + if request.sandbox_policy.is_none() { + request.sandbox_policy = Some(access_mode.sandbox_policy().to_string()); + } +} + fn should_skip_artifact_document_autopersist( run_observation: &Arc>, final_text_output: &str, @@ -334,6 +361,7 @@ async fn execute_aster_chat_request( session_recent_harness_context.run_title.as_deref(), session_recent_harness_context.content_id.as_deref(), ); + backfill_runtime_access_policies(&mut request); // 直接使用前端传递的 session_id // LimeSessionStore 会在 add_message 时自动创建不存在的 session @@ -506,6 +534,8 @@ async fn execute_aster_chat_request( .set_working_dir(Some(workspace_root.clone())) .set_effective_user_message(request.message.clone()) .set_include_context_trace(include_context_trace) + .set_approval_policy(request.approval_policy.clone()) + .set_sandbox_policy(request.sandbox_policy.clone()) .set_turn_context_metadata_from_value(request.metadata.as_ref()); // 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt @@ -1987,6 +2017,8 @@ mod tests { provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, project_id: None, workspace_id: "workspace-artifact".to_string(), web_search: None, @@ -2072,6 +2104,8 @@ mod tests { provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, project_id: None, workspace_id: "workspace-artifact".to_string(), web_search: None, @@ -2138,6 +2172,8 @@ mod tests { provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, project_id: None, workspace_id: "workspace-artifact".to_string(), web_search: None, @@ -2197,6 +2233,8 @@ mod tests { provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, project_id: None, workspace_id: "workspace-social".to_string(), web_search: None, @@ -2240,6 +2278,45 @@ mod tests { ); } + #[test] + fn backfill_runtime_access_policies_should_derive_from_legacy_harness_access_mode() { + let mut request = AsterChatRequest { + message: "继续执行".to_string(), + session_id: "session-access-legacy".to_string(), + event_name: "agent_stream".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: None, + workspace_id: "workspace-access".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: Some(json!({ + "harness": { + "access_mode": "full-access" + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + backfill_runtime_access_policies(&mut request); + + assert_eq!(request.approval_policy.as_deref(), Some("never")); + assert_eq!( + request.sandbox_policy.as_deref(), + Some("danger-full-access") + ); + } + #[tokio::test] async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() { ensure_runtime_turn_test_session_manager().await; 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 9ca2f10ed..3e1dbb16f 100644 --- a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -682,6 +682,8 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal( provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, project_id: None, workspace_id, web_search: None, @@ -758,6 +760,8 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: 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 33d17694d..2a187afab 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -928,6 +928,8 @@ mod tests { provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, project_id: Some("project-1".to_string()), workspace_id: "workspace-1".to_string(), web_search: Some(false), @@ -1112,6 +1114,8 @@ mod tests { provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, project_id: Some("project-1".to_string()), workspace_id: "workspace-1".to_string(), web_search: Some(false), @@ -1185,6 +1189,8 @@ mod tests { provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, project_id: Some("project-1".to_string()), workspace_id: "workspace-1".to_string(), web_search: None, @@ -1235,6 +1241,66 @@ mod tests { ); } + #[test] + fn test_build_chat_run_metadata_base_derives_access_mode_from_formal_turn_context() { + let metadata = build_chat_run_metadata_base( + &AsterChatRequest { + message: "hello".to_string(), + session_id: "session-access".to_string(), + event_name: "event-access".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: Some("never".to_string()), + sandbox_policy: Some("danger-full-access".to_string()), + project_id: None, + 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: None, + 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, + None, + ); + + assert_eq!( + metadata + .get("approval_policy") + .and_then(serde_json::Value::as_str), + Some("never") + ); + assert_eq!( + metadata + .get("sandbox_policy") + .and_then(serde_json::Value::as_str), + Some("danger-full-access") + ); + assert_eq!( + metadata + .get("access_mode") + .and_then(serde_json::Value::as_str), + Some("full-access") + ); + } + #[test] fn test_chat_run_observation_records_nested_artifact_protocol_paths_from_tool_result() { let mut observation = ChatRunObservation::default(); 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 index b834f803c..d16e35a1f 100644 --- 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 @@ -602,6 +602,8 @@ impl Tool for LimeSiteTool { .as_ref() .and_then(|target| target.project_id.clone()), save_title, + require_attached_session: None, + skill_title: None, }; let result = Self::apply_save_target_to_run_result( @@ -715,6 +717,8 @@ mod tests { content_id: None, project_id: None, save_title: None, + require_attached_session: None, + skill_title: None, }; let result = SiteAdapterRunResult { ok: true, @@ -785,6 +789,8 @@ mod tests { content_id: None, project_id: None, save_title: None, + require_attached_session: None, + skill_title: None, }; let result = SiteAdapterRunResult { ok: true, diff --git a/src-tauri/src/commands/external_tools_cmd.rs b/src-tauri/src/commands/external_tools_cmd.rs index 70dc2a775..8af492d35 100644 --- a/src-tauri/src/commands/external_tools_cmd.rs +++ b/src-tauri/src/commands/external_tools_cmd.rs @@ -24,12 +24,12 @@ pub struct CodexCliStatus { pub error: Option, } -/// 检查 Codex CLI 状态 +/// 检查 Lime CLI 状态 #[tauri::command] pub async fn check_codex_cli_status() -> Result { let mut status = CodexCliStatus::default(); - // 1. 检查 codex 命令是否存在 + // 1. 检查 Lime 命令是否存在 let version_result = Command::new("codex") .arg("--version") .stdout(Stdio::piped()) @@ -150,7 +150,7 @@ pub async fn get_external_tools() -> Result, String> { tools.push(ExternalTool { id: "codex-cli".to_string(), name: "Codex CLI".to_string(), - description: "OpenAI Codex 命令行工具,支持 Agent 模式和工具调用".to_string(), + description: "Lime 命令行工具,支持 Agent 模式和工具调用".to_string(), installed: codex_status.installed, configured: codex_status.logged_in, install_command: "npm i -g @openai/codex".to_string(), diff --git a/src-tauri/src/commands/site_capability_cmd.rs b/src-tauri/src/commands/site_capability_cmd.rs index 80759b20b..7aba4e8ec 100644 --- a/src-tauri/src/commands/site_capability_cmd.rs +++ b/src-tauri/src/commands/site_capability_cmd.rs @@ -1,13 +1,18 @@ use crate::database::DbConnection; +use crate::services::site_adapter_import_service::{ + import_imported_yaml_adapter_bundle_to_default_dir, ImportedYamlCompileOptions, + PersistImportedCatalogResult, +}; 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, recommend_site_adapters, run_site_adapter, - run_site_adapter_with_optional_save, save_existing_site_result_to_project, - search_site_adapters, RunSiteAdapterRequest, SaveSiteAdapterResultRequest, - SavedSiteAdapterContent, SiteAdapterDefinition, SiteAdapterRecommendation, + get_site_adapter, get_site_adapter_launch_readiness, list_site_adapters, + recommend_site_adapters, run_site_adapter, run_site_adapter_with_optional_save, + save_existing_site_result_to_project, search_site_adapters, RunSiteAdapterRequest, + SaveSiteAdapterResultRequest, SavedSiteAdapterContent, SiteAdapterDefinition, + SiteAdapterLaunchReadinessRequest, SiteAdapterLaunchReadinessResult, SiteAdapterRecommendation, SiteAdapterRunResult, }; use serde::Deserialize; @@ -35,6 +40,21 @@ pub struct SiteAdapterRecommendRequest { pub limit: Option, } +#[derive(Debug, Deserialize)] +pub struct SiteAdapterImportYamlBundleRequest { + pub yaml_bundle: String, + #[serde(default)] + pub catalog_version: Option, + #[serde(default)] + pub source_version: Option, + #[serde(default = "default_site_adapter_import_read_only")] + pub read_only: bool, +} + +fn default_site_adapter_import_read_only() -> bool { + true +} + #[tauri::command] pub fn site_list_adapters() -> Result, String> { Ok(list_site_adapters()) @@ -62,6 +82,14 @@ pub fn site_get_adapter_info( get_site_adapter(&request.name).ok_or_else(|| "未找到对应的站点适配器".to_string()) } +#[tauri::command] +pub async fn site_get_adapter_launch_readiness( + db: State<'_, DbConnection>, + request: SiteAdapterLaunchReadinessRequest, +) -> Result { + get_site_adapter_launch_readiness(db.inner(), request).await +} + #[tauri::command] pub fn site_get_adapter_catalog_status() -> Result { get_site_adapter_catalog_status() @@ -79,6 +107,20 @@ pub fn site_clear_adapter_catalog_cache() -> Result Result { + import_imported_yaml_adapter_bundle_to_default_dir( + &request.yaml_bundle, + &ImportedYamlCompileOptions { + read_only: request.read_only, + source_version: request.source_version, + }, + request.catalog_version, + ) +} + #[tauri::command] pub async fn site_run_adapter( db: State<'_, DbConnection>, diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index 2fb8b1649..3752cdcdd 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -26,7 +26,8 @@ use lime_browser_runtime::{ EventBufferSnapshot, OpenSessionRequest, }; use lime_server::chrome_bridge::{ - self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeStatusSnapshot, + self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeDisconnectResult, + ChromeBridgeStatusSnapshot, }; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; @@ -1173,6 +1174,16 @@ pub async fn get_chrome_bridge_status_global() -> Result, +) -> Result { + Ok(chrome_bridge::chrome_bridge_hub() + .disconnect_connections(profile_key.as_deref()) + .await) +} + /// 通过 ChromeBridge 执行命令(用于设置页测试) #[tauri::command] pub async fn chrome_bridge_execute_command( diff --git a/src-tauri/src/dev_bridge.rs b/src-tauri/src/dev_bridge.rs index e2f8cdb7d..1e66052d4 100644 --- a/src-tauri/src/dev_bridge.rs +++ b/src-tauri/src/dev_bridge.rs @@ -9,9 +9,12 @@ pub mod dispatcher; #[cfg(debug_assertions)] use axum::{ - extract::State, + extract::{Query, State}, http::{request::Parts as RequestParts, HeaderValue, Method}, - response::{IntoResponse, Response}, + response::{ + sse::{Event as SseEvent, KeepAlive, Sse}, + IntoResponse, Response, + }, routing::{get, post}, Json, Router, }; @@ -20,6 +23,8 @@ use serde::{Deserialize, Serialize}; #[cfg(debug_assertions)] use std::sync::Arc; #[cfg(debug_assertions)] +use std::{convert::Infallible, time::Duration}; +#[cfg(debug_assertions)] use tokio::sync::RwLock; #[cfg(debug_assertions)] use tower_http::cors::{AllowOrigin, CorsLayer}; @@ -34,7 +39,7 @@ use lime_services::{ provider_pool_service::ProviderPoolService, skill_service::SkillService, }; #[cfg(debug_assertions)] -use tauri::AppHandle; +use tauri::{AppHandle, EventId, Listener}; #[cfg(debug_assertions)] #[derive(Debug, Deserialize)] @@ -51,6 +56,12 @@ pub struct InvokeResponse { pub error: Option, } +#[cfg(debug_assertions)] +#[derive(Debug, Deserialize)] +pub struct EventStreamRequest { + pub event: String, +} + #[cfg(debug_assertions)] #[derive(Clone)] pub struct DevBridgeState { @@ -143,6 +154,7 @@ impl DevBridgeServer { let app = Router::new() .route("/invoke", post(invoke_command)) + .route("/events", get(stream_events)) .route("/health", get(health_check).post(health_check)) .layer( // CORS 配置 - 允许本地开发前端访问 @@ -176,6 +188,20 @@ impl DevBridgeServer { } } +#[cfg(debug_assertions)] +#[derive(Clone)] +struct DevBridgeEventListenerGuard { + app_handle: AppHandle, + listener_id: EventId, +} + +#[cfg(debug_assertions)] +impl Drop for DevBridgeEventListenerGuard { + fn drop(&mut self) { + self.app_handle.unlisten(self.listener_id); + } +} + #[cfg(debug_assertions)] fn invoke_command( State(state): State, @@ -198,6 +224,63 @@ fn invoke_command( } } +#[cfg(debug_assertions)] +async fn stream_events( + State(state): State, + Query(req): Query, +) -> Response { + let event_name = req.event.trim().to_string(); + if event_name.is_empty() { + return ( + axum::http::StatusCode::BAD_REQUEST, + "missing event query parameter", + ) + .into_response(); + } + + let Some(app_handle) = state.app_handle.clone() else { + return ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "dev bridge app handle unavailable", + ) + .into_response(); + }; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let listener_event_name = event_name.clone(); + let listener_id = app_handle.listen_any(listener_event_name.clone(), move |event| { + let payload = event.payload(); + let payload_value = serde_json::from_str::(payload) + .unwrap_or_else(|_| serde_json::Value::String(payload.to_string())); + let serialized = serde_json::json!({ + "event": listener_event_name.clone(), + "payload": payload_value, + }) + .to_string(); + let _ = tx.send(serialized); + }); + + let cleanup_handle = app_handle.clone(); + let stream = async_stream::stream! { + let _listener_guard = DevBridgeEventListenerGuard { + app_handle: cleanup_handle, + listener_id, + }; + + while let Some(payload) = rx.recv().await { + yield Ok::(SseEvent::default().data(payload)); + } + }; + + Sse::new(stream) + .keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keepalive"), + ) + .into_response() +} + #[cfg(debug_assertions)] async fn health_check() -> impl IntoResponse { Json(serde_json::json!({ diff --git a/src-tauri/src/dev_bridge/dispatcher/browser/bridge.rs b/src-tauri/src/dev_bridge/dispatcher/browser/bridge.rs index 102470f1e..9d58e5ef7 100644 --- a/src-tauri/src/dev_bridge/dispatcher/browser/bridge.rs +++ b/src-tauri/src/dev_bridge/dispatcher/browser/bridge.rs @@ -29,6 +29,18 @@ pub(super) async fn try_handle( "get_chrome_bridge_status" => serde_json::to_value( crate::commands::webview_cmd::get_chrome_bridge_status_global().await?, )?, + "disconnect_browser_connector_session" => { + let args = args_or_default(args); + let profile_key = args + .get("profileKey") + .or_else(|| args.get("profile_key")) + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + serde_json::to_value( + crate::commands::webview_cmd::disconnect_browser_connector_session(profile_key) + .await?, + )? + } "get_browser_backend_policy" => serde_json::to_value( crate::commands::webview_cmd::get_browser_backend_policy_global().await?, )?, diff --git a/src-tauri/src/dev_bridge/dispatcher/browser/site.rs b/src-tauri/src/dev_bridge/dispatcher/browser/site.rs index b3c48f813..a8e539018 100644 --- a/src-tauri/src/dev_bridge/dispatcher/browser/site.rs +++ b/src-tauri/src/dev_bridge/dispatcher/browser/site.rs @@ -1,6 +1,9 @@ use super::super::get_db; use super::{parse_request, DynError}; use crate::dev_bridge::DevBridgeState; +use crate::services::site_adapter_import_service::{ + import_imported_yaml_adapter_bundle_to_default_dir, ImportedYamlCompileOptions, +}; use crate::services::site_adapter_registry::{ apply_site_adapter_catalog_bootstrap, clear_site_adapter_catalog_cache, get_site_adapter_catalog_status, @@ -48,6 +51,18 @@ pub(super) async fn try_handle( "site_clear_adapter_catalog_cache" => { serde_json::to_value(clear_site_adapter_catalog_cache()?)? } + "site_import_adapter_yaml_bundle" => { + let request: crate::commands::site_capability_cmd::SiteAdapterImportYamlBundleRequest = + parse_request(args)?; + serde_json::to_value(import_imported_yaml_adapter_bundle_to_default_dir( + &request.yaml_bundle, + &ImportedYamlCompileOptions { + read_only: request.read_only, + source_version: request.source_version, + }, + request.catalog_version, + )?)? + } "site_run_adapter" | "site_debug_run_adapter" => { let request: crate::services::site_capability_service::RunSiteAdapterRequest = parse_request(args)?; diff --git a/src-tauri/src/services/README.md b/src-tauri/src/services/README.md index 467756fa1..74505abd3 100644 --- a/src-tauri/src/services/README.md +++ b/src-tauri/src/services/README.md @@ -11,6 +11,7 @@ - `mod.rs` - 模块入口 - `novel_service.rs` - 小说编排服务(项目/设定/章节生成/一致性检查) +- `site_adapter_import_service.rs` - 外部适配器来源导入与 Lime 标准编译层 - `provider_pool_service.rs` - Provider 凭证池服务(多凭证轮询) - `token_cache_service.rs` - Token 缓存服务 - `mcp_service.rs` - MCP 服务器管理 diff --git a/src-tauri/src/services/automation_service/executor.rs b/src-tauri/src/services/automation_service/executor.rs index 5bbac391b..bd7626949 100644 --- a/src-tauri/src/services/automation_service/executor.rs +++ b/src-tauri/src/services/automation_service/executor.rs @@ -2,16 +2,12 @@ //! //! 负责把结构化自动化任务映射到 Aster 执行链路。 -use super::{AutomationJobRecord, AutomationPayload}; +use super::{AutomationJobRecord, AutomationPayload, BROWSER_AUTOMATION_RETIRED_MESSAGE}; use crate::agent::AsterAgentWrapper; -use crate::app::AppState; use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::commands::aster_agent_cmd::{ build_queued_turn_task, build_runtime_queue_executor, AsterChatRequest, }; -use crate::commands::browser_runtime_cmd::{ - launch_browser_session_with_db, LaunchBrowserSessionRequest, -}; use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; use crate::mcp::McpManagerState; @@ -73,31 +69,8 @@ pub async fn execute_job( ) .await } - AutomationPayload::BrowserSession { - profile_id, - profile_key, - url, - environment_preset_id, - target_id, - open_window, - stream_mode, - } => { - execute_browser_session( - job, - db, - app_handle, - LaunchBrowserSessionRequest { - profile_id: Some(profile_id), - profile_key, - url, - environment_preset_id, - environment: None, - target_id, - open_window, - stream_mode, - }, - ) - .await + AutomationPayload::BrowserSession { .. } => { + Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string()) } } } @@ -164,6 +137,8 @@ async fn execute_agent_turn( provider_preference: None, model_preference: None, thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, project_id: None, workspace_id: job.workspace_id.clone(), web_search: Some(web_search), @@ -208,47 +183,6 @@ async fn execute_agent_turn( }) } -async fn execute_browser_session( - job: &AutomationJobRecord, - db: &DbConnection, - app_handle: &Option, - request: LaunchBrowserSessionRequest, -) -> Result { - let app = app_handle - .as_ref() - .ok_or_else(|| "应用句柄不可用,无法执行浏览器自动化任务".to_string())?; - let app_state = app - .try_state::() - .ok_or_else(|| "AppState 未初始化,无法执行浏览器自动化任务".to_string())?; - let app_state = app_state.inner().clone(); - - let response = - launch_browser_session_with_db(app.clone(), app_state, db.clone(), request).await?; - let session_id = response.session.session_id.clone(); - Ok(JobExecutionResult { - output: format!("浏览器任务已启动: {} -> {}", job.name, session_id), - output_data: Some(json!({ - "kind": "browser_session", - "job_id": job.id.clone(), - "job_name": job.name.clone(), - "workspace_id": job.workspace_id.clone(), - "session_id": response.session.session_id.clone(), - "profile_key": response.session.profile_key.clone(), - "environment_preset_id": response.session.environment_preset_id.clone(), - "environment_preset_name": response.session.environment_preset_name.clone(), - "target_id": response.session.target_id.clone(), - "target_title": response.session.target_title.clone(), - "target_url": response.session.target_url.clone(), - "lifecycle_state": response.session.lifecycle_state, - "control_mode": response.session.control_mode, - "remote_debugging_port": response.session.remote_debugging_port, - "ws_debugger_url": response.session.ws_debugger_url.clone(), - })), - session_id: Some(session_id), - browser_session: Some(response.session), - }) -} - fn build_prompt(job: &AutomationJobRecord, prompt: &str, web_search: bool) -> String { let mut sections = vec![ "你是一个自动化任务执行助手。".to_string(), diff --git a/src-tauri/src/services/automation_service/mod.rs b/src-tauri/src/services/automation_service/mod.rs index b36bf704d..05e4c9d53 100644 --- a/src-tauri/src/services/automation_service/mod.rs +++ b/src-tauri/src/services/automation_service/mod.rs @@ -17,8 +17,6 @@ use self::schedule::{ describe_schedule, next_run_for_schedule, preview_next_run, validate_schedule, }; use crate::database::dao::agent_run::AgentRunStatus; -use crate::services::browser_environment_service::get_browser_environment_preset; -use crate::services::browser_profile_service::get_browser_profile; use crate::services::execution_tracker_service::{ExecutionTracker, RunHandle, RunSource}; use chrono::Utc; use lime_browser_runtime::{BrowserStreamMode, CdpSessionState}; @@ -38,7 +36,6 @@ use std::time::Duration; use tauri::Emitter; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; -use url::Url; use uuid::Uuid; pub type AutomationJobRecord = AutomationJob; @@ -149,6 +146,11 @@ pub struct AutomationService { app_handle: Option, } +pub(super) const BROWSER_AUTOMATION_RETIRED_MESSAGE: &str = + "浏览器自动化任务已下线,不再允许创建或执行"; +pub(super) const BROWSER_AUTOMATION_RETIRED_LAST_ERROR: &str = + "浏览器自动化任务已下线,请删除该任务"; + impl AutomationService { pub fn new(config: AutomationSettings) -> Self { Self { @@ -202,6 +204,17 @@ impl AutomationService { self.status.running = true; self.update_next_poll(); + let retired_job_count = { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + retire_legacy_browser_automation_jobs(&conn)? + }; + if retired_job_count > 0 { + tracing::info!( + "[Automation] 已停用 {} 条遗留浏览器自动化任务,后续不会再后台启动 Chrome", + retired_job_count + ); + } + let interval_secs = self.config.poll_interval_secs.max(5); let app_handle = self.app_handle.clone(); tokio::spawn(async move { @@ -377,6 +390,17 @@ impl AutomationService { .ok_or_else(|| format!("自动化任务不存在: {id}"))? }; + if is_browser_session_payload(&job.payload) { + let mut legacy_job = job.clone(); + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + retire_browser_automation_job( + &conn, + &mut legacy_job, + BROWSER_AUTOMATION_RETIRED_LAST_ERROR, + )?; + return Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string()); + } + let result = Self::execute_job_once(&job, db, &self.app_handle, &self.config).await?; Ok(AutomationCycleResult { job_count: 1, @@ -467,6 +491,16 @@ impl AutomationService { let started_at_str = started_at.to_rfc3339(); let is_browser_session = is_browser_session_payload(&working_job.payload); + if is_browser_session { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + retire_browser_automation_job( + &conn, + &mut working_job, + BROWSER_AUTOMATION_RETIRED_LAST_ERROR, + )?; + return Ok("error".to_string()); + } + set_active_job_state( &mut working_job, "running", @@ -713,17 +747,6 @@ fn normalize_optional_string(value: Option) -> Option { .filter(|item| !item.is_empty()) } -fn validate_optional_http_url(value: Option<&str>, field_name: &str) -> Result<(), String> { - let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else { - return Ok(()); - }; - let parsed = Url::parse(raw).map_err(|error| format!("{field_name}无效: {error}"))?; - match parsed.scheme() { - "http" | "https" => Ok(()), - _ => Err(format!("{field_name}仅支持 http/https")), - } -} - fn validate_draft(draft: &AutomationJobDraft) -> Result<(), String> { validate_schedule(&draft.schedule, Utc::now())?; validate_payload(&draft.payload)?; @@ -773,63 +796,65 @@ fn validate_payload(payload: &AutomationPayload) -> Result<(), String> { } } } - AutomationPayload::BrowserSession { profile_id, .. } => { - if profile_id.trim().is_empty() { - return Err("浏览器任务必须绑定浏览器资料".to_string()); - } + AutomationPayload::BrowserSession { .. } => { + return Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string()); } } Ok(()) } fn validate_payload_with_conn( - conn: &Connection, + _conn: &Connection, payload: &AutomationPayload, ) -> Result<(), String> { match payload { AutomationPayload::AgentTurn { .. } => Ok(()), - AutomationPayload::BrowserSession { - profile_id, - profile_key, - url, - environment_preset_id, - .. - } => { - let profile_id = profile_id.trim(); - let profile = get_browser_profile(conn, profile_id)? - .filter(|record| record.archived_at.is_none()) - .ok_or_else(|| format!("未找到可用的浏览器资料: {profile_id}"))?; - - if let Some(expected_profile_key) = profile_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - if profile.profile_key != expected_profile_key { - return Err(format!( - "浏览器资料 {profile_id} 的 profile_key 与任务配置不一致: {} != {expected_profile_key}", - profile.profile_key - )); - } - } - - if let Some(environment_preset_id) = environment_preset_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - get_browser_environment_preset(conn, environment_preset_id)? - .filter(|record| record.archived_at.is_none()) - .ok_or_else(|| { - format!("未找到可用的浏览器环境预设: {environment_preset_id}") - })?; - } - - validate_optional_http_url(url.as_deref(), "浏览器启动地址") + AutomationPayload::BrowserSession { .. } => { + Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string()) } } } +fn retire_legacy_browser_automation_jobs(conn: &Connection) -> Result { + let jobs = AutomationJobDao::list(conn).map_err(|e| format!("查询自动化任务失败: {e}"))?; + let mut retired_job_count = 0usize; + + for mut job in jobs { + if !is_browser_session_payload(&job.payload) { + continue; + } + retire_browser_automation_job(conn, &mut job, BROWSER_AUTOMATION_RETIRED_LAST_ERROR)?; + retired_job_count = retired_job_count.saturating_add(1); + } + + Ok(retired_job_count) +} + +fn retire_browser_automation_job( + conn: &Connection, + job: &mut AutomationJobRecord, + reason: &str, +) -> Result<(), String> { + let retired_at = Utc::now().to_rfc3339(); + let previous_running_started_at = job.running_started_at.clone(); + + job.enabled = false; + job.next_run_at = None; + job.last_status = Some("error".to_string()); + job.last_error = Some(reason.to_string()); + if job.last_run_at.is_none() { + job.last_run_at = previous_running_started_at.clone(); + } + if previous_running_started_at.is_some() { + job.last_finished_at = Some(retired_at.clone()); + } + job.running_started_at = None; + job.auto_disabled_until = None; + job.updated_at = retired_at; + + AutomationJobDao::update(conn, job).map_err(|e| format!("更新自动化任务失败: {e}")) +} + fn build_tracker_start_metadata(job: &AutomationJobRecord) -> Value { let mut metadata = Map::from_iter([ ("job_id".to_string(), Value::String(job.id.clone())), @@ -1165,11 +1190,6 @@ pub(super) fn append_payload_tracking_metadata(metadata: &mut Map mod tests { use super::*; use crate::database::schema::create_tables; - use crate::services::browser_environment_service::{ - save_browser_environment_preset, SaveBrowserEnvironmentPresetInput, - }; - use crate::services::browser_profile_service::{save_browser_profile, SaveBrowserProfileInput}; - use lime_core::database::dao::browser_profile::BrowserProfileTransportKind; use rusqlite::Connection; fn setup_db() -> Connection { @@ -1179,72 +1199,22 @@ mod tests { } #[test] - fn validate_payload_with_conn_should_accept_browser_session_payload() { + fn validate_payload_with_conn_should_reject_browser_session_payload() { let conn = setup_db(); - let profile = save_browser_profile( - &conn, - SaveBrowserProfileInput { - id: None, - profile_key: "shop_us".to_string(), - name: "美区店铺".to_string(), - description: None, - site_scope: None, - launch_url: Some("https://seller.example.com".to_string()), - transport_kind: BrowserProfileTransportKind::ManagedCdp, - }, - ) - .expect("保存浏览器资料失败"); - let preset = save_browser_environment_preset( - &conn, - SaveBrowserEnvironmentPresetInput { - id: None, - name: "美区桌面".to_string(), - description: None, - proxy_server: None, - timezone_id: Some("America/Los_Angeles".to_string()), - locale: Some("en-US".to_string()), - accept_language: Some("en-US,en;q=0.9".to_string()), - geolocation_lat: None, - geolocation_lng: None, - geolocation_accuracy_m: None, - user_agent: None, - platform: None, - viewport_width: Some(1440), - viewport_height: Some(900), - device_scale_factor: Some(2.0), - }, - ) - .expect("保存浏览器环境预设失败"); - let payload = AutomationPayload::BrowserSession { - profile_id: profile.id, + profile_id: "profile-1".to_string(), profile_key: Some("shop_us".to_string()), url: Some("https://seller.example.com/dashboard".to_string()), - environment_preset_id: Some(preset.id), + environment_preset_id: Some("preset-1".to_string()), target_id: None, open_window: false, stream_mode: BrowserStreamMode::Events, }; - validate_payload_with_conn(&conn, &payload).expect("浏览器任务负载校验失败"); - } - - #[test] - fn validate_payload_with_conn_should_reject_missing_browser_profile() { - let conn = setup_db(); - let payload = AutomationPayload::BrowserSession { - profile_id: "missing-profile".to_string(), - profile_key: Some("shop_us".to_string()), - url: Some("https://seller.example.com/dashboard".to_string()), - environment_preset_id: None, - target_id: None, - open_window: false, - stream_mode: BrowserStreamMode::Events, - }; - - let error = - validate_payload_with_conn(&conn, &payload).expect_err("缺失浏览器资料时应返回错误"); - assert!(error.contains("未找到可用的浏览器资料")); + assert_eq!( + validate_payload_with_conn(&conn, &payload), + Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string()) + ); } #[test] @@ -1306,6 +1276,103 @@ mod tests { assert_eq!(metadata.get("session_id"), Some(&json!("session-1"))); } + #[test] + fn retire_legacy_browser_automation_jobs_should_disable_browser_session_jobs() { + let conn = setup_db(); + let browser_job = AutomationJob { + id: "job-browser-1".to_string(), + name: "浏览器巡检".to_string(), + description: Some("旧浏览器自动化".to_string()), + enabled: true, + workspace_id: "workspace-1".to_string(), + execution_mode: AutomationExecutionMode::Intelligent, + schedule: TaskSchedule::Every { every_secs: 300 }, + payload: json!({ + "kind": "browser_session", + "profile_id": "profile-1", + "profile_key": "shop_us", + "url": "https://seller.example.com/dashboard", + "open_window": false, + "stream_mode": "events" + }), + delivery: DeliveryConfig::default(), + timeout_secs: None, + max_retries: 3, + next_run_at: Some("2026-03-16T00:05:00Z".to_string()), + last_status: Some("running".to_string()), + last_error: None, + last_run_at: Some("2026-03-16T00:00:00Z".to_string()), + last_finished_at: None, + running_started_at: Some("2026-03-16T00:00:00Z".to_string()), + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: None, + created_at: "2026-03-16T00:00:00Z".to_string(), + updated_at: "2026-03-16T00:00:00Z".to_string(), + }; + let agent_job = AutomationJob { + id: "job-agent-1".to_string(), + name: "日报摘要".to_string(), + description: None, + enabled: true, + workspace_id: "workspace-1".to_string(), + execution_mode: AutomationExecutionMode::Skill, + schedule: TaskSchedule::Cron { + expr: "0 9 * * *".to_string(), + tz: Some("Asia/Shanghai".to_string()), + }, + payload: json!({ + "kind": "agent_turn", + "prompt": "请输出日报摘要", + "web_search": false + }), + delivery: DeliveryConfig::default(), + timeout_secs: None, + max_retries: 3, + next_run_at: Some("2026-03-16T09:00:00Z".to_string()), + last_status: Some("success".to_string()), + last_error: None, + last_run_at: Some("2026-03-16T08:59:00Z".to_string()), + last_finished_at: Some("2026-03-16T09:00:05Z".to_string()), + running_started_at: None, + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: None, + created_at: "2026-03-16T00:00:00Z".to_string(), + updated_at: "2026-03-16T00:00:00Z".to_string(), + }; + + AutomationJobDao::create(&conn, &browser_job).expect("创建浏览器任务失败"); + AutomationJobDao::create(&conn, &agent_job).expect("创建 agent 任务失败"); + + let retired_job_count = + retire_legacy_browser_automation_jobs(&conn).expect("停用遗留浏览器任务失败"); + assert_eq!(retired_job_count, 1); + + let updated_browser = AutomationJobDao::get(&conn, "job-browser-1") + .expect("读取浏览器任务失败") + .expect("浏览器任务不存在"); + assert!(!updated_browser.enabled); + assert_eq!(updated_browser.next_run_at, None); + assert_eq!(updated_browser.running_started_at, None); + assert_eq!(updated_browser.last_status.as_deref(), Some("error")); + assert_eq!( + updated_browser.last_error.as_deref(), + Some(BROWSER_AUTOMATION_RETIRED_LAST_ERROR) + ); + + let updated_agent = AutomationJobDao::get(&conn, "job-agent-1") + .expect("读取 agent 任务失败") + .expect("agent 任务不存在"); + assert!(updated_agent.enabled); + assert_eq!( + updated_agent.next_run_at.as_deref(), + Some("2026-03-16T09:00:00Z") + ); + } + #[test] fn build_tracker_finish_metadata_should_include_delivery_summary() { let job = AutomationJob { @@ -1544,4 +1611,22 @@ mod tests { Err("自动化任务 request_metadata 必须为对象".to_string()) ); } + + #[test] + fn validate_payload_should_reject_browser_session_payload() { + let payload = AutomationPayload::BrowserSession { + profile_id: "profile-1".to_string(), + profile_key: Some("shop_us".to_string()), + url: Some("https://seller.example.com/dashboard".to_string()), + environment_preset_id: None, + target_id: None, + open_window: false, + stream_mode: BrowserStreamMode::Events, + }; + + assert_eq!( + validate_payload(&payload), + Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string()) + ); + } } diff --git a/src-tauri/src/services/browser_connector_service.rs b/src-tauri/src/services/browser_connector_service.rs index 8dc85a48e..845f36ce3 100644 --- a/src-tauri/src/services/browser_connector_service.rs +++ b/src-tauri/src/services/browser_connector_service.rs @@ -8,6 +8,7 @@ use serde_json::Value; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use tauri::{AppHandle, Manager}; const SETTINGS_SUBDIR: &str = "connectors"; @@ -26,6 +27,12 @@ const SYSTEM_CONNECTOR_DEFINITIONS: [(&str, &str, &str); 5] = [ ("contacts", "通讯录", "搜索、读取和创建联系人。"), ]; +const AUTH_STATUS_NOT_DETERMINED: &str = "not_determined"; +const AUTH_STATUS_AUTHORIZED: &str = "authorized"; +const AUTH_STATUS_DENIED: &str = "denied"; +const AUTH_STATUS_ERROR: &str = "error"; +const AUTH_STATUS_UNSUPPORTED: &str = "unsupported"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowserConnectorAutoConfig { #[serde(rename = "serverUrl")] @@ -67,6 +74,10 @@ pub struct SystemConnectorSnapshot { pub description: String, pub enabled: bool, pub available: bool, + pub visible: bool, + pub authorization_status: String, + pub last_error: Option, + pub capabilities: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -81,10 +92,24 @@ pub struct BrowserConnectorSettingsSnapshot { struct BrowserConnectorSettingsRecord { enabled: bool, install_root_dir: Option, - system_connectors: HashMap, + system_connectors: HashMap, updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum StoredSystemConnectorState { + LegacyBool(bool), + Detailed(SystemConnectorStateRecord), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SystemConnectorStateRecord { + enabled: bool, + authorization_status: String, + last_error: Option, +} + #[derive(Debug, Clone)] struct ManifestInfo { name: String, @@ -96,7 +121,7 @@ impl Default for BrowserConnectorSettingsRecord { Self { enabled: true, install_root_dir: None, - system_connectors: default_system_connector_states(), + system_connectors: default_system_connector_state_records(), updated_at: Utc::now().to_rfc3339(), } } @@ -109,6 +134,146 @@ fn default_system_connector_states() -> HashMap { .collect() } +fn default_system_connector_state_records() -> HashMap { + default_system_connector_states() + .into_iter() + .map(|(id, enabled)| (id, StoredSystemConnectorState::LegacyBool(enabled))) + .collect() +} + +fn default_connector_record(enabled: bool) -> SystemConnectorStateRecord { + SystemConnectorStateRecord { + enabled, + authorization_status: if cfg!(target_os = "macos") { + AUTH_STATUS_NOT_DETERMINED.to_string() + } else { + AUTH_STATUS_UNSUPPORTED.to_string() + }, + last_error: None, + } +} + +fn normalize_connector_record( + state: Option<&StoredSystemConnectorState>, +) -> SystemConnectorStateRecord { + match state { + Some(StoredSystemConnectorState::LegacyBool(enabled)) => default_connector_record(*enabled), + Some(StoredSystemConnectorState::Detailed(record)) => record.clone(), + None => default_connector_record(false), + } +} + +fn connector_capabilities(id: &str) -> Vec { + match id { + "reminders" => vec![ + "list_reminders".to_string(), + "create_reminder".to_string(), + "update_reminder".to_string(), + ], + "calendar" => vec![ + "list_events".to_string(), + "create_event".to_string(), + "update_event".to_string(), + ], + "notes" => vec![ + "list_notes".to_string(), + "read_note".to_string(), + "create_note".to_string(), + ], + "mail" => vec![ + "list_mailboxes".to_string(), + "read_messages".to_string(), + "create_draft".to_string(), + ], + "contacts" => vec![ + "search_contacts".to_string(), + "read_contact".to_string(), + "create_contact".to_string(), + ], + _ => Vec::new(), + } +} + +#[cfg(target_os = "macos")] +fn connector_probe_script(id: &str) -> Option<&'static str> { + match id { + "reminders" => Some(r#"tell application id "com.apple.reminders" to count of lists"#), + "calendar" => Some(r#"tell application id "com.apple.iCal" to count of calendars"#), + "notes" => Some(r#"tell application id "com.apple.Notes" to count of folders"#), + "mail" => Some(r#"tell application id "com.apple.mail" to count of mailboxes"#), + "contacts" => Some(r#"tell application id "com.apple.AddressBook" to count of people"#), + _ => None, + } +} + +fn truncate_connector_error(input: &str) -> String { + input + .trim() + .split('\n') + .find(|line| !line.trim().is_empty()) + .unwrap_or(input) + .trim() + .to_string() +} + +#[cfg(target_os = "macos")] +fn request_connector_authorization(id: &str) -> Result { + let script = connector_probe_script(id).ok_or_else(|| format!("未知的系统连接器: {id}"))?; + let output = Command::new("osascript") + .args(["-e", script]) + .output() + .map_err(|error| format!("调用 osascript 失败: {error}"))?; + + if output.status.success() { + return Ok(SystemConnectorStateRecord { + enabled: true, + authorization_status: AUTH_STATUS_AUTHORIZED.to_string(), + last_error: None, + }); + } + + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let combined = format!("{stderr}\n{stdout}"); + let normalized = combined.to_ascii_lowercase(); + let last_error = truncate_connector_error(&combined); + + if normalized.contains("not authorized") + || normalized.contains("not permitted") + || normalized.contains("(-1743)") + || normalized.contains("1743") + { + return Ok(SystemConnectorStateRecord { + enabled: false, + authorization_status: AUTH_STATUS_DENIED.to_string(), + last_error: Some(if last_error.is_empty() { + "系统已拒绝该连接器的自动化权限。".to_string() + } else { + last_error + }), + }); + } + + Ok(SystemConnectorStateRecord { + enabled: false, + authorization_status: AUTH_STATUS_ERROR.to_string(), + last_error: Some(if last_error.is_empty() { + "系统连接器授权失败。".to_string() + } else { + last_error + }), + }) +} + +#[cfg(not(target_os = "macos"))] +fn request_connector_authorization(_id: &str) -> Result { + Ok(SystemConnectorStateRecord { + enabled: false, + authorization_status: AUTH_STATUS_UNSUPPORTED.to_string(), + last_error: Some("当前平台暂不支持系统连接器。".to_string()), + }) +} + fn browser_connector_settings_path() -> Result { Ok(lime_core::app_paths::preferred_data_dir() .map_err(|error| format!("获取应用数据目录失败: {error}"))? @@ -135,7 +300,10 @@ fn load_settings_record() -> Result { serde_json::from_str(&content).map_err(|error| format!("解析连接器设置失败: {error}"))?; for (id, enabled) in default_system_connector_states() { - record.system_connectors.entry(id).or_insert(enabled); + record + .system_connectors + .entry(id) + .or_insert(StoredSystemConnectorState::LegacyBool(enabled)); } Ok(record) @@ -406,16 +574,28 @@ fn build_settings_snapshot( enabled: record.enabled, install_root_dir, install_dir, - system_connectors: SYSTEM_CONNECTOR_DEFINITIONS - .iter() - .map(|(id, label, description)| SystemConnectorSnapshot { - id: (*id).to_string(), - label: (*label).to_string(), - description: (*description).to_string(), - enabled: record.system_connectors.get(*id).copied().unwrap_or(false), - available: cfg!(target_os = "macos"), - }) - .collect(), + system_connectors: if cfg!(target_os = "macos") { + SYSTEM_CONNECTOR_DEFINITIONS + .iter() + .map(|(id, label, description)| { + let connector_record = + normalize_connector_record(record.system_connectors.get(*id)); + SystemConnectorSnapshot { + id: (*id).to_string(), + label: (*label).to_string(), + description: (*description).to_string(), + enabled: connector_record.enabled, + available: true, + visible: true, + authorization_status: connector_record.authorization_status, + last_error: connector_record.last_error, + capabilities: connector_capabilities(id), + } + }) + .collect() + } else { + Vec::new() + }, } } @@ -454,7 +634,23 @@ pub fn update_system_connector_enabled( } let mut record = load_settings_record()?; - record.system_connectors.insert(id.to_string(), enabled); + let next_state = if enabled { + request_connector_authorization(id)? + } else { + let mut current = normalize_connector_record(record.system_connectors.get(id)); + current.enabled = false; + current.last_error = None; + if !cfg!(target_os = "macos") { + current.authorization_status = AUTH_STATUS_UNSUPPORTED.to_string(); + } else if current.authorization_status == AUTH_STATUS_ERROR { + current.authorization_status = AUTH_STATUS_NOT_DETERMINED.to_string(); + } + current + }; + record.system_connectors.insert( + id.to_string(), + StoredSystemConnectorState::Detailed(next_state), + ); record.updated_at = Utc::now().to_rfc3339(); save_settings_record(&record)?; Ok(build_settings_snapshot(&record)) @@ -520,4 +716,37 @@ mod tests { assert_eq!(status.installed_version.as_deref(), Some("1.0.0")); assert_eq!(status.bundled_version, "1.1.0"); } + + #[cfg(target_os = "macos")] + #[test] + fn settings_snapshot_should_expose_visible_system_connectors_on_macos() { + let record = BrowserConnectorSettingsRecord::default(); + let snapshot = build_settings_snapshot(&record); + + assert_eq!( + snapshot.system_connectors.len(), + SYSTEM_CONNECTOR_DEFINITIONS.len() + ); + assert!(snapshot + .system_connectors + .iter() + .all(|connector| connector.visible)); + assert!(snapshot + .system_connectors + .iter() + .all(|connector| connector.available)); + assert!(snapshot + .system_connectors + .iter() + .all(|connector| !connector.capabilities.is_empty())); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn settings_snapshot_should_hide_system_connectors_on_non_macos() { + let record = BrowserConnectorSettingsRecord::default(); + let snapshot = build_settings_snapshot(&record); + + assert!(snapshot.system_connectors.is_empty()); + } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index f922ee32b..0c8897145 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -36,6 +36,7 @@ pub mod runtime_evidence_pack_service; pub mod runtime_handoff_artifact_service; pub mod runtime_replay_case_service; pub mod runtime_review_decision_service; +pub mod site_adapter_import_service; pub mod site_adapter_registry; pub mod site_capability_service; pub mod sysinfo_service; diff --git a/src-tauri/src/services/site_adapter_import_service.rs b/src-tauri/src/services/site_adapter_import_service.rs new file mode 100644 index 000000000..c3d5d66b0 --- /dev/null +++ b/src-tauri/src/services/site_adapter_import_service.rs @@ -0,0 +1,1450 @@ +use crate::services::site_adapter_registry::resolve_imported_adapter_dir; +use once_cell::sync::Lazy; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Number, Value}; +use serde_yaml::Value as YamlValue; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +const IMPORTED_REGISTRY_VERSION: u32 = 1; + +static IMPORTED_TEMPLATE_TOKEN_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"\$\{\{\s*([\s\S]*?)\s*\}\}") + .expect("imported yaml template token regex should compile") +}); + +static IMPORTED_ARGS_ENTRY_EXPR_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^args\.([a-zA-Z0-9_]+)(?:\s*\|\s*(urlencode))?$") + .expect("imported yaml args entry expr regex should compile") +}); + +#[derive(Debug, Clone)] +pub struct ImportedYamlCompileOptions { + pub read_only: bool, + pub source_version: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompiledImportedSiteAdapter { + pub name: String, + pub domain: String, + pub description: String, + pub read_only: bool, + pub capabilities: Vec, + pub args: Vec, + pub example: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_hint: Option, + pub entry: CompiledImportedSiteAdapterEntry, + pub script: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_version: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompiledImportedSiteAdapterArg { + 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)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CompiledImportedSiteAdapterEntry { + FixedUrl { url: String }, + UrlTemplate { template: String }, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PersistImportedCatalogResult { + pub directory: String, + pub adapter_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub catalog_version: Option, +} + +#[derive(Debug, Deserialize)] +struct ImportedYamlAdapterDocument { + site: String, + name: String, + #[serde(default)] + description: Option, + domain: String, + #[serde(default)] + strategy: Option, + #[serde(default)] + browser: bool, + #[serde(default)] + args: BTreeMap, + #[serde(default)] + columns: Vec, + #[serde(default)] + pipeline: Vec, +} + +#[derive(Debug, Deserialize)] +struct ImportedYamlAdapterArgDocument { + #[serde(rename = "type")] + arg_type: String, + #[serde(default)] + default: Option, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Clone)] +enum ImportedYamlPipelineStep { + Navigate(String), + Evaluate(String), + Map(BTreeMap), + Filter(YamlValue), + Limit(YamlValue), + Sort { + by: YamlValue, + order: ImportedYamlSortOrder, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ImportedYamlSortOrder { + Asc, + Desc, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum JsValueResolverConfig { + Literal { value: Value }, + Expr { source: String }, + Template { source: String }, +} + +#[derive(Debug, Clone, Serialize)] +struct PersistedImportedCatalogDocument { + registry_version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + catalog_version: Option, + adapters: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct PersistedImportedCatalogEntry { + name: String, + domain: String, + description: String, + read_only: bool, + capabilities: Vec, + args: Vec, + example: String, + #[serde(skip_serializing_if = "Option::is_none")] + auth_hint: Option, + entry: CompiledImportedSiteAdapterEntry, + script_file: String, + #[serde(skip_serializing_if = "Option::is_none")] + source_version: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct PersistedImportedCatalogArg { + name: String, + description: String, + required: bool, + arg_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + example: Option, +} + +const IMPORTER_RUNTIME_SCRIPT: &str = r#" +const __lime = (() => { + const splitTopLevel = (source, separator) => { + const segments = []; + let current = ""; + let depth = 0; + let quote = null; + let escaped = false; + + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (escaped) { + current += char; + escaped = false; + continue; + } + if (quote) { + current += char; + if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === "\"" || char === "`") { + quote = char; + current += char; + continue; + } + if (char === "(" || char === "[" || char === "{") { + depth += 1; + current += char; + continue; + } + if (char === ")" || char === "]" || char === "}") { + depth = Math.max(0, depth - 1); + current += char; + continue; + } + if (char === separator && depth === 0) { + if (separator === "|" && (source[index - 1] === "|" || source[index + 1] === "|")) { + current += char; + continue; + } + segments.push(current.trim()); + current = ""; + continue; + } + current += char; + } + + if (current.trim()) { + segments.push(current.trim()); + } + return segments; + }; + + const stripExprWrapper = (source) => { + if (typeof source !== "string") { + return source; + } + const trimmed = source.trim(); + if (trimmed.startsWith("${{") && trimmed.endsWith("}}")) { + return trimmed.slice(3, -2).trim(); + } + return trimmed; + }; + + const evalBase = (expression, ctx) => + Function("ctx", `with (ctx) { return (${expression}); }`)(ctx); + + const filters = { + default: (value, fallbackValue) => + value === undefined || value === null || value === "" ? fallbackValue : value, + json: (value) => JSON.stringify(value), + urlencode: (value) => encodeURIComponent(value === undefined || value === null ? "" : String(value)), + join: (value, separator = ", ") => (Array.isArray(value) ? value.join(separator) : String(value ?? "")), + upper: (value) => String(value ?? "").toUpperCase(), + lower: (value) => String(value ?? "").toLowerCase(), + trim: (value) => String(value ?? "").trim(), + truncate: (value, size = 30) => { + const text = String(value ?? ""); + const limit = Number(size); + if (!Number.isFinite(limit) || limit < 0) { + return text; + } + return text.length > limit ? text.slice(0, limit) : text; + }, + replace: (value, searchValue, replaceValue = "") => + String(value ?? "").split(String(searchValue ?? "")).join(String(replaceValue ?? "")), + keys: (value) => (value && typeof value === "object" ? Object.keys(value) : []), + length: (value) => { + if (Array.isArray(value) || typeof value === "string") { + return value.length; + } + if (value && typeof value === "object") { + return Object.keys(value).length; + } + return 0; + }, + first: (value) => (Array.isArray(value) ? value[0] : null), + last: (value) => (Array.isArray(value) ? value[value.length - 1] : null), + slugify: (value) => + String(value ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""), + sanitize: (value) => String(value ?? "").replace(/[<>]/g, ""), + ext: (value) => { + const raw = String(value ?? ""); + const lastDot = raw.lastIndexOf("."); + return lastDot >= 0 ? raw.slice(lastDot + 1) : ""; + }, + basename: (value) => { + const normalized = String(value ?? "").replace(/\\/g, "/"); + const parts = normalized.split("/"); + return parts[parts.length - 1] || ""; + }, + }; + + const evaluateFilterArgs = (source, ctx) => { + const trimmed = source.trim(); + if (!trimmed) { + return { name: "", args: [] }; + } + const openIndex = trimmed.indexOf("("); + if (openIndex < 0 || !trimmed.endsWith(")")) { + return { name: trimmed, args: [] }; + } + const name = trimmed.slice(0, openIndex).trim(); + const argsSource = trimmed.slice(openIndex + 1, -1); + const args = splitTopLevel(argsSource, ",").map((segment) => evalBase(segment, ctx)); + return { name, args }; + }; + + const expr = (source, ctx) => { + const normalized = stripExprWrapper(source); + if (!normalized) { + return null; + } + const segments = splitTopLevel(normalized, "|"); + let value = evalBase(segments[0], ctx); + for (const segment of segments.slice(1)) { + const { name, args } = evaluateFilterArgs(segment, ctx); + const filter = filters[name]; + if (!filter) { + throw new Error(`不支持的来源 filter: ${name}`); + } + value = filter(value, ...args); + } + return value; + }; + + const stringify = (value) => { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); + }; + + const interpolate = (source, ctx) => + String(source).replace(/\$\{\{\s*([\s\S]*?)\s*\}\}/g, (_, inner) => stringify(expr(inner, ctx))); + + const resolve = (config, ctx) => { + if (!config || typeof config !== "object") { + return null; + } + switch (config.kind) { + case "literal": + return config.value; + case "expr": + return expr(config.source, ctx); + case "template": + return interpolate(config.source, ctx); + default: + throw new Error(`未知的 resolver kind: ${String(config.kind)}`); + } + }; + + const compare = (left, right) => { + if (left === right) { + return 0; + } + if (left === undefined || left === null) { + return -1; + } + if (right === undefined || right === null) { + return 1; + } + if (typeof left === "number" && typeof right === "number") { + return left - right; + } + return String(left).localeCompare(String(right), "zh-CN"); + }; + + const makeContext = (items, item, index) => ({ + args, + helpers, + state: items, + data: items, + item, + index, + Math, + JSON, + Number, + String, + Boolean, + Date, + URL, + location, + document, + window, + }); + + const sortItems = (items, config) => { + const order = config?.order === "desc" ? -1 : 1; + const copied = Array.isArray(items) ? [...items] : []; + copied.sort((left, right) => { + const leftKey = resolve(config.by, makeContext(copied, left, 0)); + const rightKey = resolve(config.by, makeContext(copied, right, 0)); + return compare(leftKey, rightKey) * order; + }); + return copied; + }; + + return { + interpolate, + makeContext, + resolve, + sortItems, + }; +})(); +"#; + +pub fn compile_imported_yaml_adapter( + yaml: &str, + options: &ImportedYamlCompileOptions, +) -> Result { + let document: ImportedYamlAdapterDocument = serde_yaml::from_str(yaml) + .map_err(|error| format!("解析导入型 YAML 适配器失败: {error}"))?; + compile_imported_yaml_adapter_document(document, options) +} + +pub fn compile_imported_yaml_adapter_bundle( + yaml_bundle: &str, + options: &ImportedYamlCompileOptions, +) -> Result, String> { + let mut adapters = Vec::new(); + let mut seen_names = BTreeSet::new(); + + for (index, deserializer) in serde_yaml::Deserializer::from_str(yaml_bundle).enumerate() { + let document = ImportedYamlAdapterDocument::deserialize(deserializer) + .map_err(|error| format!("解析第 {} 个导入型 YAML 适配器失败: {error}", index + 1))?; + let adapter = compile_imported_yaml_adapter_document(document, options) + .map_err(|error| format!("编译第 {} 个导入型 YAML 适配器失败: {error}", index + 1))?; + let normalized_name = adapter.name.trim().to_ascii_lowercase(); + if !seen_names.insert(normalized_name) { + return Err(format!("导入内容中存在重复适配器: {}", adapter.name)); + } + adapters.push(adapter); + } + + if adapters.is_empty() { + return Err("导入内容中未找到任何 YAML 适配器文档".to_string()); + } + + Ok(adapters) +} + +pub fn compile_imported_yaml_adapter_file( + path: &Path, + options: &ImportedYamlCompileOptions, +) -> Result { + let yaml = fs::read_to_string(path) + .map_err(|error| format!("读取导入型 YAML 适配器文件失败 {}: {error}", path.display()))?; + compile_imported_yaml_adapter(&yaml, options) +} + +pub fn persist_compiled_imported_adapters( + dir: &Path, + adapters: &[CompiledImportedSiteAdapter], + catalog_version: Option, +) -> Result { + if dir.exists() { + fs::remove_dir_all(dir) + .map_err(|error| format!("清理 imported 适配器目录失败 {}: {error}", dir.display()))?; + } + fs::create_dir_all(dir.join("scripts")) + .map_err(|error| format!("创建 imported 适配器目录失败 {}: {error}", dir.display()))?; + + let mut manifest_entries = Vec::with_capacity(adapters.len()); + let mut seen_names = BTreeSet::new(); + for adapter in adapters { + if !seen_names.insert(adapter.name.to_ascii_lowercase()) { + return Err(format!("重复的 imported adapter: {}", adapter.name)); + } + let script_file = build_imported_script_file(&adapter.name); + let script_path = dir.join(&script_file); + if let Some(parent) = script_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 imported 适配器脚本目录失败 {}: {error}", + parent.display() + ) + })?; + } + fs::write(&script_path, &adapter.script).map_err(|error| { + format!( + "写入 imported 适配器脚本失败 {}: {error}", + script_path.display() + ) + })?; + + manifest_entries.push(PersistedImportedCatalogEntry { + name: adapter.name.clone(), + domain: adapter.domain.clone(), + description: adapter.description.clone(), + read_only: adapter.read_only, + capabilities: adapter.capabilities.clone(), + args: adapter + .args + .iter() + .map(|arg| PersistedImportedCatalogArg { + name: arg.name.clone(), + description: arg.description.clone(), + required: arg.required, + arg_type: arg.arg_type.clone(), + example: arg.example.clone(), + }) + .collect(), + example: adapter.example.clone(), + auth_hint: adapter.auth_hint.clone(), + entry: adapter.entry.clone(), + script_file, + source_version: adapter.source_version.clone(), + }); + } + + let document = PersistedImportedCatalogDocument { + registry_version: IMPORTED_REGISTRY_VERSION, + catalog_version: normalize_optional_text(catalog_version), + adapters: manifest_entries, + }; + let index_path = dir.join("index.json"); + let content = serde_json::to_string_pretty(&document) + .map_err(|error| format!("序列化 imported 适配器目录失败: {error}"))?; + fs::write(&index_path, content).map_err(|error| { + format!( + "写入 imported 适配器索引失败 {}: {error}", + index_path.display() + ) + })?; + + Ok(PersistImportedCatalogResult { + directory: dir.display().to_string(), + adapter_count: adapters.len(), + catalog_version: document.catalog_version, + }) +} + +pub fn persist_compiled_imported_adapters_to_default_dir( + adapters: &[CompiledImportedSiteAdapter], + catalog_version: Option, +) -> Result { + let dir = + resolve_imported_adapter_dir().ok_or_else(|| "无法解析 imported 适配器目录".to_string())?; + persist_compiled_imported_adapters(&dir, adapters, catalog_version) +} + +pub fn import_imported_yaml_adapter_bundle_to_default_dir( + yaml_bundle: &str, + options: &ImportedYamlCompileOptions, + catalog_version: Option, +) -> Result { + let adapters = compile_imported_yaml_adapter_bundle(yaml_bundle, options)?; + persist_compiled_imported_adapters_to_default_dir(&adapters, catalog_version) +} + +fn compile_imported_yaml_adapter_document( + document: ImportedYamlAdapterDocument, + options: &ImportedYamlCompileOptions, +) -> Result { + let site = normalize_required_text(&document.site, "site")?; + let command_name = normalize_required_text(&document.name, "name")?; + let adapter_name = format!("{site}/{command_name}"); + let description = document + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("从外部来源导入的站点适配器") + .to_string(); + let domain = normalize_required_text(&document.domain, "domain")?; + + let args = document + .args + .into_iter() + .map(|(name, arg)| compile_imported_yaml_arg(name, arg)) + .collect::, _>>()?; + let steps = document + .pipeline + .into_iter() + .map(parse_pipeline_step) + .collect::, _>>()?; + let entry = compile_pipeline_entry(&domain, &steps)?; + let script = compile_pipeline_script(&adapter_name, &document.columns, &steps)?; + + Ok(CompiledImportedSiteAdapter { + name: adapter_name.clone(), + domain, + description, + read_only: options.read_only, + capabilities: derive_capabilities(&adapter_name), + args: args.clone(), + example: build_example(&adapter_name, &args), + auth_hint: derive_auth_hint(document.strategy.as_deref(), document.browser), + entry, + script, + source_version: normalize_optional_text(options.source_version.clone()), + }) +} + +fn compile_imported_yaml_arg( + name: String, + arg: ImportedYamlAdapterArgDocument, +) -> Result { + let normalized_name = normalize_required_text(&name, "arg.name")?; + let normalized_arg_type = arg.arg_type.trim().to_ascii_lowercase(); + let arg_type = match normalized_arg_type.as_str() { + "str" | "string" => "string", + "int" | "integer" => "integer", + other => return Err(format!("暂不支持的导入型 YAML 参数类型: {other}")), + } + .to_string(); + let example = arg + .default + .as_ref() + .map(yaml_to_json_value) + .transpose()? + .or_else(|| default_arg_example(&arg_type)); + + Ok(CompiledImportedSiteAdapterArg { + name: normalized_name, + description: arg + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("从外部来源导入的参数") + .to_string(), + required: arg.default.is_none(), + arg_type, + example, + }) +} + +fn parse_pipeline_step(value: YamlValue) -> Result { + let Some(mapping) = value.as_mapping() else { + return Err("导入型 YAML pipeline step 必须是对象".to_string()); + }; + if mapping.len() != 1 { + return Err("导入型 YAML pipeline step 只能包含一个操作".to_string()); + } + + let Some((raw_key, raw_value)) = mapping.iter().next() else { + return Err("导入型 YAML pipeline step 不能为空".to_string()); + }; + let Some(key) = raw_key.as_str() else { + return Err("导入型 YAML pipeline step key 必须是字符串".to_string()); + }; + + match key { + "navigate" => Ok(ImportedYamlPipelineStep::Navigate(yaml_value_to_string( + raw_value, key, + )?)), + "evaluate" => Ok(ImportedYamlPipelineStep::Evaluate(yaml_value_to_string( + raw_value, key, + )?)), + "map" => Ok(ImportedYamlPipelineStep::Map(parse_map_step(raw_value)?)), + "filter" => Ok(ImportedYamlPipelineStep::Filter(raw_value.clone())), + "limit" => Ok(ImportedYamlPipelineStep::Limit(raw_value.clone())), + "sort" => parse_sort_step(raw_value), + unsupported => Err(format!( + "当前 Lime 外部适配器导入层暂不支持 pipeline step: {unsupported}" + )), + } +} + +fn parse_map_step(value: &YamlValue) -> Result, String> { + let Some(mapping) = value.as_mapping() else { + return Err("导入型 YAML map step 必须是对象".to_string()); + }; + + let mut result = BTreeMap::new(); + for (raw_key, raw_value) in mapping { + let Some(key) = raw_key.as_str() else { + return Err("导入型 YAML map 字段名必须是字符串".to_string()); + }; + result.insert(key.trim().to_string(), raw_value.clone()); + } + Ok(result) +} + +fn parse_sort_step(value: &YamlValue) -> Result { + let Some(mapping) = value.as_mapping() else { + return Err("导入型 YAML sort step 必须是对象".to_string()); + }; + let by = mapping + .get(&YamlValue::String("by".to_string())) + .cloned() + .ok_or_else(|| "导入型 YAML sort step 缺少 by".to_string())?; + let order = match mapping + .get(&YamlValue::String("order".to_string())) + .and_then(YamlValue::as_str) + .unwrap_or("asc") + .trim() + { + "asc" => ImportedYamlSortOrder::Asc, + "desc" => ImportedYamlSortOrder::Desc, + other => { + return Err(format!( + "导入型 YAML sort.order 仅支持 asc / desc,当前为 {other}" + )) + } + }; + + Ok(ImportedYamlPipelineStep::Sort { by, order }) +} + +fn compile_pipeline_entry( + domain: &str, + steps: &[ImportedYamlPipelineStep], +) -> Result { + let navigate = steps.iter().find_map(|step| match step { + ImportedYamlPipelineStep::Navigate(url) => Some(url.as_str()), + _ => None, + }); + + if let Some(url) = navigate { + compile_navigate_entry(url) + } else { + Ok(CompiledImportedSiteAdapterEntry::FixedUrl { + url: default_domain_entry_url(domain), + }) + } +} + +fn compile_navigate_entry(url: &str) -> Result { + let trimmed = normalize_required_text(url, "pipeline.navigate")?; + if !trimmed.contains("${{") { + return Ok(CompiledImportedSiteAdapterEntry::FixedUrl { url: trimmed }); + } + + let mut unsupported_expr = None::; + let converted = + IMPORTED_TEMPLATE_TOKEN_REGEX.replace_all(&trimmed, |captures: ®ex::Captures<'_>| { + let expr = captures + .get(1) + .map(|value| value.as_str()) + .unwrap_or_default() + .trim(); + let Some(arg_capture) = IMPORTED_ARGS_ENTRY_EXPR_REGEX.captures(expr) else { + unsupported_expr = Some(expr.to_string()); + return String::new(); + }; + + let arg_name = arg_capture + .get(1) + .map(|value| value.as_str()) + .unwrap_or_default(); + let filter = arg_capture.get(2).map(|value| value.as_str()); + match filter { + Some("urlencode") => format!("{{{{{arg_name}|urlencode}}}}"), + _ => format!("{{{{{arg_name}}}}}"), + } + }); + if let Some(expr) = unsupported_expr { + return Err(format!( + "当前仅支持 args.* 形式的 navigate 模板,暂不支持: {expr}" + )); + } + + Ok(CompiledImportedSiteAdapterEntry::UrlTemplate { + template: converted.into_owned(), + }) +} + +fn compile_pipeline_script( + adapter_name: &str, + columns: &[String], + steps: &[ImportedYamlPipelineStep], +) -> Result { + if steps.is_empty() { + return Err(format!("来源适配器 {adapter_name} 缺少 pipeline")); + } + + let mut statements = Vec::new(); + let mut saw_evaluate = false; + let mut saw_non_navigate = false; + for (index, step) in steps.iter().enumerate() { + match step { + ImportedYamlPipelineStep::Navigate(_) => { + if index != 0 || saw_non_navigate { + return Err(format!("来源适配器 {adapter_name} 仅支持第一步为 navigate")); + } + } + ImportedYamlPipelineStep::Evaluate(source) => { + saw_non_navigate = true; + saw_evaluate = true; + statements.push(build_evaluate_statement(index, source)?); + } + ImportedYamlPipelineStep::Map(fields) => { + saw_non_navigate = true; + statements.push(build_map_statement(index, fields)?); + } + ImportedYamlPipelineStep::Filter(condition) => { + saw_non_navigate = true; + statements.push(build_filter_statement(index, condition)?); + } + ImportedYamlPipelineStep::Limit(limit) => { + saw_non_navigate = true; + statements.push(build_limit_statement(index, limit)?); + } + ImportedYamlPipelineStep::Sort { by, order } => { + saw_non_navigate = true; + statements.push(build_sort_statement(index, by, *order)?); + } + } + } + + if !saw_evaluate { + return Err(format!( + "来源适配器 {adapter_name} 当前至少需要一个 evaluate step 才能导入" + )); + } + + let columns_literal = serde_json::to_string(columns) + .map_err(|error| format!("编码来源适配器 columns 失败: {error}"))?; + + Ok(format!( + r#"async (args, helpers) => {{ +{runtime} + let __state = null; +{statements} + const __data = Array.isArray(__state) + ? {{ + items: __state, + count: __state.length, + columns: {columns_literal}, + }} + : __state; + return {{ + ok: true, + data: __data, + source_url: location.href, + }}; +}}"#, + runtime = IMPORTER_RUNTIME_SCRIPT, + statements = indent_lines(&statements.join("\n"), 2), + columns_literal = columns_literal, + )) +} + +fn build_evaluate_statement(index: usize, source: &str) -> Result { + let source_literal = serde_json::to_string(source) + .map_err(|error| format!("编码 evaluate step 失败: {error}"))?; + Ok(format!( + r#"const __ctx_{index} = __lime.makeContext(__state, null, 0); +const __eval_source_{index} = __lime.interpolate({source_literal}, __ctx_{index}); +__state = await (0, eval)(__eval_source_{index});"#, + index = index, + source_literal = source_literal, + )) +} + +fn build_map_statement( + index: usize, + fields: &BTreeMap, +) -> Result { + let mut compiled_fields = BTreeMap::new(); + for (key, value) in fields { + compiled_fields.insert(key.clone(), compile_value_resolver_config(value)?); + } + let config_literal = serde_json::to_string(&compiled_fields) + .map_err(|error| format!("编码 map step 失败: {error}"))?; + + Ok(format!( + r#"const __map_config_{index} = {config_literal}; +const __map_items_{index} = Array.isArray(__state) ? __state : []; +__state = __map_items_{index}.map((item, index) => {{ + const __ctx = __lime.makeContext(__map_items_{index}, item, index); + const __next = {{}}; + for (const [field, config] of Object.entries(__map_config_{index})) {{ + __next[field] = __lime.resolve(config, __ctx); + }} + return __next; +}});"#, + index = index, + config_literal = config_literal, + )) +} + +fn build_filter_statement(index: usize, condition: &YamlValue) -> Result { + let config = compile_value_resolver_config(condition)?; + let config_literal = serde_json::to_string(&config) + .map_err(|error| format!("编码 filter step 失败: {error}"))?; + + Ok(format!( + r#"const __filter_config_{index} = {config_literal}; +const __filter_items_{index} = Array.isArray(__state) ? __state : []; +__state = __filter_items_{index}.filter((item, index) => {{ + const __ctx = __lime.makeContext(__filter_items_{index}, item, index); + return Boolean(__lime.resolve(__filter_config_{index}, __ctx)); +}});"#, + index = index, + config_literal = config_literal, + )) +} + +fn build_limit_statement(index: usize, limit: &YamlValue) -> Result { + let config = compile_value_resolver_config(limit)?; + let config_literal = + serde_json::to_string(&config).map_err(|error| format!("编码 limit step 失败: {error}"))?; + + Ok(format!( + r#"const __limit_config_{index} = {config_literal}; +const __limit_items_{index} = Array.isArray(__state) ? __state : []; +const __limit_ctx_{index} = __lime.makeContext(__limit_items_{index}, null, 0); +const __limit_raw_{index} = __lime.resolve(__limit_config_{index}, __limit_ctx_{index}); +const __limit_value_{index} = Number(__limit_raw_{index}); +if (Array.isArray(__state) && Number.isFinite(__limit_value_{index})) {{ + __state = __limit_items_{index}.slice(0, Math.max(0, __limit_value_{index})); +}}"#, + index = index, + config_literal = config_literal, + )) +} + +fn build_sort_statement( + index: usize, + by: &YamlValue, + order: ImportedYamlSortOrder, +) -> Result { + let config = compile_sort_resolver_config(by)?; + let order_literal = match order { + ImportedYamlSortOrder::Asc => "asc", + ImportedYamlSortOrder::Desc => "desc", + }; + let sort_literal = serde_json::to_string(&serde_json::json!({ + "by": config, + "order": order_literal, + })) + .map_err(|error| format!("编码 sort step 失败: {error}"))?; + + Ok(format!( + r#"const __sort_config_{index} = {sort_literal}; +const __sort_items_{index} = Array.isArray(__state) ? __state : []; +__state = __lime.sortItems(__sort_items_{index}, __sort_config_{index});"#, + index = index, + sort_literal = sort_literal, + )) +} + +fn compile_sort_resolver_config(value: &YamlValue) -> Result { + if let Some(raw) = value.as_str() { + let trimmed = raw.trim(); + if is_imported_expr_wrapper(trimmed) || trimmed.contains("${{") { + return compile_value_resolver_config(value); + } + if is_simple_identifier(trimmed) { + return Ok(JsValueResolverConfig::Expr { + source: format!( + "item?.[{}]", + serde_json::to_string(trimmed).unwrap_or_default() + ), + }); + } + } + compile_value_resolver_config(value) +} + +fn compile_value_resolver_config(value: &YamlValue) -> Result { + if let Some(raw) = value.as_str() { + let trimmed = raw.trim(); + if is_imported_expr_wrapper(trimmed) { + return Ok(JsValueResolverConfig::Expr { + source: strip_imported_expr_wrapper(trimmed).to_string(), + }); + } + if trimmed.contains("${{") { + return Ok(JsValueResolverConfig::Template { + source: trimmed.to_string(), + }); + } + return Ok(JsValueResolverConfig::Literal { + value: Value::String(trimmed.to_string()), + }); + } + + Ok(JsValueResolverConfig::Literal { + value: yaml_to_json_value(value)?, + }) +} + +fn derive_capabilities(adapter_name: &str) -> Vec { + let Some(command_name) = adapter_name.split('/').next_back() else { + return vec!["research".to_string()]; + }; + + let mut capabilities = BTreeSet::new(); + capabilities.insert("research".to_string()); + for token in command_name + .split(|ch: char| matches!(ch, '-' | '_' | '/' | ' ')) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + match token { + "search" | "hot" | "top" | "best" | "new" | "latest" | "feed" | "quote" | "issues" + | "issue" | "question" | "topic" | "topics" | "profile" | "user" | "read" + | "ranking" | "news" | "nodes" | "categories" | "category" => { + capabilities.insert(token.to_string()); + } + _ => {} + } + } + capabilities.into_iter().collect() +} + +fn derive_auth_hint(strategy: Option<&str>, browser: bool) -> Option { + let normalized_strategy = strategy + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default(); + if browser || matches!(normalized_strategy, "cookie" | "intercept") { + Some("该适配器依赖已有浏览器上下文,必要时请先在目标站点完成登录。".to_string()) + } else { + None + } +} + +fn build_example(adapter_name: &str, args: &[CompiledImportedSiteAdapterArg]) -> String { + let mut payload = Map::new(); + for arg in args { + let value = arg + .example + .clone() + .unwrap_or_else(|| match arg.arg_type.as_str() { + "integer" => Value::Number(Number::from(1)), + _ => Value::String("示例".to_string()), + }); + payload.insert(arg.name.clone(), value); + } + format!("{adapter_name} {}", Value::Object(payload)) +} + +fn yaml_value_to_string(value: &YamlValue, field: &str) -> Result { + value + .as_str() + .map(str::trim) + .filter(|raw| !raw.is_empty()) + .map(ToString::to_string) + .ok_or_else(|| format!("导入型 YAML {field} 必须是非空字符串")) +} + +fn yaml_to_json_value(value: &YamlValue) -> Result { + match value { + YamlValue::Null => Ok(Value::Null), + YamlValue::Bool(raw) => Ok(Value::Bool(*raw)), + YamlValue::Number(raw) => { + if let Some(number) = raw.as_i64() { + Ok(Value::Number(Number::from(number))) + } else if let Some(number) = raw.as_u64() { + Ok(Value::Number(Number::from(number))) + } else if let Some(number) = raw.as_f64() { + Number::from_f64(number) + .map(Value::Number) + .ok_or_else(|| "导入型 YAML 浮点参数超出范围".to_string()) + } else { + Err("无法解析导入型 YAML number".to_string()) + } + } + YamlValue::String(raw) => Ok(Value::String(raw.clone())), + YamlValue::Sequence(items) => items + .iter() + .map(yaml_to_json_value) + .collect::, _>>() + .map(Value::Array), + YamlValue::Mapping(mapping) => { + let mut object = Map::new(); + for (raw_key, raw_value) in mapping { + let Some(key) = raw_key.as_str() else { + return Err("导入型 YAML object key 必须是字符串".to_string()); + }; + object.insert(key.to_string(), yaml_to_json_value(raw_value)?); + } + Ok(Value::Object(object)) + } + YamlValue::Tagged(tagged) => yaml_to_json_value(&tagged.value), + } +} + +fn default_arg_example(arg_type: &str) -> Option { + match arg_type { + "integer" => Some(Value::Number(Number::from(5))), + "string" => Some(Value::String("示例".to_string())), + _ => None, + } +} + +fn default_domain_entry_url(domain: &str) -> String { + if domain.starts_with("http://") || domain.starts_with("https://") { + domain.to_string() + } else { + format!("https://{domain}") + } +} + +fn is_imported_expr_wrapper(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.starts_with("${{") && trimmed.ends_with("}}") +} + +fn strip_imported_expr_wrapper(value: &str) -> &str { + let trimmed = value.trim(); + trimmed + .strip_prefix("${{") + .and_then(|raw| raw.strip_suffix("}}")) + .map(str::trim) + .unwrap_or(trimmed) +} + +fn is_simple_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_') { + return false; + } + chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') +} + +fn build_imported_script_file(adapter_name: &str) -> String { + format!("scripts/{}.js", sanitize_path_segment(adapter_name)) +} + +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 indent_lines(source: &str, spaces: usize) -> String { + let prefix = " ".repeat(spaces); + source + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!("{prefix}{line}") + } + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + const REAL_WORLD_IMPORTED_ADAPTER_BUNDLE_FIXTURE: &str = + include_str!("../../tests/fixtures/site-adapters/imported-real-world-bundle.yaml"); + + #[test] + fn should_compile_imported_yaml_reddit_hot_adapter_into_lime_script() { + let adapter = compile_imported_yaml_adapter( + r#" +site: reddit +name: hot +description: Reddit 热门帖子 +domain: www.reddit.com +args: + subreddit: + type: str + default: "" + description: Subreddit name + limit: + type: int + default: 20 + description: Number of posts +pipeline: + - navigate: https://www.reddit.com + - evaluate: | + (async () => { + const sub = ${{ args.subreddit | json }}; + const path = sub ? '/r/' + sub + '/hot.json' : '/hot.json'; + const limit = ${{ args.limit }}; + const res = await fetch(path + '?limit=' + limit, { credentials: 'include' }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title, + subreddit: c.data.subreddit_name_prefixed, + score: c.data.score, + comments: c.data.num_comments, + })); + })() + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + subreddit: ${{ item.subreddit }} + score: ${{ item.score }} + - limit: ${{ args.limit }} +columns: [rank, title, subreddit, score] +"#, + &ImportedYamlCompileOptions { + read_only: true, + source_version: Some("imported-test".to_string()), + }, + ) + .expect("adapter should compile"); + + assert_eq!(adapter.name, "reddit/hot"); + assert!(matches!( + adapter.entry, + CompiledImportedSiteAdapterEntry::FixedUrl { ref url } + if url == "https://www.reddit.com" + )); + assert_eq!(adapter.source_version.as_deref(), Some("imported-test")); + assert!(adapter.capabilities.contains(&"hot".to_string())); + assert!(adapter.script.contains("__lime")); + assert!(adapter.script.contains("columns")); + } + + #[test] + fn should_convert_imported_yaml_navigate_template_to_lime_url_template() { + let adapter = compile_imported_yaml_adapter( + r#" +site: yahoo-finance +name: quote +description: Yahoo quote +domain: finance.yahoo.com +args: + symbol: + type: str + description: 股票代码 +pipeline: + - navigate: https://finance.yahoo.com/quote/${{ args.symbol | urlencode }}/ + - evaluate: | + (() => ({ title: document.title }))() +"#, + &ImportedYamlCompileOptions { + read_only: true, + source_version: None, + }, + ) + .expect("adapter should compile"); + + assert!(matches!( + adapter.entry, + CompiledImportedSiteAdapterEntry::UrlTemplate { ref template } + if template == "https://finance.yahoo.com/quote/{{symbol|urlencode}}/" + )); + } + + #[test] + fn should_reject_unsupported_imported_yaml_pipeline_step() { + let error = compile_imported_yaml_adapter( + r#" +site: xiaohongshu +name: feed +description: 小红书 feed +domain: www.xiaohongshu.com +pipeline: + - navigate: https://www.xiaohongshu.com/explore + - tap: + store: feed + action: fetchFeeds +"#, + &ImportedYamlCompileOptions { + read_only: true, + source_version: None, + }, + ) + .expect_err("tap should be rejected"); + + assert!(error.contains("tap")); + } + + #[test] + fn should_compile_imported_yaml_bundle_into_multiple_adapters() { + let adapters = compile_imported_yaml_adapter_bundle( + r#" +site: reddit +name: hot +description: Reddit 热门 +domain: www.reddit.com +pipeline: + - navigate: https://www.reddit.com + - evaluate: | + (() => [])() +--- +site: zhihu +name: hot +description: 知乎热榜 +domain: www.zhihu.com +pipeline: + - navigate: https://www.zhihu.com + - evaluate: | + (() => [])() +"#, + &ImportedYamlCompileOptions { + read_only: true, + source_version: Some("bundle-test".to_string()), + }, + ) + .expect("bundle should compile"); + + assert_eq!(adapters.len(), 2); + assert_eq!(adapters[0].name, "reddit/hot"); + assert_eq!(adapters[1].name, "zhihu/hot"); + assert_eq!(adapters[0].source_version.as_deref(), Some("bundle-test")); + assert_eq!(adapters[1].source_version.as_deref(), Some("bundle-test")); + } + + #[test] + fn should_compile_real_world_imported_yaml_bundle_fixture() { + let adapters = compile_imported_yaml_adapter_bundle( + REAL_WORLD_IMPORTED_ADAPTER_BUNDLE_FIXTURE, + &ImportedYamlCompileOptions { + read_only: true, + source_version: Some("fixture-real-world".to_string()), + }, + ) + .expect("real world bundle should compile"); + + let adapter_names = adapters + .iter() + .map(|adapter| adapter.name.as_str()) + .collect::>(); + assert_eq!( + adapter_names, + vec![ + "zhihu/hot", + "zhihu/search", + "linux-do/hot", + "yahoo-finance/quote", + "smzdm/search", + ] + ); + + let yahoo_quote = adapters + .iter() + .find(|adapter| adapter.name == "yahoo-finance/quote") + .expect("yahoo-finance/quote should exist"); + assert_eq!( + yahoo_quote.args.first().map(|arg| arg.arg_type.as_str()), + Some("string") + ); + assert_eq!( + yahoo_quote.source_version.as_deref(), + Some("fixture-real-world") + ); + assert!(matches!( + yahoo_quote.entry, + CompiledImportedSiteAdapterEntry::UrlTemplate { ref template } + if template == "https://finance.yahoo.com/quote/{{symbol|urlencode}}/" + )); + assert_eq!( + yahoo_quote.auth_hint.as_deref(), + Some("该适配器依赖已有浏览器上下文,必要时请先在目标站点完成登录。") + ); + + let smzdm_search = adapters + .iter() + .find(|adapter| adapter.name == "smzdm/search") + .expect("smzdm/search should exist"); + assert!(matches!( + smzdm_search.entry, + CompiledImportedSiteAdapterEntry::UrlTemplate { ref template } + if template == "https://search.smzdm.com/?c=home&s={{query|urlencode}}&v=b" + )); + assert!(smzdm_search.capabilities.contains(&"search".to_string())); + } + + #[test] + fn should_persist_compiled_imported_catalog() { + let temp_dir = tempdir().expect("temp dir should exist"); + let adapter = compile_imported_yaml_adapter( + r#" +site: zhihu +name: hot +description: 知乎热榜 +domain: www.zhihu.com +args: + limit: + type: int + default: 5 + description: 数量 +pipeline: + - navigate: https://www.zhihu.com + - evaluate: | + (() => ([{ title: "A" }, { title: "B" }]))() + - map: + title: ${{ item.title }} + - limit: ${{ args.limit }} +"#, + &ImportedYamlCompileOptions { + read_only: true, + source_version: Some("imported-test".to_string()), + }, + ) + .expect("adapter should compile"); + + let result = persist_compiled_imported_adapters( + temp_dir.path(), + &[adapter], + Some("imported-catalog-test".to_string()), + ) + .expect("catalog should persist"); + + assert_eq!(result.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\": \"imported-catalog-test\"")); + assert!(index_content.contains("\"name\": \"zhihu/hot\"")); + let script_content = fs::read_to_string(temp_dir.path().join("scripts/zhihu-hot.js")) + .expect("script should exist"); + assert!(script_content.contains("__lime")); + } +} diff --git a/src-tauri/src/services/site_adapter_registry.rs b/src-tauri/src/services/site_adapter_registry.rs index 34bdc5895..a020a7c75 100644 --- a/src-tauri/src/services/site_adapter_registry.rs +++ b/src-tauri/src/services/site_adapter_registry.rs @@ -7,6 +7,7 @@ use std::fs; use std::path::{Path, PathBuf}; const BUNDLED_ADAPTER_RELATIVE_DIR: &str = "resources/site-adapters/bundled"; +const IMPORTED_ADAPTER_RELATIVE_DIR: &str = "site-adapters/imported"; const SERVER_SYNCED_ADAPTER_RELATIVE_DIR: &str = "site-adapters/server-synced"; const BUNDLED_INDEX_FALLBACK: &str = include_str!("../../resources/site-adapters/bundled/index.json"); @@ -35,6 +36,7 @@ impl SiteAdapterArgType { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SiteAdapterSourceKind { Bundled, + Imported, ServerSynced, } @@ -42,6 +44,7 @@ impl SiteAdapterSourceKind { pub fn as_str(self) -> &'static str { match self { Self::Bundled => "bundled", + Self::Imported => "imported", Self::ServerSynced => "server_synced", } } @@ -81,7 +84,7 @@ pub struct SiteAdapterSpec { #[derive(Debug, Deserialize, Serialize)] struct SiteAdapterRegistryDocument { - #[serde(default = "default_registry_version")] + #[serde(default = "default_registry_version", alias = "registryVersion")] registry_version: u32, #[serde(default, alias = "catalogVersion", alias = "version")] catalog_version: Option, @@ -118,6 +121,7 @@ struct SiteAdapterArgManifest { name: String, description: String, required: bool, + #[serde(alias = "argType")] arg_type: SiteAdapterArgTypeManifest, #[serde(default)] example: Option, @@ -175,7 +179,7 @@ pub struct SiteAdapterCatalogStatus { #[derive(Debug, Deserialize)] struct SiteAdapterCatalogBootstrapDocument { - #[serde(default = "default_registry_version")] + #[serde(default = "default_registry_version", alias = "registryVersion")] registry_version: u32, #[serde(default, alias = "catalogVersion", alias = "version")] catalog_version: Option, @@ -203,7 +207,8 @@ struct SiteAdapterCatalogBootstrapEntry { entry: SiteAdapterEntryManifest, #[serde(default, alias = "sourceVersion")] source_version: Option, - script: String, + #[serde(default)] + script: Option, } pub fn normalize_site_adapter_name(value: &str) -> String { @@ -212,6 +217,9 @@ pub fn normalize_site_adapter_name(value: &str) -> String { pub fn load_site_adapter_specs() -> Result, String> { let mut merged = BTreeMap::new(); + for spec in load_imported_site_adapters()? { + merged.insert(normalize_site_adapter_name(&spec.name), spec); + } for spec in load_bundled_site_adapters()? { merged.insert(normalize_site_adapter_name(&spec.name), spec); } @@ -230,7 +238,26 @@ pub fn find_site_adapter_spec(name: &str) -> Result, Str } pub fn get_site_adapter_catalog_status() -> Result { - get_site_adapter_catalog_status_from_dir(resolve_server_synced_adapter_dir()) + let server_synced = get_site_adapter_catalog_status_from_dir( + resolve_server_synced_adapter_dir(), + SiteAdapterSourceKind::ServerSynced, + )?; + if server_synced.exists { + return Ok(server_synced); + } + + let imported = get_site_adapter_catalog_status_from_dir( + resolve_imported_adapter_dir(), + SiteAdapterSourceKind::Imported, + )?; + if imported.exists { + return Ok(imported); + } + + Ok(empty_site_adapter_catalog_status( + SiteAdapterSourceKind::Bundled, + None, + )) } pub fn apply_site_adapter_catalog_bootstrap( @@ -243,7 +270,9 @@ pub fn apply_site_adapter_catalog_bootstrap( } pub fn clear_site_adapter_catalog_cache() -> Result { - clear_site_adapter_catalog_cache_at_dir(resolve_server_synced_adapter_dir()) + clear_site_adapter_catalog_cache_at_dir(resolve_server_synced_adapter_dir())?; + clear_site_adapter_catalog_cache_at_dir(resolve_imported_adapter_dir())?; + get_site_adapter_catalog_status() } pub fn build_entry_url( @@ -275,35 +304,27 @@ fn load_server_synced_site_adapters() -> Result, String> { load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::ServerSynced) } +fn load_imported_site_adapters() -> Result, String> { + let Some(dir) = resolve_imported_adapter_dir() else { + return Ok(Vec::new()); + }; + if !dir.exists() { + return Ok(Vec::new()); + } + load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::Imported) +} + fn get_site_adapter_catalog_status_from_dir( dir: Option, + source_kind: SiteAdapterSourceKind, ) -> 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, - }); + return Ok(empty_site_adapter_catalog_status(source_kind, None)); }; 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, - }); + return Ok(empty_site_adapter_catalog_status(source_kind, Some(dir))); } let content = fs::read_to_string(&index_path) @@ -313,9 +334,9 @@ fn get_site_adapter_catalog_status_from_dir( Ok(SiteAdapterCatalogStatus { exists: true, - source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(), + source_kind: source_kind.as_str().to_string(), registry_version: document.registry_version, - directory, + directory: Some(dir.display().to_string()), catalog_version: document.catalog_version, tenant_id: document.tenant_id, synced_at: document.synced_at, @@ -331,7 +352,10 @@ fn apply_site_adapter_catalog_bootstrap_to_dir( .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())) + get_site_adapter_catalog_status_from_dir( + Some(dir.to_path_buf()), + SiteAdapterSourceKind::ServerSynced, + ) } fn clear_site_adapter_catalog_cache_at_dir( @@ -356,7 +380,23 @@ fn clear_site_adapter_catalog_cache_at_dir( .map_err(|error| format!("清理站点适配器缓存失败 {}: {error}", dir.display()))?; } - get_site_adapter_catalog_status_from_dir(Some(dir)) + get_site_adapter_catalog_status_from_dir(Some(dir), SiteAdapterSourceKind::ServerSynced) +} + +fn empty_site_adapter_catalog_status( + source_kind: SiteAdapterSourceKind, + dir: Option, +) -> SiteAdapterCatalogStatus { + SiteAdapterCatalogStatus { + exists: false, + source_kind: source_kind.as_str().to_string(), + registry_version: default_registry_version(), + directory: dir.map(|value| value.display().to_string()), + catalog_version: None, + tenant_id: None, + synced_at: None, + adapter_count: 0, + } } fn load_site_adapters_from_dir( @@ -524,7 +564,7 @@ fn write_server_synced_catalog_to_dir( return Err(format!("站点适配器重复: {}", entry.name)); } - let script = normalize_required_text(&entry.script, "script")?; + let script = resolve_server_synced_entry_script(&entry)?; let script_file = build_server_synced_script_file(&entry.name); let script_path = dir.join(&script_file); if let Some(parent) = script_path.parent() { @@ -569,6 +609,39 @@ fn write_server_synced_catalog_to_dir( .map_err(|error| format!("写入站点适配器索引失败 {}: {error}", index_path.display())) } +fn resolve_server_synced_entry_script( + entry: &SiteAdapterCatalogBootstrapEntry, +) -> Result { + if let Some(script) = entry + .script + .as_ref() + .and_then(|value| normalize_optional_text(Some(value.clone()))) + { + return Ok(script); + } + + let bundled_manifest = find_embedded_bundled_manifest_entry(&entry.name)?.ok_or_else(|| { + format!( + "站点适配器 {} 缺少 script,且未命中 bundled 回退", + entry.name + ) + })?; + load_embedded_bundled_script(&bundled_manifest.script_file).map(|value| value.to_string()) +} + +fn find_embedded_bundled_manifest_entry( + adapter_name: &str, +) -> Result, String> { + let document: SiteAdapterRegistryDocument = serde_json::from_str(BUNDLED_INDEX_FALLBACK) + .map_err(|error| format!("解析内置站点适配器索引失败: {error}"))?; + let normalized_name = normalize_site_adapter_name(adapter_name); + + Ok(document + .adapters + .into_iter() + .find(|entry| normalize_site_adapter_name(&entry.name) == normalized_name)) +} + fn load_embedded_bundled_script(script_file: &str) -> Result<&'static str, String> { match script_file { "scripts/36kr-newsflash.js" => Ok(include_str!( @@ -583,6 +656,18 @@ fn load_embedded_bundled_script(script_file: &str) -> Result<&'static str, Strin "scripts/github-search.js" => Ok(include_str!( "../../resources/site-adapters/bundled/scripts/github-search.js" )), + "scripts/linux-do-categories.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/linux-do-categories.js" + )), + "scripts/linux-do-hot.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/linux-do-hot.js" + )), + "scripts/smzdm-search.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/smzdm-search.js" + )), + "scripts/yahoo-finance-quote.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/yahoo-finance-quote.js" + )), "scripts/zhihu-hot.js" => Ok(include_str!( "../../resources/site-adapters/bundled/scripts/zhihu-hot.js" )), @@ -610,6 +695,12 @@ fn resolve_server_synced_adapter_dir() -> Option { .map(|root| root.join(SERVER_SYNCED_ADAPTER_RELATIVE_DIR)) } +pub(crate) fn resolve_imported_adapter_dir() -> Option { + lime_core::app_paths::preferred_data_dir() + .ok() + .map(|root| root.join(IMPORTED_ADAPTER_RELATIVE_DIR)) +} + fn resolve_packaged_resource_root() -> Option { let mut path = std::env::current_exe().ok()?; path.pop(); @@ -784,8 +875,15 @@ fn default_registry_version() -> u32 { #[cfg(test)] mod tests { use super::*; + use crate::services::site_adapter_import_service::{ + compile_imported_yaml_adapter_bundle, persist_compiled_imported_adapters, + ImportedYamlCompileOptions, + }; use tempfile::tempdir; + const REAL_WORLD_IMPORTED_ADAPTER_BUNDLE_FIXTURE: &str = + include_str!("../../tests/fixtures/site-adapters/imported-real-world-bundle.yaml"); + #[test] fn should_load_bundled_registry_from_resources() { let adapters = load_bundled_site_adapters().expect("bundled adapters should load"); @@ -801,6 +899,72 @@ mod tests { assert!(github.script.contains("a.v-align-middle")); } + #[test] + fn should_load_selected_bundled_market_finance_and_community_adapters_from_embedded_index() { + let adapters = load_site_adapters_from_embedded_index(SiteAdapterSourceKind::Bundled) + .expect("embedded bundled adapters should load"); + + let linux_do_hot = adapters + .iter() + .find(|adapter| adapter.name == "linux-do/hot") + .expect("linux-do/hot should exist"); + assert_eq!(linux_do_hot.source_kind, SiteAdapterSourceKind::Bundled); + assert_eq!(linux_do_hot.source_version.as_deref(), Some("2026-03-28")); + assert!(matches!( + linux_do_hot.entry, + SiteAdapterEntrySpec::FixedUrl { ref url } if url == "https://linux.do" + )); + assert_eq!( + linux_do_hot.auth_hint.as_deref(), + Some("请先在浏览器中登录 linux.do,再重试该命令。") + ); + assert!(linux_do_hot.script.contains("/top.json?period=")); + + let linux_do_categories = adapters + .iter() + .find(|adapter| adapter.name == "linux-do/categories") + .expect("linux-do/categories should exist"); + assert_eq!( + linux_do_categories.source_kind, + SiteAdapterSourceKind::Bundled + ); + assert_eq!( + linux_do_categories.source_version.as_deref(), + Some("2026-03-28") + ); + assert!(matches!( + linux_do_categories.entry, + SiteAdapterEntrySpec::FixedUrl { ref url } if url == "https://linux.do" + )); + assert!(linux_do_categories.script.contains("/categories.json")); + + let yahoo = adapters + .iter() + .find(|adapter| adapter.name == "yahoo-finance/quote") + .expect("yahoo-finance/quote should exist"); + assert_eq!(yahoo.source_kind, SiteAdapterSourceKind::Bundled); + assert_eq!(yahoo.source_version.as_deref(), Some("2026-03-28")); + assert!(matches!( + yahoo.entry, + SiteAdapterEntrySpec::UrlTemplate { ref template } + if template == "https://finance.yahoo.com/quote/{{symbol|urlencode}}/" + )); + assert!(yahoo.script.contains("query1.finance.yahoo.com")); + + let smzdm = adapters + .iter() + .find(|adapter| adapter.name == "smzdm/search") + .expect("smzdm/search should exist"); + assert_eq!(smzdm.source_kind, SiteAdapterSourceKind::Bundled); + assert_eq!(smzdm.source_version.as_deref(), Some("2026-03-28")); + assert!(matches!( + smzdm.entry, + SiteAdapterEntrySpec::UrlTemplate { ref template } + if template == "https://search.smzdm.com/?c=home&s={{query|urlencode}}&v=b" + )); + assert!(smzdm.script.contains("li.feed-row-wide")); + } + #[test] fn should_render_url_template_with_urlencode() { let spec = SiteAdapterSpec { @@ -876,6 +1040,114 @@ mod tests { assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1")); } + #[test] + fn should_load_imported_adapters_from_imported_catalog() { + 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#" + { + "catalog_version": "imported-catalog-1", + "adapters": [ + { + "name": "reddit/hot", + "domain": "www.reddit.com", + "description": "imported reddit hot", + "read_only": true, + "capabilities": ["research", "hot"], + "args": [], + "example": "reddit/hot {}", + "entry": { + "kind": "fixed_url", + "url": "https://www.reddit.com" + }, + "script_file": "scripts/reddit-hot.js", + "source_version": "imported-1" + } + ] + } + "#, + ) + .expect("index should write"); + fs::write( + dir.join("scripts/reddit-hot.js"), + "async () => ({ ok: true, data: { items: [] } })", + ) + .expect("script should write"); + + let adapters = load_site_adapters_from_dir(dir, SiteAdapterSourceKind::Imported) + .expect("imported adapters should load"); + assert_eq!(adapters.len(), 1); + assert_eq!(adapters[0].name, "reddit/hot"); + assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::Imported); + assert_eq!(adapters[0].source_version.as_deref(), Some("imported-1")); + } + + #[test] + fn should_load_real_world_imported_bundle_persisted_by_import_service() { + let temp_dir = tempdir().expect("temp dir should exist"); + let adapters = compile_imported_yaml_adapter_bundle( + REAL_WORLD_IMPORTED_ADAPTER_BUNDLE_FIXTURE, + &ImportedYamlCompileOptions { + read_only: true, + source_version: Some("fixture-real-world".to_string()), + }, + ) + .expect("real world bundle should compile"); + + let persist_result = persist_compiled_imported_adapters( + temp_dir.path(), + &adapters, + Some("fixture-imported-catalog".to_string()), + ) + .expect("real world bundle should persist"); + assert_eq!(persist_result.adapter_count, 5); + + let status = get_site_adapter_catalog_status_from_dir( + Some(temp_dir.path().to_path_buf()), + SiteAdapterSourceKind::Imported, + ) + .expect("imported catalog status should load"); + assert!(status.exists); + assert_eq!(status.source_kind, "imported"); + assert_eq!( + status.catalog_version.as_deref(), + Some("fixture-imported-catalog") + ); + assert_eq!(status.adapter_count, 5); + + let loaded = load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::Imported) + .expect("imported adapters should load"); + assert_eq!(loaded.len(), 5); + + let yahoo_quote = loaded + .iter() + .find(|adapter| adapter.name == "yahoo-finance/quote") + .expect("yahoo-finance/quote should exist"); + assert_eq!(yahoo_quote.source_kind, SiteAdapterSourceKind::Imported); + assert_eq!( + yahoo_quote.source_version.as_deref(), + Some("fixture-real-world") + ); + assert!(matches!( + yahoo_quote.entry, + SiteAdapterEntrySpec::UrlTemplate { ref template } + if template == "https://finance.yahoo.com/quote/{{symbol|urlencode}}/" + )); + assert_eq!( + yahoo_quote.auth_hint.as_deref(), + Some("该适配器依赖已有浏览器上下文,必要时请先在目标站点完成登录。") + ); + + let smzdm_search = loaded + .iter() + .find(|adapter| adapter.name == "smzdm/search") + .expect("smzdm/search should exist"); + assert!(smzdm_search.capabilities.contains(&"search".to_string())); + } + #[test] fn should_upgrade_legacy_server_synced_github_search_fixed_url_to_template() { let temp_dir = tempdir().expect("temp dir should exist"); @@ -1018,6 +1290,60 @@ mod tests { assert_eq!(adapters[0].source_version.as_deref(), Some("tenant-sync-1")); } + #[test] + fn should_persist_server_synced_bootstrap_catalog_with_bundled_script_fallback() { + let temp_dir = tempdir().expect("temp dir should exist"); + let payload = serde_json::json!({ + "siteAdapterCatalog": { + "registryVersion": 1, + "catalogVersion": "tenant-sync-2", + "tenantId": "tenant-demo", + "syncedAt": "2026-03-28T10:00:00.000Z", + "adapters": [ + { + "name": "github/search", + "domain": "github.com", + "description": "server synced github search", + "readOnly": true, + "capabilities": ["search"], + "args": [ + { + "name": "query", + "description": "搜索关键词", + "required": true, + "argType": "string", + "example": "lime" + } + ], + "example": "github/search {\"query\":\"lime\"}", + "entry": { + "kind": "url_template", + "template": "https://github.com/search?q={{query|urlencode}}&type=repositories" + }, + "sourceVersion": "tenant-sync-2" + } + ] + } + }); + + let status = apply_site_adapter_catalog_bootstrap_to_dir(temp_dir.path(), &payload) + .expect("bootstrap catalog should persist with bundled fallback"); + assert!(status.exists); + assert_eq!(status.catalog_version.as_deref(), Some("tenant-sync-2")); + + let script_content = fs::read_to_string(temp_dir.path().join("scripts/github-search.js")) + .expect("fallback bundled script should be written"); + assert!(script_content.contains("helpers.uniqueBy")); + + 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-2")); + assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::ServerSynced); + } + #[test] fn should_clear_server_synced_catalog_cache() { let temp_dir = tempdir().expect("temp dir should exist"); diff --git a/src-tauri/src/services/site_capability_service.rs b/src-tauri/src/services/site_capability_service.rs index 3779eaffd..e515082d5 100644 --- a/src-tauri/src/services/site_capability_service.rs +++ b/src-tauri/src/services/site_capability_service.rs @@ -69,6 +69,36 @@ pub struct SiteAdapterRecommendation { pub score: u32, } +#[derive(Debug, Clone, Deserialize)] +pub struct SiteAdapterLaunchReadinessRequest { + pub adapter_name: String, + #[serde(default)] + pub profile_key: Option, + #[serde(default)] + pub target_id: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SiteAdapterLaunchReadinessStatus { + Ready, + RequiresBrowserRuntime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SiteAdapterLaunchReadinessResult { + pub status: SiteAdapterLaunchReadinessStatus, + pub adapter: String, + pub domain: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_id: Option, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub report_hint: Option, +} + #[derive(Debug, Clone, Deserialize)] pub struct RunSiteAdapterRequest { pub adapter_name: String, @@ -86,6 +116,10 @@ pub struct RunSiteAdapterRequest { pub project_id: Option, #[serde(default)] pub save_title: Option, + #[serde(default)] + pub require_attached_session: Option, + #[serde(default)] + pub skill_title: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -174,6 +208,14 @@ struct SiteAdapterRecommendationCandidate { score: u32, } +#[derive(Debug, Clone)] +struct SiteAdapterAttachedLaunchCandidate { + profile_key: String, + target_id: String, + current_url_matches: bool, + saved_existing_session: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SiteAdapterTransportRoute { ManagedCdp, @@ -284,6 +326,23 @@ pub async fn recommend_site_adapters( )) } +pub async fn get_site_adapter_launch_readiness( + db: &DbConnection, + request: SiteAdapterLaunchReadinessRequest, +) -> Result { + let normalized_name = normalize_site_adapter_name(&request.adapter_name); + let spec = find_site_adapter_spec(&normalized_name)? + .ok_or_else(|| "未找到对应的站点适配器".to_string())?; + + resolve_site_adapter_launch_readiness_for_spec( + db, + &spec, + request.profile_key.as_deref(), + request.target_id.as_deref(), + ) + .await +} + pub fn build_site_result_document_title(adapter_name: &str, custom_title: Option<&str>) -> String { let normalized_custom_title = custom_title .map(str::trim) @@ -586,9 +645,54 @@ pub async fn run_site_adapter( } }; - let profile_key = - match resolve_effective_profile_key(db, request.profile_key.as_deref(), &spec.domain).await + let attached_session_readiness = if request.require_attached_session.unwrap_or(false) { + match resolve_site_adapter_launch_readiness_for_spec( + db, + &spec, + request.profile_key.as_deref(), + request.target_id.as_deref(), + ) + .await { + Ok(result) => { + if result.status != SiteAdapterLaunchReadinessStatus::Ready { + return build_error_result( + &spec, + result + .profile_key + .clone() + .unwrap_or_else(|| requested_profile_key.clone()), + None, + result.target_id.clone(), + entry_url, + "attached_session_required", + &result.message, + ); + } + Some(result) + } + Err(error) => { + return build_error_result( + &spec, + requested_profile_key.clone(), + None, + None, + entry_url, + "internal_error", + &error, + ); + } + } + } else { + None + }; + + let resolved_request_profile_key = attached_session_readiness + .as_ref() + .and_then(|result| result.profile_key.as_deref()) + .or(request.profile_key.as_deref()); + let profile_key = + match resolve_effective_profile_key(db, resolved_request_profile_key, &spec.domain).await { Ok(value) => value, Err(error) => { return build_error_result( @@ -617,6 +721,21 @@ pub async fn run_site_adapter( ); } }; + if request.require_attached_session.unwrap_or(false) + && transport_route != SiteAdapterTransportRoute::ExistingSession + { + return build_error_result( + &spec, + profile_key, + None, + attached_session_readiness + .as_ref() + .and_then(|result| result.target_id.clone()), + entry_url, + "attached_session_required", + "当前执行链路没有附着到真实浏览器会话,请先去浏览器工作台连接目标站点后重试。", + ); + } let wrapped_script = match build_wrapped_adapter_script(&spec.script, &args) { Ok(value) => value, @@ -633,13 +752,16 @@ pub async fn run_site_adapter( } }; let timeout_ms = normalize_timeout_ms(request.timeout_ms); + let resolved_target_id = attached_session_readiness + .and_then(|result| result.target_id) + .or_else(|| normalize_requested_target_id(request.target_id.as_deref())); match transport_route { SiteAdapterTransportRoute::ExistingSession => { run_existing_session_adapter( &spec, profile_key, - request.target_id, + resolved_target_id, entry_url, timeout_ms, wrapped_script, @@ -651,7 +773,7 @@ pub async fn run_site_adapter( db, &spec, profile_key, - request.target_id, + resolved_target_id, entry_url, timeout_ms, wrapped_script, @@ -759,6 +881,13 @@ fn normalize_requested_profile_key(profile_key: Option<&str>) -> Option .map(ToString::to_string) } +fn normalize_requested_target_id(target_id: Option<&str>) -> Option { + target_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + fn resolve_requested_profile_key(profile_key: Option<&str>) -> String { normalize_requested_profile_key(profile_key).unwrap_or_else(|| DEFAULT_PROFILE_KEY.to_string()) } @@ -951,6 +1080,199 @@ async fn resolve_effective_profile_key( ) } +fn build_site_adapter_launch_readiness_result( + status: SiteAdapterLaunchReadinessStatus, + spec: &SiteAdapterSpec, + profile_key: Option, + target_id: Option, + message: impl Into, +) -> SiteAdapterLaunchReadinessResult { + let report_hint = match status { + SiteAdapterLaunchReadinessStatus::Ready => None, + SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime => { + build_site_adapter_report_hint("attached_session_required") + } + }; + + SiteAdapterLaunchReadinessResult { + status, + adapter: spec.name.clone(), + domain: spec.domain.clone(), + profile_key, + target_id, + message: message.into(), + report_hint, + } +} + +fn build_site_adapter_attached_session_required_message(spec: &SiteAdapterSpec) -> String { + format!( + "当前没有检测到已附着到真实浏览器的 {} 页面,请先去浏览器工作台连接浏览器并打开目标页面。", + spec.domain + ) +} + +fn build_site_adapter_attached_session_missing_target_message(spec: &SiteAdapterSpec) -> String { + format!( + "已检测到真实浏览器会话,但当前没有命中 {} 的目标标签页;请先打开目标页面后再回到 Claw 执行。", + spec.domain + ) +} + +fn build_site_adapter_attached_session_ready_message(spec: &SiteAdapterSpec) -> String { + format!( + "已检测到 {} 的真实浏览器页面,Claw 可以直接复用当前会话执行。", + spec.domain + ) +} + +async fn resolve_site_adapter_launch_readiness_for_spec( + db: &DbConnection, + spec: &SiteAdapterSpec, + profile_key: Option<&str>, + target_id: Option<&str>, +) -> Result { + let normalized_profile_key = normalize_requested_profile_key(profile_key); + let normalized_target_id = normalize_requested_target_id(target_id); + let profiles = load_active_browser_profiles(db)?; + let status_snapshot = chrome_bridge::chrome_bridge_hub() + .get_status_snapshot() + .await; + + if let Some(requested_profile_key) = normalized_profile_key.clone() { + let transport = load_profile_transport(db, &requested_profile_key)?; + if transport == Some(BrowserProfileTransportKind::ManagedCdp) { + return Ok(build_site_adapter_launch_readiness_result( + SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime, + spec, + Some(requested_profile_key), + normalized_target_id, + "当前资料属于 Lime 托管浏览器,不允许在 Claw 内静默接管执行;请改走浏览器工作台。", + )); + } + + let observer = status_snapshot + .observers + .iter() + .find(|item| item.profile_key == requested_profile_key); + if observer.is_none() { + return Ok(build_site_adapter_launch_readiness_result( + SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime, + spec, + Some(requested_profile_key), + normalized_target_id, + build_site_adapter_attached_session_required_message(spec), + )); + } + + if let Some(explicit_target_id) = normalized_target_id.clone() { + return Ok(build_site_adapter_launch_readiness_result( + SiteAdapterLaunchReadinessStatus::Ready, + spec, + Some(requested_profile_key), + Some(explicit_target_id), + build_site_adapter_attached_session_ready_message(spec), + )); + } + + let tabs = match load_existing_session_tabs(&requested_profile_key).await { + Ok(result) => result, + Err(error) => { + tracing::debug!( + "[site_capability] readiness 读取 existing_session 标签页失败: profile_key={}, error={}", + requested_profile_key, + error + ); + Vec::new() + } + }; + let selected_target = select_existing_session_target(&tabs, &spec.domain).map(|tab| tab.id); + let is_ready = selected_target.is_some(); + + return Ok(build_site_adapter_launch_readiness_result( + if is_ready { + SiteAdapterLaunchReadinessStatus::Ready + } else { + SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime + }, + spec, + Some(requested_profile_key), + selected_target, + if is_ready { + build_site_adapter_attached_session_ready_message(spec) + } else { + build_site_adapter_attached_session_missing_target_message(spec) + }, + )); + } + + let mut attached_candidates = Vec::new(); + for observer in &status_snapshot.observers { + let transport = profiles + .iter() + .find(|profile| profile.profile_key == observer.profile_key) + .map(|profile| profile.transport_kind); + if transport == Some(BrowserProfileTransportKind::ManagedCdp) { + continue; + } + + let tabs = match load_existing_session_tabs(&observer.profile_key).await { + Ok(result) => result, + Err(error) => { + tracing::debug!( + "[site_capability] readiness 读取自动附着标签页失败: profile_key={}, error={}", + observer.profile_key, + error + ); + Vec::new() + } + }; + let Some(selected_target) = select_existing_session_target(&tabs, &spec.domain) else { + continue; + }; + + attached_candidates.push(SiteAdapterAttachedLaunchCandidate { + profile_key: observer.profile_key.clone(), + target_id: selected_target.id, + current_url_matches: observer_matches_site_domain(observer, &spec.domain), + saved_existing_session: transport == Some(BrowserProfileTransportKind::ExistingSession), + }); + } + + if let Some(candidate) = attached_candidates + .into_iter() + .max_by_key(|item| (item.current_url_matches, item.saved_existing_session)) + { + return Ok(build_site_adapter_launch_readiness_result( + SiteAdapterLaunchReadinessStatus::Ready, + spec, + Some(candidate.profile_key), + Some(candidate.target_id), + build_site_adapter_attached_session_ready_message(spec), + )); + } + + let has_attached_observer = status_snapshot.observers.iter().any(|observer| { + profiles + .iter() + .find(|profile| profile.profile_key == observer.profile_key) + .map(|profile| profile.transport_kind != BrowserProfileTransportKind::ManagedCdp) + .unwrap_or(true) + }); + + Ok(build_site_adapter_launch_readiness_result( + SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime, + spec, + None, + None, + if has_attached_observer { + build_site_adapter_attached_session_missing_target_message(spec) + } else { + build_site_adapter_attached_session_required_message(spec) + }, + )) +} + fn resolve_transport_route_from_state( profile_transport: Option, has_attached_observer: bool, @@ -2174,6 +2496,14 @@ fn looks_like_no_matching_context_message(message: &str) -> bool { || normalized.contains("上下文") } +fn looks_like_attached_session_required_message(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("附着") + || normalized.contains("真实浏览器") + || normalized.contains("浏览器工作台") + || normalized.contains("attached session") +} + fn normalize_site_adapter_error_code( error_code: Option<&str>, error_message: Option<&str>, @@ -2193,6 +2523,14 @@ fn normalize_site_adapter_error_code( return Some("auth_required".to_string()); } + if matches!( + normalized_code.as_deref(), + Some("attached_session_required") + ) || looks_like_attached_session_required_message(normalized_message) + { + return Some("attached_session_required".to_string()); + } + if matches!( normalized_code.as_deref(), Some("target_not_found") | Some("no_matching_context") @@ -2225,6 +2563,10 @@ fn looks_like_navigation_timeout_error(error: &str) -> bool { fn build_site_adapter_report_hint(error_code: &str) -> Option { match error_code { + "attached_session_required" => Some( + "Claw 不会在后台偷偷启动浏览器;请先进入浏览器工作台连接真实浏览器并打开目标站点页面,再返回 Claw 重试。" + .to_string(), + ), "auth_required" => Some( "请先确认当前浏览器资料已经登录目标站点,再重试;如果仍失败,请附上当前页面 URL 和登录状态。" .to_string(), @@ -2336,6 +2678,63 @@ mod tests { .is_some()); } + #[test] + fn should_expose_selected_bundled_market_finance_and_community_adapters() { + let linux_do_hot = get_site_adapter("linux-do/hot").expect("linux-do/hot should resolve"); + assert_eq!(linux_do_hot.source_kind.as_deref(), Some("bundled")); + assert_eq!(linux_do_hot.source_version.as_deref(), Some("2026-03-28")); + assert_eq!( + linux_do_hot + .example_args + .get("period") + .and_then(Value::as_str), + Some("weekly") + ); + assert_eq!( + linux_do_hot + .example_args + .get("limit") + .and_then(Value::as_i64), + Some(10) + ); + + let linux_do_categories = + get_site_adapter("linux-do/categories").expect("linux-do/categories should resolve"); + assert_eq!(linux_do_categories.source_kind.as_deref(), Some("bundled")); + assert_eq!( + linux_do_categories.source_version.as_deref(), + Some("2026-03-28") + ); + assert_eq!( + linux_do_categories + .example_args + .get("limit") + .and_then(Value::as_i64), + Some(10) + ); + + let yahoo = + get_site_adapter("yahoo-finance/quote").expect("yahoo-finance/quote should resolve"); + assert_eq!(yahoo.source_kind.as_deref(), Some("bundled")); + assert_eq!(yahoo.source_version.as_deref(), Some("2026-03-28")); + assert_eq!( + yahoo.example_args.get("symbol").and_then(Value::as_str), + Some("AAPL") + ); + + let smzdm = get_site_adapter("smzdm/search").expect("smzdm/search should resolve"); + assert_eq!(smzdm.source_kind.as_deref(), Some("bundled")); + assert_eq!(smzdm.source_version.as_deref(), Some("2026-03-28")); + assert_eq!( + smzdm.example_args.get("query").and_then(Value::as_str), + Some("Mac mini") + ); + assert_eq!( + smzdm.example_args.get("limit").and_then(Value::as_i64), + Some(5) + ); + } + #[test] fn should_search_site_adapters_by_keyword() { let adapters = search_site_adapters("issue"); @@ -2343,6 +2742,25 @@ mod tests { assert_eq!(adapters[0].name, "github/issues"); } + #[test] + fn should_search_selected_bundled_adapters_by_domain_and_capability() { + let community = search_site_adapters("linux.do"); + assert!(community + .iter() + .any(|adapter| adapter.name == "linux-do/hot")); + assert!(community + .iter() + .any(|adapter| adapter.name == "linux-do/categories")); + + let finance = search_site_adapters("finance"); + assert!(finance + .iter() + .any(|adapter| adapter.name == "yahoo-finance/quote")); + + let deals = search_site_adapters("deals"); + assert!(deals.iter().any(|adapter| adapter.name == "smzdm/search")); + } + #[test] fn should_prefer_attached_existing_session_profile_for_matching_site() { let attached_profile_keys = HashSet::from(["research_attach".to_string()]); @@ -2716,6 +3134,81 @@ mod tests { assert!(hint.contains("timeout_ms")); } + #[test] + fn should_build_attached_session_required_report_hint() { + let hint = build_site_adapter_report_hint("attached_session_required") + .expect("attached_session_required 应返回提示"); + assert!(hint.contains("不会在后台偷偷启动浏览器")); + } + + #[tokio::test] + async fn should_report_requires_browser_runtime_when_no_attached_session_exists() { + let db = setup_test_db(); + + let readiness = get_site_adapter_launch_readiness( + &db, + SiteAdapterLaunchReadinessRequest { + adapter_name: "github/search".to_string(), + profile_key: None, + target_id: None, + }, + ) + .await + .expect("readiness should resolve"); + + assert_eq!( + readiness.status, + SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime + ); + assert!(readiness.message.contains("浏览器工作台")); + } + + #[tokio::test] + async fn should_block_managed_profile_when_attached_session_is_required() { + let db = setup_test_db(); + { + let conn = lock_db(&db).expect("lock db should succeed"); + BrowserProfileDao::upsert( + &conn, + &UpsertBrowserProfileInput { + id: None, + profile_key: "managed-github".to_string(), + name: "托管 GitHub".to_string(), + description: Some("托管浏览器".to_string()), + site_scope: Some("github.com".to_string()), + launch_url: Some("https://github.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + profile_dir: "/tmp/managed-github".to_string(), + managed_profile_dir: Some("/tmp/managed-github".to_string()), + }, + ) + .expect("managed 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("managed-github".to_string()), + target_id: None, + timeout_ms: Some(5_000), + content_id: None, + project_id: None, + save_title: None, + require_attached_session: Some(true), + skill_title: Some("GitHub 仓库线索检索".to_string()), + }, + ) + .await; + + assert!(!result.ok); + assert_eq!( + result.error_code.as_deref(), + Some("attached_session_required") + ); + } + #[test] fn should_save_existing_site_result_to_project_as_document() { let db = setup_test_db(); @@ -2741,6 +3234,8 @@ mod tests { content_id: None, project_id: None, save_title: None, + require_attached_session: None, + skill_title: None, }, result: SiteAdapterRunResult { ok: true, @@ -2815,6 +3310,8 @@ mod tests { content_id: None, project_id: None, save_title: None, + require_attached_session: None, + skill_title: None, }, result: SiteAdapterRunResult { ok: false, @@ -2866,6 +3363,8 @@ mod tests { content_id: None, project_id: Some(workspace.id.clone()), save_title: Some("自动保存的 GitHub MCP 搜索结果".to_string()), + require_attached_session: None, + skill_title: None, }; let result = SiteAdapterRunResult { ok: true, @@ -2926,6 +3425,8 @@ mod tests { content_id: None, project_id: Some("project-1".to_string()), save_title: None, + require_attached_session: None, + skill_title: None, }; let result = SiteAdapterRunResult { ok: false, @@ -2995,6 +3496,8 @@ mod tests { content_id: None, project_id: None, save_title: None, + require_attached_session: None, + skill_title: None, }, ) .await; @@ -3055,6 +3558,8 @@ mod tests { content_id: Some(existing.id.clone()), project_id: None, save_title: Some("不会用于当前主稿".to_string()), + require_attached_session: None, + skill_title: None, }, result: SiteAdapterRunResult { ok: true, @@ -3154,6 +3659,8 @@ mod tests { content_id: Some(existing.id.clone()), project_id: None, save_title: Some("不应覆盖当前主稿标题".to_string()), + require_attached_session: None, + skill_title: None, }; let result = SiteAdapterRunResult { ok: true, diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index f3ef9cb02..c99532e1e 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.97.0", + "version": "0.98.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 8c403ac6d..fa3eed971 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.97.0", + "version": "0.98.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tests/fixtures/site-adapters/imported-real-world-bundle.yaml b/src-tauri/tests/fixtures/site-adapters/imported-real-world-bundle.yaml new file mode 100644 index 000000000..4c474aa70 --- /dev/null +++ b/src-tauri/tests/fixtures/site-adapters/imported-real-world-bundle.yaml @@ -0,0 +1,287 @@ +site: zhihu +name: hot +description: 知乎热榜 +domain: www.zhihu.com + +args: + limit: + type: int + default: 20 + description: Number of items to return + +pipeline: + - navigate: https://www.zhihu.com + + - evaluate: | + (async () => { + const res = await fetch('https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', { + credentials: 'include' + }); + const text = await res.text(); + const d = JSON.parse( + text.replace(/("id"\s*:\s*)(\d{16,})/g, '$1"$2"') + ); + return (d?.data || []).map((item) => { + const t = item.target || {}; + const questionId = t.id == null ? '' : String(t.id); + return { + title: t.title, + url: 'https://www.zhihu.com/question/' + questionId, + answer_count: t.answer_count, + follower_count: t.follower_count, + heat: item.detail_text || '', + }; + }); + })() + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + heat: ${{ item.heat }} + answers: ${{ item.answer_count }} + url: ${{ item.url }} + + - limit: ${{ args.limit }} + +columns: [rank, title, heat, answers] +--- +site: zhihu +name: search +description: 知乎搜索 +domain: www.zhihu.com + +args: + query: + positional: true + type: str + required: true + description: Search query + limit: + type: int + default: 10 + description: Number of results + +pipeline: + - navigate: https://www.zhihu.com + + - evaluate: | + (async () => { + const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(//g, '').replace(/<\/em>/g, '').trim(); + const keyword = ${{ args.query | json }}; + const limit = ${{ args.limit }}; + const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + limit, { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data || []) + .filter(item => item.type === 'search_result') + .map(item => { + const obj = item.object || {}; + const q = obj.question || {}; + return { + type: obj.type, + title: strip(obj.title || q.name || ''), + excerpt: strip(obj.excerpt || '').substring(0, 100), + author: obj.author?.name || '', + votes: obj.voteup_count || 0, + url: obj.type === 'answer' + ? 'https://www.zhihu.com/question/' + q.id + '/answer/' + obj.id + : obj.type === 'article' + ? 'https://zhuanlan.zhihu.com/p/' + obj.id + : 'https://www.zhihu.com/question/' + obj.id, + }; + }); + })() + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + type: ${{ item.type }} + author: ${{ item.author }} + votes: ${{ item.votes }} + + - limit: ${{ args.limit }} + +columns: [rank, title, type, author, votes] +--- +site: linux-do +name: hot +description: linux.do 热门话题 +domain: linux.do +browser: true + +args: + limit: + type: int + default: 20 + description: Number of topics + period: + type: str + default: weekly + description: Time period + choices: [all, daily, weekly, monthly, yearly] + +pipeline: + - navigate: https://linux.do + + - evaluate: | + (async () => { + const period = ${{ args.period | json }}; + const res = await fetch('/top.json?period=' + encodeURIComponent(period), { credentials: 'include' }); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); + let data; + try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } + const topics = data?.topic_list?.topics || []; + const cats = data?.topic_list?.categories || data?.categories || []; + const catMap = Object.fromEntries(cats.map(c => [c.id, c.name])); + return topics.slice(0, ${{ args.limit }}).map(t => ({ + title: t.title, + replies: (t.posts_count || 1) - 1, + views: t.views, + likes: t.like_count, + category: catMap[t.category_id] || String(t.category_id), + })); + })() + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + replies: ${{ item.replies }} + views: ${{ item.views }} + likes: ${{ item.likes }} + category: ${{ item.category }} + + - limit: ${{ args.limit }} + +columns: [rank, title, replies, views, likes, category] +--- +site: yahoo-finance +name: quote +description: "Yahoo Finance 股票行情" +domain: finance.yahoo.com +strategy: cookie +browser: true + +args: + symbol: + positional: true + type: string + required: true + description: "Stock ticker (e.g. AAPL, MSFT, TSLA)" + +columns: [symbol, name, price, change, changePercent, open, high, low, volume, marketCap] + +pipeline: + - navigate: https://finance.yahoo.com/quote/${{ args.symbol | urlencode }}/ + - evaluate: | + (async () => { + const sym = ${{ args.symbol | json }}.toUpperCase().trim(); + + try { + const chartUrl = 'https://query1.finance.yahoo.com/v8/finance/chart/' + encodeURIComponent(sym) + '?interval=1d&range=1d'; + const resp = await fetch(chartUrl); + if (resp.ok) { + const d = await resp.json(); + const chart = d?.chart?.result?.[0]; + if (chart) { + const meta = chart.meta || {}; + const prevClose = meta.previousClose || meta.chartPreviousClose; + const price = meta.regularMarketPrice; + const change = price != null && prevClose != null ? (price - prevClose) : null; + const changePct = change != null && prevClose ? ((change / prevClose) * 100) : null; + return [{ + symbol: meta.symbol || sym, + name: meta.shortName || meta.longName || sym, + price: price != null ? Number(price.toFixed(2)) : null, + change: change != null ? change.toFixed(2) : null, + changePercent: changePct != null ? changePct.toFixed(2) + '%' : null, + open: chart.indicators?.quote?.[0]?.open?.[0] || null, + high: meta.regularMarketDayHigh || null, + low: meta.regularMarketDayLow || null, + volume: meta.regularMarketVolume || null, + marketCap: null, + }]; + } + } + } catch(e) {} + + const priceEl = document.querySelector('[data-testid="qsp-price"]'); + const changeEl = document.querySelector('[data-testid="qsp-price-change"]'); + const changePctEl = document.querySelector('[data-testid="qsp-price-change-percent"]'); + const titleEl = document.querySelector('title'); + if (priceEl) { + return [{ + symbol: sym, + name: titleEl ? titleEl.textContent.split('(')[0].trim() : sym, + price: priceEl.textContent.replace(/,/g, ''), + change: changeEl ? changeEl.textContent : null, + changePercent: changePctEl ? changePctEl.textContent : null, + open: null, high: null, low: null, volume: null, marketCap: null, + }]; + } + return []; + })() + - map: + symbol: ${{ item.symbol }} + name: ${{ item.name }} + price: ${{ item.price }} + change: ${{ item.change }} + changePercent: ${{ item.changePercent }} + open: ${{ item.open }} + high: ${{ item.high }} + low: ${{ item.low }} + volume: ${{ item.volume }} + marketCap: ${{ item.marketCap }} +--- +site: smzdm +name: search +description: "什么值得买搜索好价" +domain: www.smzdm.com +strategy: cookie +browser: true + +args: + query: + positional: true + type: string + required: true + description: Search keyword + limit: + type: int + default: 20 + description: Number of results + +columns: [rank, title, price, mall, comments, url] + +pipeline: + - navigate: https://search.smzdm.com/?c=home&s=${{ args.query | urlencode }}&v=b + - evaluate: | + (() => { + const limit = ${{ args.limit }}; + const items = document.querySelectorAll('li.feed-row-wide'); + const results = []; + items.forEach((li) => { + if (results.length >= limit) return; + const titleEl = li.querySelector('h5.feed-block-title > a') || li.querySelector('h5 > a'); + if (!titleEl) return; + const title = (titleEl.getAttribute('title') || titleEl.textContent || '').trim(); + const url = titleEl.getAttribute('href') || titleEl.href || ''; + const priceEl = li.querySelector('.z-highlight'); + const price = priceEl ? priceEl.textContent.trim() : ''; + let mall = ''; + const mallEl = li.querySelector('.z-feed-foot-r .feed-block-extras span') + || li.querySelector('.z-feed-foot-r span'); + if (mallEl) mall = mallEl.textContent.trim(); + const commentEl = li.querySelector('.feed-btn-comment'); + const comments = commentEl ? parseInt(commentEl.textContent.trim()) || 0 : 0; + results.push({ rank: results.length + 1, title, price, mall, comments, url }); + }); + return results; + })() + - map: + rank: ${{ item.rank }} + title: ${{ item.title }} + price: ${{ item.price }} + mall: ${{ item.mall }} + comments: ${{ item.comments }} + url: ${{ item.url }} diff --git a/src/App.tsx b/src/App.tsx index c1098c2ae..110aba9e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,52 +8,37 @@ * _需求: 2.2, 3.2, 5.2_ */ -import React, { Suspense, lazy, useState, useEffect, useCallback } from "react"; +import React, { Suspense, lazy, useState, useCallback } from "react"; import styled from "styled-components"; -import { getWindowsStartupDiagnostics } from "@/lib/api/serverRuntime"; import { withI18nPatch } from "./i18n/withI18nPatch"; +import { AppPageContent } from "./components/AppPageContent"; import { SplashScreen } from "./components/SplashScreen"; import { AppSidebar } from "./components/AppSidebar"; import { ProjectType, createProject, - ensureDefaultWorkspaceReady, isUserProjectType, resolveProjectRootPath, } from "./lib/api/project"; import { useOnboardingState } from "./components/onboarding"; -import { showRegistryLoadError } from "./lib/utils/connectError"; import { useDeepLink } from "./hooks/useDeepLink"; import { useRelayRegistry } from "./hooks/useRelayRegistry"; +import { useSkillCatalogBootstrap } from "./hooks/useSkillCatalogBootstrap"; import { useServiceSkillCatalogBootstrap } from "./hooks/useServiceSkillCatalogBootstrap"; import { useSiteAdapterCatalogBootstrap } from "./hooks/useSiteAdapterCatalogBootstrap"; +import { useAppNavigation } from "./hooks/useAppNavigation"; +import { useAppShellLayout } from "./hooks/useAppShellLayout"; +import { useAppStartupEffects } from "./hooks/useAppStartupEffects"; import { useGlobalTrayModelSync } from "./hooks/useGlobalTrayModelSync"; import { useOemLimeHubProviderSync } from "./hooks/useOemLimeHubProviderSync"; import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; import { SoundProvider } from "./contexts/SoundProvider"; import { ComponentDebugOverlay } from "./components/dev"; import { - AgentPageParams, - AutomationPageParams, - BrowserRuntimePageParams, - getThemeByWorkspacePage, getThemeWorkspacePage, - isThemeWorkspacePage, - LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, - MemoryPageParams, - OpenClawPageParams, - Page, - PageParams, - ProjectDetailPageParams, - SettingsPageParams, - StylePageParams, - ThemeWorkspacePage, WorkspaceTheme, } from "./types/page"; import { toast } from "sonner"; -import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; -import { buildHomeAgentParams } from "@/lib/workspace/navigation"; -import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; import { SettingsTabs } from "./types/settings"; const AppContainer = styled.div` @@ -73,79 +58,6 @@ const MainContent = styled.main<{ $withSidebarGap?: boolean }>` padding-left: ${(props) => (props.$withSidebarGap ? "10px" : "0")}; `; -const PageWrapper = styled.div<{ $isActive: boolean }>` - flex: 1; - padding: 24px; - overflow: auto; - display: ${(props) => (props.$isActive ? "block" : "none")}; -`; - -const FullscreenWrapper = styled.div<{ $isActive: boolean }>` - flex: 1; - min-height: 0; - overflow: hidden; - display: ${(props) => (props.$isActive ? "flex" : "none")}; - flex-direction: column; - position: relative; -`; - -const THEME_WORKSPACE_PAGES: ThemeWorkspacePage[] = [ - "workspace-general", - "workspace-social-media", - "workspace-poster", - "workspace-music", - "workspace-knowledge", - "workspace-planning", - "workspace-document", - "workspace-video", - "workspace-novel", -]; - -const SettingsPageV2 = lazy(() => - import("./components/settings-v2").then((module) => ({ - default: module.SettingsPageV2, - })), -); -const ToolsPage = lazy(() => - import("./components/tools/ToolsPage").then((module) => ({ - default: module.ToolsPage, - })), -); -const ResourcesPage = lazy(() => - import("./components/resources").then((module) => ({ - default: module.ResourcesPage, - })), -); -const MemoryPage = lazy(() => - import("./components/memory").then((module) => ({ - default: module.MemoryPage, - })), -); -const StylePage = lazy(() => - import("./components/style").then((module) => ({ - default: module.StylePage, - })), -); -const PluginsPage = lazy(() => - import("./components/plugins/PluginsPage").then((module) => ({ - default: module.PluginsPage, - })), -); -const ImageGenPage = lazy(() => - import("./components/image-gen").then((module) => ({ - default: module.ImageGenPage, - })), -); -const AutomationPage = lazy(() => - import("./components/automation").then((module) => ({ - default: module.AutomationPage, - })), -); -const OpenClawPage = lazy(() => - import("./components/openclaw").then((module) => ({ - default: module.OpenClawPage, - })), -); const RecentImageInsertFloating = lazy(() => import("./components/image-gen/RecentImageInsertFloating").then((module) => ({ default: module.RecentImageInsertFloating, @@ -156,36 +68,6 @@ const CreateProjectDialog = lazy(() => default: module.CreateProjectDialog, })), ); -const WorkbenchPage = lazy(() => - import("./components/workspace").then((module) => ({ - default: module.WorkbenchPage, - })), -); -const BrowserRuntimeWorkspace = lazy(() => - import("@/features/browser-runtime").then((module) => ({ - default: module.BrowserRuntimeWorkspace, - })), -); -const TerminalWorkspace = lazy(() => - import("./components/terminal").then((module) => ({ - default: module.TerminalWorkspace, - })), -); -const SysinfoView = lazy(() => - import("./components/terminal").then((module) => ({ - default: module.SysinfoView, - })), -); -const FileBrowserView = lazy(() => - import("./components/terminal").then((module) => ({ - default: module.FileBrowserView, - })), -); -const WebView = lazy(() => - import("./components/terminal").then((module) => ({ - default: module.WebView, - })), -); const OnboardingWizard = lazy(() => import("./components/onboarding").then((module) => ({ default: module.OnboardingWizard, @@ -196,12 +78,6 @@ const ConnectConfirmDialog = lazy(() => default: module.ConnectConfirmDialog, })), ); -const AgentChatPage = lazy(() => - import("./components/agent/chat").then((module) => ({ - default: module.AgentChatPage, - })), -); - const pageLoadingFallback = (
); -function isTauriDesktopEnvironment(): boolean { - return hasTauriInvokeCapability(); -} - -function isWindowsNavigatorPlatform(): boolean { - if (typeof navigator === "undefined") { - return false; - } - - const platform = navigator.platform || ""; - const userAgent = navigator.userAgent || ""; - return /win/i.test(platform) || /windows/i.test(userAgent); -} - function AppContent() { const [showSplash, setShowSplash] = useState(true); - const [currentPage, setCurrentPage] = useState("agent"); - const [pageParams, setPageParams] = useState(() => - buildHomeAgentParams(), - ); + const { currentPage, pageParams, handleNavigate } = useAppNavigation(); const [agentHasMessages, setAgentHasMessages] = useState(false); const { needsOnboarding, completeOnboarding } = useOnboardingState(); @@ -249,6 +108,7 @@ function AppContent() { projectName: string; } | null>(null); + useSkillCatalogBootstrap(); useServiceSkillCatalogBootstrap(); useSiteAdapterCatalogBootstrap(); useOemLimeHubProviderSync(); @@ -257,110 +117,6 @@ function AppContent() { pageParams, }); - const resolveWorkspacePage = useCallback( - (workspaceTheme?: WorkspaceTheme): ThemeWorkspacePage => { - if (workspaceTheme) { - return getThemeWorkspacePage(workspaceTheme); - } - - if (typeof window !== "undefined") { - const savedPage = localStorage.getItem( - LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, - ); - - if ( - savedPage && - THEME_WORKSPACE_PAGES.includes(savedPage as ThemeWorkspacePage) - ) { - return savedPage as ThemeWorkspacePage; - } - } - - return getThemeWorkspacePage("general"); - }, - [], - ); - - const handleNavigate = useCallback( - (page: Page, params?: PageParams) => { - if ( - page === "memory" && - (params as { section?: string } | undefined)?.section === - "style-library" - ) { - setCurrentPage("style"); - setPageParams({ section: "library" } as StylePageParams); - return; - } - - if (page === "projects") { - const projectParams = params as - | { - projectId?: string; - workspaceTheme?: WorkspaceTheme; - } - | undefined; - const targetWorkspacePage = resolveWorkspacePage( - projectParams?.workspaceTheme, - ); - - if (typeof window !== "undefined") { - localStorage.setItem( - LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, - targetWorkspacePage, - ); - } - - setCurrentPage(targetWorkspacePage); - setPageParams({ - ...(projectParams?.projectId - ? { projectId: projectParams.projectId } - : {}), - workspaceViewMode: "project-management", - }); - return; - } - - if (page === "project-detail") { - const projectParams = params as ProjectDetailPageParams | undefined; - const targetWorkspacePage = resolveWorkspacePage( - projectParams?.workspaceTheme, - ); - const workspaceViewMode = projectParams?.projectId - ? "workspace" - : "project-management"; - - if (typeof window !== "undefined") { - localStorage.setItem( - LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, - targetWorkspacePage, - ); - } - - setCurrentPage(targetWorkspacePage); - setPageParams({ - ...(projectParams?.projectId - ? { projectId: projectParams.projectId } - : {}), - workspaceViewMode, - workspaceOpenProjectStyleGuide: - projectParams?.openProjectStyleGuide ?? false, - workspaceOpenProjectStyleGuideSourceEntryId: - projectParams?.openProjectStyleGuideSourceEntryId, - }); - return; - } - - if (isThemeWorkspacePage(page) && typeof window !== "undefined") { - localStorage.setItem(LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, page); - } - - setCurrentPage(page); - setPageParams(params ? { ...params } : {}); - }, - [resolveWorkspacePage], - ); - const _handleRequestRecommendation = useCallback( (shortLabel: string, fullPrompt: string, currentTheme: string) => { const themeLabels: Record = { @@ -455,383 +211,20 @@ function AppContent() { const { error: registryError, refresh: _refreshRegistry } = useRelayRegistry(); - - useEffect(() => { - if (registryError) { - console.warn("[App] Registry 加载失败:", registryError); - showRegistryLoadError(registryError.message); - } - }, [registryError]); - - useEffect(() => { - if (!isTauriDesktopEnvironment() || !isWindowsNavigatorPlatform()) { - return; - } - - void getWindowsStartupDiagnostics() - .then((diagnostics) => { - if (!diagnostics.summary_message) { - return; - } - - if (diagnostics.has_blocking_issues) { - toast.error("Windows 启动自检发现阻塞问题", { - description: diagnostics.summary_message, - duration: 12000, - }); - return; - } - - if (diagnostics.has_warnings) { - toast.warning("Windows 环境检测提示", { - description: diagnostics.summary_message, - duration: 8000, - }); - } - }) - .catch((error) => { - console.warn("[App] 获取 Windows 启动诊断失败:", error); - }); - }, []); - - useEffect(() => { - void ensureDefaultWorkspaceReady() - .then((result) => { - if (result?.repaired) { - recordWorkspaceRepair({ - workspaceId: result.workspaceId, - rootPath: result.rootPath, - source: "app_startup", - }); - console.info( - "[App] 启动时检测到默认工作区目录缺失,已自动修复:", - result.rootPath, - ); - } - }) - .catch((error) => { - console.warn("[App] 启动时工作区健康检查失败:", error); - }); - }, []); - - useEffect(() => { - const mainElement = document.querySelector("main"); - if (mainElement) { - mainElement.scrollTop = 0; - } - }, [currentPage]); + useAppStartupEffects({ + currentPage, + registryError, + }); + const { shouldShowAppSidebar, shouldAddMainContentGap } = useAppShellLayout({ + currentPage, + pageParams, + agentHasMessages, + }); const handleSplashComplete = useCallback(() => { setShowSplash(false); }, []); - const renderThemeWorkspaces = () => { - if (!THEME_WORKSPACE_PAGES.includes(currentPage as ThemeWorkspacePage)) { - return null; - } - - const page = currentPage as ThemeWorkspacePage; - const theme = getThemeByWorkspacePage(page); - - return ( -
- -
- ); - }; - - const renderCurrentPage = () => { - if (currentPage === "image-gen") { - return ( -
- -
- ); - } - - if (currentPage === "automation") { - return ( -
- -
- ); - } - - if (currentPage === "agent") { - return ( -
- -
- ); - } - - if (isThemeWorkspacePage(currentPage)) { - return renderThemeWorkspaces(); - } - - if (currentPage === "terminal") { - return ( -
- -
- ); - } - - if (currentPage === "sysinfo") { - return ( - - - - ); - } - - if (currentPage === "files") { - return ( - - - - ); - } - - if (currentPage === "web") { - return ( - - - - ); - } - - if (currentPage === "resources") { - return ( -
- -
- ); - } - - if (currentPage === "tools") { - return ( - - - - ); - } - - if (currentPage === "browser-runtime") { - const browserRuntimeParams = pageParams as BrowserRuntimePageParams; - return ( - - - - ); - } - - if (currentPage === "plugins") { - return ( - - - - ); - } - - if (currentPage === "style") { - return ( -
- -
- ); - } - - if (currentPage === "memory") { - return ( -
- -
- ); - } - - if (currentPage === "openclaw") { - return ( -
- -
- ); - } - - if (currentPage === "settings") { - return ( -
- -
- ); - } - - return null; - }; - const handleOnboardingComplete = useCallback(() => { completeOnboarding(); }, [completeOnboarding]); @@ -852,26 +245,6 @@ function AppContent() { ); } - const currentAgentParams = pageParams as AgentPageParams; - const shouldHideSidebarForAgent = - currentPage === "agent" && - (Boolean(currentAgentParams.fromResources) || - Boolean(currentAgentParams.immersiveHome) || - (agentHasMessages && Boolean(currentAgentParams.lockTheme))); - - const shouldShowAppSidebar = - currentPage !== "settings" && - currentPage !== "memory" && - currentPage !== "image-gen" && - currentPage !== "tools" && - currentPage !== "plugins" && - currentPage !== "resources" && - !isThemeWorkspacePage(currentPage) && - !shouldHideSidebarForAgent; - - const shouldAddMainContentGap = - shouldShowAppSidebar && currentPage === "agent"; - return ( @@ -885,7 +258,12 @@ function AppContent() { )} - {renderCurrentPage()} + diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx new file mode 100644 index 000000000..c0ae56015 --- /dev/null +++ b/src/components/AppPageContent.tsx @@ -0,0 +1,377 @@ +/** + * 应用页面分发层 + * + * 负责根据当前页面类型渲染对应主内容,避免主入口继续膨胀。 + */ + +import { lazy } from "react"; +import styled from "styled-components"; +import type { + AgentPageParams, + AutomationPageParams, + BrowserRuntimePageParams, + MemoryPageParams, + OpenClawPageParams, + Page, + PageParams, + SettingsPageParams, + StylePageParams, +} from "@/types/page"; +import { + getThemeByWorkspacePage, + isThemeWorkspacePage, + type ThemeWorkspacePage, +} from "@/types/page"; + +const PageWrapper = styled.div<{ $isActive: boolean }>` + flex: 1; + padding: 24px; + overflow: auto; + display: ${(props) => (props.$isActive ? "block" : "none")}; +`; + +const FullscreenWrapper = styled.div<{ $isActive: boolean }>` + flex: 1; + min-height: 0; + overflow: hidden; + display: ${(props) => (props.$isActive ? "flex" : "none")}; + flex-direction: column; + position: relative; +`; + +const columnPageStyle = { + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", +} as const; + +const SettingsPageV2 = lazy(() => + import("./settings-v2").then((module) => ({ + default: module.SettingsPageV2, + })), +); +const ToolsPage = lazy(() => + import("./tools/ToolsPage").then((module) => ({ + default: module.ToolsPage, + })), +); +const ResourcesPage = lazy(() => + import("./resources").then((module) => ({ + default: module.ResourcesPage, + })), +); +const MemoryPage = lazy(() => + import("./memory").then((module) => ({ + default: module.MemoryPage, + })), +); +const StylePage = lazy(() => + import("./style").then((module) => ({ + default: module.StylePage, + })), +); +const PluginsPage = lazy(() => + import("./plugins/PluginsPage").then((module) => ({ + default: module.PluginsPage, + })), +); +const ImageGenPage = lazy(() => + import("./image-gen").then((module) => ({ + default: module.ImageGenPage, + })), +); +const AutomationPage = lazy(() => + import("./automation").then((module) => ({ + default: module.AutomationPage, + })), +); +const OpenClawPage = lazy(() => + import("./openclaw").then((module) => ({ + default: module.OpenClawPage, + })), +); +const SkillsWorkspacePage = lazy(() => + import("./skills").then((module) => ({ + default: module.SkillsWorkspacePage, + })), +); +const WorkbenchPage = lazy(() => + import("./workspace").then((module) => ({ + default: module.WorkbenchPage, + })), +); +const BrowserRuntimeWorkspace = lazy(() => + import("@/features/browser-runtime").then((module) => ({ + default: module.BrowserRuntimeWorkspace, + })), +); +const TerminalWorkspace = lazy(() => + import("./terminal").then((module) => ({ + default: module.TerminalWorkspace, + })), +); +const SysinfoView = lazy(() => + import("./terminal").then((module) => ({ + default: module.SysinfoView, + })), +); +const FileBrowserView = lazy(() => + import("./terminal").then((module) => ({ + default: module.FileBrowserView, + })), +); +const WebView = lazy(() => + import("./terminal").then((module) => ({ + default: module.WebView, + })), +); +const AgentChatPage = lazy(() => + import("./agent/chat").then((module) => ({ + default: module.AgentChatPage, + })), +); + +interface AppPageContentProps { + currentPage: Page; + pageParams: PageParams; + onNavigate: (page: Page, params?: PageParams) => void; + onAgentHasMessagesChange: (hasMessages: boolean) => void; +} + +function renderThemeWorkspace( + currentPage: ThemeWorkspacePage, + pageParams: PageParams, + onNavigate: (page: Page, params?: PageParams) => void, +) { + const theme = getThemeByWorkspacePage(currentPage); + const agentPageParams = pageParams as AgentPageParams; + + return ( +
+ +
+ ); +} + +export function AppPageContent({ + currentPage, + pageParams, + onNavigate, + onAgentHasMessagesChange, +}: AppPageContentProps) { + if (currentPage === "image-gen") { + return ( +
+ +
+ ); + } + + if (currentPage === "automation") { + return ( +
+ +
+ ); + } + + if (currentPage === "agent") { + const agentPageParams = pageParams as AgentPageParams; + + return ( +
+ +
+ ); + } + + if (isThemeWorkspacePage(currentPage)) { + return renderThemeWorkspace(currentPage, pageParams, onNavigate); + } + + if (currentPage === "terminal") { + return ( +
+ +
+ ); + } + + if (currentPage === "sysinfo") { + return ( + + + + ); + } + + if (currentPage === "files") { + return ( + + + + ); + } + + if (currentPage === "web") { + return ( + + + + ); + } + + if (currentPage === "resources") { + return ( +
+ +
+ ); + } + + if (currentPage === "tools") { + return ( + + + + ); + } + + if (currentPage === "browser-runtime") { + const browserRuntimeParams = pageParams as BrowserRuntimePageParams; + + return ( + + + + ); + } + + if (currentPage === "plugins") { + return ( + + + + ); + } + + if (currentPage === "style") { + return ( +
+ +
+ ); + } + + if (currentPage === "memory") { + return ( +
+ +
+ ); + } + + if (currentPage === "openclaw") { + return ( +
+ +
+ ); + } + + if (currentPage === "skills") { + return ( +
+ +
+ ); + } + + if (currentPage === "settings") { + return ( +
+ +
+ ); + } + + return null; +} diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 99ded535d..e4374baf9 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -102,4 +102,19 @@ describe("AppSidebar", () => { ).not.toBeNull(); expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false"); }); + + it("旧导航配置未包含技能时也应显示固定技能入口", async () => { + mockGetConfig.mockResolvedValue({ + navigation: { + enabled_items: ["home-general", "claw"], + }, + }); + + const container = mountSidebar({ + agentEntry: "new-task", + } as AgentPageParams); + await flushEffects(); + + expect(container.textContent).toContain("技能"); + }); }); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index cf3be9c04..62a5586c0 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -542,7 +542,7 @@ export function AppSidebar({ const filteredMainMenuItems = useMemo(() => { return MAIN_SIDEBAR_NAV_ITEMS.filter((item) => - enabledNavItems.includes(item.id), + item.configurable === false || enabledNavItems.includes(item.id), ); }, [enabledNavItems]); diff --git a/src/components/agent/AgentSkillsPanel.tsx b/src/components/agent/AgentSkillsPanel.tsx index 0aae70c55..9f6e98573 100644 --- a/src/components/agent/AgentSkillsPanel.tsx +++ b/src/components/agent/AgentSkillsPanel.tsx @@ -23,7 +23,7 @@ interface AgentSkillsPanelProps { skills: string[]; /** 是否正在加载 */ loading: boolean; - /** 点击"管理 Skills"按钮的回调 */ + /** 点击“打开技能中心”按钮的回调 */ onManageClick: () => void; } @@ -33,8 +33,8 @@ interface AgentSkillsPanelProps { * 功能: * - 显示已加载 Skills 数量 * - 以紧凑格式显示 Skill 名称列表(用 · 分隔) - * - 提供"管理 Skills"按钮导航到 Skills 设置页面 - * - 无 Skills 时显示提示文本和安装链接 + * - 提供“打开技能中心”按钮导航到 Skills 主入口 + * - 无 Skills 时显示提示文本和技能中心入口 * - 显示使用提示 * * @param skills - 已加载的 Skills 名称列表 @@ -101,19 +101,19 @@ export function AgentSkillsPanel({ className="w-full" > - 管理 Skills + 打开技能中心 ) : ( <> {/* 无 Skills 提示 */}

- 暂无已安装的 Skills, + 暂无已安装的技能,

diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx index 43b9dc47c..8ff094317 100644 --- a/src/components/agent/chat/AgentChatHomeShell.test.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.test.tsx @@ -35,6 +35,7 @@ const { mockCreateServiceSkillRun, mockGetServiceSkillRun, mockIsTerminalServiceSkillRunStatus, + mockSiteGetAdapterLaunchReadiness, mockToastLoading, mockToastSuccess, mockToastError, @@ -217,6 +218,7 @@ const { const mockCreateServiceSkillRun = vi.fn(); const mockGetServiceSkillRun = vi.fn(); const mockIsTerminalServiceSkillRunStatus = vi.fn(); + const mockSiteGetAdapterLaunchReadiness = vi.fn(); const mockToastLoading = vi.fn(); const mockToastSuccess = vi.fn(); const mockToastError = vi.fn(); @@ -303,6 +305,7 @@ const { mockClawSolutions, mockUseServiceSkills: vi.fn(() => ({ skills: mockServiceSkills, + groups: [], isLoading: false, error: null, refresh: vi.fn(), @@ -314,6 +317,7 @@ const { mockCreateServiceSkillRun, mockGetServiceSkillRun, mockIsTerminalServiceSkillRunStatus, + mockSiteGetAdapterLaunchReadiness, mockToastLoading, mockToastSuccess, mockToastError, @@ -428,6 +432,9 @@ vi.mock("./utils/chatToolPreferences", () => ({ task: false, subagent: false, })), + alignChatToolPreferencesWithExecutionStrategy: vi.fn( + (preferences: Record) => preferences, + ), saveChatToolPreferences: mockSaveChatToolPreferences, })); @@ -464,6 +471,11 @@ vi.mock("@/lib/api/project", () => ({ listProjects: mockListProjects, })); +vi.mock("@/lib/webview-api", () => ({ + siteGetAdapterLaunchReadiness: (...args: unknown[]) => + mockSiteGetAdapterLaunchReadiness(...args), +})); + vi.mock("sonner", () => ({ toast: { loading: mockToastLoading, @@ -573,6 +585,7 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({ open, onLaunch, onCreateAutomation, + onOpenBrowserRuntime, }: { skill: { id: string; title: string; runnerType?: string } | null; open: boolean; @@ -584,6 +597,10 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({ skill: { id: string; title: string; runnerType?: string }, slotValues: Record, ) => void; + onOpenBrowserRuntime?: ( + skill: { id: string; title: string; runnerType?: string }, + slotValues: Record, + ) => void; }) => open && skill ? ( <> @@ -611,6 +628,19 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({ > 启动服务型技能 + {skill.id === "github-repo-radar" && onOpenBrowserRuntime ? ( + + ) : null} {skill.runnerType === "scheduled" && onCreateAutomation ? ( - - {onOpenArtifactFromTimeline && blockTargets.length > 1 ? ( -
- {blockTargets.slice(0, 4).map((target) => ( - - ))} -
- ) : null} -
+ ); } @@ -735,6 +914,36 @@ function renderGroupItemDetails( ); } +function renderTimelineItemDetails( + item: AgentThreadItem, + onFileClick?: (fileName: string, content: string) => void, + onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void, + onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void, + onOpenSubagentSession?: (sessionId: string) => void, + onPermissionResponse?: (response: ConfirmResponse) => void, + options?: { + groupedToolCall?: boolean; + groupMarker?: string; + }, +) { + if (isThinkingTimelineItem(item)) { + if (options?.groupedToolCall) { + return ; + } + return renderThinkingItemDetails(item); + } + + return renderGroupItemDetails( + item, + onFileClick, + onOpenArtifactFromTimeline, + onOpenSavedSiteContent, + onOpenSubagentSession, + onPermissionResponse, + options, + ); +} + function resolveCompactTechnicalSummary(block: AgentThreadOrderedBlock): string { return `处理了 ${block.items.length} 个步骤`; } @@ -783,7 +992,7 @@ function resolveFocusBlockIndex(params: { if (pendingAction?.uiKind === "browser_preflight") { const browserIndex = findLastBlockIndex( blocks, - (block) => block.kind === "browser", + (block) => block.items.some((item) => isBrowserTimelineItem(item)), ); if (browserIndex >= 0) { return browserIndex; @@ -839,7 +1048,9 @@ function resolveExpandedBlockIndexes(params: { if (focusBlockIndex >= 0) { const focusBlock = blocks[focusBlockIndex]; const completedThinkingBlock = - focusBlock?.kind === "thinking" && focusBlock.status === "completed"; + Boolean(focusBlock) && + focusBlock.status === "completed" && + focusBlock.items.every((item) => isThinkingTimelineItem(item)); const shouldExpandFocus = focusBlock?.status !== "completed" || (!completedThinkingBlock && turn.status === "running") || @@ -875,18 +1086,10 @@ function normalizeBlockPreviewLine( } if ( - kind === "file" && + kind === "artifact" && !hasAnyPrefix(trimmed, ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "]) ) { - return `看了 ${trimmed}`; - } - - if (kind === "command" && !hasAnyPrefix(trimmed, ["执行了 ", "跑了 ", "运行了 "])) { - return `执行了 ${trimmed}`; - } - - if (kind === "search" && !hasAnyPrefix(trimmed, ["搜了 ", "查了 ", "搜索了 ", "检索了 "])) { - return `搜了 ${trimmed}`; + return `产出了 ${trimmed}`; } if ( @@ -908,12 +1111,26 @@ function normalizeBlockPreviewLine( } function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] { + const isTurnSummaryOnlyBlock = + block.items.length > 0 && + block.items.every((item) => item.type === "turn_summary"); + const isThinkingOnlyBlock = block.items.every((item) => + isThinkingTimelineItem(item), + ); const normalizedPreviewLines = block.previewLines .map((line) => normalizeBlockPreviewLine(block.kind, line)) .filter((line) => line.trim().length > 0) .map((line) => shortenInlineText(line, 92) || line); - if (block.kind === "thinking") { + if (isTurnSummaryOnlyBlock) { + if (normalizedPreviewLines.length > 0) { + return normalizedPreviewLines; + } + + return [block.status === "in_progress" ? "处理中" : "当前进展"]; + } + + if (isThinkingOnlyBlock) { const headline = block.status === "in_progress" ? "思考中" : "已完成思考"; if (normalizedPreviewLines.length > 0) { return [ @@ -925,6 +1142,10 @@ function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] { return [headline]; } + if (block.kind === "process" && block.items.length > 1) { + return [block.title, ...normalizedPreviewLines]; + } + if (normalizedPreviewLines.length > 0) { return normalizedPreviewLines; } @@ -948,6 +1169,29 @@ function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] { return [block.title]; } +function resolveProcessMixLabel(block: AgentThreadOrderedBlock): string | null { + if (block.kind !== "process" || block.items.length <= 1) { + return null; + } + + const toolCount = block.items.filter((item) => + isToolExecutionTimelineItem(item), + ).length; + const thinkingCount = block.items.filter((item) => + isThinkingTimelineItem(item), + ).length; + + const parts: string[] = []; + if (toolCount > 0) { + parts.push(`${toolCount} 个工具步骤`); + } + if (thinkingCount > 0) { + parts.push(`${thinkingCount} 条思路`); + } + + return parts.length > 0 ? parts.join(",") : null; +} + function resolveThreadInlineStatusHint(params: { turn: AgentThreadTurn; actionRequests?: ActionRequired[]; @@ -1085,7 +1329,9 @@ function TimelineBlockCard({ focusRequestKey?: number; }) { const dataTestId = `agent-thread-block:${index + 1}:${block.kind}`; - const isThinkingBlock = block.kind === "thinking"; + const isThinkingOnlyBlock = block.items.every((item) => + isThinkingTimelineItem(item), + ); const summaryLines = resolveBlockSummaryLines(block); const headline = summaryLines[0] || block.title; const supportingLines = summaryLines.slice(1, 3); @@ -1093,18 +1339,21 @@ function TimelineBlockCard({ const hasFocusedItem = Boolean( focusedItemId && block.items.some((item) => item.id === focusedItemId), ); + const shouldRenderGroupedToolRows = + block.kind === "process" && block.items.length > 1; const detailEntries = block.items.flatMap((item) => { - const content = - block.kind === "thinking" - ? renderThinkingItemDetails(item) - : renderGroupItemDetails( - item, - onFileClick, - onOpenArtifactFromTimeline, - onOpenSavedSiteContent, - onOpenSubagentSession, - onPermissionResponse, - ); + const content = renderTimelineItemDetails( + item, + onFileClick, + onOpenArtifactFromTimeline, + onOpenSavedSiteContent, + onOpenSubagentSession, + onPermissionResponse, + { + groupedToolCall: shouldRenderGroupedToolRows, + groupMarker: block.items[0]?.id === item.id ? "└" : "·", + }, + ); return content ? [{ id: item.id, content }] : []; }); @@ -1128,17 +1377,16 @@ function TimelineBlockCard({ }, [focusRequestKey, hasFocusedItem]); const singleItemContent = - block.items.length === 1 && (!isThinkingBlock || block.status !== "completed") - ? (block.kind === "thinking" - ? renderThinkingItemDetails(block.items[0]!) - : renderGroupItemDetails( - block.items[0]!, - onFileClick, - onOpenArtifactFromTimeline, - onOpenSavedSiteContent, - onOpenSubagentSession, - onPermissionResponse, - )) + block.items.length === 1 && + !(isThinkingOnlyBlock && block.status === "completed") + ? renderTimelineItemDetails( + block.items[0]!, + onFileClick, + onOpenArtifactFromTimeline, + onOpenSavedSiteContent, + onOpenSubagentSession, + onPermissionResponse, + ) : null; if (singleItemContent) { @@ -1146,7 +1394,9 @@ function TimelineBlockCard({
1 ? block.countLabel : null; + const processMixLabel = resolveProcessMixLabel(block); + const summaryDetailHint = + hasDetailEntries && block.items.length > 1 && !open + ? processMixLabel || block.rawDetailLabel + : null; const summaryToneClassName = cn( "text-slate-900", block.status === "in_progress" && "text-sky-700", @@ -1183,7 +1440,7 @@ function TimelineBlockCard({ className={cn( "list-none rounded-md px-2 py-1.5", hasDetailEntries ? "cursor-pointer" : "cursor-default", - emphasis === "active" && !isThinkingBlock && "bg-sky-50/45", + emphasis === "active" && !isThinkingOnlyBlock && "bg-sky-50/45", )} onClick={(event) => { if (!hasDetailEntries) { @@ -1210,6 +1467,16 @@ function TimelineBlockCard({ > {visibleHeadline} + {summaryCountLabel ? ( + + {summaryCountLabel} + + ) : null} + {summaryDetailHint ? ( + + {summaryDetailHint} + + ) : null}
{visibleSupportingLines.length > 0 ? ( diff --git a/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx new file mode 100644 index 000000000..df2726fc4 --- /dev/null +++ b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx @@ -0,0 +1,160 @@ +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 { AgentThreadTimelineArtifactCard } from "./AgentThreadTimelineArtifactCard"; +import type { AgentThreadItem } from "../types"; + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedHarness[] = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + + vi.clearAllMocks(); +}); + +function createFileArtifactItem( + overrides: Partial> = {}, +): Extract { + return { + id: "artifact-1", + thread_id: "thread-1", + turn_id: "turn-1", + sequence: 1, + status: "completed", + started_at: "2026-03-28T01:00:00Z", + completed_at: "2026-03-28T01:00:01Z", + updated_at: "2026-03-28T01:00:01Z", + type: "file_artifact", + path: ".lime/artifacts/thread-1/analysis-20260328.artifact.json", + source: "artifact_document_service", + 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: [{ id: "source-1", title: "内部周报" }], + metadata: { + currentVersionId: "artifact-document:demo:v2", + currentVersionNo: 2, + }, + }), + metadata: { + artifact_id: "artifact-document:demo", + artifact_block_id: ["hero-1", "body-1"], + }, + ...overrides, + }; +} + +function renderCard( + item: Extract, + props?: { + timestamp?: string | null; + onFileClick?: (fileName: string, content: string) => void; + onOpenArtifactFromTimeline?: (target: { + filePath: string; + content: string; + timelineItemId: string; + blockId?: string; + artifactId?: string; + }) => void; + }, +): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ container, root }); + return container; +} + +describe("AgentThreadTimelineArtifactCard", () => { + it("结构化 artifact 文稿应收敛为可读结果卡而不是原始 JSON", () => { + const container = renderCard(createFileArtifactItem(), { + onOpenArtifactFromTimeline: vi.fn(), + }); + + expect( + container.querySelector('[data-testid="timeline-file-artifact-card"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("季度复盘"); + expect(container.textContent).toContain("分析"); + expect(container.textContent).toContain("可阅读"); + expect(container.textContent).toContain("文稿服务"); + expect(container.textContent).toContain("2 个区块"); + expect(container.textContent).toContain("1 条来源"); + expect(container.textContent).toContain("V2"); + expect(container.textContent).toContain("定位到 本轮重点是补齐来源线索与交付节奏。"); + expect(container.textContent).not.toContain("artifact_document_service"); + expect(container.textContent).not.toContain("schemaVersion"); + expect(container.textContent).not.toContain('"artifactId"'); + }); + + it("普通 JSON 文件也不应把原始结构直接摊在聊天区", () => { + const container = renderCard( + createFileArtifactItem({ + path: ".lime/artifacts/thread-1/runtime-state.json", + source: "artifact_snapshot", + content: JSON.stringify({ + queue: ["turn-1"], + retryable: true, + }), + metadata: {}, + }), + ); + + expect(container.textContent).toContain("已同步"); + expect(container.textContent).toContain("包含结构化结果,点击在画布中查看完整内容。"); + expect(container.textContent).not.toContain('"queue"'); + expect(container.textContent).not.toContain('"retryable"'); + }); +}); diff --git a/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.tsx b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.tsx new file mode 100644 index 000000000..06b413994 --- /dev/null +++ b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.tsx @@ -0,0 +1,327 @@ +import { ArrowUpRight, FileStack, FileText } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + resolveArtifactDocumentCurrentVersion, + type ArtifactDocumentBlock, + type ArtifactDocumentKind, + type ArtifactDocumentStatus, + type ArtifactDocumentV1, +} from "@/lib/artifact-document"; +import { + resolveArtifactProtocolDocumentPayload, + resolveArtifactProtocolPreviewText, +} from "@/lib/artifact-protocol"; +import type { AgentThreadItem } from "../types"; +import { + resolveTimelineArtifactNavigation, + type ArtifactTimelineOpenTarget, +} from "../utils/artifactTimelineNavigation"; + +interface AgentThreadTimelineArtifactCardProps { + item: Extract; + timestamp?: string | null; + onFileClick?: (fileName: string, content: string) => void; + onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function normalizeText(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function resolveFileName(path: string): string { + const normalized = path.replace(/\\/g, "/").trim(); + const parts = normalized.split("/"); + return parts[parts.length - 1] || normalized; +} + +function truncateMiddle(value: string, maxLength = 72): string { + const normalized = value.trim(); + if (normalized.length <= maxLength) { + return normalized; + } + + const headLength = Math.max(20, Math.ceil((maxLength - 1) * 0.58)); + const tailLength = Math.max(14, maxLength - headLength - 1); + return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`; +} + +function truncateInlineText(value: string, maxLength = 160): string { + const normalized = value.trim().replace(/\s+/g, " "); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + +function resolveArtifactDocumentKindLabel( + kind?: ArtifactDocumentKind, +): string | null { + switch (kind) { + case "report": + return "报告"; + case "roadmap": + return "路线图"; + case "prd": + return "PRD"; + case "brief": + return "简报"; + case "analysis": + return "分析"; + case "comparison": + return "对比"; + case "plan": + return "计划"; + case "table_report": + return "表格报告"; + default: + return kind || null; + } +} + +function resolveArtifactDocumentStatusLabel( + status?: ArtifactDocumentStatus, +): string | null { + switch (status) { + case "draft": + return "草稿"; + case "streaming": + return "生成中"; + case "ready": + return "可阅读"; + case "failed": + return "失败"; + case "archived": + return "已归档"; + default: + return status || null; + } +} + +function resolveArtifactSourceLabel(source?: string): string | null { + switch (source) { + case "artifact_snapshot": + return "已同步"; + case "artifact_document_service": + return "文稿服务"; + case "tool_result": + return "处理结果"; + case "tool_start": + return "开始处理"; + case "message_content": + return "消息内容"; + default: + return source && !source.includes("_") ? source : null; + } +} + +function resolveBlockLabel( + document: ArtifactDocumentV1 | null, + blockId: string, +): string { + const block = document?.blocks.find((entry) => entry.id === blockId); + if (!block) { + return blockId; + } + + const record = block as ArtifactDocumentBlock & Record; + const fallbackByType: Record = { + hero_summary: "摘要", + section_header: "章节", + rich_text: "正文", + callout: "提示", + key_points: "要点", + }; + const label = + normalizeText(record.title) || + normalizeText(record.summary) || + normalizeText(record.description) || + normalizeText(record.label) || + normalizeText(record.text) || + normalizeText(record.markdown); + + return label ? truncateInlineText(label, 20) : fallbackByType[block.type] || blockId; +} + +function resolveFallbackPreview(content: string | undefined): string | null { + const normalized = normalizeText(content); + if (!normalized) { + return null; + } + + if (/^[[{]/.test(normalized)) { + return "包含结构化结果,点击在画布中查看完整内容。"; + } + + return truncateInlineText(normalized); +} + +function resolveDocumentPreview( + document: ArtifactDocumentV1 | null, + displayTitle: string, +): string | null { + if (!document) { + return null; + } + + const preview = normalizeText(resolveArtifactProtocolPreviewText(document)); + if (!preview || preview === displayTitle) { + return "已同步到工作区,可继续在画布里阅读、编辑和定位到对应区块。"; + } + + return truncateInlineText(preview); +} + +export function AgentThreadTimelineArtifactCard({ + item, + timestamp, + onFileClick, + onOpenArtifactFromTimeline, +}: AgentThreadTimelineArtifactCardProps) { + const metadata = asRecord(item.metadata); + const navigation = resolveTimelineArtifactNavigation(item); + const blockTargets = navigation?.blockTargets || []; + const shouldOpenFocusedBlock = + Boolean(onOpenArtifactFromTimeline) && blockTargets.length === 1; + const document = resolveArtifactProtocolDocumentPayload({ + content: item.content, + metadata, + }); + const currentVersion = document + ? resolveArtifactDocumentCurrentVersion(document) + : null; + const displayTitle = + normalizeText(document?.title) || resolveFileName(item.path); + const displayPath = truncateMiddle(item.path, 84); + const previewText = + resolveDocumentPreview(document, displayTitle) || + resolveFallbackPreview(item.content) || + "点击在画布中打开完整内容。"; + const sourceLabel = resolveArtifactSourceLabel(item.source); + const kindLabel = resolveArtifactDocumentKindLabel(document?.kind); + const statusLabel = resolveArtifactDocumentStatusLabel( + currentVersion?.status || document?.status, + ); + const blockCount = document?.blocks.length || 0; + const sourceCount = document?.sources.length || 0; + + return ( +
+ + + {onOpenArtifactFromTimeline && blockTargets.length > 1 ? ( +
+ {blockTargets.slice(0, 4).map((target) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx index d4efe8571..e4024f05e 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx @@ -235,14 +235,12 @@ function MockArtifactDocumentPreview({ controller, target, onArtifactDocumentControllerChange, - artifactDocumentLayoutMode, }: { controller: ArtifactWorkbenchDocumentController | null; target: CanvasWorkbenchPreviewTarget; onArtifactDocumentControllerChange?: ( controller: ArtifactWorkbenchDocumentController | null, ) => void; - artifactDocumentLayoutMode?: "full" | "canvas-only"; }) { React.useEffect(() => { onArtifactDocumentControllerChange?.( @@ -255,7 +253,7 @@ function MockArtifactDocumentPreview({ return (
- {artifactDocumentLayoutMode}:{target.kind}:{target.title} + {target.kind}:{target.title}
); } @@ -589,7 +587,6 @@ describe("CanvasWorkbenchLayout", () => { it("命中文档产物时应把文稿 inspector 收口到右侧工作台", async () => { const controller = createMockArtifactDocumentController(); const previewOptions: Array<{ - artifactDocumentLayoutMode?: "full" | "canvas-only"; onArtifactDocumentControllerChange?: ( value: ArtifactWorkbenchDocumentController | null, ) => void; @@ -613,7 +610,6 @@ describe("CanvasWorkbenchLayout", () => { onRevealPath: vi.fn(async () => undefined), renderPreview: (target, options) => { previewOptions.push({ - artifactDocumentLayoutMode: options?.artifactDocumentLayoutMode, onArtifactDocumentControllerChange: options?.onArtifactDocumentControllerChange, }); @@ -621,7 +617,6 @@ describe("CanvasWorkbenchLayout", () => { { expect( container.querySelector('[data-testid="preview-panel"]')?.textContent, - ).toContain("canvas-only:artifact:board-review.artifact.json"); - expect(previewOptions.at(-1)?.artifactDocumentLayoutMode).toBe("canvas-only"); + ).toContain("artifact:board-review.artifact.json"); + expect( + container.querySelector('button[aria-label="展开当前文稿检查器"]'), + ).not.toBeNull(); expect( container.querySelector('[data-testid="canvas-workbench-document-inspector"]'), - ).not.toBeNull(); + ).toBeNull(); expect(container.textContent).toContain("当前文稿"); expect(container.textContent).toContain("统一在右侧切换产物与版本"); expect(container.textContent).toContain("董事会季度复盘"); expect(container.textContent).toContain("需要优先补齐来源与版本线索。"); + expect(container.textContent).toContain( + "默认先收起概览、来源、版本与编辑", + ); + + clickButtonByLabel(container, "展开当前文稿检查器"); + await flushEffects(); + + expect( + container.querySelector('button[aria-label="折叠当前文稿检查器"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="canvas-workbench-document-inspector"]'), + ).not.toBeNull(); }); it("工作区文件为二进制时应展示不支持预览提示", async () => { diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index d30c309c0..515b45872 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -196,7 +196,6 @@ export interface CanvasWorkbenchLayoutProps { target: CanvasWorkbenchPreviewTarget, options?: { stackedWorkbenchTrigger?: ReactNode; - artifactDocumentLayoutMode?: "full" | "canvas-only"; onArtifactDocumentControllerChange?: ( controller: ArtifactWorkbenchDocumentController | null, ) => void; @@ -501,6 +500,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ const [stackedWorkbenchWidth, setStackedWorkbenchWidth] = useState( null, ); + const [documentInspectorCollapsed, setDocumentInspectorCollapsed] = useState(true); const [selectedKey, setSelectedKey] = useState(null); const [artifactDocumentController, setArtifactDocumentController] = useState(null); @@ -817,6 +817,10 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ setArtifactDocumentController(null); }, [selectedEntry]); + useEffect(() => { + setDocumentInspectorCollapsed(true); + }, [selectedEntry?.key, artifactDocumentController?.document?.artifactId]); + const currentTarget = useMemo(() => { if (activeTab === "team" && teamView?.enabled) { return { @@ -1054,6 +1058,23 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ const showDocumentInspector = Boolean( selectedEntry?.source === "artifact" && artifactDocumentController?.document, ); + const documentTitle = + artifactDocumentController?.document?.title?.trim() || + selectedEntry?.title || + "当前文稿"; + const documentSummary = + artifactDocumentController?.document?.summary?.trim() || + "当前选中的结构化文稿已接入右侧工作台,按需展开查看概览、来源、版本与编辑。"; + const versionCount = artifactDocumentController?.versionHistory.length || 0; + const sourceCount = artifactDocumentController?.sourceLinks.length || 0; + const diffCount = + artifactDocumentController?.currentVersionDiff?.changedBlocks.length || 0; + const currentVersionLabel = artifactDocumentController?.currentVersion + ? `v${artifactDocumentController.currentVersion.versionNo}` + : null; + const documentInspectorButtonLabel = documentInspectorCollapsed + ? "展开当前文稿检查器" + : "折叠当前文稿检查器"; return (
@@ -1121,28 +1142,65 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ {showDocumentInspector && artifactDocumentController ? ( - +
+ + + {documentInspectorCollapsed ? ( +
+ 默认先收起概览、来源、版本与编辑,避免小屏进入时直接挤占画布空间;需要时再展开查看。 +
+ ) : ( + + )} +
) : null}
); @@ -1694,7 +1752,6 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ ) : renderPreview(currentTarget, { stackedWorkbenchTrigger, - artifactDocumentLayoutMode: "canvas-only", onArtifactDocumentControllerChange: handleArtifactDocumentControllerChange, })} diff --git a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx index 96a07e7a5..2487aa60c 100644 --- a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx +++ b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx @@ -17,6 +17,7 @@ const { mockProviderPoolGetOverview, mockApiKeyProvidersGetProviders, mockEmitProviderDataChanged, + mockWechatChannelSetRuntimeModel, } = vi.hoisted(() => ({ mockInitAsterAgent: vi.fn(), mockCreateAgentRuntimeSession: vi.fn(), @@ -36,6 +37,7 @@ const { mockProviderPoolGetOverview: vi.fn(), mockApiKeyProvidersGetProviders: vi.fn(), mockEmitProviderDataChanged: vi.fn(), + mockWechatChannelSetRuntimeModel: vi.fn(async () => undefined), })); vi.mock("@/lib/api/agentRuntime", async () => { @@ -103,6 +105,10 @@ vi.mock("@/lib/providerDataEvents", () => ({ emitProviderDataChanged: mockEmitProviderDataChanged, })); +vi.mock("@/lib/api/channelsRuntime", () => ({ + wechatChannelSetRuntimeModel: mockWechatChannelSetRuntimeModel, +})); + import { useAsterAgentChat } from "../hooks/useAsterAgentChat"; import { ChatModelSelector } from "./ChatModelSelector"; @@ -270,6 +276,7 @@ beforeEach(() => { mockProviderPoolGetOverview.mockResolvedValue([]); mockApiKeyProvidersGetProviders.mockResolvedValue([]); mockEmitProviderDataChanged.mockImplementation(() => {}); + mockWechatChannelSetRuntimeModel.mockResolvedValue(undefined); const createdAt = Math.floor(Date.now() / 1000); mockListAgentRuntimeSessions.mockResolvedValue([ diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index c8e78d7ac..766fd95b9 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -350,9 +350,15 @@ describe("EmptyState", () => { latestCall.onSelectSkill?.(skill); }); - const sendButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("开始生成"), - ); + await act(async () => { + await Promise.resolve(); + }); + + expect(container.textContent).toContain("已挂载 canvas-design"); + + const sendButton = container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement | null; expect(sendButton).toBeTruthy(); act(() => { @@ -370,6 +376,50 @@ describe("EmptyState", () => { expect(onSend).toHaveBeenCalledWith("帮我设计封面", "react", undefined); }); + it("首页技能卡应复用统一的技能数量文案", async () => { + const container = renderEmptyState({ + skills: [ + { + key: "writer", + name: "写作助手", + description: "desc", + directory: "writer", + installed: true, + sourceKind: "builtin", + }, + ], + serviceSkills: [ + { + id: "trend-briefing", + title: "趋势情报", + summary: "输出趋势摘要", + category: "研究", + outputHint: "摘要", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + executionLocation: "client_default", + slotSchema: [], + version: "seed-v1", + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "浏览器执行", + runnerTone: "emerald", + runnerDescription: "复用登录态完成情报任务。", + actionLabel: "开始执行", + automationStatus: null, + }, + ], + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(container.textContent).toContain("2 项技能可挂载"); + }); + it("点击地球按钮应切换联网搜索开关", async () => { const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>(); const container = renderEmptyState({ @@ -381,7 +431,7 @@ describe("EmptyState", () => { }); const globeToggle = container.querySelector( - 'button[title="开启联网搜索"]', + 'button[title="联网搜索已关闭"]', ) as HTMLButtonElement | null; expect(globeToggle).toBeTruthy(); @@ -411,9 +461,9 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const sendButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("开始生成"), - ); + const sendButton = container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement | null; expect(sendButton).toBeTruthy(); act(() => { @@ -449,9 +499,9 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const sendButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("开始生成"), - ); + const sendButton = container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement | null; expect(sendButton).toBeTruthy(); act(() => { @@ -501,9 +551,9 @@ describe("EmptyState", () => { latestCall.onSelectSkill?.(skill); }); - const sendButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("开始生成"), - ); + const sendButton = container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement | null; expect(sendButton).toBeTruthy(); act(() => { @@ -517,38 +567,40 @@ describe("EmptyState", () => { ); }); - it("通用主题工具栏应包含附件、思考、后台任务与多代理开关", async () => { + it("通用主题工具栏应包含附件、思考、Plan 与多代理开关", async () => { const onThinkingEnabledChange = vi.fn<(enabled: boolean) => void>(); - const onTaskEnabledChange = vi.fn<(enabled: boolean) => void>(); const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>(); + const setExecutionStrategy = vi.fn< + (strategy: "react" | "code_orchestrated" | "auto") => void + >(); const container = renderEmptyState({ activeTheme: "general", thinkingEnabled: false, onThinkingEnabledChange, - taskEnabled: false, - onTaskEnabledChange, subagentEnabled: false, onSubagentEnabledChange, + executionStrategy: "react", + setExecutionStrategy, }); await act(async () => { await Promise.resolve(); }); const attachButton = container.querySelector( - 'button[title="上传文件"]', + 'button[title="添加图片"]', ) as HTMLButtonElement | null; expect(attachButton).toBeTruthy(); const thinkingButton = container.querySelector( - 'button[title="开启深度思考"]', + 'button[title="深度思考已关闭"]', ) as HTMLButtonElement | null; expect(thinkingButton).toBeTruthy(); - const taskButton = container.querySelector( - 'button[title="开启后台任务偏好"]', + const planButton = container.querySelector( + '[data-testid="inputbar-plan-toggle"]', ) as HTMLButtonElement | null; - expect(taskButton).toBeTruthy(); + expect(planButton).toBeTruthy(); const subagentButton = container.querySelector( - 'button[title="开启多代理偏好"]', + 'button[title="多代理偏好已关闭"]', ) as HTMLButtonElement | null; expect(subagentButton).toBeTruthy(); @@ -556,14 +608,14 @@ describe("EmptyState", () => { thinkingButton?.click(); }); act(() => { - taskButton?.click(); + planButton?.click(); }); act(() => { subagentButton?.click(); }); expect(onThinkingEnabledChange).toHaveBeenCalledWith(true); - expect(onTaskEnabledChange).toHaveBeenCalledWith(true); + expect(setExecutionStrategy).toHaveBeenCalledWith("code_orchestrated"); expect(onSubagentEnabledChange).toHaveBeenCalledWith(true); }); diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 9f5491765..9fcb501fc 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -37,8 +37,6 @@ import { EmptyStateComposerPanel } from "./EmptyStateComposerPanel"; import { EmptyStateHero } from "./EmptyStateHero"; import { EmptyStateQuickActions } from "./EmptyStateQuickActions"; import { - EMPTY_STATE_BACKGROUND_ORB_LEFT_CLASSNAME, - EMPTY_STATE_BACKGROUND_ORB_RIGHT_CLASSNAME, EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME, EMPTY_STATE_PAGE_CONTAINER_CLASSNAME, EMPTY_STATE_SECONDARY_ACTION_BUTTON_CLASSNAME, @@ -47,17 +45,21 @@ import { getEmptyStateThemeTabIconClassName, } from "./emptyStateSurfaceTokens"; import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill"; +import type { SkillSelectionSourceProps } from "./Inputbar/components/skillSelectionBindings"; import type { Character } from "@/lib/api/memory"; -import type { Skill } from "@/lib/api/skills"; import type { WorkspaceSettings } from "@/types/workspace"; import type { MessageImage } from "../types"; import type { TeamDefinition } from "../utils/teamDefinitions"; import { isGeneralResearchTheme } from "../utils/generalAgentPrompt"; +import type { AgentAccessMode } from "../hooks/agentChatStorage"; import { getClipboardImageCandidates, readImageAttachment, } from "../utils/imageAttachments"; -import type { ServiceSkillHomeItem } from "../service-skills/types"; +import { + getActiveSkillDisplayLabel, + getSkillSelectionSummaryLabel, +} from "./Inputbar/components/skillSelectionDisplay"; // Import Assets import capabilitySkillsPlaceholder from "@/assets/claw-home/capability-skills-placeholder.svg"; @@ -93,28 +95,6 @@ function scheduleDeferredConfigLoad(task: () => void): () => void { }; } -const backgroundOrbDrift = keyframes` - 0%, 100% { - transform: translate3d(0, 0, 0) scale(1); - opacity: 0.92; - } - 50% { - transform: translate3d(18px, -14px, 0) scale(1.05); - opacity: 1; - } -`; - -const backgroundOrbPulse = keyframes` - 0%, 100% { - transform: translate3d(0, 0, 0) scale(1); - opacity: 0.9; - } - 50% { - transform: translate3d(-16px, 18px, 0) scale(1.08); - opacity: 1; - } -`; - const contentReveal = keyframes` from { opacity: 0; @@ -132,28 +112,6 @@ const PageContainer = styled.div.attrs({ isolation: isolate; `; -const BackgroundOrbLeft = styled.div.attrs({ - className: EMPTY_STATE_BACKGROUND_ORB_LEFT_CLASSNAME, -})` - animation: ${backgroundOrbDrift} 18s ease-in-out infinite; - will-change: transform, opacity; - - @media (prefers-reduced-motion: reduce) { - animation: none; - } -`; - -const BackgroundOrbRight = styled.div.attrs({ - className: EMPTY_STATE_BACKGROUND_ORB_RIGHT_CLASSNAME, -})` - animation: ${backgroundOrbPulse} 22s ease-in-out infinite; - will-change: transform, opacity; - - @media (prefers-reduced-motion: reduce) { - animation: none; - } -`; - const ContentWrapper = styled.div.attrs({ className: EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME, })` @@ -164,7 +122,7 @@ const ContentWrapper = styled.div.attrs({ } `; -interface EmptyStateProps { +interface EmptyStateProps extends SkillSelectionSourceProps { input: string; setInput: (value: string) => void; onSend: ( @@ -192,13 +150,13 @@ interface EmptyStateProps { setExecutionStrategy?: ( strategy: "react" | "code_orchestrated" | "auto", ) => void; + accessMode?: AgentAccessMode; + setAccessMode?: (mode: AgentAccessMode) => void; onManageProviders?: () => void; webSearchEnabled?: boolean; onWebSearchEnabledChange?: (enabled: boolean) => void; thinkingEnabled?: boolean; onThinkingEnabledChange?: (enabled: boolean) => void; - taskEnabled?: boolean; - onTaskEnabledChange?: (enabled: boolean) => void; subagentEnabled?: boolean; onSubagentEnabledChange?: (enabled: boolean) => void; selectedTeam?: TeamDefinition | null; @@ -211,20 +169,6 @@ interface EmptyStateProps { selectedText?: string; /** 角色列表(用于 @ 引用) */ characters?: Character[]; - /** 技能列表(用于 @ 引用) */ - skills?: Skill[]; - /** 服务型技能列表(用于 @ 引用) */ - serviceSkills?: ServiceSkillHomeItem[]; - /** 技能列表加载状态 */ - isSkillsLoading?: boolean; - /** 选择服务型技能回调 */ - onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; - /** 跳转到设置页安装技能 */ - onNavigateToSettings?: () => void; - /** 导入本地技能 */ - onImportSkill?: () => void | Promise; - /** 刷新技能 */ - onRefreshSkills?: () => void | Promise; /** 启动浏览器协助 */ onLaunchBrowserAssist?: () => void | Promise; /** 浏览器协助启动中 */ @@ -388,13 +332,13 @@ export const EmptyState: React.FC = ({ setModel, executionStrategy = "react", setExecutionStrategy, + accessMode, + setAccessMode, onManageProviders, webSearchEnabled = false, onWebSearchEnabledChange, thinkingEnabled = false, onThinkingEnabledChange, - taskEnabled = false, - onTaskEnabledChange, subagentEnabled = false, onSubagentEnabledChange, selectedTeam = null, @@ -406,9 +350,9 @@ export const EmptyState: React.FC = ({ hasContentId = false, selectedText = "", characters = [], - skills = [], - serviceSkills = [], - isSkillsLoading = false, + skills, + serviceSkills, + isSkillsLoading, onSelectServiceSkill, onNavigateToSettings, onImportSkill, @@ -424,8 +368,25 @@ export const EmptyState: React.FC = ({ configLoadStrategy = "immediate", supportingSlotOverride, }) => { - const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } = - useActiveSkill(); + const { wrapTextWithSkill, buildSkillSelection } = useActiveSkill(); + const skillSelection = buildSkillSelection({ + skills, + serviceSkills, + isSkillsLoading, + onSelectServiceSkill, + onNavigateToSettings, + onImportSkill, + onRefreshSkills, + }); + const currentSkill = skillSelection.activeSkill; + const clearSelectedSkill = skillSelection.onClearSkill; + const skillOptionCount = + skillSelection.skills.length + skillSelection.serviceSkills.length; + const activeSkillDisplayLabel = getActiveSkillDisplayLabel(currentSkill); + const skillSummaryLabel = getSkillSelectionSummaryLabel({ + activeSkill: currentSkill, + skillCount: skillOptionCount, + }); // 从配置中读取启用的主题 const [enabledThemes, setEnabledThemes] = useState( @@ -684,7 +645,7 @@ export const EmptyState: React.FC = ({ imagesToSend, ); setPendingImages([]); - clearActiveSkill(); + clearSelectedSkill?.(); return; } @@ -705,15 +666,11 @@ export const EmptyState: React.FC = ({ imagesToSend, ); setPendingImages([]); - clearActiveSkill(); + clearSelectedSkill?.(); }; - const executionStrategyLabel = - executionStrategy === "auto" - ? "Auto" - : executionStrategy === "code_orchestrated" - ? "Plan" - : "ReAct"; + const planEnabled = executionStrategy === "code_orchestrated"; + const executionModeLabel = planEnabled ? "Plan 已开启" : "直接执行"; const activeCategory = ALL_CATEGORIES.find((category) => category.id === activeTheme) || @@ -817,7 +774,7 @@ export const EmptyState: React.FC = ({ }, { key: "execution", - label: `执行 ${executionStrategyLabel}`, + label: executionModeLabel, tone: "sky", }, ]; @@ -854,10 +811,10 @@ export const EmptyState: React.FC = ({ }); } - if (activeSkill) { + if (activeSkillDisplayLabel) { badges.push({ key: "skill", - label: `技能 ${activeSkill.name}`, + label: activeSkillDisplayLabel, tone: "emerald", }); } @@ -865,14 +822,14 @@ export const EmptyState: React.FC = ({ return badges.slice(0, 5); }, [ activeCategory.label, - activeSkill, activeTheme, creationMode, depth, - executionStrategyLabel, + executionModeLabel, platform, showCreationModeSelector, webSearchEnabled, + activeSkillDisplayLabel, ]); const workspaceCards = useMemo(() => { @@ -892,11 +849,7 @@ export const EmptyState: React.FC = ({ key: "skills", eyebrow: "能力层", title: "技能", - value: activeSkill - ? `当前技能 ${activeSkill.name}` - : skills.length > 0 - ? `${skills.length} 项技能可用` - : "按需挂载能力", + value: skillSummaryLabel, description: "把技能当作任务能力层来用,可把固定工作流、提示链和工具调用打包进一次对话。", icon: , @@ -908,11 +861,7 @@ export const EmptyState: React.FC = ({ key: "automation", eyebrow: "能力层", title: "自动化", - value: taskEnabled - ? "后台任务已开启" - : executionStrategy === "auto" - ? "自动执行策略" - : `${executionStrategyLabel} 执行`, + value: planEnabled ? "Plan 编排已开启" : "按当前对话直接执行", description: "支持把复杂任务按步骤推进,适合长链路处理、批量执行和需要持续产出的工作流。", icon: , @@ -963,14 +912,11 @@ export const EmptyState: React.FC = ({ return cards; }, [ - activeSkill, browserAssistLoading, - executionStrategyLabel, - executionStrategy, + planEnabled, onLaunchBrowserAssist, - skills.length, + skillSummaryLabel, subagentEnabled, - taskEnabled, ]); const workspaceFeatures = useMemo(() => { @@ -1118,8 +1064,9 @@ export const EmptyState: React.FC = ({ setModel={setModel} workspaceId={projectId} executionStrategy={executionStrategy} - executionStrategyLabel={executionStrategyLabel} setExecutionStrategy={setExecutionStrategy} + accessMode={accessMode} + setAccessMode={setAccessMode} onManageProviders={onManageProviders} modelSelectorBackgroundPreload={modelSelectorBackgroundPreload} isGeneralTheme={isGeneralTheme} @@ -1133,16 +1080,7 @@ export const EmptyState: React.FC = ({ onEntryTaskTypeChange={setEntryTaskType} onEntrySlotChange={handleEntrySlotChange} characters={characters} - skills={skills} - serviceSkills={serviceSkills} - activeSkill={activeSkill} - setActiveSkill={setActiveSkill} - onSelectServiceSkill={onSelectServiceSkill} - clearActiveSkill={clearActiveSkill} - isSkillsLoading={isSkillsLoading} - onNavigateToSettings={onNavigateToSettings} - onImportSkill={onImportSkill} - onRefreshSkills={onRefreshSkills} + skillSelection={skillSelection} showCreationModeSelector={showCreationModeSelector} creationMode={creationMode} onCreationModeChange={onCreationModeChange} @@ -1160,8 +1098,6 @@ export const EmptyState: React.FC = ({ setStylePopoverOpen={setStylePopoverOpen} thinkingEnabled={thinkingEnabled} onThinkingEnabledChange={onThinkingEnabledChange} - taskEnabled={taskEnabled} - onTaskEnabledChange={onTaskEnabledChange} subagentEnabled={subagentEnabled} onSubagentEnabledChange={onSubagentEnabledChange} selectedTeam={selectedTeam} @@ -1195,7 +1131,7 @@ export const EmptyState: React.FC = ({ const headerControls = onProjectChange ? (
-
+
= ({ return ( - - ({ ChatModelSelector: () =>
, @@ -62,9 +70,25 @@ afterEach(() => { }); mounted.container.remove(); } + vi.useRealTimers(); + resetStableProcessingNoticeMemoryForTest(); vi.clearAllMocks(); }); +function createSkillSelection( + overrides: Partial = {}, +): SkillSelectionProps { + return createSkillSelectionProps({ + skills: [], + onSelectSkill: vi.fn(), + onClearSkill: vi.fn(), + onNavigateToSettings: vi.fn(), + onImportSkill: vi.fn(), + onRefreshSkills: vi.fn(), + ...overrides, + }); +} + function renderPanel( props?: Partial>, ) { @@ -83,7 +107,6 @@ function renderPanel( model: "gpt-4.1", setModel: vi.fn(), executionStrategy: "react", - executionStrategyLabel: "ReAct", setExecutionStrategy: vi.fn(), onManageProviders: vi.fn(), isGeneralTheme: false, @@ -103,14 +126,7 @@ function renderPanel( onEntryTaskTypeChange: vi.fn(), onEntrySlotChange: vi.fn(), characters: [], - skills: [], - activeSkill: null, - setActiveSkill: vi.fn(), - clearActiveSkill: vi.fn(), - isSkillsLoading: false, - onNavigateToSettings: vi.fn(), - onImportSkill: vi.fn(), - onRefreshSkills: vi.fn(), + skillSelection: createSkillSelection(), showCreationModeSelector: false, creationMode: "guided", onCreationModeChange: vi.fn(), @@ -128,8 +144,6 @@ function renderPanel( setStylePopoverOpen: vi.fn(), thinkingEnabled: false, onThinkingEnabledChange: vi.fn(), - taskEnabled: false, - onTaskEnabledChange: vi.fn(), subagentEnabled: false, onSubagentEnabledChange: vi.fn(), webSearchEnabled: false, @@ -177,7 +191,6 @@ function renderStatefulPanel( model="gpt-4.1" setModel={vi.fn()} executionStrategy="react" - executionStrategyLabel="ReAct" setExecutionStrategy={vi.fn()} onManageProviders={vi.fn()} isGeneralTheme @@ -197,14 +210,7 @@ function renderStatefulPanel( onEntryTaskTypeChange={vi.fn()} onEntrySlotChange={vi.fn()} characters={[]} - skills={[]} - activeSkill={null} - setActiveSkill={vi.fn()} - clearActiveSkill={vi.fn()} - isSkillsLoading={false} - onNavigateToSettings={vi.fn()} - onImportSkill={vi.fn()} - onRefreshSkills={vi.fn()} + skillSelection={createSkillSelection()} showCreationModeSelector={false} creationMode="guided" onCreationModeChange={vi.fn()} @@ -222,8 +228,6 @@ function renderStatefulPanel( setStylePopoverOpen={vi.fn()} thinkingEnabled={false} onThinkingEnabledChange={vi.fn()} - taskEnabled={false} - onTaskEnabledChange={vi.fn()} subagentEnabled={subagentEnabled} onSubagentEnabledChange={setSubagentEnabled} webSearchEnabled={false} @@ -246,6 +250,31 @@ function renderStatefulPanel( } describe("EmptyStateComposerPanel", () => { + it("首页空态输入区应保留技能下拉,但与 @ 面板共用同一技能入口", () => { + const container = renderPanel({ + isGeneralTheme: true, + skillSelection: createSkillSelection({ + skills: [ + { + key: "writer", + name: "写作助手", + description: "用于写作", + directory: "writer", + installed: true, + sourceKind: "builtin", + }, + ], + }), + }); + + expect( + container.querySelector('[data-testid="empty-state-character-mention"]'), + ).toBeTruthy(); + expect( + container.querySelector('[data-testid="empty-state-skill-selector"]'), + ).toBeTruthy(); + }); + it("应将 onPaste 绑定到输入框", () => { const onPaste = vi.fn(); const container = renderPanel({ onPaste }); @@ -272,10 +301,10 @@ describe("EmptyStateComposerPanel", () => { onRemoveImage, }); - expect(container.querySelector('img[alt="待发送图片 1"]')).toBeTruthy(); + expect(container.querySelector('img[alt="预览 1"]')).toBeTruthy(); const removeButton = container.querySelector( - 'button[aria-label="移除待发送图片 1"]', + 'button[aria-label="移除图片 1"]', ) as HTMLButtonElement | null; expect(removeButton).toBeTruthy(); @@ -360,12 +389,34 @@ describe("EmptyStateComposerPanel", () => { ).toBeNull(); const toggleButton = container.querySelector( - 'button[title="开启多代理偏好"]', + 'button[title="多代理偏好已关闭"]', ) as HTMLButtonElement | null; expect(toggleButton).toBeTruthy(); }); + it("应通过 Plan 开关透传执行策略切换,不再渲染执行模式下拉", () => { + const setExecutionStrategy = vi.fn(); + const container = renderPanel({ + executionStrategy: "react", + setExecutionStrategy, + }); + + const planToggle = container.querySelector( + '[data-testid="inputbar-plan-toggle"]', + ) as HTMLButtonElement | null; + + expect(planToggle).toBeTruthy(); + expect(container.textContent).not.toContain("ReAct"); + expect(container.textContent).not.toContain("Auto"); + + act(() => { + planToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(setExecutionStrategy).toHaveBeenCalledWith("code_orchestrated"); + }); + it("即使已经保留 Team 方案,关闭 Team mode 后也不应显示 TeamSelector", () => { const container = renderPanel({ isGeneralTheme: true, @@ -383,11 +434,12 @@ describe("EmptyStateComposerPanel", () => { expect(enableButton).toBeNull(); expect( - container.querySelector('button[title="开启多代理偏好"]'), + container.querySelector('button[title="多代理偏好已关闭"]'), ).toBeTruthy(); }); - it("命中稳妥模式模型时应在首页输入区前置提示", () => { + it("命中稳妥模式模型时应短暂提示后自动收起且不再重复提醒", () => { + vi.useFakeTimers(); const container = renderPanel({ providerType: "openai", model: "glm-4.7", @@ -400,13 +452,34 @@ describe("EmptyStateComposerPanel", () => { ).toBeTruthy(); expect(container.textContent).toContain("稳妥模式"); expect(container.textContent).toContain("依次开始同类请求"); + + act(() => { + vi.advanceTimersByTime(STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS + 1); + }); + + expect( + container.querySelector( + '[data-testid="empty-state-stable-processing-notice"]', + ), + ).toBeNull(); + + const nextContainer = renderPanel({ + providerType: "openai", + model: "glm-4.7", + }); + + expect( + nextContainer.querySelector( + '[data-testid="empty-state-stable-processing-notice"]', + ), + ).toBeNull(); }); it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => { const container = renderStatefulPanel(); const enableButton = container.querySelector( - 'button[title="开启多代理偏好"]', + 'button[title="多代理偏好已关闭"]', ) as HTMLButtonElement | null; expect(enableButton).toBeTruthy(); @@ -441,7 +514,7 @@ describe("EmptyStateComposerPanel", () => { ).toBeTruthy(); const toggleButton = container.querySelector( - 'button[title="关闭多代理偏好"]', + 'button[title="多代理偏好已开启"]', ) as HTMLButtonElement | null; expect(toggleButton).toBeTruthy(); @@ -458,7 +531,7 @@ describe("EmptyStateComposerPanel", () => { container.querySelector('[data-testid="empty-state-team-selector"]'), ).toBeNull(); expect( - container.querySelector('button[title="开启多代理偏好"]'), + container.querySelector('button[title="多代理偏好已关闭"]'), ).toBeTruthy(); }); @@ -479,10 +552,34 @@ describe("EmptyStateComposerPanel", () => { container.querySelector('[data-testid="empty-state-team-mode-enable-button"]'), ).toBeNull(); expect( - container.querySelector('button[title="开启多代理偏好"]'), + container.querySelector('button[title="多代理偏好已关闭"]'), ).toBeTruthy(); expect(enableButton).toBeTruthy(); expect(enableButton?.textContent).toContain("启用 Team"); expect(container.textContent).toContain("当前任务更适合 Team 协作"); }); + + it("应渲染权限模式选择并透传切换", () => { + const setAccessMode = vi.fn(); + const container = renderPanel({ + accessMode: "current", + setAccessMode, + }); + + const select = container.querySelector( + '[data-testid="inputbar-access-mode-select"]', + ) as HTMLSelectElement | null; + + expect(select).toBeTruthy(); + expect(select?.value).toBe("current"); + + act(() => { + if (select) { + select.value = "full-access"; + select.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + expect(setAccessMode).toHaveBeenCalledWith("full-access"); + }); }); diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx index e17439817..104150d27 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx @@ -1,21 +1,12 @@ import React, { useMemo, useRef, useState } from "react"; -import styled, { keyframes } from "styled-components"; +import styled from "styled-components"; import { - ArrowRight, BrainCircuit, ChevronDown, - Code2, Globe, - Lightbulb, - ListChecks, - Paperclip, - Search, - Workflow, - X, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, @@ -29,14 +20,16 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Badge } from "@/components/ui/badge"; -import { ChatModelSelector } from "./ChatModelSelector"; import { TeamSuggestionBar } from "./TeamSuggestionBar"; import { CharacterMention } from "./Inputbar/components/CharacterMention"; +import { InputbarAccessModeSelect } from "./Inputbar/components/InputbarAccessModeSelect"; +import { InputbarCore } from "./Inputbar/components/InputbarCore"; +import { InputbarExecutionStrategySelect } from "./Inputbar/components/InputbarExecutionStrategySelect"; +import { InputbarModelExtra } from "./Inputbar/components/InputbarModelExtra"; import { SkillBadge } from "./Inputbar/components/SkillBadge"; import { SkillSelector } from "./Inputbar/components/SkillSelector"; import { TeamSelector } from "./Inputbar/components/TeamSelector"; import { StableProcessingNotice } from "./StableProcessingNotice"; -import type { ServiceSkillHomeItem } from "../service-skills/types"; import type { WorkspaceSettings } from "@/types/workspace"; import { CREATION_MODE_CONFIG } from "./constants"; import type { @@ -46,7 +39,6 @@ import type { EntryTaskType, } from "./types"; import type { Character } from "@/lib/api/memory"; -import type { Skill } from "@/lib/api/skills"; import type { MessageImage } from "../types"; import type { TeamDefinition } from "../utils/teamDefinitions"; @@ -57,303 +49,17 @@ import iconToutiao from "@/assets/platforms/toutiao.png"; import iconJuejin from "@/assets/platforms/juejin.png"; import iconCsdn from "@/assets/platforms/csdn.png"; import { - EMPTY_STATE_ICON_TOOL_BUTTON_CLASSNAME, EMPTY_STATE_PASSIVE_BADGE_CLASSNAME, - EMPTY_STATE_PRIMARY_ACTION_BUTTON_CLASSNAME, EMPTY_STATE_SELECT_TRIGGER_CLASSNAME, - getEmptyStateIconToolButtonClassName, } from "./emptyStateSurfaceTokens"; import type { ModelSelectorProps } from "@/components/input-kit"; import { getTeamSuggestion } from "../utils/teamSuggestion"; - -const composerReveal = keyframes` - from { - opacity: 0; - transform: translateY(16px); - } - to { - opacity: 1; - transform: translateY(0); - } -`; - -const composerAura = keyframes` - 0%, 100% { - transform: translate3d(0, 0, 0) scale(1); - opacity: 0.78; - } - 50% { - transform: translate3d(22px, -16px, 0) scale(1.08); - opacity: 1; - } -`; - -const composerSheen = keyframes` - 0% { - transform: translateX(-150%); - } - 15%, - 100% { - transform: translateX(170%); - } -`; - -const buttonGlow = keyframes` - 0%, 100% { - box-shadow: 0 14px 28px -18px rgba(15, 23, 42, 0.28); - } - 50% { - box-shadow: 0 18px 34px -18px rgba(15, 23, 42, 0.36); - } -`; - -const InputCard = styled.div` - width: 100%; - position: relative; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.96) 0%, - rgba(248, 250, 252, 0.92) 100% - ); - border: 1px solid rgba(226, 232, 240, 0.82); - border-radius: 24px; - box-shadow: - 0 18px 32px -24px rgba(15, 23, 42, 0.14), - 0 10px 18px -16px rgba(15, 23, 42, 0.08); - overflow: visible; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - backdrop-filter: blur(14px); - animation: ${composerReveal} 600ms cubic-bezier(0.22, 1, 0.36, 1) both; - - &::before { - content: ""; - position: absolute; - left: -8%; - top: -18%; - width: 240px; - height: 240px; - border-radius: 999px; - background: radial-gradient( - circle, - rgba(16, 185, 129, 0.1) 0%, - rgba(16, 185, 129, 0.04) 42%, - transparent 72% - ); - filter: blur(26px); - animation: ${composerAura} 14s ease-in-out infinite; - pointer-events: none; - } - - &::after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: -32%; - width: 26%; - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 0.34) 48%, - rgba(255, 255, 255, 0) 100% - ); - opacity: 0.55; - transform: translateX(-150%); - animation: ${composerSheen} 10s ease-in-out infinite; - pointer-events: none; - mix-blend-mode: screen; - } - - > * { - position: relative; - z-index: 1; - } - - &:hover { - box-shadow: - 0 20px 36px -24px rgba(15, 23, 42, 0.16), - 0 12px 20px -18px rgba(15, 23, 42, 0.1); - border-color: rgba(203, 213, 225, 0.92); - } - - &:focus-within { - border-color: rgba(148, 163, 184, 0.86); - box-shadow: - 0 0 0 3px rgba(226, 232, 240, 0.78), - 0 20px 36px -24px rgba(15, 23, 42, 0.12); - } - - @media (prefers-reduced-motion: reduce) { - animation: none; - - &::before, - &::after { - animation: none; - } - } -`; - -const StyledTextarea = styled(Textarea)` - min-height: 76px; - padding: 14px 18px; - border: none; - font-size: 15px; - line-height: 1.5; - resize: none; - background: transparent; - color: #0f172a; - - &::placeholder { - color: rgba(100, 116, 139, 0.82); - font-weight: 300; - } - - &:focus-visible { - ring: 0; - outline: none; - box-shadow: none; - } - - @media (min-width: 768px) { - min-height: 88px; - padding: 16px 20px; - } -`; - -const Toolbar = styled.div` - display: flex; - align-items: flex-start; - flex-wrap: wrap; - justify-content: space-between; - gap: 8px 10px; - padding: 10px 14px 12px 14px; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0) 0%, - rgba(241, 245, 249, 0.82) 100% - ); - border-top: 1px solid rgba(226, 232, 240, 0.82); - border-bottom-left-radius: 24px; - border-bottom-right-radius: 24px; -`; - -const PendingImagesRow = styled.div` - display: flex; - flex-wrap: wrap; - gap: 10px; - padding: 0 18px 12px; -`; - -const PendingImageItem = styled.div` - position: relative; - width: 64px; - height: 64px; - overflow: hidden; - border-radius: 16px; - border: 1px solid rgba(226, 232, 240, 0.92); - background: rgba(248, 250, 252, 0.96); - box-shadow: 0 10px 20px -18px rgba(15, 23, 42, 0.3); -`; - -const PendingImagePreview = styled.img` - width: 100%; - height: 100%; - object-fit: cover; -`; - -const PendingImageRemoveButton = styled.button` - position: absolute; - top: 6px; - right: 6px; - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border: none; - border-radius: 999px; - color: #fff; - background: rgba(15, 23, 42, 0.72); - box-shadow: 0 6px 14px -10px rgba(15, 23, 42, 0.5); - cursor: pointer; - transition: background-color 0.16s ease; - - &:hover { - background: rgba(220, 38, 38, 0.92); - } -`; - -const ToolLoginLeft = styled.div` - display: flex; - align-items: center; - gap: 7px; - flex-wrap: wrap; - flex: 1 1 640px; -`; - -const ToolbarRight = styled.div` - display: flex; - align-items: center; - justify-content: flex-end; - margin-left: auto; - - @media (max-width: 640px) { - width: 100%; - margin-left: 0; - } -`; - -const LaunchButton = styled(Button).attrs({ - className: EMPTY_STATE_PRIMARY_ACTION_BUTTON_CLASSNAME, -})` - position: relative; - overflow: hidden; - background: linear-gradient( - 135deg, - rgba(15, 23, 42, 0.96) 0%, - rgba(71, 85, 105, 0.98) 100% - ); - box-shadow: 0 14px 28px -18px rgba(15, 23, 42, 0.28); - animation: ${buttonGlow} 3.2s ease-in-out infinite; - transition: - transform 180ms ease, - box-shadow 180ms ease, - filter 180ms ease; - - &::before { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - 110deg, - rgba(255, 255, 255, 0) 24%, - rgba(255, 255, 255, 0.18) 48%, - rgba(255, 255, 255, 0) 72% - ); - transform: translateX(-130%); - animation: ${composerSheen} 5.8s ease-in-out infinite; - pointer-events: none; - } - - &:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 18px 34px -18px rgba(15, 23, 42, 0.36); - filter: brightness(1.02); - } - - &:disabled { - opacity: 0.62; - animation: none; - } - - @media (prefers-reduced-motion: reduce) { - animation: none; - - &::before { - animation: none; - } - } -`; +import { useStableProcessingNotice } from "../hooks/useStableProcessingNotice"; +import { + buildSkillSelectionBindings, + type SkillSelectionProps, +} from "./Inputbar/components/skillSelectionBindings"; +import type { AgentAccessMode } from "../hooks/agentChatStorage"; const ColorDot = styled.div<{ $color: string }>` width: 16px; @@ -479,10 +185,11 @@ interface EmptyStateComposerPanelProps { setModel: (model: string) => void; workspaceId?: string | null; executionStrategy?: "react" | "code_orchestrated" | "auto"; - executionStrategyLabel: string; setExecutionStrategy?: ( strategy: "react" | "code_orchestrated" | "auto", ) => void; + accessMode?: AgentAccessMode; + setAccessMode?: (mode: AgentAccessMode) => void; onManageProviders?: () => void; modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"]; isGeneralTheme: boolean; @@ -496,16 +203,7 @@ interface EmptyStateComposerPanelProps { onEntryTaskTypeChange: (type: EntryTaskType) => void; onEntrySlotChange: (key: string, value: string) => void; characters: Character[]; - skills: Skill[]; - serviceSkills?: ServiceSkillHomeItem[]; - activeSkill?: Skill | null; - setActiveSkill: (skill: Skill) => void; - onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; - clearActiveSkill: () => void; - isSkillsLoading: boolean; - onNavigateToSettings?: () => void; - onImportSkill?: () => void | Promise; - onRefreshSkills?: () => void | Promise; + skillSelection: SkillSelectionProps; showCreationModeSelector: boolean; creationMode: CreationMode; onCreationModeChange?: (mode: CreationMode) => void; @@ -523,8 +221,6 @@ interface EmptyStateComposerPanelProps { setStylePopoverOpen: (open: boolean) => void; thinkingEnabled: boolean; onThinkingEnabledChange?: (enabled: boolean) => void; - taskEnabled: boolean; - onTaskEnabledChange?: (enabled: boolean) => void; subagentEnabled: boolean; onSubagentEnabledChange?: (enabled: boolean) => void; selectedTeam?: TeamDefinition | null; @@ -552,8 +248,9 @@ export function EmptyStateComposerPanel({ setModel, workspaceId, executionStrategy = "react", - executionStrategyLabel, setExecutionStrategy, + accessMode, + setAccessMode, onManageProviders, modelSelectorBackgroundPreload = "immediate", isGeneralTheme, @@ -567,16 +264,7 @@ export function EmptyStateComposerPanel({ onEntryTaskTypeChange, onEntrySlotChange, characters, - skills, - serviceSkills = [], - activeSkill, - setActiveSkill, - onSelectServiceSkill, - clearActiveSkill, - isSkillsLoading, - onNavigateToSettings, - onImportSkill, - onRefreshSkills, + skillSelection, showCreationModeSelector, creationMode, onCreationModeChange, @@ -594,8 +282,6 @@ export function EmptyStateComposerPanel({ setStylePopoverOpen, thinkingEnabled, onThinkingEnabledChange, - taskEnabled, - onTaskEnabledChange, subagentEnabled, onSubagentEnabledChange, selectedTeam, @@ -618,13 +304,14 @@ export function EmptyStateComposerPanel({ const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState< number | null >(null); - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - onSend(); - } - }; + const shouldShowStableNotice = useStableProcessingNotice({ + providerType, + model, + }); + const activeSkill = skillSelection.activeSkill ?? null; + const clearActiveSkill = skillSelection.onClearSkill; + const { mentionProps: mentionSkillProps, selectorProps: skillSelectorProps } = + buildSkillSelectionBindings(skillSelection); const getPlatformIcon = (value: string) => PLATFORM_ICON_MAP[value]; const getPlatformLabel = (value: string) => @@ -663,84 +350,371 @@ export function EmptyStateComposerPanel({ onSubagentEnabledChange?.(!subagentEnabled); }; - return ( - - {isEntryTheme && ( - - - {entryTaskTypes.map((task) => { - const taskTemplate = - task === entryTaskType - ? entryTemplate - : getEntryTaskTemplate(task); - return ( - onEntryTaskTypeChange(task)} - title={taskTemplate?.description} - > - {taskTemplate?.label || task} - - ); - })} - + const handleToolAction = (tool: string) => { + switch (tool) { + case "attach": + imageInputRef.current?.click(); + return; + case "thinking": + onThinkingEnabledChange?.(!thinkingEnabled); + return; + case "web_search": + onWebSearchEnabledChange?.(!webSearchEnabled); + return; + case "subagent_mode": + handleToggleSubagentMode(); + return; + default: + return; + } + }; - - {entryPreview.split(/(\[[^\]]+\])/g).map((chunk, index) => { - const isToken = /^\[[^\]]+\]$/.test(chunk); - if (!chunk) return null; - if (!isToken) { + const topExtra = + isEntryTheme || + Boolean(activeSkill) || + shouldShowTeamSuggestion || + shouldShowStableNotice ? ( + <> + {isEntryTheme ? ( + + + {entryTaskTypes.map((task) => { + const taskTemplate = + task === entryTaskType + ? entryTemplate + : getEntryTaskTemplate(task); return ( - - {chunk} - + onEntryTaskTypeChange(task)} + title={taskTemplate?.description} + > + {taskTemplate?.label || task} + ); - } + })} + - return {chunk}; - })} - - - - {entryTemplate.slots.map((slot) => ( - - onEntrySlotChange(slot.key, event.target.value) + + {entryPreview.split(/(\[[^\]]+\])/g).map((chunk, index) => { + const isToken = /^\[[^\]]+\]$/.test(chunk); + if (!chunk) return null; + if (!isToken) { + return ( + + {chunk} + + ); } - placeholder={slot.placeholder} - className="h-9 rounded-xl border-slate-200/80 bg-white/88 text-xs shadow-none focus-visible:ring-1 focus-visible:ring-slate-200" - /> - ))} - - - )} - {activeSkill ? ( - + return {chunk}; + })} + + + + {entryTemplate.slots.map((slot) => ( + + onEntrySlotChange(slot.key, event.target.value) + } + placeholder={slot.placeholder} + className="h-9 rounded-xl border-slate-200/80 bg-white/88 text-xs shadow-none focus-visible:ring-1 focus-visible:ring-slate-200" + /> + ))} + + + ) : null} + + {activeSkill ? ( + + ) : null} + + {shouldShowTeamSuggestion ? ( + + ) : null} + + {shouldShowStableNotice ? ( + + ) : null} + + ) : undefined; + + const shouldShowThemeSpecificExtra = + activeTheme === "social-media" || + showCreationModeSelector || + activeTheme === "knowledge" || + activeTheme === "planning" || + activeTheme === "poster"; + const shouldShowModelExtra = Boolean(providerType?.trim() && model?.trim()); + const shouldShowLeftExtra = + isGeneralTheme || + shouldShowTeamSelector || + Boolean(setExecutionStrategy) || + shouldShowModelExtra || + Boolean(setAccessMode) || + shouldShowThemeSpecificExtra; + const leftExtra = shouldShowLeftExtra ? ( + <> + {isGeneralTheme ? : null} + + {shouldShowTeamSelector ? ( + onSelectTeam?.(team)} + /> ) : null} - setInput(event.target.value)} - onKeyDown={handleKeyDown} - onPaste={onPaste} - placeholder={placeholder} + + + + + + {activeTheme === "social-media" ? ( + + ) : null} + + {showCreationModeSelector ? ( + + ) : null} + + {activeTheme === "knowledge" ? ( + + ) : null} + + {activeTheme === "planning" ? ( + + + 旅行/职业/活动 + + ) : null} + + {activeTheme === "poster" ? ( + <> + { + setRatioPopoverOpen(open); + if (open) setStylePopoverOpen(false); + }} + > + + + + +
+ 宽高比 +
+ + {["1:1", "3:4", "4:3", "9:16", "16:9", "21:9"].map((item) => ( + { + setRatio(item); + setRatioPopoverOpen(false); + }} + > +
+ {item} +
+ ))} +
+
+
+ + { + setStylePopoverOpen(open); + if (open) setRatioPopoverOpen(false); + }} + > + + + + +
+ {[ + ["minimal", "#e2e8f0", "极简风格"], + ["tech", "#3b82f6", "科技质感"], + ["warm", "#f59e0b", "温暖治愈"], + ].map(([value, color, label]) => ( + + ))} +
+
+
+ + ) : null} + + ) : undefined; + + return ( + <> - {pendingImages.length > 0 ? ( - <> -
- 已添加图片 {pendingImages.length} 张 -
- - {pendingImages.map((image, index) => ( - - - onRemoveImage?.(index)} - > - - - - ))} - - - ) : null} - - {shouldShowTeamSuggestion ? ( - - ) : null} - - + onPaste(event as React.ClipboardEvent) + : undefined + } + placeholder={placeholder} + activeTheme={activeTheme} + allowEmptySend={isEntryTheme} + topExtra={topExtra} + leftExtra={leftExtra} /> - - - - {isGeneralTheme ? ( - - ) : null} - {shouldShowTeamSelector ? ( - onSelectTeam?.(team)} - /> - ) : null} - - - - {activeTheme === "social-media" ? ( - - ) : null} - - {showCreationModeSelector ? ( - - ) : null} - - {activeTheme === "knowledge" ? ( - <> - - - 联网搜索 - - - - ) : null} - - {activeTheme === "planning" ? ( - - - 旅行/职业/活动 - - ) : null} - - {activeTheme === "poster" ? ( - <> - { - setRatioPopoverOpen(open); - if (open) setStylePopoverOpen(false); - }} - > - - - - -
- 宽高比 -
- - {["1:1", "3:4", "4:3", "9:16", "16:9", "21:9"].map( - (item) => ( - { - setRatio(item); - setRatioPopoverOpen(false); - }} - > -
- {item} -
- ), - )} -
-
-
- - { - setStylePopoverOpen(open); - if (open) setRatioPopoverOpen(false); - }} - > - - - - -
- {[ - ["minimal", "#e2e8f0", "极简风格"], - ["tech", "#3b82f6", "科技质感"], - ["warm", "#f59e0b", "温暖治愈"], - ].map(([value, color, label]) => ( - - ))} -
-
-
- - ) : null} - - {isGeneralTheme ? ( - <> - - - - - - ) : null} - - - - {setExecutionStrategy ? ( - - ) : null} -
- - - - 开始生成 - - - -
-
+ ); } diff --git a/src/components/agent/chat/components/EmptyStateHero.tsx b/src/components/agent/chat/components/EmptyStateHero.tsx index c285f42aa..345ac94ed 100644 --- a/src/components/agent/chat/components/EmptyStateHero.tsx +++ b/src/components/agent/chat/components/EmptyStateHero.tsx @@ -19,15 +19,6 @@ const heroReveal = keyframes` } `; -const orbFloat = keyframes` - 0%, 100% { - transform: translate3d(0, 0, 0) scale(1); - } - 50% { - transform: translate3d(16px, -12px, 0) scale(1.06); - } -`; - const cardReveal = keyframes` from { opacity: 0; @@ -49,57 +40,6 @@ const HeroSection = styled.section` } `; -const HeroOrbLeft = styled.div` - pointer-events: none; - position: absolute; - left: -6rem; - top: -5.4rem; - height: 14rem; - width: 14rem; - border-radius: 999px; - background: rgba(167, 243, 208, 0.24); - filter: blur(48px); - animation: ${orbFloat} 16s ease-in-out infinite; - - @media (prefers-reduced-motion: reduce) { - animation: none; - } -`; - -const HeroOrbRight = styled.div` - pointer-events: none; - position: absolute; - right: -4rem; - top: -1.2rem; - height: 12rem; - width: 12rem; - border-radius: 999px; - background: rgba(186, 230, 253, 0.26); - filter: blur(42px); - animation: ${orbFloat} 19s ease-in-out infinite reverse; - - @media (prefers-reduced-motion: reduce) { - animation: none; - } -`; - -const HeroOrbBottom = styled.div` - pointer-events: none; - position: absolute; - bottom: -5.4rem; - left: 33%; - height: 11rem; - width: 11rem; - border-radius: 999px; - background: rgba(253, 230, 138, 0.16); - filter: blur(44px); - animation: ${orbFloat} 17s ease-in-out infinite; - - @media (prefers-reduced-motion: reduce) { - animation: none; - } -`; - const HeroContent = styled.div` position: relative; display: flex; @@ -337,12 +277,9 @@ export function EmptyStateHero({ }: EmptyStateHeroProps) { return ( - - - - +
{eyebrow} @@ -442,13 +379,13 @@ export function EmptyStateHero({ ) : null} {features.length > 0 ? ( - +
{features.map((feature) => (
{feature.title} diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index 0fb991446..0b3bbf24c 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -1390,14 +1390,14 @@ describe("HarnessStatusPanel", () => { plan: { phase: "ready", items: [], - summaryText: "已决定:直接回答优先\n当前请求无需工具介入。", + summaryText: "直接回答优先\n当前请求无需工具介入。", }, }), }); expect(document.body.textContent).toContain("计划状态"); expect(document.body.textContent).toContain("已就绪"); - expect(document.body.textContent).toContain("已决定:直接回答优先"); + expect(document.body.textContent).toContain("直接回答优先"); expect(document.body.textContent).toContain("规划状态"); }); diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMention.test.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMention.test.tsx index ee263cb96..e1f9353e9 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMention.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMention.test.tsx @@ -339,6 +339,7 @@ function createServiceSkill( runnerDescription: "当前先进入工作区生成首版任务方案,后续再接本地自动化。", actionLabel: "先做方案", automationStatus: null, + groupKey: "general", ...overrides, }; } @@ -401,17 +402,19 @@ describe("CharacterMention", () => { serviceSkills: [ createServiceSkill(), createServiceSkill({ - id: "carousel-post-replication", - title: "复制轮播帖", - entryHint: "拆结构并输出一版可继续改的轮播帖。", - aliases: ["轮播帖", "小红书轮播"], + id: "github-repo-radar", + title: "GitHub 仓库雷达", + summary: "围绕仓库与 Issue 快速扫描线索。", + entryHint: "补一个关键词,我先帮你扫 GitHub 仓库与讨论。", + aliases: ["仓库雷达", "GitHub 搜索"], + category: "GitHub", runnerType: "instant", - defaultExecutorBinding: "agent_turn", - runnerLabel: "本地即时执行", - runnerTone: "emerald", - runnerDescription: "客户端起步版可直接进入工作区执行。", + defaultExecutorBinding: "browser_assist", + runnerLabel: "浏览器协助", + runnerTone: "sky", + runnerDescription: "进入真实浏览器执行只读采集。", actionLabel: "填写参数", - promptTemplateKey: "replication", + groupKey: "github", }), ], }); @@ -419,9 +422,10 @@ describe("CharacterMention", () => { await typeAtAndWait(textarea); - expect(document.body.textContent).toContain("服务技能"); + expect(document.body.textContent).toContain("技能组 · 通用技能"); + expect(document.body.textContent).toContain("技能组 · GitHub"); expect(document.body.textContent).toContain("每日趋势摘要"); - expect(document.body.textContent).toContain("复制轮播帖"); + expect(document.body.textContent).toContain("GitHub 仓库雷达"); }); it("服务技能过滤应支持命中别名", () => { @@ -502,12 +506,12 @@ describe("CharacterMention", () => { await typeSlashAndWait(textarea); - expect(document.body.textContent).toContain("Codex 命令"); + expect(document.body.textContent).toContain("Lime 命令"); expect(document.body.textContent).toContain("/compact"); expect(document.body.textContent).toContain("/review"); }); - it("slash 面板选择 Codex 命令时应回填到输入框", async () => { + it("slash 面板选择 Lime 命令时应回填到输入框", async () => { const onChangeSpy = vi.fn<(value: string) => void>(); const container = renderHarness({ onChangeSpy, diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx index b90a46c7f..8a3e8e6e6 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx @@ -6,9 +6,8 @@ import React, { Suspense, - lazy, - useState, useEffect, + useState, useMemo, useRef, useCallback, @@ -23,25 +22,23 @@ import { filterCodexSlashCommands, type CodexSlashCommandDefinition, } from "../../../commands"; -import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; import { filterBuiltinCommands, type BuiltinInputCommand, } from "./builtinCommands"; - -const preloadCharacterMentionPanel = () => import("./CharacterMentionPanel"); - -const CharacterMentionPanel = lazy(async () => { - const module = await preloadCharacterMentionPanel(); - return { default: module.CharacterMentionPanel }; -}); +import { + LazyCharacterMentionPanel, + preloadCharacterMentionPanel, +} from "./characterMentionPanelLoader"; +import { partitionMentionableSkills } from "./skillQuery"; +import { useIdleModulePreload } from "./useIdleModulePreload"; interface CharacterMentionProps { /** 角色列表 */ characters: Character[]; /** 技能列表 */ skills?: Skill[]; - /** 服务型技能列表 */ + /** 技能目录项列表 */ serviceSkills?: ServiceSkillHomeItem[]; /** 输入框 ref */ inputRef: React.RefObject; @@ -53,7 +50,7 @@ interface CharacterMentionProps { onSelectCharacter?: (character: Character) => void; /** 选择已安装技能回调 */ onSelectSkill?: (skill: Skill) => void; - /** 选择服务型技能回调 */ + /** 选择技能目录项回调 */ onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; /** 选择内建命令回调 */ onSelectBuiltinCommand?: (command: BuiltinInputCommand) => void; @@ -148,11 +145,9 @@ export function CharacterMention({ const commandRef = useRef(null); const panelRef = useRef(null); - useEffect(() => { - return scheduleIdleModulePreload(() => { - void preloadCharacterMentionPanel(); - }); - }, []); + useIdleModulePreload(() => { + void preloadCharacterMentionPanel(); + }); const filteredBuiltinCommands = useMemo( () => filterBuiltinCommands(mentionQuery), @@ -177,29 +172,10 @@ export function CharacterMention({ ); }, [characters, mentionQuery]); - const installedSkills = useMemo(() => { - const installed = skills.filter((s) => s.installed); - if (!mentionQuery) return installed; - const query = mentionQuery.toLowerCase(); - return installed.filter( - (s) => - s.name.toLowerCase().includes(query) || - s.key.toLowerCase().includes(query) || - s.description?.toLowerCase().includes(query), - ); - }, [skills, mentionQuery]); - - const availableSkills = useMemo(() => { - const available = skills.filter((s) => !s.installed); - if (!mentionQuery) return available; - const query = mentionQuery.toLowerCase(); - return available.filter( - (s) => - s.name.toLowerCase().includes(query) || - s.key.toLowerCase().includes(query) || - s.description?.toLowerCase().includes(query), - ); - }, [skills, mentionQuery]); + const { installedSkills, availableSkills } = useMemo( + () => partitionMentionableSkills(skills, mentionQuery), + [skills, mentionQuery], + ); const updateMentionState = useCallback(() => { const textarea = inputRef.current; @@ -398,7 +374,7 @@ export function CharacterMention({ toast.info(`技能「${skill.name}」尚未安装`, { action: onNavigateToSettings ? { - label: "去安装", + label: "去技能中心", onClick: onNavigateToSettings, } : undefined, @@ -596,7 +572,7 @@ export function CharacterMention({
} > - = { + github: { title: "GitHub", sort: 10 }, + zhihu: { title: "知乎", sort: 20 }, + "linux-do": { title: "Linux.do", sort: 30 }, + bilibili: { title: "Bilibili", sort: 40 }, + "36kr": { title: "36Kr", sort: 50 }, + smzdm: { title: "什么值得买", sort: 60 }, + "yahoo-finance": { title: "Yahoo Finance", sort: 70 }, + general: { title: "通用技能", sort: 90 }, +}; + +function resolveServiceSkillGroupKey(skill: ServiceSkillHomeItem): string { + const normalized = skill.groupKey?.trim(); + return normalized ? normalized : "general"; +} + +function resolveServiceSkillGroupTitle(groupKey: string): string { + return SERVICE_SKILL_GROUP_META[groupKey]?.title ?? groupKey; +} + +function resolveServiceSkillGroupSort(groupKey: string): number { + return SERVICE_SKILL_GROUP_META[groupKey]?.sort ?? 80; +} + +function groupMentionServiceSkills( + skills: ServiceSkillHomeItem[], +): MentionServiceSkillGroup[] { + const groups = new Map(); + + for (const skill of skills) { + const groupKey = resolveServiceSkillGroupKey(skill); + const current = groups.get(groupKey); + if (current) { + current.skills.push(skill); + continue; + } + + groups.set(groupKey, { + key: groupKey, + title: resolveServiceSkillGroupTitle(groupKey), + sort: resolveServiceSkillGroupSort(groupKey), + skills: [skill], + }); + } + + return Array.from(groups.values()).sort((left, right) => { + if (left.sort !== right.sort) { + return left.sort - right.sort; + } + return left.title.localeCompare(right.title, "zh-CN"); + }); +} + interface CharacterMentionPanelProps { mode: "mention" | "slash"; mentionQuery: string; @@ -60,13 +123,17 @@ export const CharacterMentionPanel: React.FC = ({ onNavigateToSettings, }) => { const visibleBuiltinCommands = mode === "mention" ? builtinCommands : []; - const visibleServiceSkills = mode === "mention" ? mentionServiceSkills : []; + const visibleServiceSkillGroups = React.useMemo( + () => + mode === "mention" ? groupMentionServiceSkills(mentionServiceSkills) : [], + [mentionServiceSkills, mode], + ); const visibleCharacters = mode === "mention" ? filteredCharacters : []; const visibleSlashCommands = mode === "slash" ? slashCommands : []; const hasFilteredResults = visibleSlashCommands.length > 0 || visibleBuiltinCommands.length > 0 || - visibleServiceSkills.length > 0 || + visibleServiceSkillGroups.length > 0 || visibleCharacters.length > 0 || installedSkills.length > 0 || availableSkills.length > 0; @@ -93,13 +160,13 @@ export const CharacterMentionPanel: React.FC = ({ onMouseDown={(e) => e.preventDefault()} onClick={onNavigateToSettings} > - 去技能设置 + 去技能中心 ) : null}
) : null} {visibleSlashCommands.length > 0 ? ( - + {visibleSlashCommands.map((command) => ( = ({ ))} ) : null} - {visibleServiceSkills.length > 0 ? ( - - {visibleServiceSkills.map((skill) => ( + {visibleServiceSkillGroups.map((group) => ( + + {group.skills.map((skill) => ( onSelectServiceSkill(skill)} @@ -153,7 +220,12 @@ export const CharacterMentionPanel: React.FC = ({ >
-
{skill.title}
+
+
{skill.title}
+ + {group.title} + +
{resolveServiceSkillEntryDescription(skill)}
@@ -161,7 +233,7 @@ export const CharacterMentionPanel: React.FC = ({ ))} - ) : null} + ))} {visibleCharacters.length > 0 ? ( {visibleCharacters.map((character) => ( diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarAccessModeSelect.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarAccessModeSelect.tsx new file mode 100644 index 000000000..35a044853 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarAccessModeSelect.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { ShieldCheck } from "lucide-react"; +import { + MetaSelect, + MetaSelectIcon, + MetaSelectWrap, +} from "../styles"; +import type { AgentAccessMode } from "../../../hooks/agentChatStorage"; + +interface InputbarAccessModeSelectProps { + isFullscreen?: boolean; + accessMode?: AgentAccessMode; + setAccessMode?: (mode: AgentAccessMode) => void; +} + +const ACCESS_MODE_OPTIONS: Array<{ + value: AgentAccessMode; + label: string; +}> = [ + { value: "read-only", label: "只读" }, + { value: "current", label: "按需确认" }, + { value: "full-access", label: "完全访问" }, +]; + +export const InputbarAccessModeSelect: React.FC< + InputbarAccessModeSelectProps +> = ({ isFullscreen = false, accessMode = "current", setAccessMode }) => { + if (isFullscreen || !setAccessMode) { + return null; + } + + return ( + + + + + + setAccessMode(event.target.value as AgentAccessMode) + } + $width="92px" + > + {ACCESS_MODE_OPTIONS.map((option) => ( + + ))} + + + ); +}; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index 5c1dc5bcc..2127cc35e 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -1,8 +1,6 @@ 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 { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; import type { AsterSessionExecutionRuntime, QueuedTurnSnapshot, @@ -17,11 +15,17 @@ import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel"; import { InputbarModelExtra } from "./InputbarModelExtra"; import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice"; import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect"; +import { InputbarAccessModeSelect } from "./InputbarAccessModeSelect"; import { StableProcessingNotice } from "../../StableProcessingNotice"; import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt"; import type { TeamDefinition } from "../../../utils/teamDefinitions"; -import { shouldShowStableProcessingNotice } from "../../../utils/stableProcessingExperience"; import type { WorkspaceSettings } from "@/types/workspace"; +import { useStableProcessingNotice } from "../../../hooks/useStableProcessingNotice"; +import { + buildSkillSelectionBindings, + type SkillSelectionProps, +} from "./skillSelectionBindings"; +import type { AgentAccessMode } from "../../../hooks/agentChatStorage"; import type { ThemeWorkbenchGateState, ThemeWorkbenchQuickAction, @@ -35,20 +39,11 @@ interface InputbarComposerSectionProps { themeWorkbenchQueueItems: ThemeWorkbenchWorkflowStep[]; inputAdapter: ChatInputAdapter; characters: Character[]; - skills: Skill[]; - serviceSkills?: ServiceSkillHomeItem[]; - isSkillsLoading?: boolean; + skillSelection: SkillSelectionProps; textareaRef: React.RefObject; input: string; - activeSkill?: Skill | null; onSelectCharacter?: (character: Character) => void; - onSelectSkill: (skill: Skill) => void; - onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; onSelectBuiltinCommand: (command: BuiltinInputCommand | null) => void; - onClearSkill?: () => void; - onNavigateToSettings?: () => void; - onImportSkill?: () => void | Promise; - onRefreshSkills?: () => void | Promise; selectedTeam?: TeamDefinition | null; onSelectTeam?: (team: TeamDefinition | null) => void; teamWorkspaceSettings?: WorkspaceSettings | null; @@ -70,6 +65,8 @@ interface InputbarComposerSectionProps { onManageProviders?: () => void; executionRuntime?: AsterSessionExecutionRuntime | null; isExecutionRuntimeActive?: boolean; + accessMode?: AgentAccessMode; + setAccessMode?: (mode: AgentAccessMode) => void; setExecutionStrategy?: ( strategy: "react" | "code_orchestrated" | "auto", ) => void; @@ -88,20 +85,11 @@ export const InputbarComposerSection: React.FC< themeWorkbenchQueueItems, inputAdapter, characters, - skills, - serviceSkills = [], - isSkillsLoading, + skillSelection, textareaRef, input, - activeSkill, onSelectCharacter, - onSelectSkill, - onSelectServiceSkill, onSelectBuiltinCommand, - onClearSkill, - onNavigateToSettings, - onImportSkill, - onRefreshSkills, selectedTeam, onSelectTeam, teamWorkspaceSettings, @@ -122,7 +110,8 @@ export const InputbarComposerSection: React.FC< activeTheme, onManageProviders, executionRuntime, - isExecutionRuntimeActive, + accessMode, + setAccessMode, setExecutionStrategy, topExtra, queuedTurns, @@ -137,26 +126,26 @@ export const InputbarComposerSection: React.FC< const currentPendingImages = (inputAdapter.state.attachments as MessageImage[] | undefined) || pendingImages; + const { mentionProps: mentionSkillProps, selectorProps: skillSelectorProps } = + buildSkillSelectionBindings(skillSelection); const resolvedProviderType = inputAdapter.model?.providerType; const resolvedModel = inputAdapter.model?.model; - const shouldShowStableNotice = - !isThemeWorkbenchVariant && - shouldShowStableProcessingNotice({ - providerType: resolvedProviderType, - model: resolvedModel, - }); + const shouldShowStableNotice = useStableProcessingNotice({ + providerType: resolvedProviderType, + model: resolvedModel, + }); + const showStableNotice = + !isThemeWorkbenchVariant && shouldShowStableNotice; const shouldShowVisionNotice = currentPendingImages.length > 0 && Boolean(resolvedProviderType?.trim()) && Boolean(resolvedModel?.trim()); const resolvedTopExtra = - topExtra || shouldShowStableNotice || shouldShowVisionNotice ? ( + topExtra || showStableNotice || shouldShowVisionNotice ? ( <> {topExtra} - {shouldShowStableNotice ? ( + {showStableNotice ? ( {showSkillSelector ? ( - + ) : null} {isGeneralResearchTheme(activeTheme) ? ( activeTools["subagent_mode"] ? ( @@ -284,6 +259,12 @@ export const InputbarComposerSection: React.FC< /> ) : null ) : null} + + } - rightExtra={ - - } /> ); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx index b1c35d76d..d5025de36 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx @@ -4,21 +4,64 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InputbarCore } from "./InputbarCore"; +const { + mockGetVoiceInputConfig, + mockStartRecording, + mockStopRecording, + mockTranscribeAudio, + mockPolishVoiceText, + mockCancelRecording, +} = vi.hoisted(() => ({ + mockGetVoiceInputConfig: vi.fn(async () => ({ + enabled: true, + shortcut: "Alt+Space", + processor: { + polish_enabled: true, + default_instruction_id: "default", + }, + output: { + mode: "type", + type_delay_ms: 0, + }, + instructions: [], + sound_enabled: false, + translate_instruction_id: "", + })), + mockStartRecording: vi.fn(async () => undefined), + mockStopRecording: vi.fn(async () => ({ + audio_data: [1, 2, 3, 4], + sample_rate: 16000, + duration: 1.2, + })), + mockTranscribeAudio: vi.fn(async () => ({ + text: "原始识别文本", + provider: "mock", + })), + mockPolishVoiceText: vi.fn(async () => ({ + text: "润色后的文本", + instruction_name: "默认润色", + })), + mockCancelRecording: vi.fn(async () => undefined), +})); + vi.mock("./InputbarTools", () => ({ InputbarTools: () =>
tools
, })); -vi.mock("@/components/ui/tooltip", () => ({ - TooltipProvider: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - TooltipContent: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), +vi.mock("@/lib/api/asrProvider", () => ({ + getVoiceInputConfig: mockGetVoiceInputConfig, + startRecording: mockStartRecording, + stopRecording: mockStopRecording, + transcribeAudio: mockTranscribeAudio, + polishVoiceText: mockPolishVoiceText, + cancelRecording: mockCancelRecording, +})); + +vi.mock("@/hooks/useVoiceSound", () => ({ + useVoiceSound: () => ({ + playStartSound: vi.fn(), + playStopSound: vi.fn(), + }), })); const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; @@ -43,7 +86,7 @@ afterEach(() => { vi.clearAllMocks(); }); -const renderInputbarCore = ( +const renderInputbarCore = async ( props?: Partial>, ) => { const container = document.createElement("div"); @@ -58,21 +101,23 @@ const renderInputbarCore = ( onSend={vi.fn()} activeTools={{}} onToolClick={vi.fn()} - showTranslate={false} toolMode="attach-only" visualVariant="floating" {...props} />, ); }); + await act(async () => { + await Promise.resolve(); + }); mountedRoots.push({ root, container }); return container; }; describe("InputbarCore", () => { - it("主题工作台未聚焦时应使用单行紧凑态,点击展开,移出后收起", () => { - const container = renderInputbarCore(); + it("主题工作台未聚焦时应使用单行紧凑态,点击展开,移出后收起", async () => { + const container = await renderInputbarCore(); const textarea = container.querySelector( "textarea", ) as HTMLTextAreaElement | null; @@ -93,8 +138,11 @@ describe("InputbarCore", () => { expect(textarea?.className).not.toContain("floating-collapsed"); expect( - container.querySelector('[data-testid="inputbar-tools"]'), + container.querySelector('button[aria-label="添加图片"]'), ).toBeTruthy(); + expect( + container.querySelector('[data-testid="inputbar-tools"]'), + ).toBeNull(); act(() => { inputBar?.dispatchEvent( @@ -108,7 +156,7 @@ describe("InputbarCore", () => { expect(textarea?.className).not.toContain("floating-collapsed"); expect( container.querySelector('[data-testid="inputbar-tools"]'), - ).toBeTruthy(); + ).toBeNull(); act(() => { textarea?.blur(); @@ -126,10 +174,79 @@ describe("InputbarCore", () => { ).toBeNull(); }); - it("生成中应显示稍后处理与停止按钮,并渲染待处理列表", () => { + it("点击展开按钮应切换输入框展开态", async () => { + const container = await renderInputbarCore({ + visualVariant: "default", + toolMode: "default", + }); + const textarea = container.querySelector( + "textarea", + ) as HTMLTextAreaElement | null; + const expandButton = container.querySelector( + 'button[aria-label="展开输入框"]', + ) as HTMLButtonElement | null; + + expect(textarea?.className).not.toContain("composer-expanded"); + expect(expandButton).toBeTruthy(); + + await act(async () => { + expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(textarea?.className).toContain("composer-expanded"); + expect( + container.querySelector('button[aria-label="收起输入框"]'), + ).toBeTruthy(); + }); + + it("点击麦克风按钮应执行语音识别并把结果写回输入框", async () => { + const setText = vi.fn(); + const container = await renderInputbarCore({ + visualVariant: "default", + toolMode: "default", + setText, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const micButton = container.querySelector( + 'button[aria-label="开始语音输入"]', + ) as HTMLButtonElement | null; + expect(micButton).toBeTruthy(); + + await act(async () => { + micButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(mockStartRecording).toHaveBeenCalledTimes(1); + + const stopDictationButton = container.querySelector( + 'button[aria-label="停止语音输入"]', + ) as HTMLButtonElement | null; + expect(stopDictationButton).toBeTruthy(); + + await act(async () => { + stopDictationButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockStopRecording).toHaveBeenCalledTimes(1); + expect(mockTranscribeAudio).toHaveBeenCalledTimes(1); + expect(mockPolishVoiceText).toHaveBeenCalledWith("原始识别文本"); + expect(setText).toHaveBeenCalledWith("润色后的文本"); + }); + + it("生成中应显示稍后处理与停止按钮,并渲染待处理列表", async () => { const onSend = vi.fn(); const onStop = vi.fn(); - const container = renderInputbarCore({ + const container = await renderInputbarCore({ text: "下一条需求", onSend, onStop, @@ -149,9 +266,9 @@ describe("InputbarCore", () => { const queueButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("稍后处理"), ); - const stopButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("停止"), - ); + const stopButton = container.querySelector( + 'button[aria-label="停止"]', + ) as HTMLButtonElement | null; expect(queueButton).toBeTruthy(); expect(stopButton).toBeTruthy(); @@ -180,9 +297,9 @@ describe("InputbarCore", () => { expect(onStop).toHaveBeenCalledTimes(1); }); - it("点击图片删除按钮应触发 onRemoveImage", () => { + it("点击图片删除按钮应触发 onRemoveImage", async () => { const onRemoveImage = vi.fn(); - const container = renderInputbarCore({ + const container = await renderInputbarCore({ pendingImages: [ { data: "aGVsbG8=", diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx index b10073227..7e2e8112a 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx @@ -3,6 +3,10 @@ import { ActionButtonGroup, Container, InputBarContainer, + InputColumn, + InputIconButton, + MainRow, + MetaSlot, StyledTextarea, BottomBar, LeftSection, @@ -14,20 +18,23 @@ import { ImagePreviewItem, ImagePreviewImg, ImageRemoveButton, - ToolButton, } from "../styles"; import { InputbarTools } from "./InputbarTools"; -import { ArrowUp, Square, X, Languages } from "lucide-react"; -import { BaseComposer } from "@/components/input-kit"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; + ArrowUp, + ChevronDown, + ChevronUp, + ImagePlus, + Loader2, + Mic, + Square, + X, +} from "lucide-react"; +import { BaseComposer } from "@/components/input-kit"; import type { MessageImage } from "../../../types"; import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime"; import { QueuedTurnsPanel } from "./QueuedTurnsPanel"; +import { useInputbarDictation } from "../hooks/useInputbarDictation"; const INTERACTIVE_TARGET_SELECTOR = "button, a, input, textarea, select, option, [role='button'], [contenteditable=''], [contenteditable='true'], [contenteditable='plaintext-only']"; @@ -69,13 +76,12 @@ interface InputbarCoreProps { placeholder?: string; /** 工具栏模式 */ toolMode?: "default" | "attach-only"; - /** 是否显示翻译按钮 */ - showTranslate?: boolean; /** 是否显示顶部拖拽条 */ showDragHandle?: boolean; /** 视觉风格 */ visualVariant?: "default" | "floating"; activeTheme?: string; + allowEmptySend?: boolean; queuedTurns?: QueuedTurnSnapshot[]; onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise; onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise; @@ -103,17 +109,33 @@ export const InputbarCore: React.FC = ({ topExtra, placeholder, toolMode = "default", - showTranslate = true, showDragHandle = true, visualVariant = "default", activeTheme, + allowEmptySend = false, queuedTurns = [], onPromoteQueuedTurn, onRemoveQueuedTurn, }) => { const [isComposerExpanded, setIsComposerExpanded] = useState(false); + const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const inputBarContainerRef = useRef(null); + const fallbackTextareaRef = useRef(null); + const resolvedTextareaRef = externalTextareaRef ?? fallbackTextareaRef; const isFloatingVariant = visualVariant === "floating"; + const { + dictationEnabled, + dictationState, + isDictating, + isDictationBusy, + isDictationProcessing, + handleDictationToggle, + } = useInputbarDictation({ + text, + setText, + textareaRef: resolvedTextareaRef, + disabled, + }); const shouldCollapseFloatingTools = isFloatingVariant && toolMode === "attach-only" && @@ -139,6 +161,7 @@ export const InputbarCore: React.FC = ({ isFullscreen ? "flex-1 resize-none" : "", isFloatingVariant ? "floating-composer" : "", shouldUseCompactFloatingComposer ? "floating-collapsed" : "", + isTextareaExpanded ? "composer-expanded" : "", ] .filter(Boolean) .join(" "); @@ -148,10 +171,32 @@ export const InputbarCore: React.FC = ({ ] .filter(Boolean) .join(" "); - const leftSectionClassName = shouldCollapseFloatingTools ? "floating-collapsed" : ""; + const mainRowClassName = [ + isFloatingVariant ? "floating-composer" : "", + shouldUseCompactFloatingComposer ? "floating-collapsed" : "", + ] + .filter(Boolean) + .join(" "); + const leftSectionClassName = shouldCollapseFloatingTools + ? "floating-collapsed" + : ""; const rightSectionClassName = shouldUseCompactFloatingComposer ? "floating-collapsed" : ""; + const shouldRenderMetaBar = + !shouldUseCompactFloatingComposer && + (Boolean(leftExtra) || + Boolean(rightExtra) || + (toolMode === "default" && !shouldCollapseFloatingTools)); + const dictationButtonTitle = isDictationProcessing + ? dictationState === "polishing" + ? "语音润色中" + : "语音识别中" + : isDictating + ? "停止语音输入" + : dictationEnabled + ? "开始语音输入" + : "语音输入未启用"; const handleExpandComposer = useCallback(() => { if (!isFloatingVariant || toolMode !== "attach-only") { @@ -201,6 +246,13 @@ export const InputbarCore: React.FC = ({ [onRemoveImage], ); + const handleToggleTextareaExpanded = useCallback(() => { + setIsTextareaExpanded((previous) => !previous); + if (isFloatingVariant) { + setIsComposerExpanded(true); + } + }, [isFloatingVariant]); + return ( = ({ isFullscreen={isFullscreen} fillHeightWhenFullscreen hasAdditionalContent={pendingImages.length > 0} - maxAutoHeight={isFloatingVariant ? 160 : 300} - textareaRef={externalTextareaRef} + maxAutoHeight={isTextareaExpanded ? 320 : isFloatingVariant ? 160 : 120} + textareaRef={resolvedTextareaRef} onEscape={() => onToolClick("fullscreen")} allowSendWhileLoading + rows={isTextareaExpanded ? 6 : 1} + allowEmptySend={allowEmptySend} placeholder={ placeholder || (isFullscreen @@ -281,67 +335,118 @@ export const InputbarCore: React.FC = ({ onRemoveQueuedTurn={onRemoveQueuedTurn} /> - - - - - {leftExtra && ( -
{leftExtra}
- )} - {!shouldCollapseFloatingTools ? ( - - ) : null} -
- - - {rightExtra} - {showTranslate ? ( - - - - onToolClick("translate")}> - - - - 翻译 - - - ) : null} - - {isLoading ? ( - - - 停止 - - ) : null} - + onToolClick("attach")} + aria-label="添加图片" + title="添加图片" + > + + + + + + + + {isTextareaExpanded ? ( + + ) : ( + + )} + + void handleDictationToggle()} + disabled={disabled || isDictationProcessing} + className={ + isDictationProcessing + ? "is-processing" + : isDictating + ? "is-recording" + : "" + } + aria-label={dictationButtonTitle} + title={dictationButtonTitle} + > + {isDictationProcessing ? ( + + ) : isDictating ? ( + + ) : ( + + )} + + {isLoading ? ( + - - {isLoading ? 稍后处理 : null} + 稍后处理 + + ) : null} + {isLoading ? ( + + + + ) : null} + {!isLoading ? ( + + - - -
+ ) : null} + + + + {shouldRenderMetaBar ? ( + + + {leftExtra ? {leftExtra} : null} + {!shouldCollapseFloatingTools ? ( + + ) : null} + + + {rightExtra ? ( + + {rightExtra} + + ) : null} + + ) : null} ); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx index 918d5e2fe..4b77c80bc 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx @@ -1,11 +1,11 @@ import React from "react"; -import { Code2 } from "lucide-react"; +import { ListChecks } from "lucide-react"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from "@/components/ui/select"; + MetaToggleButton, + MetaToggleCheck, + MetaToggleGlyph, + MetaToggleLabel, +} from "../styles"; interface InputbarExecutionStrategySelectProps { isFullscreen?: boolean; @@ -18,57 +18,36 @@ interface InputbarExecutionStrategySelectProps { export const InputbarExecutionStrategySelect: React.FC< InputbarExecutionStrategySelectProps -> = ({ - isFullscreen = false, - isThemeWorkbenchVariant = false, - executionStrategy, - setExecutionStrategy, -}) => { - if (isFullscreen || isThemeWorkbenchVariant || !setExecutionStrategy) { +> = (props) => { + const { + isFullscreen = false, + executionStrategy, + setExecutionStrategy, + } = props; + + if (isFullscreen || !setExecutionStrategy) { return null; } - const resolvedExecutionStrategy = executionStrategy || "react"; - const executionStrategyLabel = - resolvedExecutionStrategy === "auto" - ? "Auto" - : resolvedExecutionStrategy === "code_orchestrated" - ? "Plan" - : "ReAct"; + const planEnabled = executionStrategy === "code_orchestrated"; return ( - + + + + + Plan + ); }; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx index 0798e53f5..29d64249f 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx @@ -1,11 +1,9 @@ import React from "react"; import { Badge } from "@/components/ui/badge"; +import type { ModelSelectorProps } from "@/components/input-kit"; import type { AsterSessionExecutionRuntime } from "@/lib/api/agentRuntime"; import { ChatModelSelector } from "../../ChatModelSelector"; -import { - getExecutionRuntimeDisplayLabel, - getOutputSchemaRuntimeLabel, -} from "../../../utils/sessionExecutionRuntime"; +import { getOutputSchemaRuntimeLabel } from "../../../utils/sessionExecutionRuntime"; interface InputbarModelExtraProps { isFullscreen?: boolean; @@ -17,7 +15,7 @@ interface InputbarModelExtraProps { activeTheme?: string; onManageProviders?: () => void; executionRuntime?: AsterSessionExecutionRuntime | null; - isExecutionRuntimeActive?: boolean; + backgroundPreload?: ModelSelectorProps["backgroundPreload"]; } const NOOP_SET_PROVIDER_TYPE = (_type: string) => {}; @@ -33,25 +31,18 @@ export const InputbarModelExtra: React.FC = ({ activeTheme, onManageProviders, executionRuntime = null, - isExecutionRuntimeActive = false, + backgroundPreload, }) => { 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 ( -
+
= ({ compactTrigger popoverSide="top" onManageProviders={onManageProviders} + backgroundPreload={backgroundPreload} /> - {executionRuntimeLabel ? ( - - {executionRuntimeLabel} - - ) : null} {outputSchemaLabel ? ( 结构化输出 {outputSchemaLabel} diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx index fb4f19a7f..511ed9b7b 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx @@ -132,22 +132,21 @@ describe("InputbarOverlayShell", () => { ).toBeNull(); }); - it("A2UI 表单进入粘性保留期时应显示同步提示并禁用提交", () => { + it("输入栏 overlay 不再承载 A2UI 表单与提交提示", () => { const container = renderShell({ taskFiles: [], pendingA2UIForm: createPendingA2UIForm(), pendingA2UIFormStale: true, + submissionNotice: { + title: "补充信息已确认", + summary: "已继续处理。", + }, + isSubmissionNoticeVisible: true, onA2UISubmit: vi.fn(), }); - const submitButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("继续处理"), - ) as HTMLButtonElement | undefined; - - expect(container.textContent).toContain("同步中"); - expect(container.textContent).toContain( - "正在同步最新上下文,表单暂时不可提交。", - ); - expect(submitButton?.disabled).toBe(true); + expect(container.textContent).not.toContain("继续处理"); + expect(container.textContent).not.toContain("同步中"); + expect(container.textContent).not.toContain("补充信息已确认"); }); }); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx index a238a35d7..0f451c078 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx @@ -5,11 +5,7 @@ import type { A2UIFormData, A2UIResponse, } from "@/components/content-creator/a2ui/types"; -import { - A2UISubmissionNotice, - type A2UISubmissionNoticeData, -} from "./A2UISubmissionNotice"; -import { A2UIFloatingForm } from "./A2UIFloatingForm"; +import type { A2UISubmissionNoticeData } from "./A2UISubmissionNotice"; import { HintRoutePopup } from "./HintRoutePopup"; import { TaskFilesPanel } from "./TaskFilesPanel"; import type { HintRouteItem } from "../hooks/useHintRoutes"; @@ -64,11 +60,11 @@ export const InputbarOverlayShell: React.FC = ({ onToggleTaskFiles, onTaskFileClick, overlayAccessory, - submissionNotice, - isSubmissionNoticeVisible, - pendingA2UIForm, - pendingA2UIFormStale = false, - onA2UISubmit, + submissionNotice: _submissionNotice, + isSubmissionNoticeVisible: _isSubmissionNoticeVisible, + pendingA2UIForm: _pendingA2UIForm, + pendingA2UIFormStale: _pendingA2UIFormStale = false, + onA2UISubmit: _onA2UISubmit, fileInputRef, onFileSelect, }) => ( @@ -92,20 +88,6 @@ export const InputbarOverlayShell: React.FC = ({ {overlayAccessory} ) : null} - {submissionNotice ? ( - - ) : null} - {pendingA2UIForm && onA2UISubmit ? ( - - ) : null} = ({ toolMode = "default", activeTheme, }) => { - const modeLabel = - executionStrategy === "auto" - ? "Auto" - : executionStrategy === "code_orchestrated" - ? "Plan" - : "ReAct"; const strategyEnabled = - executionStrategy !== "react" || activeTools["execution_strategy"]; + executionStrategy === "code_orchestrated" || + activeTools["execution_strategy"]; const isGeneralTheme = isGeneralResearchTheme(activeTheme); return ( - -
- - - onToolClick?.("attach")}> - - - - 上传文件 - +
+ {toolMode === "default" ? ( + <> + onToolClick?.("thinking")} + className={activeTools["thinking"] ? "active" : ""} + aria-pressed={activeTools["thinking"]} + title={`深度思考${activeTools["thinking"] ? "已开启" : "已关闭"}`} + > + + 思考 + - {toolMode === "default" ? ( - <> - - - onToolClick?.("thinking")} - className={activeTools["thinking"] ? "active" : ""} - > - - - - - 深度思考 {activeTools["thinking"] ? "(已开启)" : ""} - - + onToolClick?.("web_search")} + className={activeTools["web_search"] ? "active" : ""} + aria-pressed={activeTools["web_search"]} + title={`联网搜索${activeTools["web_search"] ? "已开启" : "已关闭"}`} + > + + 搜索 + - - - onToolClick?.("web_search")} - className={activeTools["web_search"] ? "active" : ""} - > - - - - - 联网搜索 {activeTools["web_search"] ? "(已开启)" : ""} - - + {isGeneralTheme ? ( + <> + onToolClick?.("subagent_mode")} + className={activeTools["subagent_mode"] ? "active" : ""} + aria-pressed={activeTools["subagent_mode"]} + title={`多代理偏好${activeTools["subagent_mode"] ? "已开启" : "已关闭"}`} + > + + 多代理 + + + ) : null} - {isGeneralTheme ? ( - <> - - - onToolClick?.("task_mode")} - className={activeTools["task_mode"] ? "active" : ""} - > - - - - - 后台任务 {activeTools["task_mode"] ? "(偏好已开启)" : ""} - - - - - - onToolClick?.("subagent_mode")} - className={activeTools["subagent_mode"] ? "active" : ""} - > - - - - - 多代理 {activeTools["subagent_mode"] ? "(偏好已开启)" : ""} - - - - ) : null} - - {showExecutionStrategy && ( - - - onToolClick?.("execution_strategy")} - className={strategyEnabled ? "active" : ""} - > - - - - 执行模式: {modeLabel} - - )} - - ) : null} -
- + {showExecutionStrategy ? ( + <> + + onToolClick?.("execution_strategy")} + className={strategyEnabled ? "active" : ""} + aria-pressed={strategyEnabled} + title={`Plan 模式${strategyEnabled ? "已开启" : "已关闭"}`} + > + + Plan + + + ) : null} + + ) : null} +
); }; diff --git a/src/components/agent/chat/components/Inputbar/components/SkillSelector.test.tsx b/src/components/agent/chat/components/Inputbar/components/SkillSelector.test.tsx index 6c4c21eba..d081663bf 100644 --- a/src/components/agent/chat/components/Inputbar/components/SkillSelector.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/SkillSelector.test.tsx @@ -4,6 +4,8 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SkillSelector } from "./SkillSelector"; import type { Skill } from "@/lib/api/skills"; +import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; +import { SKILL_SELECTION_DISPLAY_COPY } from "./skillSelectionDisplay"; const mockToastInfo = vi.fn(); const mockPopoverState = vi.hoisted(() => ({ @@ -50,13 +52,14 @@ vi.mock("@/components/ui/popover", () => ({ })); vi.mock("@/components/ui/command", () => { - const Command = ({ - children, - shouldFilter: _shouldFilter, - ...props - }: React.HTMLAttributes & { shouldFilter?: boolean }) => ( -
{children}
- ); + const Command = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { shouldFilter?: boolean } + >(({ children, shouldFilter: _shouldFilter, ...props }, ref) => ( +
+ {children} +
+ )); const CommandInput = ({ value, @@ -148,6 +151,41 @@ function createSkill(name: string, key: string, installed: boolean): Skill { }; } +function createServiceSkill( + id: string, + title: string, +): ServiceSkillHomeItem { + return { + id, + title, + summary: `${title} 摘要`, + category: "情报研究", + outputHint: "结构化结果", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + executionLocation: "client_default", + defaultArtifactKind: "analysis", + version: "seed-v1", + themeTarget: "general", + slotSchema: [], + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "站点登录态采集", + runnerTone: "emerald", + runnerDescription: "复用真实登录态执行任务。", + actionLabel: "开始执行", + automationStatus: null, + groupKey: "github", + siteCapabilityBinding: { + adapterName: "github/search", + autoRun: true, + saveMode: "project_resource", + }, + }; +} + function renderSkillSelector( props?: Partial>, ) { @@ -171,14 +209,14 @@ function renderSkillSelector( return container; } -async function preloadSkillSelectorPanel() { +async function preloadSharedSkillPanel() { await act(async () => { - await import("./SkillSelectorPanel"); + await import("./CharacterMentionPanel"); }); } async function openSkillSelector(container: HTMLElement) { - await preloadSkillSelectorPanel(); + await preloadSharedSkillPanel(); const triggerButton = container.querySelector( '[data-testid="skill-selector-trigger"]', @@ -225,10 +263,16 @@ describe("SkillSelector", () => { await openSkillSelector(container); - expect(container.textContent).toContain("不使用技能"); + expect(container.textContent).toContain("已挂载 研究助手"); + expect(container.textContent).toContain( + SKILL_SELECTION_DISPLAY_COPY.clearActionLabel, + ); const clearButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("不使用技能"), + (button) => + button.textContent?.includes( + SKILL_SELECTION_DISPLAY_COPY.clearActionLabel, + ), ); expect(clearButton).toBeTruthy(); @@ -261,7 +305,7 @@ describe("SkillSelector", () => { expect(mockToastInfo.mock.calls[0]?.[0]).toContain("尚未安装"); expect(mockToastInfo.mock.calls[0]?.[1]).toMatchObject({ action: { - label: "去安装", + label: "去技能中心", onClick: onNavigateToSettings, }, }); @@ -311,6 +355,33 @@ describe("SkillSelector", () => { expect(onRefreshSkills).toHaveBeenCalledTimes(1); }); + it("应复用同一面板渲染服务技能并回调 onSelectServiceSkill", async () => { + const onSelectServiceSkill = vi.fn< + (skill: ServiceSkillHomeItem) => void + >(); + const serviceSkill = createServiceSkill( + "github-repo-radar", + "GitHub 仓库线索检索", + ); + const container = renderSkillSelector({ + serviceSkills: [serviceSkill], + onSelectServiceSkill, + }); + + await openSkillSelector(container); + + const skillButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("GitHub 仓库线索检索"), + ); + expect(skillButton).toBeTruthy(); + + act(() => { + skillButton?.click(); + }); + + expect(onSelectServiceSkill).toHaveBeenCalledWith(serviceSkill); + }); + it("加载中且无技能时应显示加载状态", async () => { const container = renderSkillSelector({ isLoading: true, @@ -322,4 +393,16 @@ describe("SkillSelector", () => { expect(container.textContent).toContain("技能加载中"); }); + + it("未选择技能时应展示统一的空态文案", async () => { + const container = renderSkillSelector({ + skills: [], + }); + + await openSkillSelector(container); + + expect(container.textContent).toContain( + SKILL_SELECTION_DISPLAY_COPY.emptySelectionLabel, + ); + }); }); diff --git a/src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx b/src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx index f28a77985..15ed21291 100644 --- a/src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx +++ b/src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx @@ -1,12 +1,11 @@ import React, { Suspense, - lazy, useCallback, - useEffect, useMemo, + useRef, useState, } from "react"; -import { Zap } from "lucide-react"; +import { FolderOpen, Loader2, RefreshCw, Zap } from "lucide-react"; import { Popover, PopoverContent, @@ -15,20 +14,27 @@ import { import type { Skill } from "@/lib/api/skills"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; -import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; - -const preloadSkillSelectorPanel = () => import("./SkillSelectorPanel"); - -const SkillSelectorPanel = lazy(async () => { - const module = await preloadSkillSelectorPanel(); - return { default: module.SkillSelectorPanel }; -}); +import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter"; +import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; +import type { BuiltinInputCommand } from "./builtinCommands"; +import { + LazyCharacterMentionPanel, + preloadCharacterMentionPanel, +} from "./characterMentionPanelLoader"; +import { + getActiveSkillDisplayLabel, + SKILL_SELECTION_DISPLAY_COPY, +} from "./skillSelectionDisplay"; +import { partitionMentionableSkills } from "./skillQuery"; +import { useIdleModulePreload } from "./useIdleModulePreload"; interface SkillSelectorProps { skills?: Skill[]; + serviceSkills?: ServiceSkillHomeItem[]; activeSkill?: Skill | null; isLoading?: boolean; onSelectSkill: (skill: Skill) => void; + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; onClearSkill?: () => void; onNavigateToSettings?: () => void; onImportSkill?: () => void | Promise; @@ -37,24 +43,162 @@ interface SkillSelectorProps { className?: string; } -function matchesSkillQuery(skill: Skill, query: string): boolean { - if (!query) { - return true; - } - - const normalizedQuery = query.toLowerCase(); - return ( - skill.name.toLowerCase().includes(normalizedQuery) || - skill.key.toLowerCase().includes(normalizedQuery) || - skill.description?.toLowerCase().includes(normalizedQuery) === true - ); +interface SkillSelectorContentProps { + activeSkill: Skill | null; + installedSkills: Skill[]; + availableSkills: Skill[]; + mentionServiceSkills?: ServiceSkillHomeItem[]; + query: string; + refreshBusy: boolean; + hasResults: boolean; + canRefresh: boolean; + canImport: boolean; + importing: boolean; + commandRef: React.RefObject; + onQueryChange: (query: string) => void; + onSelectInstalledSkill: (skill: Skill) => void; + onSelectAvailableSkill: (skill: Skill) => void; + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; + onClearSkill?: () => void; + onNavigateToSettings?: () => void; + onRefresh?: () => void; + onImport?: () => void; } +export const SkillSelectorContent: React.FC = ({ + activeSkill, + installedSkills, + availableSkills, + mentionServiceSkills = [], + query, + refreshBusy, + hasResults, + canRefresh, + canImport, + importing, + commandRef, + onQueryChange, + onSelectInstalledSkill, + onSelectAvailableSkill, + onSelectServiceSkill, + onClearSkill, + onNavigateToSettings, + onRefresh, + onImport, +}) => { + const activeSkillLabel = getActiveSkillDisplayLabel(activeSkill); + + return ( + + 加载中... +
+ } + > +
+
+
+
+
+ {SKILL_SELECTION_DISPLAY_COPY.titleLabel} +
+
+ {activeSkillLabel ?? + SKILL_SELECTION_DISPLAY_COPY.emptySelectionLabel} +
+
+ {activeSkill && onClearSkill ? ( + + ) : null} +
+ {refreshBusy && !hasResults ? ( +
+ {SKILL_SELECTION_DISPLAY_COPY.loadingLabel} +
+ ) : null} +
+ undefined} + onSelectServiceSkill={(skill) => onSelectServiceSkill?.(skill)} + onSelectSlashCommand={() => undefined} + onSelectCharacter={() => undefined} + onSelectInstalledSkill={onSelectInstalledSkill} + onSelectAvailableSkill={onSelectAvailableSkill} + onNavigateToSettings={onNavigateToSettings} + /> + {canRefresh || canImport ? ( +
+
+ {canRefresh ? ( + + ) : null} + {canImport ? ( + + ) : null} +
+
+ ) : null} + {!hasResults && !canRefresh && !canImport ? ( +
+ 暂无更多技能操作。 +
+ ) : null} +
+ + ); +}; + export const SkillSelector: React.FC = ({ skills = [], + serviceSkills = [], activeSkill = null, isLoading = false, onSelectSkill, + onSelectServiceSkill, onClearSkill, onNavigateToSettings, onImportSkill, @@ -67,37 +211,31 @@ export const SkillSelector: React.FC = ({ const [importing, setImporting] = useState(false); const [refreshing, setRefreshing] = useState(false); const [autoRefreshTriggered, setAutoRefreshTriggered] = useState(false); + const commandRef = useRef(null); - useEffect(() => { - return scheduleIdleModulePreload(() => { - void preloadSkillSelectorPanel(); - }); - }, []); + useIdleModulePreload(() => { + void preloadCharacterMentionPanel(); + }); - useEffect(() => { + React.useEffect(() => { if (!open) { setQuery(""); setAutoRefreshTriggered(false); } }, [open]); - const installedSkills = useMemo( - () => - skills.filter( - (skill) => skill.installed && matchesSkillQuery(skill, query), - ), + const { installedSkills, availableSkills } = useMemo( + () => partitionMentionableSkills(skills, query), [query, skills], ); - const availableSkills = useMemo( - () => - skills.filter( - (skill) => !skill.installed && matchesSkillQuery(skill, query), - ), - [query, skills], + const filteredServiceSkills = useMemo( + () => filterMentionableServiceSkills(serviceSkills, query), + [query, serviceSkills], ); const hasResults = + filteredServiceSkills.length > 0 || installedSkills.length > 0 || availableSkills.length > 0 || Boolean(activeSkill && onClearSkill); @@ -118,7 +256,7 @@ export const SkillSelector: React.FC = ({ } }, [onRefreshSkills, refreshBusy]); - useEffect(() => { + React.useEffect(() => { if ( !open || autoRefreshTriggered || @@ -150,12 +288,17 @@ export const SkillSelector: React.FC = ({ setOpen(false); }; + const handleSelectServiceSkill = (skill: ServiceSkillHomeItem) => { + onSelectServiceSkill?.(skill); + setOpen(false); + }; + const handleSelectAvailableSkill = (skill: Skill) => { setOpen(false); toast.info(`技能「${skill.name}」尚未安装`, { action: onNavigateToSettings ? { - label: "去安装", + label: "去技能中心", onClick: onNavigateToSettings, } : undefined, @@ -207,39 +350,34 @@ export const SkillSelector: React.FC = ({ sideOffset={8} > {open ? ( - - 加载中... -
+ { + setOpen(false); + onNavigateToSettings(); + } + : undefined } - > - void handleRefresh()} - onSelectInstalledSkill={handleSelectInstalledSkill} - onSelectAvailableSkill={handleSelectAvailableSkill} - onClearSkill={handleClearSkill} - onNavigateToSettings={ - onNavigateToSettings - ? () => { - setOpen(false); - onNavigateToSettings(); - } - : undefined - } - onImport={() => void handleImport()} - /> - + onRefresh={() => void handleRefresh()} + onImport={() => void handleImport()} + /> ) : null} diff --git a/src/components/agent/chat/components/Inputbar/components/SkillSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/SkillSelectorPanel.tsx deleted file mode 100644 index ffa8a7ec1..000000000 --- a/src/components/agent/chat/components/Inputbar/components/SkillSelectorPanel.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React from "react"; -import { - Check, - FolderOpen, - Loader2, - RefreshCw, - Settings2, - X, - Zap, -} from "lucide-react"; -import { - Command, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import type { Skill } from "@/lib/api/skills"; -import { cn } from "@/lib/utils"; - -interface SkillSelectorPanelProps { - activeSkill: Skill | null; - installedSkills: Skill[]; - availableSkills: Skill[]; - query: string; - canRefresh: boolean; - refreshBusy: boolean; - canImport: boolean; - importing: boolean; - hasResults: boolean; - onQueryChange: (query: string) => void; - onRefresh: () => void; - onSelectInstalledSkill: (skill: Skill) => void; - onSelectAvailableSkill: (skill: Skill) => void; - onClearSkill?: () => void; - onNavigateToSettings?: () => void; - onImport: () => void; -} - -export const SkillSelectorPanel: React.FC = ({ - activeSkill, - installedSkills, - availableSkills, - query, - canRefresh, - refreshBusy, - canImport, - importing, - hasResults, - onQueryChange, - onRefresh, - onSelectInstalledSkill, - onSelectAvailableSkill, - onClearSkill, - onNavigateToSettings, - onImport, -}) => ( - -
-
- 技能能力 -
-
- {activeSkill ? `当前已启用 ${activeSkill.name}` : "为当前任务挂载额外能力"} -
-
-
- - {canRefresh ? ( - - ) : null} -
- - {activeSkill && onClearSkill ? ( - - - -
-
不使用技能
-
- 当前已选:{activeSkill.name} -
-
-
-
- ) : null} - - {installedSkills.length > 0 ? ( - - {installedSkills.map((skill) => { - const selected = activeSkill?.key === skill.key; - return ( - onSelectInstalledSkill(skill)} - className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50" - > - -
-
- - {skill.name} - - - /{skill.key} - -
- {skill.description ? ( -
- {skill.description} -
- ) : null} -
- {selected ? ( - - ) : null} -
- ); - })} -
- ) : null} - - {availableSkills.length > 0 ? ( - - {availableSkills.map((skill) => ( - onSelectAvailableSkill(skill)} - className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 opacity-80 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50" - > - -
-
- - {skill.name} - - - /{skill.key} - -
- {skill.description ? ( -
- {skill.description} -
- ) : null} -
-
- ))} -
- ) : null} - - {!hasResults ? ( -
- {refreshBusy ? ( -
- -
技能加载中...
-
- ) : ( - <> -
暂无可用技能
- {onNavigateToSettings ? ( - - ) : null} - - )} -
- ) : null} -
- {canImport ? ( -
- -
- ) : null} -
-); diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx index 8b1c03d02..a93bf22e2 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx @@ -1,7 +1,6 @@ import React, { Suspense, lazy, - useEffect, useMemo, useState, } from "react"; @@ -9,8 +8,8 @@ import { Users } from "lucide-react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { WorkspaceSettings } from "@/types/workspace"; -import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; import type { TeamDefinition } from "../../../utils/teamDefinitions"; +import { useIdleModulePreload } from "./useIdleModulePreload"; const preloadTeamSelectorPanel = () => import("./TeamSelectorPanel"); @@ -52,13 +51,11 @@ export const TeamSelector: React.FC = ({ }) => { const [open, setOpen] = useState(false); - useEffect(() => { - return scheduleIdleModulePreload(() => { - void preloadTeamSelectorPanel(); - }); - }, []); + useIdleModulePreload(() => { + void preloadTeamSelectorPanel(); + }); - useEffect(() => { + React.useEffect(() => { if (autoOpenToken === null || autoOpenToken === undefined) { return; } diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx index 26a0af7da..424dccb0d 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx @@ -3,6 +3,10 @@ import { createRoot, type Root } from "react-dom/client"; import type { WorkspaceSettings } from "@/types/workspace"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TeamSelectorPanel } from "./TeamSelectorPanel"; +import { + resetStableProcessingNoticeMemoryForTest, + STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS, +} from "../../../hooks/useStableProcessingNotice"; import { createTeamDefinitionFromPreset, type TeamDefinition, @@ -91,6 +95,8 @@ describe("TeamSelectorPanel", () => { mounted.container.remove(); } localStorage.clear(); + vi.useRealTimers(); + resetStableProcessingNoticeMemoryForTest(); vi.clearAllMocks(); }); @@ -216,7 +222,8 @@ describe("TeamSelectorPanel", () => { expect(mockToast.success).toHaveBeenCalled(); }); - it("命中稳妥模式模型时应提前提示 Team 会依次开始", async () => { + it("命中稳妥模式模型时应短暂提示 Team 会依次开始后自动收起", async () => { + vi.useFakeTimers(); const { container } = renderPanel({ providerType: "openai", model: "glm-4.7", @@ -232,5 +239,26 @@ describe("TeamSelectorPanel", () => { expect(container.textContent).toContain("稳妥模式"); expect(container.textContent).toContain("协作成员"); expect(container.textContent).toContain("依次开始"); + + act(() => { + vi.advanceTimersByTime(STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS + 1); + }); + + expect( + container.querySelector( + '[data-testid="team-selector-stable-processing-notice"]', + ), + ).toBeNull(); + + const nextRender = renderPanel({ + providerType: "openai", + model: "glm-4.7", + }); + + expect( + nextRender.container.querySelector( + '[data-testid="team-selector-stable-processing-notice"]', + ), + ).toBeNull(); }); }); diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx index 2b73fe92d..3c6e9c71b 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx @@ -18,6 +18,7 @@ import { cn } from "@/lib/utils"; import type { WorkspaceSettings } from "@/types/workspace"; import { toast } from "sonner"; import { StableProcessingNotice } from "../../StableProcessingNotice"; +import { useStableProcessingNotice } from "../../../hooks/useStableProcessingNotice"; import { BUILTIN_TEAM_PROFILE_OPTIONS, BUILTIN_TEAM_SKILL_OPTIONS, @@ -316,6 +317,10 @@ export const TeamSelectorPanel: React.FC = ({ const isProjectScopedCustomTeam = Boolean( workspaceSettings && onPersistCustomTeams, ); + const shouldShowStableNotice = useStableProcessingNotice({ + providerType, + model, + }); useEffect(() => { setCustomTeams(resolveCustomTeams(workspaceSettings)); @@ -1027,13 +1032,13 @@ export const TeamSelectorPanel: React.FC = ({ ) : null}
- + {shouldShowStableNotice ? ( + + ) : null} {selectedTeam ? (
+ import("./CharacterMentionPanel"); + +export const LazyCharacterMentionPanel = lazy(async () => { + const module = await preloadCharacterMentionPanel(); + return { default: module.CharacterMentionPanel }; +}); diff --git a/src/components/agent/chat/components/Inputbar/components/skillQuery.test.ts b/src/components/agent/chat/components/Inputbar/components/skillQuery.test.ts new file mode 100644 index 000000000..20bd16a12 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/skillQuery.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import type { Skill } from "@/lib/api/skills"; +import { + matchesMentionableSkillQuery, + partitionMentionableSkills, +} from "./skillQuery"; + +function createSkill(name: string, key: string, installed: boolean): Skill { + return { + key, + name, + description: `${name} 的描述`, + directory: `${key}-dir`, + installed, + sourceKind: "builtin", + }; +} + +describe("skillQuery", () => { + it("应按同一搜索规则匹配技能名称、key 与描述", () => { + const skill = createSkill("结构化写作", "structured-writing", true); + + expect(matchesMentionableSkillQuery(skill, "结构化")).toBe(true); + expect(matchesMentionableSkillQuery(skill, "structured")).toBe(true); + expect(matchesMentionableSkillQuery(skill, "描述")).toBe(true); + expect(matchesMentionableSkillQuery(skill, "不存在")).toBe(false); + }); + + it("应按安装状态拆分可提及技能", () => { + const installedSkill = createSkill("写作助手", "writer", true); + const availableSkill = createSkill("仓库检索", "repo-radar", false); + + const result = partitionMentionableSkills( + [installedSkill, availableSkill], + "写作", + ); + + expect(result.installedSkills).toEqual([installedSkill]); + expect(result.availableSkills).toEqual([]); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/components/skillQuery.ts b/src/components/agent/chat/components/Inputbar/components/skillQuery.ts new file mode 100644 index 000000000..dfe8477d8 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/skillQuery.ts @@ -0,0 +1,50 @@ +import type { Skill } from "@/lib/api/skills"; + +function normalizeSkillQuery(query: string): string { + return query.trim().toLowerCase(); +} + +export function matchesMentionableSkillQuery( + skill: Skill, + query: string, +): boolean { + const normalizedQuery = normalizeSkillQuery(query); + if (!normalizedQuery) { + return true; + } + + return ( + skill.name.toLowerCase().includes(normalizedQuery) || + skill.key.toLowerCase().includes(normalizedQuery) || + skill.description?.toLowerCase().includes(normalizedQuery) === true + ); +} + +export function partitionMentionableSkills( + skills: Skill[], + query: string, +): { + installedSkills: Skill[]; + availableSkills: Skill[]; +} { + const installedSkills: Skill[] = []; + const availableSkills: Skill[] = []; + + for (const skill of skills) { + if (!matchesMentionableSkillQuery(skill, query)) { + continue; + } + + if (skill.installed) { + installedSkills.push(skill); + continue; + } + + availableSkills.push(skill); + } + + return { + installedSkills, + availableSkills, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/components/skillSelectionBindings.ts b/src/components/agent/chat/components/Inputbar/components/skillSelectionBindings.ts new file mode 100644 index 000000000..13cf0e03a --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/skillSelectionBindings.ts @@ -0,0 +1,79 @@ +import type { Skill } from "@/lib/api/skills"; +import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; + +export interface SkillSelectionSourceProps { + skills?: Skill[]; + serviceSkills?: ServiceSkillHomeItem[]; + isSkillsLoading?: boolean; + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; + onNavigateToSettings?: () => void; + onImportSkill?: () => void | Promise; + onRefreshSkills?: () => void | Promise; +} + +export interface SkillSelectionControllerProps { + activeSkill?: Skill | null; + onSelectSkill: (skill: Skill) => void; + onClearSkill?: () => void; +} + +export interface SkillSelectionProps + extends SkillSelectionSourceProps, + SkillSelectionControllerProps { + skills: Skill[]; + serviceSkills: ServiceSkillHomeItem[]; + activeSkill: Skill | null; + isSkillsLoading: boolean; +} + +export function createSkillSelectionProps({ + skills = [], + serviceSkills = [], + activeSkill = null, + isSkillsLoading = false, + ...rest +}: SkillSelectionSourceProps & + SkillSelectionControllerProps): SkillSelectionProps { + return { + ...rest, + skills, + serviceSkills, + activeSkill, + isSkillsLoading, + }; +} + +export function buildSkillSelectionBindings({ + skills, + serviceSkills = [], + activeSkill = null, + isSkillsLoading = false, + onSelectSkill, + onSelectServiceSkill, + onClearSkill, + onNavigateToSettings, + onImportSkill, + onRefreshSkills, +}: SkillSelectionProps) { + return { + mentionProps: { + skills, + serviceSkills, + onSelectSkill, + onSelectServiceSkill, + onNavigateToSettings, + }, + selectorProps: { + skills, + serviceSkills, + activeSkill, + isLoading: isSkillsLoading, + onSelectSkill, + onSelectServiceSkill, + onClearSkill, + onNavigateToSettings, + onImportSkill, + onRefreshSkills, + }, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/components/skillSelectionDisplay.test.ts b/src/components/agent/chat/components/Inputbar/components/skillSelectionDisplay.test.ts new file mode 100644 index 000000000..b9619011d --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/skillSelectionDisplay.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { Skill } from "@/lib/api/skills"; +import { + getActiveSkillDisplayLabel, + getSkillSelectionSummaryLabel, + SKILL_SELECTION_DISPLAY_COPY, +} from "./skillSelectionDisplay"; + +function createSkill(name: string): Skill { + return { + key: name, + name, + description: `${name} 的描述`, + directory: name, + installed: true, + sourceKind: "builtin", + }; +} + +describe("skillSelectionDisplay", () => { + it("激活技能时应返回统一的挂载文案", () => { + const skill = createSkill("写作助手"); + + expect(getActiveSkillDisplayLabel(skill)).toBe("已挂载 写作助手"); + expect( + getSkillSelectionSummaryLabel({ + activeSkill: skill, + skillCount: 5, + }), + ).toBe("已挂载 写作助手"); + }); + + it("未激活技能但存在能力来源时应显示统一数量文案", () => { + expect( + getSkillSelectionSummaryLabel({ + activeSkill: null, + skillCount: 3, + }), + ).toBe("3 项技能可挂载"); + }); + + it("无激活技能且无能力来源时应回退到空态文案", () => { + expect( + getSkillSelectionSummaryLabel({ + activeSkill: null, + skillCount: 0, + }), + ).toBe(SKILL_SELECTION_DISPLAY_COPY.emptySelectionLabel); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/components/skillSelectionDisplay.ts b/src/components/agent/chat/components/Inputbar/components/skillSelectionDisplay.ts new file mode 100644 index 000000000..62526fa5b --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/skillSelectionDisplay.ts @@ -0,0 +1,38 @@ +import type { Skill } from "@/lib/api/skills"; + +export const SKILL_SELECTION_DISPLAY_COPY = { + titleLabel: "技能能力", + emptySelectionLabel: "按需挂载任务能力", + clearActionLabel: "清空技能", + loadingLabel: "技能加载中...", +} as const; + +export function getActiveSkillDisplayLabel( + activeSkill?: Skill | null, +): string | null { + if (!activeSkill) { + return null; + } + + return `已挂载 ${activeSkill.name}`; +} + +export function getSkillSelectionSummaryLabel({ + activeSkill, + skillCount, +}: { + activeSkill?: Skill | null; + skillCount: number; +}): string { + const activeSkillLabel = getActiveSkillDisplayLabel(activeSkill); + + if (activeSkillLabel) { + return activeSkillLabel; + } + + if (skillCount > 0) { + return `${skillCount} 项技能可挂载`; + } + + return SKILL_SELECTION_DISPLAY_COPY.emptySelectionLabel; +} diff --git a/src/components/agent/chat/components/Inputbar/components/useIdleModulePreload.ts b/src/components/agent/chat/components/Inputbar/components/useIdleModulePreload.ts new file mode 100644 index 000000000..7fd7f929f --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/useIdleModulePreload.ts @@ -0,0 +1,13 @@ +import { useEffect, useRef } from "react"; +import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; + +export function useIdleModulePreload(task: () => void): void { + const taskRef = useRef(task); + taskRef.current = task; + + useEffect(() => { + return scheduleIdleModulePreload(() => { + taskRef.current(); + }); + }, []); +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.test.tsx b/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.test.tsx new file mode 100644 index 000000000..fcf78deb2 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.test.tsx @@ -0,0 +1,117 @@ +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 { useA2UISubmissionNotice } from "./useA2UISubmissionNotice"; + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function renderHook(_initialProps: HookProps) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let latestValue: ReturnType | null = null; + + function Probe(currentProps: HookProps) { + latestValue = useA2UISubmissionNotice(currentProps); + return null; + } + + const render = async (nextProps: HookProps) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + return window.setTimeout(() => callback(0), 0); + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation((handle) => { + window.clearTimeout(handle); + }); + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("useA2UISubmissionNotice", () => { + it("确认提示应在 3 秒后自动淡出并卸载", async () => { + const notice = { + title: "需求已确认", + summary: "已收到你的补充信息。", + }; + const { render, getValue } = renderHook({ + notice, + enabled: true, + displayMs: 3000, + fadeOutMs: 180, + }); + + await render({ + notice, + enabled: true, + displayMs: 3000, + fadeOutMs: 180, + }); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + + expect(getValue().visibleNotice).toEqual(notice); + expect(getValue().isVisible).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(3000); + await Promise.resolve(); + }); + + expect(getValue().visibleNotice).toEqual(notice); + expect(getValue().isVisible).toBe(false); + + await act(async () => { + vi.advanceTimersByTime(180); + await Promise.resolve(); + }); + + expect(getValue().visibleNotice).toBeNull(); + expect(getValue().isVisible).toBe(false); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.ts b/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.ts index 0c0edba89..6b8d73c42 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.ts @@ -4,21 +4,27 @@ import type { A2UISubmissionNoticeData } from "../components/A2UISubmissionNotic interface UseA2UISubmissionNoticeParams { notice?: A2UISubmissionNoticeData | null; enabled: boolean; + displayMs?: number; fadeOutMs?: number; } export function useA2UISubmissionNotice({ notice, enabled, + displayMs = 3000, fadeOutMs = 180, }: UseA2UISubmissionNoticeParams) { const [visibleNotice, setVisibleNotice] = useState(null); const [isVisible, setIsVisible] = useState(false); + const dismissTimerRef = useRef | null>(null); const hideTimerRef = useRef | null>(null); useEffect(() => { return () => { + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + } if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); } @@ -26,6 +32,10 @@ export function useA2UISubmissionNotice({ }, []); useEffect(() => { + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; @@ -36,8 +46,24 @@ export function useA2UISubmissionNotice({ const frameId = window.requestAnimationFrame(() => { setIsVisible(true); }); + dismissTimerRef.current = setTimeout(() => { + setIsVisible(false); + hideTimerRef.current = setTimeout(() => { + setVisibleNotice((current) => (current === notice ? null : current)); + hideTimerRef.current = null; + }, fadeOutMs); + dismissTimerRef.current = null; + }, displayMs); return () => { window.cancelAnimationFrame(frameId); + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } }; } @@ -53,7 +79,7 @@ export function useA2UISubmissionNotice({ hideTimerRef.current = null; } }; - }, [enabled, fadeOutMs, notice]); + }, [displayMs, enabled, fadeOutMs, notice]); return { visibleNotice, diff --git a/src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts b/src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts index 19548223d..9e1dc7f6e 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts @@ -1,5 +1,10 @@ import { useState, useCallback } from "react"; import type { Skill } from "@/lib/api/skills"; +import { + createSkillSelectionProps, + type SkillSelectionProps, + type SkillSelectionSourceProps, +} from "../components/skillSelectionBindings"; export function useActiveSkill() { const [activeSkill, setActiveSkill] = useState(null); @@ -13,6 +18,22 @@ export function useActiveSkill() { ); const clearActiveSkill = useCallback(() => setActiveSkill(null), []); + const buildSkillSelection = useCallback( + (source: SkillSelectionSourceProps): SkillSelectionProps => + createSkillSelectionProps({ + ...source, + activeSkill, + onSelectSkill: setActiveSkill, + onClearSkill: clearActiveSkill, + }), + [activeSkill, clearActiveSkill], + ); - return { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill }; + return { + activeSkill, + setActiveSkill, + wrapTextWithSkill, + clearActiveSkill, + buildSkillSelection, + }; } diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts index f01984a49..20f2460b0 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts @@ -13,6 +13,7 @@ import { useInputbarToolState, type InputbarToolStates, } from "./useInputbarToolState"; +import type { SkillSelectionSourceProps } from "../components/skillSelectionBindings"; import type { ThemeWorkbenchGateState, ThemeWorkbenchWorkflowStep, @@ -83,8 +84,16 @@ export function useInputbarController({ pendingA2UIForm, a2uiSubmissionNotice, onEnableSuggestedTeam, -}: UseInputbarControllerParams) { - const { activeSkill, setActiveSkill, clearActiveSkill } = useActiveSkill(); + skills, + serviceSkills, + isSkillsLoading, + onSelectServiceSkill, + onNavigateToSettings, + onImportSkill, + onRefreshSkills, +}: UseInputbarControllerParams & SkillSelectionSourceProps) { + const { activeSkill, setActiveSkill, clearActiveSkill, buildSkillSelection } = + useActiveSkill(); const [activeBuiltinCommand, setActiveBuiltinCommand] = useState(null); const { @@ -245,6 +254,15 @@ export function useInputbarController({ : null, ) : undefined; + const skillSelection = buildSkillSelection({ + skills, + serviceSkills, + isSkillsLoading, + onSelectServiceSkill, + onNavigateToSettings, + onImportSkill, + onRefreshSkills, + }); return { textareaRef, @@ -278,6 +296,7 @@ export function useInputbarController({ isPendingA2UIFormStale, visibleA2UISubmissionNotice, isA2UISubmissionNoticeVisible, + skillSelection, activeSkill, setActiveSkill: (skill: Parameters[0]) => { setActiveBuiltinCommand(null); diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarDictation.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarDictation.ts new file mode 100644 index 000000000..9c3482efd --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarDictation.ts @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { toast } from "sonner"; +import { + cancelRecording, + getVoiceInputConfig, + polishVoiceText, + startRecording, + stopRecording, + transcribeAudio, + type VoiceInputConfig, +} from "@/lib/api/asrProvider"; +import { useVoiceSound } from "@/hooks/useVoiceSound"; + +export type InputbarDictationState = + | "idle" + | "listening" + | "transcribing" + | "polishing"; + +interface UseInputbarDictationArgs { + text: string; + setText: (value: string) => void; + textareaRef: RefObject; + disabled: boolean; +} + +function insertTranscriptAtCursor( + currentText: string, + transcript: string, + textarea: HTMLTextAreaElement | null, +) { + if (!textarea) { + return { + nextText: currentText ? `${currentText}\n${transcript}` : transcript, + cursor: currentText ? currentText.length + transcript.length + 1 : transcript.length, + }; + } + + const selectionStart = textarea.selectionStart ?? currentText.length; + const selectionEnd = textarea.selectionEnd ?? currentText.length; + const before = currentText.slice(0, selectionStart); + const after = currentText.slice(selectionEnd); + const prefix = + before.length > 0 && !/[\s\n]$/.test(before) ? "\n" : ""; + const suffix = + after.length > 0 && !/^[\s\n]/.test(after) ? "\n" : ""; + const inserted = `${prefix}${transcript}${suffix}`; + const nextText = `${before}${inserted}${after}`; + const cursor = before.length + inserted.length; + + return { nextText, cursor }; +} + +export function useInputbarDictation({ + text, + setText, + textareaRef, + disabled, +}: UseInputbarDictationArgs) { + const [dictationState, setDictationState] = + useState("idle"); + const [dictationEnabled, setDictationEnabled] = useState(false); + const [soundEnabled, setSoundEnabled] = useState(false); + const textRef = useRef(text); + const dictationStateRef = useRef("idle"); + const voiceConfigRef = useRef(null); + const { playStartSound, playStopSound } = useVoiceSound(soundEnabled); + + useEffect(() => { + textRef.current = text; + }, [text]); + + useEffect(() => { + dictationStateRef.current = dictationState; + }, [dictationState]); + + useEffect(() => { + let cancelled = false; + + getVoiceInputConfig() + .then((config) => { + if (cancelled) { + return; + } + voiceConfigRef.current = config; + setDictationEnabled(config.enabled); + setSoundEnabled(config.sound_enabled); + }) + .catch((error) => { + console.error("[输入栏] 加载语音输入配置失败:", error); + }); + + return () => { + cancelled = true; + if (dictationStateRef.current === "listening") { + void cancelRecording().catch((error) => { + console.error("[输入栏] 卸载时取消录音失败:", error); + }); + } + }; + }, []); + + const focusTextarea = useCallback((cursor: number) => { + window.requestAnimationFrame(() => { + const textarea = textareaRef.current; + textarea?.focus(); + textarea?.setSelectionRange(cursor, cursor); + }); + }, [textareaRef]); + + const insertTranscript = useCallback( + (transcript: string) => { + const { nextText, cursor } = insertTranscriptAtCursor( + textRef.current, + transcript, + textareaRef.current, + ); + setText(nextText); + focusTextarea(cursor); + }, + [focusTextarea, setText, textareaRef], + ); + + const refreshVoiceConfig = useCallback(async () => { + const config = await getVoiceInputConfig(); + voiceConfigRef.current = config; + setDictationEnabled(config.enabled); + setSoundEnabled(config.sound_enabled); + return config; + }, []); + + const startDictation = useCallback(async () => { + if (disabled || dictationStateRef.current !== "idle") { + return; + } + + let config: VoiceInputConfig; + try { + config = await refreshVoiceConfig(); + } catch (error) { + console.error("[输入栏] 读取语音配置失败:", error); + toast.error("语音输入暂不可用"); + return; + } + + if (!config.enabled) { + toast.info("请先在设置里启用语音输入"); + return; + } + + setDictationState("listening"); + playStartSound(); + + try { + await cancelRecording().catch(() => undefined); + await startRecording(config.selected_device_id); + } catch (error: any) { + console.error("[输入栏] 开始录音失败:", error); + const message = + typeof error === "string" ? error : error?.message || "无法开始录音"; + toast.error(message); + setDictationState("idle"); + } + }, [disabled, playStartSound, refreshVoiceConfig]); + + const finishDictation = useCallback(async () => { + if (dictationStateRef.current !== "listening") { + return; + } + + playStopSound(); + setDictationState("transcribing"); + + try { + const result = await stopRecording(); + if (result.duration < 0.5) { + toast.info("录音时间太短,请再试一次"); + setDictationState("idle"); + return; + } + + const transcription = await transcribeAudio( + new Uint8Array(result.audio_data), + result.sample_rate, + ); + + if (!transcription.text.trim()) { + toast.info("未识别到语音内容"); + setDictationState("idle"); + return; + } + + let finalText = transcription.text; + const config = voiceConfigRef.current ?? (await refreshVoiceConfig()); + + if (config.processor.polish_enabled) { + setDictationState("polishing"); + try { + const polished = await polishVoiceText(transcription.text); + finalText = polished.text; + } catch (error) { + console.error("[输入栏] 语音润色失败:", error); + toast.error("语音润色失败,已插入原始识别内容"); + } + } + + insertTranscript(finalText); + setDictationState("idle"); + } catch (error: any) { + console.error("[输入栏] 完成语音输入失败:", error); + const message = + typeof error === "string" ? error : error?.message || "语音识别失败"; + toast.error(message); + setDictationState("idle"); + } + }, [insertTranscript, playStopSound, refreshVoiceConfig]); + + const handleDictationToggle = useCallback(async () => { + if (dictationStateRef.current === "listening") { + await finishDictation(); + return; + } + + if (dictationStateRef.current !== "idle") { + return; + } + + await startDictation(); + }, [finishDictation, startDictation]); + + return { + dictationEnabled, + dictationState, + isDictating: dictationState === "listening", + isDictationBusy: dictationState !== "idle", + isDictationProcessing: + dictationState === "transcribing" || dictationState === "polishing", + handleDictationToggle, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts index 849858a23..10e216a80 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts @@ -59,16 +59,9 @@ export function useInputbarToolState({ ...localActiveTools, web_search: webSearchEnabled, thinking: thinkingEnabled, - task_mode: taskEnabled, subagent_mode: subagentEnabled, }), - [ - localActiveTools, - thinkingEnabled, - webSearchEnabled, - taskEnabled, - subagentEnabled, - ], + [localActiveTools, thinkingEnabled, webSearchEnabled, subagentEnabled], ); const updateToolStates = useCallback( @@ -116,17 +109,6 @@ export function useInputbarToolState({ toast.info(`联网搜索${nextWebSearch ? "已开启" : "已关闭"}`); break; } - case "task_mode": { - const nextTask = !taskEnabled; - updateToolStates({ - webSearch: webSearchEnabled, - thinking: thinkingEnabled, - task: nextTask, - subagent: subagentEnabled, - }); - toast.info(`后台任务${nextTask ? "偏好已开启" : "偏好已关闭"}`); - break; - } case "subagent_mode": { const nextSubagent = !subagentEnabled; updateToolStates({ @@ -140,21 +122,13 @@ export function useInputbarToolState({ } case "execution_strategy": if (setExecutionStrategy) { - const strategyOrder: Array< - "react" | "code_orchestrated" | "auto" - > = ["react", "code_orchestrated", "auto"]; - const currentIndex = strategyOrder.indexOf( - executionStrategy || "react", - ); const nextStrategy = - strategyOrder[(currentIndex + 1) % strategyOrder.length]; + executionStrategy === "code_orchestrated" + ? "react" + : "code_orchestrated"; setExecutionStrategy(nextStrategy); toast.info( - nextStrategy === "react" - ? "执行模式:ReAct" - : nextStrategy === "code_orchestrated" - ? "执行模式:Plan" - : "执行模式:Auto", + `Plan 模式${nextStrategy === "code_orchestrated" ? "已开启" : "已关闭"}`, ); break; } @@ -178,8 +152,7 @@ export function useInputbarToolState({ openFileDialog(); break; case "quick_action": - case "translate": - toast.info("翻译功能开发中..."); + toast.info("快捷操作开发中..."); break; case "fullscreen": setIsFullscreen((prev) => !prev); diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index 8ab7fa9dd..399678ad3 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -27,7 +27,6 @@ const mockInputbarCore = vi.fn( topExtra?: React.ReactNode; placeholder?: string; toolMode?: "default" | "attach-only"; - showTranslate?: boolean; }) => (