From af6133164fd597c13d87f6bc037d28bc75d36062 Mon Sep 17 00:00:00 2001 From: coso Date: Fri, 15 May 2026 02:38:18 +0800 Subject: [PATCH] chore: release v1.39.0 --- .gitignore | 4 + RELEASE_NOTES.md | 58 +- docs/aiprompts/command-runtime.md | 2 +- docs/aiprompts/commands.md | 4 +- docs/aiprompts/quality-workflow.md | 4 +- docs/roadmap/agentapp/README.md | 84 + .../agentapp/ai-content-engineering.md | 404 + docs/roadmap/agentapp/capability-sdk.md | 203 + docs/roadmap/agentapp/implementation-plan.md | 853 ++ docs/roadmap/agentapp/p0-technical-design.md | 584 + docs/roadmap/i18n/prd.md | 9 + package-lock.json | 4 +- package.json | 2 +- packages/lime-cli-npm/package.json | 2 +- playwright-active-history-snapshot.json | 1 - ...wright-after-reload-new-chat-boundary.json | 6 - playwright-current-session-debug.json | 183 - playwright-history-buttons.json | 62 - playwright-history-first-image-check.json | 32 - playwright-image-flow-state-after-wait.json | 31 - playwright-image-flow-state.json | 30 - playwright-new-chat-no-leak.json | 7 - ...right-second-image-after-reload-final.json | 29 - ...right-second-image-after-reload-start.json | 28 - playwright-second-image-start.json | 8 - scripts/at-command-registry-e2e.mjs | 164 +- scripts/check-command-contracts.mjs | 58 + scripts/release-updater-manifest.mjs | 129 +- scripts/release-updater-manifest.test.mjs | 84 +- src-tauri/Cargo.lock | 36 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/agent/src/skill_execution.rs | 133 +- .../crates/aster/src/agents/agent.rs | 83 +- .../default-skills/analysis/SKILL.md | 8 +- .../default-skills/image_generate/SKILL.md | 15 +- src-tauri/src/agent_tools/execution.rs | 89 +- .../aster_agent_cmd/image_skill_launch.rs | 13 +- .../request_model_resolution.rs | 98 +- .../request_model_resolution/fast_response.rs | 41 + .../request_model_resolution/tests.rs | 20 + .../commands/aster_agent_cmd/runtime_turn.rs | 5 + .../src/commands/aster_agent_cmd/tests.rs | 35 +- .../commands/aster_agent_cmd/tool_runtime.rs | 10 +- .../tool_runtime/creation_tools.rs | 6 +- src-tauri/src/commands/media_task_cmd.rs | 12 +- .../src/services/agent_timeline_service.rs | 363 +- src-tauri/src/skills/catalog.rs | 27 + src-tauri/src/skills/default_skills.rs | 9 + src-tauri/src/skills/execution.rs | 560 +- src-tauri/src/skills/runtime.rs | 1 + src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/AppPageContent.test.tsx | 31 + src/components/AppPageContent.tsx | 15 + src/components/AppSidebar.tsx | 1 + .../agent/chat/AgentChatWorkspace.tsx | 160 +- .../agent/chat/components/A2UITaskCard.tsx | 10 +- .../components/CuratedTaskLauncherDialog.tsx | 4 +- .../agent/chat/components/EmptyState.tsx | 10 +- .../EmptyStateComposerPanel.test.tsx | 38 +- .../components/EmptyStateComposerPanel.tsx | 4 + .../GeneralWorkbenchSidebar.test.tsx | 4 +- .../chat/components/ImageTaskViewer.test.tsx | 119 +- .../agent/chat/components/ImageTaskViewer.tsx | 324 +- .../ImageWorkbenchMessagePreview.test.tsx | 58 +- .../ImageWorkbenchMessagePreview.tsx | 204 +- .../components/ImageWorkbenchPreviewMedia.tsx | 184 + .../components/InlineToolProcessStep.test.tsx | 111 + .../chat/components/InlineToolProcessStep.tsx | 272 +- .../components/InputbarComposerSection.tsx | 11 + .../Inputbar/components/InputbarCore.test.tsx | 22 +- .../InputbarExecutionStrategySelect.tsx | 10 +- .../InputbarWorkflowStatusPanel.tsx | 51 +- .../Inputbar/components/TeamSelector.test.tsx | 74 + .../Inputbar/components/TeamSelector.tsx | 13 +- .../components/TeamSelectorPanel.test.tsx | 58 +- .../Inputbar/components/TeamSelectorPanel.tsx | 240 +- .../components/inputbarTeamSelectorCopy.ts | 417 + .../Inputbar/hooks/useInputbarController.ts | 6 + .../chat/components/Inputbar/index.test.tsx | 149 +- .../agent/chat/components/Inputbar/index.tsx | 20 + .../Inputbar/inputbarWorkflowCopy.ts | 246 + .../knowledge/InputbarKnowledgeControl.tsx | 87 +- .../knowledge/knowledgeHubState.test.ts | 42 +- .../Inputbar/knowledge/knowledgeHubState.ts | 106 +- .../chat/components/MarkdownRenderer.test.tsx | 96 +- .../chat/components/MarkdownRenderer.tsx | 39 +- .../chat/components/MessageActionButtons.tsx | 158 + .../chat/components/MessageArtifactCards.tsx | 137 + .../chat/components/MessageAssistantBody.tsx | 230 + .../components/MessageAssistantMetaFooter.tsx | 79 + .../chat/components/MessageCanvasShortcut.tsx | 41 + .../components/MessageImageAttachments.tsx | 27 + .../chat/components/MessageList.test.tsx | 284 +- .../agent/chat/components/MessageList.tsx | 3547 +----- .../chat/components/MessageList.types.ts | 131 + .../MessageListHistoricalPreviews.tsx | 138 + .../components/MessageListHistoryChrome.tsx | 192 + .../agent/chat/components/MessageListItem.tsx | 483 + .../components/MessageListRuntimeStatus.tsx | 127 + .../MessageListStreamingOverlayItem.tsx | 43 + .../components/MessageListUserContent.tsx | 56 + .../chat/components/MessagePreviewCards.tsx | 64 + .../components/MessageTimelineSection.tsx | 98 + .../agent/chat/components/MessageUserBody.tsx | 67 + .../components/StreamingRenderer.test.tsx | 111 +- .../chat/components/StreamingRenderer.tsx | 56 +- .../chat/components/ToolCallDisplay.test.tsx | 109 +- .../agent/chat/components/ToolCallDisplay.tsx | 346 +- .../imageWorkbenchMessageDisplay.test.ts | 143 + .../imageWorkbenchMessageDisplay.ts | 173 + .../chat/components/imageWorkbenchTypes.ts | 1 + .../messageAssistantMetaFooterState.ts | 105 + .../chat/components/messageListConstants.ts | 35 + .../messageListHistoricalPreviewText.ts | 59 + .../components/messageListInlineProcess.ts | 457 + .../components/messageListItemProjection.ts | 586 + .../components/messageListKnowledgeSource.ts | 60 + .../chat/components/messageListPerformance.ts | 24 + .../components/messageListUserContentState.ts | 73 + .../useMessageListHistoricalHydration.ts | 185 + .../useMessageListPromptCacheNotice.ts | 45 + .../components/useMessageListRenderWindow.ts | 222 + .../useMessageListScrollController.ts | 116 + .../components/useMessageListTelemetry.ts | 237 + .../components/useMessageListTimelineState.ts | 333 + .../chat/home/HomeSceneSkillManagerDialog.tsx | 4 +- .../chat/home/buildHomeSkillSurface.test.ts | 9 +- .../agent/chat/hooks/agentChatHistory.test.ts | 333 +- .../agent/chat/hooks/agentChatHistory.ts | 465 +- .../agent/chat/hooks/agentChatShared.ts | 1 + .../agent/chat/hooks/agentChatStorage.ts | 36 +- .../hooks/agentSessionScopedStorage.test.ts | 64 + .../chat/hooks/agentSessionScopedStorage.ts | 92 +- .../chat/hooks/agentSessionState.test.ts | 66 + .../agent/chat/hooks/agentSessionState.ts | 74 +- .../hooks/agentStreamCompletionController.ts | 4 + .../chat/hooks/agentStreamEventProcessor.ts | 30 +- .../hooks/agentStreamRuntimeHandler.test.ts | 32 +- .../chat/hooks/agentStreamRuntimeHandler.ts | 24 + .../agentStreamSlashSkillPreflight.test.ts | 44 +- .../hooks/agentStreamSlashSkillPreflight.ts | 137 +- .../agentStreamSubmissionLifecycle.test.ts | 2 +- .../chat/hooks/agentStreamSubmitDraft.test.ts | 8 +- .../chat/hooks/agentStreamSubmitExecution.ts | 2 + .../chat/hooks/agentStreamTurnEventBinding.ts | 4 + .../agent/chat/hooks/skillCommand.ts | 22 +- .../agent/chat/hooks/useAgentSession.ts | 77 +- .../chat/hooks/useAsterAgentChat.test.tsx | 259 + src/components/agent/chat/index.test.tsx | 118 +- .../creationReplaySlotPrefill.ts | 2 +- .../serviceSkillLaunchPrefill.ts | 2 +- .../chat/service-skills/skillPresentation.ts | 2 +- .../chat/service-skills/useServiceSkills.ts | 7 +- .../skill-selection/CharacterMentionPanel.tsx | 4 +- .../inputCapabilitySections.test.ts | 6 +- .../inputCapabilitySections.ts | 4 +- src/components/agent/chat/styles/index.ts | 25 +- .../activityPreviewSelectors.ts | 2 +- .../boardChromeSelectors.ts | 2 +- .../canvasLaneSelectors.test.ts | 6 +- .../canvasLaneSelectors.ts | 2 +- .../liveRuntimeProjector.ts | 2 +- .../selectedSessionDetailSelectors.ts | 2 +- src/components/agent/chat/types.ts | 6 + .../chat/utils/buildUserInputSubmitOp.test.ts | 2 + .../chat/utils/curatedTaskTemplates.test.ts | 6 +- .../agent/chat/utils/curatedTaskTemplates.ts | 2 +- .../chat/utils/imageWorkbenchCommand.test.ts | 179 +- .../agent/chat/utils/imageWorkbenchCommand.ts | 68 +- .../utils/imageWorkbenchPresentation.test.ts | 135 +- .../chat/utils/imageWorkbenchPresentation.ts | 69 +- .../utils/imageWorkbenchStatusText.test.ts | 27 + .../chat/utils/imageWorkbenchStatusText.ts | 47 +- .../agent/chat/utils/limeTaskProtocolNoise.ts | 89 + .../utils/mentionCommandReplayText.test.ts | 34 +- .../chat/utils/mentionCommandReplayText.ts | 27 +- .../agent/chat/utils/protocolResidue.test.ts | 20 + .../agent/chat/utils/protocolResidue.ts | 19 +- .../utils/sceneAppCuratedTaskReference.ts | 2 +- .../utils/submitOpRuntimeCompaction.test.ts | 2 + .../chat/utils/submitOpRuntimeCompaction.ts | 48 + .../utils/taskPreviewFromToolResult.test.ts | 52 + .../chat/utils/taskPreviewFromToolResult.ts | 71 +- .../chat/utils/threadReliabilityView.test.ts | 2 +- .../chat/utils/toolProcessSummary.test.ts | 29 +- .../agent/chat/utils/toolProcessSummary.ts | 33 +- .../chat/utils/workflowInputState.test.ts | 41 +- .../agent/chat/utils/workflowInputState.ts | 73 +- .../chat/utils/workflowStepPresentation.ts | 52 +- .../chat/workspace/imageSkillLaunch.test.ts | 26 +- .../agent/chat/workspace/imageSkillLaunch.ts | 57 +- .../agent/chat/workspace/imageTaskPersona.ts | 132 +- .../chat/workspace/imageWorkbenchHelpers.ts | 69 +- .../imageWorkbenchPreviewSelection.test.ts | 141 + .../imageWorkbenchPreviewSelection.ts | 145 + .../imageWorkbenchResourceManager.test.ts | 148 + .../imageWorkbenchResourceManager.ts | 195 + .../workspace/imageWorkbenchStateCache.ts | 20 +- .../workspace/mediaTaskPolicyEvaluation.ts | 28 +- .../modelSkillLaunchDescriptors.test.ts | 100 + .../workspace/modelSkillLaunchDescriptors.ts | 289 + .../agent/chat/workspace/sceneSkillGate.ts | 2 +- .../useWorkspaceA2UIRuntime.test.tsx | 63 + .../chat/workspace/useWorkspaceA2UIRuntime.ts | 50 +- ...eWorkspaceAudioTaskPreviewRuntime.test.tsx | 4 +- .../useWorkspaceCanvasSceneRuntime.tsx | 4 + ...eWorkspaceImageTaskPreviewRuntime.test.tsx | 767 +- .../useWorkspaceImageTaskPreviewRuntime.ts | 621 +- ...kspaceImageWorkbenchActionRuntime.test.tsx | 106 +- ...useWorkspaceImageWorkbenchActionRuntime.ts | 224 +- .../useWorkspaceSendActions.test.tsx | 163 +- .../chat/workspace/useWorkspaceSendActions.ts | 625 +- ...ceTranscriptionTaskPreviewRuntime.test.tsx | 4 +- .../api-key-provider/ProviderSetting.tsx | 362 +- .../ProviderSetting.ui.test.tsx | 64 + src/components/memory/MemoryPage.test.tsx | 2 +- .../components/A2UIFormControls.test.tsx | 6 +- .../catalog/basic/components/AudioPlayer.tsx | 2 +- .../a2ui/catalog/basic/components/Button.tsx | 7 +- .../a2ui/catalog/basic/components/Modal.tsx | 2 +- .../a2ui/catalog/basic/components/Tabs.tsx | 2 +- .../a2ui/catalog/basic/components/Text.tsx | 32 +- .../a2ui/catalog/basic/components/Video.tsx | 2 +- .../a2ui/components/A2UIRenderer.test.tsx | 32 +- src/components/workspace/a2ui/layoutTokens.ts | 4 +- .../workspace/a2ui/rendererTokens.ts | 29 +- .../workspace/a2ui/taskCardPrimitives.tsx | 12 +- .../workspace/a2ui/taskCardTokens.ts | 33 +- .../workspace/a2ui/taskFormTokens.ts | 48 +- src/features/agent-app/featureFlag.test.ts | 32 + src/features/agent-app/featureFlag.ts | 63 + .../fixtures/content-engineering-app.json | 84 + src/features/agent-app/index.ts | 16 + src/features/agent-app/install/cleanupPlan.ts | 70 + .../agent-app/install/installedAppPreview.ts | 44 + .../agent-app/install/packageIdentity.ts | 47 + .../agent-app/manifest/normalizeManifest.ts | 100 + .../agent-app/manifest/parseManifest.test.ts | 61 + .../agent-app/manifest/parseManifest.ts | 49 + .../agent-app/projection/projectApp.test.ts | 58 + .../agent-app/projection/projectApp.ts | 197 + .../readiness/checkReadiness.test.ts | 54 + .../agent-app/readiness/checkReadiness.ts | 199 + .../readiness/hostCapabilityProfile.ts | 18 + src/features/agent-app/types.ts | 393 + .../agent-app/ui/AgentAppLabPage.test.tsx | 66 + src/features/agent-app/ui/AgentAppLabPage.tsx | 390 + .../resourceManagerSession.test.ts | 55 + .../resourceManagerSession.ts | 51 +- src/i18n/__tests__/loadNamespace.test.ts | 32 + src/i18n/__tests__/types.test.ts | 93 + src/i18n/agentResources.ts | 37 + src/i18n/loadNamespace.ts | 52 +- src/i18n/resources/en-US/agent.json | 2169 +--- src/i18n/resources/en-US/agentHome.json | 148 + src/i18n/resources/en-US/agentInputbar.json | 249 + .../resources/en-US/agentMessageList.json | 73 + src/i18n/resources/en-US/agentRuntime.json | 603 + src/i18n/resources/en-US/agentSkills.json | 934 ++ .../resources/en-US/agentTeamWorkspace.json | 406 + src/i18n/resources/en-US/navigation.json | 1 + src/i18n/resources/en-US/settings.json | 10 +- src/i18n/resources/ja-JP/agent.json | 2169 +--- src/i18n/resources/ja-JP/agentHome.json | 148 + src/i18n/resources/ja-JP/agentInputbar.json | 249 + .../resources/ja-JP/agentMessageList.json | 73 + src/i18n/resources/ja-JP/agentRuntime.json | 603 + src/i18n/resources/ja-JP/agentSkills.json | 934 ++ .../resources/ja-JP/agentTeamWorkspace.json | 406 + src/i18n/resources/ja-JP/navigation.json | 1 + src/i18n/resources/ja-JP/settings.json | 10 +- src/i18n/resources/ko-KR/agent.json | 2169 +--- src/i18n/resources/ko-KR/agentHome.json | 148 + src/i18n/resources/ko-KR/agentInputbar.json | 249 + .../resources/ko-KR/agentMessageList.json | 73 + src/i18n/resources/ko-KR/agentRuntime.json | 603 + src/i18n/resources/ko-KR/agentSkills.json | 934 ++ .../resources/ko-KR/agentTeamWorkspace.json | 406 + src/i18n/resources/ko-KR/navigation.json | 1 + src/i18n/resources/ko-KR/settings.json | 10 +- src/i18n/resources/zh-CN/agent.json | 2169 +--- src/i18n/resources/zh-CN/agentHome.json | 148 + src/i18n/resources/zh-CN/agentInputbar.json | 249 + .../resources/zh-CN/agentMessageList.json | 73 + src/i18n/resources/zh-CN/agentRuntime.json | 603 + src/i18n/resources/zh-CN/agentSkills.json | 934 ++ .../resources/zh-CN/agentTeamWorkspace.json | 406 + src/i18n/resources/zh-CN/navigation.json | 1 + src/i18n/resources/zh-CN/settings.json | 10 +- src/i18n/resources/zh-TW/agent.json | 2169 +--- src/i18n/resources/zh-TW/agentHome.json | 148 + src/i18n/resources/zh-TW/agentInputbar.json | 249 + .../resources/zh-TW/agentMessageList.json | 73 + src/i18n/resources/zh-TW/agentRuntime.json | 603 + src/i18n/resources/zh-TW/agentSkills.json | 934 ++ .../resources/zh-TW/agentTeamWorkspace.json | 406 + src/i18n/resources/zh-TW/navigation.json | 1 + src/i18n/resources/zh-TW/settings.json | 10 +- src/i18n/types.d.ts | 16 +- src/lib/api/skillCatalog.test.ts | 124 +- src/lib/api/skillCatalog.ts | 384 +- .../base-setup/seededCommandPackage.test.ts | 23 +- src/lib/base-setup/seededCommandPackage.ts | 26 + src/lib/dev-bridge/http-client.test.ts | 2 +- src/lib/dev-bridge/http-client.ts | 7 +- .../governance/modalityExecutionProfiles.json | 261 +- .../governance/modalityRuntimeContracts.json | 53 +- src/lib/navigation/sidebarNav.test.ts | 10 + src/lib/navigation/sidebarNav.ts | 27 +- src/lib/tauri-mock/agentRuntimeMocks.ts | 623 + src/lib/tauri-mock/browserMocks.ts | 1560 +++ src/lib/tauri-mock/companionMocks.ts | 97 + src/lib/tauri-mock/configSystemMocks.ts | 356 + src/lib/tauri-mock/core.ts | 10304 +--------------- src/lib/tauri-mock/fileSystemMocks.ts | 130 + src/lib/tauri-mock/knowledgeMocks.ts | 443 + src/lib/tauri-mock/layeredDesignMocks.ts | 244 + src/lib/tauri-mock/logMocks.ts | 14 + src/lib/tauri-mock/mcpMocks.ts | 21 + src/lib/tauri-mock/mediaTaskMocks.ts | 1185 ++ src/lib/tauri-mock/memoryMocks.ts | 252 + src/lib/tauri-mock/modelMocks.ts | 17 + src/lib/tauri-mock/providerMocks.ts | 22 + .../tauri-mock/runtimeToolInventoryMocks.ts | 994 ++ src/lib/tauri-mock/sceneAppAutomationMocks.ts | 2264 ++++ src/lib/tauri-mock/sessionFileMocks.ts | 39 + src/lib/tauri-mock/skillForgeMocks.ts | 1611 +++ src/lib/tauri-mock/skillManagementMocks.ts | 50 + src/lib/tauri-mock/updateMocks.ts | 54 + src/lib/tauri-mock/voiceMocks.ts | 192 + src/lib/tauri-mock/workspaceMocks.ts | 68 + src/types/page.ts | 6 + src/vite-env.d.ts | 1 + 334 files changed, 44460 insertions(+), 27233 deletions(-) create mode 100644 docs/roadmap/agentapp/README.md create mode 100644 docs/roadmap/agentapp/ai-content-engineering.md create mode 100644 docs/roadmap/agentapp/capability-sdk.md create mode 100644 docs/roadmap/agentapp/implementation-plan.md create mode 100644 docs/roadmap/agentapp/p0-technical-design.md delete mode 100644 playwright-active-history-snapshot.json delete mode 100644 playwright-after-reload-new-chat-boundary.json delete mode 100644 playwright-current-session-debug.json delete mode 100644 playwright-history-buttons.json delete mode 100644 playwright-history-first-image-check.json delete mode 100644 playwright-image-flow-state-after-wait.json delete mode 100644 playwright-image-flow-state.json delete mode 100644 playwright-new-chat-no-leak.json delete mode 100644 playwright-second-image-after-reload-final.json delete mode 100644 playwright-second-image-after-reload-start.json delete mode 100644 playwright-second-image-start.json create mode 100644 src/components/agent/chat/components/ImageWorkbenchPreviewMedia.tsx create mode 100644 src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx create mode 100644 src/components/agent/chat/components/Inputbar/components/inputbarTeamSelectorCopy.ts create mode 100644 src/components/agent/chat/components/Inputbar/inputbarWorkflowCopy.ts create mode 100644 src/components/agent/chat/components/MessageActionButtons.tsx create mode 100644 src/components/agent/chat/components/MessageArtifactCards.tsx create mode 100644 src/components/agent/chat/components/MessageAssistantBody.tsx create mode 100644 src/components/agent/chat/components/MessageAssistantMetaFooter.tsx create mode 100644 src/components/agent/chat/components/MessageCanvasShortcut.tsx create mode 100644 src/components/agent/chat/components/MessageImageAttachments.tsx create mode 100644 src/components/agent/chat/components/MessageList.types.ts create mode 100644 src/components/agent/chat/components/MessageListHistoricalPreviews.tsx create mode 100644 src/components/agent/chat/components/MessageListHistoryChrome.tsx create mode 100644 src/components/agent/chat/components/MessageListItem.tsx create mode 100644 src/components/agent/chat/components/MessageListRuntimeStatus.tsx create mode 100644 src/components/agent/chat/components/MessageListStreamingOverlayItem.tsx create mode 100644 src/components/agent/chat/components/MessageListUserContent.tsx create mode 100644 src/components/agent/chat/components/MessagePreviewCards.tsx create mode 100644 src/components/agent/chat/components/MessageTimelineSection.tsx create mode 100644 src/components/agent/chat/components/MessageUserBody.tsx create mode 100644 src/components/agent/chat/components/imageWorkbenchMessageDisplay.test.ts create mode 100644 src/components/agent/chat/components/imageWorkbenchMessageDisplay.ts create mode 100644 src/components/agent/chat/components/messageAssistantMetaFooterState.ts create mode 100644 src/components/agent/chat/components/messageListConstants.ts create mode 100644 src/components/agent/chat/components/messageListHistoricalPreviewText.ts create mode 100644 src/components/agent/chat/components/messageListInlineProcess.ts create mode 100644 src/components/agent/chat/components/messageListItemProjection.ts create mode 100644 src/components/agent/chat/components/messageListKnowledgeSource.ts create mode 100644 src/components/agent/chat/components/messageListPerformance.ts create mode 100644 src/components/agent/chat/components/messageListUserContentState.ts create mode 100644 src/components/agent/chat/components/useMessageListHistoricalHydration.ts create mode 100644 src/components/agent/chat/components/useMessageListPromptCacheNotice.ts create mode 100644 src/components/agent/chat/components/useMessageListRenderWindow.ts create mode 100644 src/components/agent/chat/components/useMessageListScrollController.ts create mode 100644 src/components/agent/chat/components/useMessageListTelemetry.ts create mode 100644 src/components/agent/chat/components/useMessageListTimelineState.ts create mode 100644 src/components/agent/chat/utils/imageWorkbenchStatusText.test.ts create mode 100644 src/components/agent/chat/utils/limeTaskProtocolNoise.ts create mode 100644 src/components/agent/chat/workspace/imageWorkbenchPreviewSelection.test.ts create mode 100644 src/components/agent/chat/workspace/imageWorkbenchPreviewSelection.ts create mode 100644 src/components/agent/chat/workspace/imageWorkbenchResourceManager.test.ts create mode 100644 src/components/agent/chat/workspace/imageWorkbenchResourceManager.ts create mode 100644 src/components/agent/chat/workspace/modelSkillLaunchDescriptors.test.ts create mode 100644 src/components/agent/chat/workspace/modelSkillLaunchDescriptors.ts create mode 100644 src/features/agent-app/featureFlag.test.ts create mode 100644 src/features/agent-app/featureFlag.ts create mode 100644 src/features/agent-app/fixtures/content-engineering-app.json create mode 100644 src/features/agent-app/index.ts create mode 100644 src/features/agent-app/install/cleanupPlan.ts create mode 100644 src/features/agent-app/install/installedAppPreview.ts create mode 100644 src/features/agent-app/install/packageIdentity.ts create mode 100644 src/features/agent-app/manifest/normalizeManifest.ts create mode 100644 src/features/agent-app/manifest/parseManifest.test.ts create mode 100644 src/features/agent-app/manifest/parseManifest.ts create mode 100644 src/features/agent-app/projection/projectApp.test.ts create mode 100644 src/features/agent-app/projection/projectApp.ts create mode 100644 src/features/agent-app/readiness/checkReadiness.test.ts create mode 100644 src/features/agent-app/readiness/checkReadiness.ts create mode 100644 src/features/agent-app/readiness/hostCapabilityProfile.ts create mode 100644 src/features/agent-app/types.ts create mode 100644 src/features/agent-app/ui/AgentAppLabPage.test.tsx create mode 100644 src/features/agent-app/ui/AgentAppLabPage.tsx create mode 100644 src/i18n/agentResources.ts create mode 100644 src/i18n/resources/en-US/agentHome.json create mode 100644 src/i18n/resources/en-US/agentInputbar.json create mode 100644 src/i18n/resources/en-US/agentMessageList.json create mode 100644 src/i18n/resources/en-US/agentRuntime.json create mode 100644 src/i18n/resources/en-US/agentSkills.json create mode 100644 src/i18n/resources/en-US/agentTeamWorkspace.json create mode 100644 src/i18n/resources/ja-JP/agentHome.json create mode 100644 src/i18n/resources/ja-JP/agentInputbar.json create mode 100644 src/i18n/resources/ja-JP/agentMessageList.json create mode 100644 src/i18n/resources/ja-JP/agentRuntime.json create mode 100644 src/i18n/resources/ja-JP/agentSkills.json create mode 100644 src/i18n/resources/ja-JP/agentTeamWorkspace.json create mode 100644 src/i18n/resources/ko-KR/agentHome.json create mode 100644 src/i18n/resources/ko-KR/agentInputbar.json create mode 100644 src/i18n/resources/ko-KR/agentMessageList.json create mode 100644 src/i18n/resources/ko-KR/agentRuntime.json create mode 100644 src/i18n/resources/ko-KR/agentSkills.json create mode 100644 src/i18n/resources/ko-KR/agentTeamWorkspace.json create mode 100644 src/i18n/resources/zh-CN/agentHome.json create mode 100644 src/i18n/resources/zh-CN/agentInputbar.json create mode 100644 src/i18n/resources/zh-CN/agentMessageList.json create mode 100644 src/i18n/resources/zh-CN/agentRuntime.json create mode 100644 src/i18n/resources/zh-CN/agentSkills.json create mode 100644 src/i18n/resources/zh-CN/agentTeamWorkspace.json create mode 100644 src/i18n/resources/zh-TW/agentHome.json create mode 100644 src/i18n/resources/zh-TW/agentInputbar.json create mode 100644 src/i18n/resources/zh-TW/agentMessageList.json create mode 100644 src/i18n/resources/zh-TW/agentRuntime.json create mode 100644 src/i18n/resources/zh-TW/agentSkills.json create mode 100644 src/i18n/resources/zh-TW/agentTeamWorkspace.json create mode 100644 src/lib/tauri-mock/agentRuntimeMocks.ts create mode 100644 src/lib/tauri-mock/browserMocks.ts create mode 100644 src/lib/tauri-mock/companionMocks.ts create mode 100644 src/lib/tauri-mock/configSystemMocks.ts create mode 100644 src/lib/tauri-mock/fileSystemMocks.ts create mode 100644 src/lib/tauri-mock/knowledgeMocks.ts create mode 100644 src/lib/tauri-mock/layeredDesignMocks.ts create mode 100644 src/lib/tauri-mock/logMocks.ts create mode 100644 src/lib/tauri-mock/mcpMocks.ts create mode 100644 src/lib/tauri-mock/mediaTaskMocks.ts create mode 100644 src/lib/tauri-mock/memoryMocks.ts create mode 100644 src/lib/tauri-mock/modelMocks.ts create mode 100644 src/lib/tauri-mock/providerMocks.ts create mode 100644 src/lib/tauri-mock/runtimeToolInventoryMocks.ts create mode 100644 src/lib/tauri-mock/sceneAppAutomationMocks.ts create mode 100644 src/lib/tauri-mock/sessionFileMocks.ts create mode 100644 src/lib/tauri-mock/skillForgeMocks.ts create mode 100644 src/lib/tauri-mock/skillManagementMocks.ts create mode 100644 src/lib/tauri-mock/updateMocks.ts create mode 100644 src/lib/tauri-mock/voiceMocks.ts create mode 100644 src/lib/tauri-mock/workspaceMocks.ts diff --git a/.gitignore b/.gitignore index 30d271517..5260ff055 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ docs/roadmap/* !docs/roadmap/ai-layered-design/*.md !docs/roadmap/i18n/ !docs/roadmap/i18n/*.md +!docs/roadmap/agentapp/ +!docs/roadmap/agentapp/*.md docs/gongzonghao/ docs/bussniss/ docs/oem/ @@ -136,3 +138,5 @@ governance/ !src/lib/governance/*.test.ts src-tauri/crates/aster-rust/target/ + +playwright* diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d3c0a5f88..8c49675e3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,49 +1,49 @@ -## Lime v1.38.0 +## Lime v1.39.0 -发布日期:`2026-05-14` -递交范围:当前完整 worktree,包含 tracked、deleted 与新增文件;本次按发布要求完成版本号、release note 与发布前校验,提交和推送将在获得明确确认后执行。 +发布日期:`2026-05-15` +递交范围:当前完整 worktree,包含 tracked、deleted 与新增文件;本次按发布要求完成版本号、release note、核心质量校验与发布提交,不包含 tag / push / 正式分发。 -> 发布说明:上一版 release tag 为 `v1.37.0`。本版升级到 `v1.38.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.37.0 发布说明不再作为当前 release note 事实源保留。 +> 发布说明:上一版 release tag 为 `v1.38.0`。本版升级到 `v1.39.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.38.0 发布说明不再作为当前 release note 事实源保留。 ### 发布概览 -- 应用版本从 `1.37.0` 升级到 `1.38.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。 -- `smart-input` 独立浮窗整体下线,删除旧页面、组件、窗口配置、前端 API 与治理回流入口,不再保留可关闭、拖动、圆角、旧 Logo 等旧浮窗问题面。 -- 旧 `voice-input` 独立语音窗口下线,`Fn` / 语音快捷键改为进入首页 / Claw 输入框录音按钮同一状态机,通过主窗口事件 `voice-start-recording` / `voice-stop-recording` 驱动输入栏录音。 -- 翻译快捷键、截图对话快捷键与截图实验入口清理,保留真正仍属于 current 主链的 `@翻译` skill、`translate_instruction_id` 与首页 `voice-input` guide 语义。 -- 命令边界同步清理:移除旧 `open_voice_window` / `close_voice_window`、截图命令与 smart-input 调用面,补齐 Rust 注册、前端网关、DevBridge mock、governance catalog 与 contracts 守卫。 +- 应用版本从 `1.38.0` 升级到 `1.39.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。 +- Agent Chat / Workbench 主路径继续拆分巨型渲染面,`MessageList`、输入栏、历史水合、滚动控制、任务轻卡、图片预览和 Team Workspace 选择器收敛到更小的组件、selector 与 hook。 +- i18n 资源按 Agent 子域拆包,新增并同步 `agentHome`、`agentInputbar`、`agentMessageList`、`agentRuntime`、`agentSkills`、`agentTeamWorkspace` 五语言资源,原 `agent.json` 大幅瘦身。 +- Tauri mock / DevBridge mock 从单体 `core.ts` 拆分为多个域模块,命令契约检查支持识别 `defaultMocks` 的 spread registry,避免 mock 拆分后契约扫描失真。 +- Release updater manifest 强化 macOS DMG / Windows installer URL 处理,缺失 macOS DMG 时阻断错误清单,避免官网下载页错误回退到 updater 包。 +- Agent App 方向新增路线图与技术设计文档,明确 capability SDK、AI 内容工程和 P0 技术闭环的后续主线。 ### 用户可见更新 -- 首页 / Claw 输入框成为语音识别唯一 current 入口,快捷键不再打开单独快捷输入框,也不会再残留无法关闭或无法停止识别的旧浮窗。 -- 设置页快捷键列表移除已过时、未实现或已下线的翻译 / 截图 / smart-input 入口,避免用户看到无效快捷键。 -- 输入栏录音按钮、语音润色失败兜底、图片附件、排队回合与工具按钮补齐回归测试,保证快捷键进入同一输入栏状态后仍能稳定发送。 -- 历史消息合并修复:只有在 assistant 历史中存在工具、任务预览或过程轨迹缺用户 turn 时才补回用户消息,避免普通分页历史重复补用户消息。 -- 图片任务缓存恢复回归补齐:空历史从图片工作台缓存恢复时保留原始用户输入与完成卡,新草稿不再回灌上一条会话的图片轻卡。 +- 聊天消息区在历史恢复、运行中消息、图片 / 音频 / 转写任务卡和 artifact 入口上更稳定,避免新草稿、历史水合或旧会话恢复时串入上一轮轻卡状态。 +- 输入栏与 Team 选择体验继续收敛:Team selector、工作流状态、技能选择、图片附件和多模态提示补齐五语言文案与回归断言。 +- 图片、音频、转写等媒体任务轻卡继续以统一 media task index 为事实源,LimeCore policy 输入缺口、阻断和 allow 状态展示进入本地化资源。 +- Skill Forge / Service Skill 入口链路继续补齐挂起参数、A2UI 表单、runtime binding 和显式 session enable 证据,减少“已注册”与“已可自动执行”之间的误解。 +- GUI smoke 覆盖 workspace ready、browser runtime、site adapter、runtime tool surface、`@` 命令注册、Claw streaming、知识库 GUI 和设计画布主路径。 ### 开发者与治理更新 -- 新语音快捷键事实源固定为 `src-tauri/src/voice/events.rs`、`voice/shortcut.rs`、`voice/fn_shortcut.rs` 与前端 `src/lib/api/voiceShortcutEvents.ts` / `useInputbarDictation`。 -- 旧 `smart-input` / 旧语音窗口 / 截图对话链路均归类为 `dead`,不再新增 compat 包装层。 -- `ShortcutSettings` 收敛到 Settings v2 shared 目录,旧 `src/components/smart-input/ShortcutSettings*` 删除。 -- 五语言 i18n 资源清理旧快捷键与旧入口文案,继续保持 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 覆盖一致。 -- `protocol-fact-source-guard`、`legacySurfaceCatalog` 与 hotkey catalog 回归补齐旧入口防回流断言。 +- `src/lib/tauri-mock/` 拆出 agent runtime、browser、knowledge、media task、memory、provider、skill forge、update、voice、workspace 等 mock registry,降低单文件复杂度。 +- `scripts/check-command-contracts.mjs` 适配拆分后的 mock 事实源,继续检查前端命令、Rust 注册、mock priority 与 default mock 的一致性。 +- `scripts/release-updater-manifest.mjs` 和对应测试覆盖 installer_url、macOS DMG 缺失、Windows 安装包与 updater manifest 组合边界。 +- Rust runtime、Skill 执行与 timeline 服务补充 sequence 归一化、synthetic item、fast response model routing、workspace skill runtime enable 等定向测试。 +- 删除旧 Playwright 调试 JSON 快照,并通过 `.gitignore` 收敛临时调试产物,避免 release 提交继续携带一次性浏览器状态文件。 ### 校验状态 -- `npm run verify:app-version`:通过,目标版本 `1.38.0`。 - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。 -- `CARGO_TARGET_DIR="/tmp/lime-cargo-target-v138" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主库 `1340 passed; 0 failed; 1 ignored`,集成测试通过。 -- `CARGO_TARGET_DIR="/tmp/lime-cargo-target-v138" cargo clippy --manifest-path "src-tauri/Cargo.toml" --workspace --all-targets -- -D warnings`:通过。 +- `npm run verify:app-version`:通过,目标版本 `1.39.0`。 +- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v139" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主库 `1347 passed; 0 failed; 1 ignored`,集成测试通过;真实联网测试保持 ignored。 +- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v139" cargo clippy --manifest-path "src-tauri/Cargo.toml" --workspace --all-targets -- -D warnings`:通过。 - `npm run lint`:通过。 -- `npm test -- src/components/agent/chat/hooks/agentChatHistory.test.ts src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts`:通过。 -- `npm test -- src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx`:通过。 -- `npm test`:通过,55/55 批次通过。 -- `npm run test:contracts`:通过,命令契约、Harness 契约、modality contracts 与 cleanup report contract 均通过。 -- `npm run governance:legacy-report`:通过,边界违规 `0`;本轮 smart-input / screenshot / onboarding dead surface 作为已删除或零引用清理项受控记录。 +- `npm run typecheck`:通过。 +- `npm test`:通过,`56/56` 批次通过。 +- `npm run test -- "src/components/agent/chat/workspace/useWorkspaceAudioTaskPreviewRuntime.test.tsx" "src/components/agent/chat/workspace/useWorkspaceTranscriptionTaskPreviewRuntime.test.tsx"`:通过,用于锁定媒体任务 policy meta 本地化回归。 +- `npm run test:contracts`:通过;命令契约、Harness 契约、modality contracts 与 cleanup report contract 均通过。 +- `npm run verify:gui-smoke`:通过;复用 headless Tauri / DevBridge,覆盖 workspace、browser runtime、site adapters、Skill Forge、runtime tool surface、`@` 命令、Claw streaming、knowledge GUI 与 design canvas。 - `git diff --check`:通过。 -- `npm run verify:local`:通过,包含版本一致性、前端 lint/typecheck/Vitest/contracts、Rust test 与 GUI smoke。 --- -**完整变更**: `v1.37.0` -> `v1.38.0` +**完整变更**: `v1.38.0` -> `v1.39.0` diff --git a/docs/aiprompts/command-runtime.md b/docs/aiprompts/command-runtime.md index 77fdee533..2cc4fa690 100644 --- a/docs/aiprompts/command-runtime.md +++ b/docs/aiprompts/command-runtime.md @@ -87,7 +87,7 @@ Lime 的命令体系固定按以下关系理解: 对图片任务再补一条固定约束: -`@配图/@修图/@重绘` 原始文本必须先进入 Agent turn,再由 `harness.image_skill_launch` 辅助首刀 `Skill(image_generate)`;文稿 inline 配图、封面位、图片工作台编辑/变体这类显式图片动作也一样,必须先组装 `image_task` 上下文后再复用统一发送主线。不要把 current 主链重新改回前端预翻 slash skill、前端直建任务或“按钮直调 task API”。图片 launch 还必须显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用偏航工具,并在必要时直接从当前 session tool surface 移除这些 detour tools,避免模型在“搜技能目录”里空转或把权限错误暴露给用户。当前 `image_generate` 的唯一 current 执行面是 `Skill(image_generate) -> lime_create_image_generation_task -> 标准 image task artifact + worker`;旧的 `Bash -> lime media image generate --json` / `lime task create image --json` 只允许停留在 compat 或手工 CLI 场景,不能再作为 `@配图` 首发路径。即使经过 compat 入口,最终也必须委托同一条 task artifact + worker 执行链,并禁止把任务改写到 `outputPath` / markdown 文稿。 +`@配图/@修图/@重绘` 原始文本必须先进入 Agent turn,再由 `harness.image_skill_launch` 辅助首刀 `Skill(image_generate)`;文稿 inline 配图、封面位、图片工作台编辑/变体这类显式图片动作也一样,必须先组装 `image_task` 上下文后再复用统一发送主线。不要把这些通用图片命令重新改回前端预翻 slash skill、前端直建任务或“按钮直调 task API”。统一目录显式声明的图片模型标签,例如用户在“设置 -> AI 服务商”里从已配置 Provider 模型创建的 `@Nano Banana 2` / `@GPT Images 2`,或 Lime Cloud 下发的同构 command entry,也只表示“用户已经指定图片执行模型”;发送边界仍必须把 `entry_source / provider_id / model / executor_mode / modality_contract_key / runtime_contract / routing_slot` 等上下文并入 `harness.image_skill_launch.image_task`,由同一条 Agent / Skill 回合调用 `lime_create_image_generation_task` 创建标准 task artifact。未在 catalog 中声明的任意 `@模型名` 不得自动变成图片 API 入口。图片 launch 还必须显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用偏航工具,并在必要时直接从当前 session tool surface 移除这些 detour tools,避免模型在“搜技能目录”里空转或把权限错误暴露给用户。当前通用 `image_generate` 的唯一 current 执行面是 `Skill(image_generate) -> lime_create_image_generation_task -> 标准 image task artifact + worker`;旧的 `Bash -> lime media image generate --json` / `lime task create image --json` 和前端直接 `create_image_generation_task_artifact` 只允许停留在 compat、显式工具动作或手工 CLI 场景,不能再作为聊天 @图片命令首发路径。即使经过 compat 入口,最终也必须委托同一条 task artifact + worker 执行链,并禁止把任务改写到 `outputPath` / markdown 文稿。 对所有 skills 再补一条全局约束: diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 7a3fb655d..23f8b583c 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -224,7 +224,7 @@ Skill Forge P3E tool_runtime authorization 第一刀仍继续走 `agent_runtime_ `Claw` 的图片任务当前已经收敛到同一条 current 主链: -- Agent 驱动的图片命令与显式图片动作:`@配图` / `@修图` / `@重绘` / `@image` / `/image`,以及文稿 inline 配图、封面位、图片工作台编辑/变体、带引用图或带参考图的动作,都必须先进入 Agent turn。纯文本入口由 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 保留原始用户文本发送;显式动作则由 `src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts` 组装同构的 `image_task` 上下文后,再复用统一发送主线。两类入口都会把结构化 `image_task` 写入 `request_metadata.harness.image_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs` 会物化 `skill-input-image://N` 引用,并给当前 turn 注入只允许首刀优先调用 `Skill(image_generate)` 的系统提示。当前图片 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型把 `@配图` 卡死在“先搜工具目录”或把权限报错直接暴露给用户。当前 `@配图` / `@海报` 的唯一 current 执行面是 `Skill(image_generate) -> lime_create_image_generation_task`;旧的 `Bash -> lime media image generate --json` 与 `lime task create image --json` 只允许停留在 compat 或人工 CLI 场景,不能再被 skill 当成首发路径。即使经过 compat 入口,最终也必须复用同一个 `image_generate` task artifact + worker 执行链,且忽略 `outputPath` 这类非标准落盘出口,最终仍只落到标准 task file。 +- Agent 驱动的图片命令与显式图片动作:`@配图` / `@修图` / `@重绘` / `@image` / `/image`,以及文稿 inline 配图、封面位、图片工作台编辑/变体、带引用图或带参考图的动作,都必须先进入 Agent turn。纯文本入口由 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 保留原始用户文本发送;显式动作则由 `src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts` 组装同构的 `image_task` 上下文后,再复用统一发送主线。两类入口都会把结构化 `image_task` 写入 `request_metadata.harness.image_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs` 会物化 `skill-input-image://N` 引用,并给当前 turn 注入只允许首刀优先调用 `Skill(image_generate)` 的系统提示。当前图片 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型把 `@配图` 卡死在“先搜工具目录”或把权限错误暴露给用户。统一目录显式声明的图片模型绑定标签,例如用户在“设置 -> AI 服务商”里从已配置 Provider 模型创建的 `@Nano Banana 2` / `@GPT Images 2`,或 Lime Cloud 下发的同构 command entry,也必须进入同一 `image_skill_launch -> Agent 首刀 Skill(image_generate)` 回合;这些标签只表示“用户指定了图片执行模型”,发送边界必须把 `entry_source / provider_id / model / executor_mode / modality_contract_key / runtime_contract / routing_slot` 作为路由上下文并入 `image_task`,不得绕过 Agent / Skill 编排去前端创建任务。未在 catalog 中声明的任意 `@模型名` 不得自动变成图片 API 入口。当前 `@配图` / `@海报` 的唯一 current 执行面仍是 `Skill(image_generate) -> lime_create_image_generation_task`;旧的 `Bash -> lime media image generate --json` 与 `lime task create image --json` 只允许停留在 compat 或人工 CLI 场景,不能再被 skill 当成首发路径。即使经过 compat 入口,最终也必须复用同一个 `image_generate` task artifact + worker 执行链,且忽略 `outputPath` 这类非标准落盘出口,最终仍只落到标准 task file。 - 显式图片动作如果先在前端补好了 `image_skill_launch` metadata,也必须继续复用统一发送边界去绑定真实 `session_id`。不要在图片动作侧为了拿 `session_id` 再额外 `createFreshSession(...)`,否则一次 `@配图` 会被拆成两个对话;当前正确做法是允许 metadata 先带本地 draft key,再在发送前统一替换成真实会话 ID。 - 图片结果展示固定继续走 `聊天轻卡 -> 图片工作台` 主链:通用 `tool_result` 只保留 timeline 与轻卡,不应把空内容的 `.jpg/.png/.webp` 二进制结果再镜像成通用 artifact 卡片;否则会出现重复 `output_image.jpg`、路径不一致导致去重失败、以及点击后无法在文本 workbench 打开的错误体验。 - 通用 artifact 层对同一路径必须做等价归一:`basename / 相对路径 / 绝对路径` 在前端应视作同一文件;`tool_result` 来源的产物默认后台更新,不自动选中、不自动展开工作台,避免命令执行过程中抢焦点。 @@ -235,7 +235,7 @@ Skill Forge P3E tool_runtime authorization 第一刀仍继续走 `agent_runtime_ - `list_media_task_artifacts` - `cancel_media_task_artifact` -无论入口来自纯文本命令、slash scene 组合还是显式图片动作,最终都只允许写入当前项目根目录下的标准 `image_generate` task file,并写入 `session_id / project_id / content_id / entry_source / mode` 等上下文。若当前来源是文稿 inline 配图,还会继续写入 `usage=document-inline`,并以 `relationships.slot_id` 作为正文占位块与后续任务回填的正式绑定字段;payload 中的 `slot_id` 仅保留兼容读取。若前端已经能推断目标小节,还应继续把 `anchor_section_title` 写入 task payload;若还能识别用户当前选中的具体段落,还应继续把裁剪后的 `anchor_text` 一并写入,用于正文占位图与最终图片的 paragraph 级原位落位。聊天区动态占位、正文占位替换、结果回填、刷新恢复都必须继续以 `.lime/tasks` 为唯一事实源,不允许重新回到前端直连图片服务。 +无论入口来自纯文本命令、图片模型绑定标签、slash scene 组合还是显式图片动作,最终都只允许写入当前项目根目录下的标准 `image_generate` task file,并写入 `session_id / project_id / content_id / entry_source / mode` 等上下文。若当前来源是图片模型绑定标签,还必须额外保留显式 `provider_id / model / executor_mode`,不能从当前聊天模型选择里推断图片执行模型。若当前来源是文稿 inline 配图,还会继续写入 `usage=document-inline`,并以 `relationships.slot_id` 作为正文占位块与后续任务回填的正式绑定字段;payload 中的 `slot_id` 仅保留兼容读取。若前端已经能推断目标小节,还应继续把 `anchor_section_title` 写入 task payload;若还能识别用户当前选中的具体段落,还应继续把裁剪后的 `anchor_text` 一并写入,用于正文占位图与最终图片的 paragraph 级原位落位。聊天区动态占位、正文占位替换、结果回填、刷新恢复都必须继续以 `.lime/tasks` 为唯一事实源,不允许重新回到前端直调图片服务。 - `.lime/tasks/**/*.json` 本身是内部任务状态快照,不是面向用户的正式产物。聊天区 artifact 卡片、时间线 file artifact 与默认文件面板都应把这类 JSON 隐藏掉;它们只服务恢复、轮询、取消、重试和诊断,真正给用户看的应该是轻量结果卡、tool timeline 与右侧 viewer。 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index f999709ec..9e2ddf87b 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -260,7 +260,7 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge - 检查浏览器桥接 / mock 优先路径是否同步 - 检查 `DevBridge` 是否可用 - 检查 task / service / workflow 类能力是否命中了各自的 current binding family;若某能力当前属于 `typed local_cli`,验证 runtime 是否结构化组装 `Lime CLI` 并回写统一真相源;若属于 `server_api / hybrid`,验证 run/job/task snapshot 是否正确回流,而不是把“模型有没有先写 Bash”当成通过条件 -- 检查纯文本 `Claw @配图` 是否已经走 `原始用户消息 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> lime_create_image_generation_task -> task/timeline` 主链,以及显式图片动作是否也已经走 `synthetic user message / displayContent -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task/timeline`,而不是回流前端直连图片服务、卡在 `ToolSearch / WebSearch / Read / Glob / Grep`、退回旧的 `Bash/lime media image generate --json` / `lime task create image --json` 旁路,或把 `任务 ID:{task_id}` 这类模板占位原样显示出来 +- 检查纯文本 `Claw @配图` 是否已经走 `原始用户消息 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> lime_create_image_generation_task -> task/timeline` 主链,显式图片动作是否也已经走 `synthetic user message / displayContent -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task/timeline`,统一目录声明的图片模型标签(例如设置页从 Provider 模型创建的 `@Nano Banana 2` / `@GPT Images 2`,或 Lime Cloud 下发的同构命令)是否也进入同一 `image_skill_launch` 回合并只把 Provider / model / executor_mode 作为路由上下文;未在 catalog 中声明的任意 `@模型名` 不应自动调用图片 API。图片主链不能卡在 `ToolSearch / WebSearch / Read / Glob / Grep`、退回旧的 `Bash/lime media image generate --json` / `lime task create image --json` 或聊天前端直建任务旁路,或把 `任务 ID:{task_id}` 这类模板占位原样显示出来 - 检查纯文本 `Claw @封面` 是否已经走 `原始用户消息 -> harness.cover_skill_launch -> Agent 首刀 Skill(cover_generate) -> task file` 主链,而不是回流成普通图片命令、卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或前端本地伪造结果 - 检查纯文本 `Claw @海报` 是否已经走 `原始用户消息 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> lime_create_image_generation_task -> task/timeline` 主链,而不是回流成普通聊天、另一套海报协议、旧的 Bash/CLI 图片旁路,卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或前端本地伪造结果;同时确认默认 `entry_source=at_poster_command`、默认尺寸 `4:5 -> 864x1152` 与“海报设计”语义补齐仍然成立 - 检查纯文本 `Claw @视频` 是否已经走 `原始用户消息 -> harness.video_skill_launch -> Agent 首刀 Skill(video_generate) -> current binding -> task/timeline` 主链;如果当前 binding family 是 `typed local_cli`,应看到 runtime 结构化组装的 `lime media video generate --json`;如果当前 binding family 是原生结构化 binding,则应看到对应的 create task 调用。无论哪种都不能退化成模型自由写 Bash、卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或前端本地伪造结果 @@ -313,7 +313,7 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge - 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级 - 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、浏览器动作配置、扩展安装状态、打开 Chrome 扩展 / 远程调试页,或主动断开扩展连接 - 修改 `get_model_registry_provider_ids` 兼容空结果、Provider 实时模型读取或用户 `custom_models` 合并语义 -- 修改 `create_image_generation_task_artifact`、`get_media_task_artifact`、`list_media_task_artifacts`、`cancel_media_task_artifact`、`src/lib/api/mediaTasks.ts`、`src/lib/api/skill-execution.ts`、`useWorkspaceSendActions`、`useWorkspaceImageWorkbenchActionRuntime`、`runtime_turn`,或调整 `Claw @配图 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task/timeline` 的异步图片任务主链 +- 修改 `create_image_generation_task_artifact`、`get_media_task_artifact`、`list_media_task_artifacts`、`cancel_media_task_artifact`、`src/lib/api/mediaTasks.ts`、`src/lib/api/skill-execution.ts`、`useWorkspaceSendActions`、`useWorkspaceImageWorkbenchActionRuntime`、`runtime_turn`,或调整 `Claw @配图` / 图片模型标签 `-> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task/timeline` 的异步图片任务主链 - 修改 `@封面` parser、`useWorkspaceSendActions`、`runtime_turn`、`cover_skill_launch`、`lime task create cover`、`cover_generate` skill 或 `lime_create_cover_generation_task`,尤其是调整 `Claw @封面 -> harness.cover_skill_launch -> Agent 首刀 Skill(cover_generate) -> task file` 主链 - 修改 `@海报` parser、`useWorkspaceSendActions`、`runtime_turn`、`image_skill_launch`、`lime_create_image_generation_task`、`image_generate` skill 或相关图片 timeline 展示,尤其是调整 `Claw @海报 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task/timeline` 主链 - 修改 `@播报` parser、`useWorkspaceSendActions`、`runtime_turn`、`broadcast_skill_launch`、`lime task create broadcast`、`broadcast_generate` skill 或 `lime_create_broadcast_generation_task`,尤其是调整 `Claw @播报 -> harness.broadcast_skill_launch -> Agent 首刀 Skill(broadcast_generate) -> task file` 主链 diff --git a/docs/roadmap/agentapp/README.md b/docs/roadmap/agentapp/README.md new file mode 100644 index 000000000..b51c631a4 --- /dev/null +++ b/docs/roadmap/agentapp/README.md @@ -0,0 +1,84 @@ +# Agent App 客户端路线图 + +更新时间:2026-05-15 + +## 定位 + +本目录只承载 Lime Desktop / Lime 客户端侧的 Agent App 路线图。 + +客户端职责是把 Agent App 真正安装并跑起来:本地 package cache、manifest projection、readiness、Capability SDK、UI extension host、storage namespace、worker / workflow runtime、Agent Runtime bridge、Artifact / Evidence、权限和本地数据边界。 + +服务端 / Cloud / LimeCore 的职责不写在本目录;对应文档在 `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp`。 + +```text +Lime Cloud / LimeCore + Catalog / Release / License / Tenant Enablement / Gateway / ToolHub + ↓ +Lime Desktop Client + Install / Projection / Capability SDK / UI Host / Storage / Runtime / Evidence + ↓ +Agent App Runtime Package + App UI / Worker / Workflow / Storage Schema / Business Code +``` + +## 当前事实源 + +| 分类 | 对象 | 说明 | +|---|---|---| +| current | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App v0.2 标准、runtime package、schema、参考 CLI 与示例包。 | +| current | `docs/roadmap/agentapp/capability-sdk.md` | Lime Desktop 侧 Capability SDK 与 Host Bridge 方案。 | +| current | `docs/roadmap/agentapp/implementation-plan.md` | 客户端落地实施方案,覆盖 P0-P4。 | +| current | `docs/roadmap/agentapp/p0-technical-design.md` | P0 只读 App Host 技术设计,覆盖 manifest、projection、readiness、cleanup dry-run。 | +| current | `docs/roadmap/agentapp/ai-content-engineering.md` | AI 内容工程化 App 作为客户端标杆案例的 UI / storage / workflow / artifact 方案。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | 服务端 catalog、release、tenant enablement、gateway、ToolHub 路线图。 | + +## 文档索引 + +| 文档 | 说明 | +|---|---| +| [capability-sdk.md](./capability-sdk.md) | 客户端 Capability SDK、runtime bridge、能力注入、权限拦截和 mock host。 | +| [implementation-plan.md](./implementation-plan.md) | 客户端 App Host 落地实施方案:manifest parser、projection、installer、storage、UI host、worker runtime。 | +| [p0-technical-design.md](./p0-technical-design.md) | P0 技术设计:只读 package、manifest normalize、projection、readiness、cleanup dry-run、Lab 展示。 | +| [ai-content-engineering.md](./ai-content-engineering.md) | AI 内容工程化 Product-level Agent App 的客户端产品与实现方案。 | + +## 客户端边界 + +1. 客户端负责运行 Agent App;服务端不默认运行 Agent。 +2. App 不能 import Lime 内部模块,只能通过 Capability SDK 使用 `lime.*` 能力。 +3. UI、worker、workflow、storage migration 都在客户端受控 runtime 中执行。 +4. App storage namespace、artifact namespace、event namespace 必须隔离。 +5. 客户数据、workspace data、secrets 和 overlay 不进入官方 App package。 +6. Expert 只是 `expert-chat` entry;客户端必须支持非聊天页面和业务 workflow。 +7. Cloud 下发 release / tenant enablement,客户端做本地 readiness 和能力注入。 + +## 主路线 + +```mermaid +flowchart TD + Standard[Agent App v0.2 Standard] --> Parser[Manifest Parser] + Parser --> Projection[Client Projection] + Projection --> Installer[Local Installer] + Installer --> SDK[Capability SDK] + SDK --> UI[UI Extension Host] + SDK --> Storage[Storage Namespace] + SDK --> Worker[Worker / Workflow Runtime] + SDK --> Agent[Local Agent Runtime Bridge] + SDK --> Artifact[Artifact + Evidence] + Artifact --> App[AI 内容工程化 App P0] +``` + +## 当前执行顺序 + +先按 P0 技术设计落最小只读闭环: + +```text +feature flags +→ manifest model / normalizer +→ projection model +→ readiness result +→ cleanup dry-run +→ Lab readonly UI +→ Mock SDK host +``` + +不要先做市场页、Cloud、完整深澜内容系统,也不要先把 entry 接入正式主路径。 diff --git a/docs/roadmap/agentapp/ai-content-engineering.md b/docs/roadmap/agentapp/ai-content-engineering.md new file mode 100644 index 000000000..b8da46066 --- /dev/null +++ b/docs/roadmap/agentapp/ai-content-engineering.md @@ -0,0 +1,404 @@ +# AI 内容工程化 Agent App PRD + +更新时间:2026-05-14 + +## 一句话目标 + +基于 Lime Agent App 平台开发一个独立垂直产品:`AI 内容工程化 App`。它不是“内容专家聊天框”,而是有自己 UI、storage、worker、workflow、Knowledge 绑定、Artifact 和数据复盘闭环的完整业务应用,安装到 Lime Desktop 后本地运行。 + +## 关键校准 + +这份需求不能用 `APP.md + prompt` 解决。功能背后必须有完整实现: + +```text +AI 内容工程化 App +├── UI:项目首页 / 三层知识库 / 场景穷尽 / 内容工厂 / 策略报告 / 数据复盘 +├── Storage:项目、知识条目、场景、内容资产、复盘数据、洞察 +├── Worker:文件解析、知识结构化、批量生成、去 AI 味评分、PPT / 报告生成 +├── Workflow:建项目、知识库构建、场景穷尽、内容生产、策略交付、数据复盘 +├── Agent Entries:内容策略专家、知识库整理专家、文案优化专家 +├── Skills / Tools / Knowledge:复用生态能力,不重复造底座 +└── Artifacts / Evidence / Evals:内容表、脚本、报告、PPT、复盘和质量记录 +``` + +`APP.md` 只声明这些能力和入口;真实功能在 runtime package 中实现,并通过 `@lime/app-sdk` 调用 Lime 能力。 + +## 背景 + +来自深澜智能内容工程化方案的真实需求:团队希望把个人 IP、项目事实、内容方法论、素材资产和运营复盘沉淀成可复用系统,而不是每次在 Claude、豆包、Gemini、图片工具、PPT 工具之间复制粘贴。 + +核心链路: + +```text +三层知识库 +→ 场景穷尽 +→ 批量内容生产 +→ 脚本 / 图片提示词 / 策略报告 / PPT +→ 数据复盘 +→ 选题库和知识库迭代 +``` + +这不是 Lime Core 的垂直功能,而是验证 Agent App 平台能力的标杆 App。 + +## 用户与收益 + +| 用户 | 核心诉求 | 收益 | +|------|----------|------| +| 内容运营团队 | 项目资料一次沉淀,批量生产文案、脚本、提示词。 | 从灵感驱动变为系统化生产。 | +| 个人 IP / 讲师 | 基于本人经历和表达风格产出文章、课程稿、朋友圈和私域话术。 | 语气一致、素材复用、减少重复讲背景。 | +| 交付顾问 | 为客户交付可运行系统,而不是复制 prompt 或 fork Lime。 | 交付可复制,官方 App 可升级,客户数据不被覆盖。 | +| 团队管理员 | 控制 App 可见性、模型、工具、默认知识模板、质量门禁。 | 团队能力标准化、权限可控。 | +| Lime 平台 | 验证 App SDK、UI extension、storage namespace、worker runtime、artifact/evidence。 | 新行业通过 App 扩展,不污染 Core。 | + +## 非目标 + +- 不把深澜内容系统写进 Lime Core。 +- 不把客户真实资料、私有 SOP、品牌语气、投放数据打进官方 App 包。 +- 不在 Lime Cloud 增加默认云端 Agent Runtime。 +- P0 不做完整 SaaS:不做账号体系、计费、复杂审核、公开 marketplace。 +- P0 不做全部外部平台 API,对数据复盘先支持 CSV / Excel 手动导入。 + +## 产品形态 + +### 主导航 + +```text +AI 内容工程化 App +├── 项目首页 +├── 知识库 +│ ├── IP 知识库 +│ ├── 项目知识库 +│ └── 素材库 +├── 场景穷尽 +├── 内容工厂 +│ ├── 批量文案 +│ ├── 短视频脚本 +│ └── 图片提示词 +├── 策略分析 +├── PPT / 报告 +├── 数据复盘 +├── 交付物 +├── 专家对话 +└── 设置 +``` + +### Expert entries + +专家只是 App 内的入口: + +| Expert | 用途 | +|---|---| +| 内容策略专家 | 项目定位、竞品分析、内容方向判断。 | +| 知识库整理专家 | 协助资料分层、裁剪、版本化。 | +| 文案优化专家 | 去 AI 味、平台适配、人工改稿。 | +| 数据复盘专家 | 解释方向表现、生成下周建议。 | + +## App Package 草案 + +```text +shenlan-content-engineering/ +├── APP.md +├── dist/ +│ ├── ui/ +│ └── worker/ +├── storage/ +│ ├── schema.json +│ └── migrations/ +├── workflows/ +│ ├── create-project.workflow.md +│ ├── build-knowledge.workflow.md +│ ├── exhaust-scenes.workflow.md +│ ├── batch-copy.workflow.md +│ ├── strategy-report.workflow.md +│ └── data-review.workflow.md +├── agents/ +│ ├── content-strategist.md +│ ├── knowledge-architect.md +│ └── review-analyst.md +├── skills/ +├── knowledge-templates/ +├── artifacts/ +├── evals/ +└── policies/ +``` + +## Manifest 草案 + +```yaml +manifestVersion: 0.2.0 +name: shenlan-content-engineering +version: 0.1.0 +status: draft +appType: domain-app +description: AI 内容工程化 App,用于知识库构建、场景穷尽、批量内容生产和数据复盘。 +runtimeTargets: + - local +requires: + lime: + appRuntime: ">=0.2.0 <1.0.0" + capabilities: + lime.ui: "^0.1.0" + lime.storage: "^0.1.0" + lime.files: "^0.1.0" + lime.agent: "^0.1.0" + lime.knowledge: "^0.1.0" + lime.tools: "^0.1.0" + lime.artifacts: "^0.1.0" + lime.workflow: "^0.1.0" + lime.evidence: "^0.1.0" +runtimePackage: + ui: + path: ./dist/ui + worker: + path: ./dist/worker + storage: + schema: ./storage/schema.json + migrations: ./storage/migrations +storage: + namespace: shenlan-content-engineering + schema: ./storage/schema.json + migrations: ./storage/migrations +entries: + - key: dashboard + kind: page + title: 项目首页 + route: /dashboard + - key: knowledge + kind: page + title: 三层知识库 + route: /knowledge + - key: scene_exhaustion + kind: workflow + title: 场景穷尽 + workflow: ./workflows/exhaust-scenes.workflow.md + - key: content_factory + kind: page + title: 内容工厂 + route: /content-factory + - key: content_strategist + kind: expert-chat + title: 内容策略专家 + persona: ./agents/content-strategist.md +knowledgeTemplates: + - key: ip_knowledge + standard: agentknowledge + type: personal-profile + required: false + - key: project_knowledge + standard: agentknowledge + type: brand-product + required: true + - key: material_library + standard: agentknowledge + type: content-operations + required: false +``` + +## Storage 模型 + +| 表 | 说明 | +|---|---| +| `projects` | 项目、行业、平台、是否需要 IP、默认模型和状态。 | +| `knowledge_spaces` | IP / project / material 三层知识库空间、版本和健康度。 | +| `knowledge_items` | 分区知识条目、来源、证据、字数、版本。 | +| `scenes` | 场景穷尽结果:维度、痛点、解决方案、决策阶段、标签。 | +| `content_assets` | 文案、脚本、图片提示词、报告等内容资产。 | +| `review_imports` | CSV / Excel 数据导入批次。 | +| `metrics` | 完播率、搜索占比、转化、素材复用率等指标。 | +| `insights` | 自动归因、方向判断、下步建议。 | +| `app_settings` | 禁用词、平台规则、评分阈值、默认导出格式。 | + +## Workflow + +### 知识库构建 + +```mermaid +flowchart TD + Upload[上传 PDF / Word / Excel] --> Parse[lime.files + lime.tools 解析] + Parse --> Extract[lime.agent 结构化抽取] + Extract --> Review[人工确认] + Review --> Store[lime.storage 写入 knowledge_items] + Store --> Bind[lime.knowledge 绑定 / 导出] + Bind --> Evidence[lime.evidence 记录来源和版本] +``` + +### 内容生产 + +```mermaid +flowchart TD + Select[选择项目 / 场景 / 平台 / 数量] --> Retrieve[lime.knowledge 检索] + Retrieve --> Generate[lime.agent 批量生成] + Generate --> Score[去 AI 味 + 事实支撑 Eval] + Score --> Grade[A/B/C 分级] + Grade --> Save[lime.storage 保存内容资产] + Save --> Artifact[lime.artifacts 生成内容表] +``` + +### 数据复盘 + +```mermaid +flowchart TD + Import[导入运营数据] --> Normalize[字段映射 / 清洗] + Normalize --> Analyze[归因分析] + Analyze --> Insight[生成洞察和动作] + Insight --> Update[更新选题库 / 场景权重] + Update --> Report[复盘 Artifact] +``` + +## Capability 调用映射 + +| 业务功能 | Lime Capability | +|---|---| +| 文件上传和解析 | `lime.files`、`lime.tools` | +| 三层知识库管理 | `lime.storage`、`lime.knowledge` | +| 结构化抽取 / 文案 / 脚本 | `lime.agent`、Agent Skills | +| 批量任务、进度、中断 | `lime.workflow`、`lime.agent` | +| 内容表、报告、PPT | `lime.artifacts` | +| 事实来源、版本、评估 | `lime.evidence`、Evals | +| 外部调研、飞书同步 | `lime.tools`、`lime.secrets` | +| 权限、成本、风险 | `lime.policy` | + +## MVP 范围 + +P0 只做最小闭环: + +```text +创建项目 +→ 上传资料 +→ 生成三层知识库草稿 +→ 人工确认 +→ 生成 120 个场景 +→ 批量生成 20 条文案 / 脚本 +→ 去 AI 味评分 +→ A/B/C 分级 +→ 保存为 Artifact +``` + +P0 页面: + +1. 项目首页。 +2. 三层知识库。 +3. 场景穷尽。 +4. 内容工厂。 +5. 交付物列表。 + +P0 不做: + +- PPT 完整排版引擎。 +- 飞书自动同步。 +- 外部平台 API 自动抓数。 +- 多租户商业化后台。 +- 完整 marketplace。 + +## 分期计划 + +| 阶段 | 目标 | 验收 | +|---|---|---| +| P0 App Host 验证 | 用 mock SDK 跑通 App UI + storage + workflow + artifact。 | 不修改 Lime Core 业务代码即可打开 App 页面和保存数据。 | +| P1 内容生产 MVP | 跑通知识库构建、场景穷尽、批量文案 / 脚本。 | 30 秒内输出 20 条,评分和分级可见,Artifact 可追溯。 | +| P2 策略交付 | 策略分析、报告、PPT 大纲或 pptx 导出。 | 输入项目与竞品资料,10 分钟内生成可编辑交付物。 | +| P3 数据飞轮 | 运营数据导入、自动归因、选题库迭代。 | 导入一周数据后自动生成复盘 Artifact。 | +| P4 企业启用 | Cloud catalog、tenant enablement、overlay、secrets。 | 租户可启用 App,客户数据不进入官方包。 | + +## Readiness 规则 + +| 检查 | 级别 | 说明 | +|---|---|---| +| `lime.storage` 缺失 | blocking | App 无法保存项目和内容资产。 | +| `lime.files` 缺失 | blocking | 无法上传 / 解析资料。 | +| `project_knowledge` 未绑定 | blocking | 内容生产不能运行。 | +| 必需 writer skill 缺失 | blocking | 批量文案和脚本不可运行。 | +| document parser 缺失 | warning / blocking | P0 上传资料流程 blocking;手工录入可降级。 | +| eval 未启用 | warning | 可生成草稿,但不能标记 publish-ready。 | +| 外部调研工具缺失 | warning | 策略报告降级为用户提供资料模式。 | + +## 验收标准 + +- App 不作为 Lime Core 垂直功能出现;业务 UI 来自 App runtime package。 +- App 所有底层能力调用都通过 Capability SDK。 +- App storage namespace 独立,卸载时可选择保留或导出数据。 +- 每个 Artifact 带 app version、knowledge version、skill/tool/eval provenance。 +- 官方包升级不覆盖客户 Knowledge、workspace data、secrets 或 overlay。 +- Expert entry 可用,但不是唯一入口;非聊天页面和 workflow 是一等公民。 + +## P0 用户故事 + +| 编号 | 用户故事 | 验收标准 | +|---|---|---| +| US-01 | 作为运营,我可以创建一个内容项目,并选择行业、目标平台和是否需要 IP 库。 | `projects` 写入 App storage namespace;不新增 Lime Core 业务表。 | +| US-02 | 作为运营,我可以上传产品资料,系统生成三层知识库草稿。 | 文件通过 `lime.files` 授权读取;结构化结果进入 `knowledge_items`。 | +| US-03 | 作为负责人,我可以逐条确认、裁剪和版本化知识条目。 | 知识库健康度显示字数、分区、来源和版本。 | +| US-04 | 作为内容同学,我可以基于确认后的项目知识库生成 120+ 场景。 | `scenes` 表保存应用场景、用户痛点、解决方案、决策阶段。 | +| US-05 | 作为内容同学,我可以选择场景和平台批量生成 20 条文案或脚本。 | 生成结果进入 `content_assets`,带 A/B/C 分级和去 AI 味评分。 | +| US-06 | 作为交付顾问,我可以把内容批次保存为 Artifact 并追溯来源。 | Artifact 带 app / knowledge / skill / tool / eval provenance。 | +| US-07 | 作为管理员,我可以看到缺失能力或缺失知识绑定的 readiness 提示。 | 缺少 `project_knowledge`、`lime.files` 或 writer skill 时不能静默运行。 | + +## 典型用例 + +### 用例 1:项目知识库构建 + +```text +输入:产品说明书、竞品资料、检测报告、用户差评表 +过程:文件解析 → AI 分区 → 人工确认 → 字数健康检查 → 版本化 +输出:项目知识库 v1、知识健康度、Evidence +``` + +### 用例 2:内容工厂批量生产 + +```text +输入:项目、场景、平台、数量、人群画像 +过程:知识检索 → 技能编排 → 批量生成 → 去 AI 味评分 → A/B/C 分级 +输出:内容表 Artifact、可编辑内容资产、Eval 记录 +``` + +### 用例 3:专家辅助修改 + +```text +输入:某条文案或脚本 +过程:用户从内容表打开专家对话 → 专家读取当前资产和知识版本 → 给出修改建议 +输出:修改后的内容版本、人工确认记录 +``` + +## 实现边界 + +| 责任 | 放在 App 实现 | 放在 Lime 平台 | +|---|---|---| +| 三层知识库 UI | 是 | 提供 UI host 和 storage capability。 | +| 文案生成业务规则 | 是 | 提供 Agent Runtime、Skills 调用、Evidence。 | +| 去 AI 味规则库 | 是 | 提供 Eval 执行和结果记录。 | +| 文件权限与解析入口 | 编排 | `lime.files` 和 `lime.tools` 提供授权与工具执行。 | +| 数据表物理存储 | 否 | `lime.storage` 管 namespace、CRUD、migration。 | +| Artifact 持久化 | 定义内容 | `lime.artifacts` 管创建、打开、导出和 provenance。 | +| 凭证 | 否 | `lime.secrets` 托管。 | +| 租户默认配置 | App 默认值 | Cloud / Desktop overlay 覆盖。 | + +## P0 页面验收细节 + +| 页面 | 必备组件 | Capability | +|---|---|---| +| 项目首页 | 项目列表、知识健康度、最近 Artifact、快捷操作。 | `lime.storage`、`lime.artifacts` | +| 三层知识库 | IP / 项目 / 素材 tabs、分区表、字数警告、来源查看。 | `lime.files`、`lime.storage`、`lime.knowledge` | +| 场景穷尽 | 场景表、维度筛选、人工编辑、批量保存。 | `lime.agent`、`lime.storage` | +| 内容工厂 | 平台选择、数量、场景选择、批量结果、评分与分级。 | `lime.agent`、`lime.artifacts`、`lime.evidence` | +| 交付物 | 内容表、报告、导出、版本历史。 | `lime.artifacts`、`lime.evidence` | + +## 暂缓项 + +这些功能应该进入 P2 以后,避免 P0 变成完整 SaaS: + +- 自动抓取抖音 / 小红书 / 淘宝 / 京东数据。 +- 完整 PPT 排版与模板市场。 +- 飞书自动同步和审批流。 +- 多团队权限后台。 +- 公开模板商城。 +- 自定义代码插件市场。 + +## 下一刀 + +先做 App Host 与 SDK 的垂直切片,而不是先做内容系统全部功能: + +```text +App manifest → projection → readiness → mock SDK → storage namespace → 一个页面 → 一个 workflow → 一个 Artifact +``` + +只有这条链路跑通后,再进入内容工程化的知识库和批量生成业务实现。 diff --git a/docs/roadmap/agentapp/capability-sdk.md b/docs/roadmap/agentapp/capability-sdk.md new file mode 100644 index 000000000..32e2d2759 --- /dev/null +++ b/docs/roadmap/agentapp/capability-sdk.md @@ -0,0 +1,203 @@ +# Agent App 客户端 Capability SDK 方案 + +更新时间:2026-05-14 + +## 一句话目标 + +在 Lime Desktop 客户端侧提供稳定、版本化、可授权、可 mock 的 Capability SDK;Agent App 通过 SDK 调用本地平台能力,自己实现 UI、业务流程、数据模型和交付物,而不重复造 Lime 客户端底座、不依赖内部实现。 + +## 边界 + +本文只讨论 Lime Desktop / Lime 客户端侧能力:App Host、Capability Bridge、UI Host、Storage Namespace、Worker / Workflow Runtime、本地 Agent Runtime Bridge。Cloud / LimeCore 的 catalog、release、tenant enablement、gateway、ToolHub 只作为上游输入,不在本文设计。 + +## 背景 + +真实 Agent App 和底层系统高度耦合。一个 App 可能同时需要:文件、知识库、模型任务、工具调用、Artifact、UI、storage、background task、权限、凭证、Evidence、升级和 tenant overlay。 + +如果没有 SDK,每个 App 都会重复实现这些底座;Lime 底层升级后,所有 App 都要大改。Agent App 平台能成立的前提,是把 Lime 能力封装成稳定 capability facade。 + +## 非目标 + +- P0 不做公开 marketplace、支付分账、审核流。 +- P0 不允许 App 直接运行任意无沙箱代码访问系统资源。 +- P0 不把 Cloud 变成默认 Agent Runtime。 +- P0 不把某个垂直业务写进 Lime Core。 +- P0 不要求一次性支持所有 UI 插件形态;先支持可控 extension slot。 + +## 核心原则 + +1. **SDK 暴露能力,不暴露实现**:App 不能 import Lime internal path。 +2. **声明先于调用**:manifest 先声明 capability、permission、storage、secret、network、tool。 +3. **Host 注入能力**:运行时由 Desktop 注入 capability handles。 +4. **权限双层拦截**:UI 提示只是体验,runtime bridge 必须强制拦截。 +5. **App 数据命名空间化**:每个 App 有独立 storage namespace、artifact namespace、event namespace。 +6. **Cloud 不跑默认 Agent**:Cloud 做 catalog、release、license、tenant enablement、gateway。 +7. **可 mock 和 contract test**:每个 capability 都要有 mock host 和契约测试。 +8. **升级不覆盖用户资产**:官方包、tenant overlay、workspace data、secrets 分离。 + +## 能力地图 + +| Capability | P0 范围 | 典型 API | +|---|---|---| +| `lime.ui` | 注册 page、panel、command、settings、artifact viewer。 | `registerRoute`、`openPanel`、`openArtifact` | +| `lime.storage` | App namespace、CRUD、schema、migration。 | `namespace`、`table`、`migrate` | +| `lime.files` | 用户选中文件、读取 file ref、基础解析。 | `pick`、`read`、`parse` | +| `lime.agent` | 本地 Agent task、stream、cancel、retry、trace。 | `startTask`、`streamTask`、`cancelTask` | +| `lime.knowledge` | Knowledge binding、search、export、version。 | `bind`、`search`、`export` | +| `lime.tools` | Tool Broker 调用、权限、长任务状态。 | `invoke`、`getProgress` | +| `lime.artifacts` | 创建、读取、打开、导出 Artifact。 | `create`、`open`、`export` | +| `lime.workflow` | workflow state、human review、background task。 | `start`、`checkpoint`、`awaitHuman` | +| `lime.policy` | 权限、风险、成本、企业策略。 | `requestPermission`、`check` | +| `lime.evidence` | provenance、tool call、knowledge citation、eval。 | `record`、`linkArtifact` | +| `lime.secrets` | OAuth、API key、外部凭证槽位。 | `requestSecret`、`getHandle` | +| `lime.events` | App 内外事件,UI/worker 解耦。 | `emit`、`subscribe` | + +## 架构图 + +```mermaid +flowchart TD + AppUI[App UI Bundle] --> SDK[ @lime/app-sdk] + AppWorker[App Worker / Workflow] --> SDK + SDK --> Bridge[Capability Bridge] + Bridge --> UI[Lime UI Host] + Bridge --> Storage[Storage Service] + Bridge --> Files[File Service] + Bridge --> Runtime[Local Agent Runtime] + Bridge --> Knowledge[Agent Knowledge Resolver] + Bridge --> Tool[Tool Broker / ToolHub] + Bridge --> Artifact[Artifact Store] + Bridge --> Policy[Policy Engine] + Bridge --> Evidence[Evidence Store] + Bridge --> Secrets[Secret Manager] + Cloud[Lime Cloud Catalog / Release / License] --> Installer[Desktop Installer] + Installer --> Bridge +``` + +## 安装时序 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Desktop as Lime Desktop + participant Cloud as Lime Cloud + participant Installer as App Installer + participant Bridge as Capability Bridge + participant Store as App Storage + participant Runtime as App Runtime + + User->>Desktop: 安装 Agent App + Desktop->>Cloud: 拉取 manifest / package / signature + Cloud-->>Desktop: package + release metadata + Desktop->>Installer: 校验 hash / signature / manifest + Installer->>Bridge: capability negotiation + Bridge-->>Installer: supported / degraded / blocked + Installer->>Store: 创建 namespace / 执行 migration plan + Installer->>Desktop: 生成 projection / readiness + User->>Desktop: 启用 entry + Desktop->>Runtime: 加载 UI / worker + Runtime->>Bridge: 请求 capability handles + Bridge-->>Runtime: 注入已授权 handles +``` + +## Runtime Package 契约 + +```text +app-package/ +├── APP.md +├── dist/ui +├── dist/worker +├── storage/schema.json +├── storage/migrations +├── workflows +├── agents +├── artifacts +├── policies +└── examples +``` + +安装器必须做到: + +- 只信任 package 内声明过的入口。 +- UI、worker、workflow、migration 都有 package provenance。 +- storage migration 先 dry-run / plan,再执行。 +- user data、workspace data、tenant overlay 不进入 package hash。 + +## App Manifest 核心字段 + +```yaml +requires: + lime: + appRuntime: ">=0.2.0 <1.0.0" + capabilities: + lime.ui: "^0.1.0" + lime.storage: "^0.1.0" + lime.agent: "^0.1.0" +runtimePackage: + ui: + path: ./dist/ui + worker: + path: ./dist/worker +storage: + namespace: app-id + schema: ./storage/schema.json + migrations: ./storage/migrations +entries: + - key: dashboard + kind: page + - key: advisor + kind: expert-chat + - key: nightly_review + kind: background-task +``` + +## P0 交付物 + +| 交付物 | 说明 | 验收 | +|---|---|---| +| App manifest v0.2 parser | 支持 `requires`、`runtimePackage`、`storage`、`entries`。 | 示例 App 可 validate / project。 | +| Capability SDK 类型草案 | TypeScript types + mock host。 | App 示例可以用 mock 运行单测。 | +| Desktop Installer 方案 | 安装、hash、projection、readiness、权限。 | 能生成 projection,不运行代码。 | +| Storage namespace 方案 | schema、migration、保留/删除策略。 | 卸载时可选择保留数据。 | +| UI extension slot 方案 | page / panel / settings / artifact viewer。 | App 页面不需要写进 Core。 | +| Worker runtime 方案 | long task、cancel、trace、policy。 | 能执行受控后台任务。 | +| Evidence 串联 | task/tool/knowledge/artifact/eval provenance。 | 产物能追溯 App 和知识版本。 | + +## 分期计划 + +### P0:单机 App Host 骨架 + +- 完成 Agent App v0.2 标准对齐。 +- 设计 `@lime/app-sdk` 最小 API surface 和 mock host。 +- Desktop 支持安装本地 package、projection、readiness。 +- 支持 page / expert-chat / workflow 三类 entry。 +- 支持 storage namespace + basic migration。 +- 支持 Artifact create + Evidence provenance。 + +### P1:真实垂直 App 验证 + +- 用 AI 内容工程化 App 验证 UI、storage、workflow、worker、Agent task、Artifact。 +- 支持文件选择、文档解析、Knowledge binding、批量生成和去 AI 味 eval。 +- App 所有业务 UI 不进 Lime Core。 + +### P2:Cloud Catalog / Tenant Enablement + +- Cloud 下发 app release、package hash、tenant enablement、license。 +- Desktop 只执行已启用 App。 +- 支持 tenant overlay 覆盖默认知识、工具、模型、eval 阈值。 + +### P3:安全、升级和生态 + +- App signature、sandbox、permission review、compat matrix。 +- SDK capability deprecation 策略和 contract tests。 +- App-to-App capability sharing 规则。 +- Marketplace / 私有分发 / 企业策略。 + +## 风险与应对 + +| 风险 | 影响 | 应对 | +|---|---|---| +| SDK 过厚 | 变成第二套 Lime 内部 API。 | 只暴露 capability facade,不暴露 store/internal path。 | +| SDK 过薄 | App 重复造轮子。 | P0 优先封装高频底座:storage、files、agent、artifact、knowledge、tools。 | +| UI 安全 | App UI 诱导授权或越权访问。 | Host 控制容器 + runtime permission bridge 双拦截。 | +| Migration 破坏数据 | App 升级损坏用户数据。 | migration plan、dry-run、backup、保留数据策略。 | +| Cloud 变 Runtime | 破坏 Lime 本地运行定位。 | server-assisted 必须显式声明并受 policy 控制。 | diff --git a/docs/roadmap/agentapp/implementation-plan.md b/docs/roadmap/agentapp/implementation-plan.md new file mode 100644 index 000000000..42d37db85 --- /dev/null +++ b/docs/roadmap/agentapp/implementation-plan.md @@ -0,0 +1,853 @@ +# Agent App 客户端实施方案计划 + +更新时间:2026-05-15 + +## 一句话结论 + +Agent App 在 Lime Desktop 里必须先做成一个可关闭、可卸载、可删除数据、可移除代码的“实验岛”,而不是一开始改造 Chat、Skill、Workspace、Artifact 主路径。 + +第一阶段目标不是做一个完整生态,也不是复刻深澜内容系统,而是证明:一个真实 App package 可以在本地被安装、解析、投影、检查 readiness、调用 Capability SDK、生成可追溯产物,并且在方向失败时能干净清理。 + +```text +Agent App Standard v0.2 + ↓ +Local Package / Fixture + ↓ +Lime Desktop Agent App Host(实验岛) + ↓ +Capability SDK Facade + Mock Host + ↓ +少量可删除 Adapter + ↓ +现有 Lime 本地能力:Agent / Knowledge / Tools / Artifact / Evidence / Files +``` + +## 背景 + +Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也不是把某个垂直业务写进 Lime Core。它是安装在 Lime Desktop 中运行的完整应用包:有自己的 UI、业务流程、数据模型、worker、workflow、artifact、权限和升级策略。 + +这件事最大的风险不在 Cloud,而在客户端运行时: + +1. App 需要调用 Lime 底层能力,天然和 Agent Runtime、Knowledge、Tool、Artifact、Evidence、Files、Policy 绑定。 +2. App 又不能直接依赖 Lime 内部实现,否则 Lime 一升级,所有 App 都要跟着大改。 +3. 如果一开始把 App entry 接进主导航、命令面板、Chat、Skill Catalog、Artifact schema,会快速污染主路径。 +4. 如果路线判断失败,必须能关掉开关、删掉实验目录、清掉 App 数据,而不是留下半套平台代码。 + +所以客户端实施必须采用“实验岛 + Capability SDK + 可删除 Adapter”的路线。 + +## 目标 + +| 目标 | 说明 | +|---|---| +| 验证 App package | 本地读取 Agent App v0.2 package / fixture,生成 manifest、projection、readiness。 | +| 验证 SDK 边界 | App 只能通过 `@lime/app-sdk` capability facade 调 Lime 能力,不能 import Lime internal modules。 | +| 验证真实业务切片 | 用 AI 内容工程化 App 跑通一个最小业务闭环,而不是停留在专家聊天。 | +| 验证本地数据隔离 | App storage、artifact、evidence、log、package cache 独立命名空间,可按 app 清理。 | +| 验证失败退出 | P0 就实现 cleanup plan / uninstall plan,保证失败后能删干净。 | +| 验证未来 Cloud 对接 | 客户端先用 local JSON / fixture,Cloud 只在 P5 作为 catalog / release / tenant enablement 输入。 | + +## 非目标 + +1. P0 不做公开市场、审核流、支付分账、企业分发控制台。 +2. P0 不执行任意 App 代码,不支持任意 npm install、native binary、任意文件系统访问。 +3. P0 不把 AI 内容工程化写进 Lime Core。 +4. P0 不改造 AgentChat、Skill Catalog、Artifact 主 schema。 +5. P0 不要求 Cloud 先完成;Cloud 不运行默认 Agent Runtime。 +6. P0 不承诺兼容历史实验数据;实验数据必须可删除。 +7. P0 不把 App projection 写入全局 registry。 + +## 总体原则 + +| 原则 | 落地方式 | +|---|---| +| 实验岛优先 | 代码集中在 `src/features/agent-app/`,主系统只保留 feature flag 入口。 | +| 先静态后运行 | 先做 manifest / projection / readiness,不执行 UI bundle 或 worker。 | +| 先 mock 后 adapter | SDK 先接 mock host,确认接口稳定后再接少量真实能力。 | +| 先本地后 Cloud | P0-P4 使用本地 fixture,P5 才消费 LimeCore bootstrap payload。 | +| 先可删除后扩展 | 安装、运行、产物、日志都必须有 cleanup plan。 | +| SDK 暴露能力 | App 只调用 capability,不知道 Lime 内部 store、command、runtime 路径。 | +| 产物必须可追溯 | Artifact / Evidence / Task / Log 都带 `sourceKind: agent_app` provenance。 | + +## 仓库边界 + +本文只描述 Lime Desktop 客户端: + +```text +/Users/coso/Documents/dev/ai/aiclientproxy/lime/docs/roadmap/agentapp +``` + +服务端 / Cloud / LimeCore 控制面文档在: + +```text +/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp +``` + +标准仓库事实源在: + +```text +/Users/coso/Documents/dev/ai/limecloud/agentapp +``` + +三者分工: + +| 仓库 | 责任 | 不做什么 | +|---|---|---| +| `agentapp` | 标准、schema、示例包、reference CLI。 | 不实现 Lime Desktop 内部能力。 | +| `lime` | 客户端安装、运行、SDK host、UI host、storage、runtime bridge。 | 不实现 Cloud catalog / tenant 管理。 | +| `limecore` | Catalog、release、hash、license、tenant enablement、gateway、audit metadata。 | 不运行默认 Agent、不渲染 App UI、不管理本地 App storage。 | + +## 架构图 + +```mermaid +flowchart TD + Standard[Agent App v0.2 Standard] --> Source[Local Package / Fixture] + Cloud[LimeCore Control Plane] --> Bootstrap[Optional Bootstrap Payload] + Source --> Host[Agent App Host 实验岛] + Bootstrap --> Host + + Host --> Flag[Feature Flags] + Host --> Installer[Local Installer] + Host --> Manifest[Manifest Parser] + Host --> Projection[Projection + Readiness] + Host --> Store[App Package / Storage Namespace] + Host --> SDK[Capability SDK Facade] + Host --> UIHost[UI Extension Host] + Host --> Worker[Worker / Workflow Runtime] + + SDK --> Mock[Mock Capability Host] + SDK --> Adapter[Thin Lime Adapters] + + Adapter --> Agent[Lime Local Agent Runtime] + Adapter --> Knowledge[Agent Knowledge] + Adapter --> Tools[Tool Broker] + Adapter --> Artifacts[Artifact Store] + Adapter --> Evidence[Evidence Store] + Adapter --> Files[File Service] + Adapter --> Policy[Policy / Secrets] + + UIHost --> AppUI[App UI Bundle] + Worker --> AppWorker[App Worker] + Store --> AppData[App Namespace Data] +``` + +关键点: + +- `Host` 是实验岛,不是主路径。 +- `SDK` 是稳定 facade,不是内部 API 透传。 +- `Adapter` 必须薄,且可以按目录删除。 +- `Cloud` 只提供元数据,不成为默认 runtime。 + +## 模块设计 + +客户端代码集中放在一个独立功能目录: + +```text +src/features/agent-app/ +├── README.md +├── featureFlag.ts +├── types.ts +├── manifest/ +│ ├── parseManifest.ts +│ ├── normalizeManifest.ts +│ └── parseManifest.test.ts +├── projection/ +│ ├── projectApp.ts +│ ├── projectionGuards.ts +│ └── projectApp.test.ts +├── readiness/ +│ ├── checkReadiness.ts +│ ├── capabilityNegotiation.ts +│ └── checkReadiness.test.ts +├── install/ +│ ├── packageSource.ts +│ ├── packageVerifier.ts +│ ├── localPackageStore.ts +│ ├── installedAppState.ts +│ ├── cleanupPlan.ts +│ ├── uninstallApp.ts +│ └── installFlow.test.ts +├── sdk/ +│ ├── LimeAppSdk.ts +│ ├── CapabilityHost.ts +│ ├── MockCapabilityHost.ts +│ ├── capabilityErrors.ts +│ ├── provenance.ts +│ └── contract.test.ts +├── adapters/ +│ ├── artifactAdapter.ts +│ ├── evidenceAdapter.ts +│ ├── knowledgeAdapter.ts +│ ├── agentRuntimeAdapter.ts +│ └── adapterGuards.ts +├── runtime/ +│ ├── uiExtensionHost.ts +│ ├── workerRuntimeHost.ts +│ ├── runtimePolicy.ts +│ └── runtimeSandbox.test.ts +├── ui/ +│ ├── AgentAppLabPage.tsx +│ ├── AgentAppCard.tsx +│ ├── AgentAppEntriesPanel.tsx +│ ├── AgentAppReadinessPanel.tsx +│ ├── AgentAppRunPanel.tsx +│ └── AgentAppCleanupPanel.tsx +└── fixtures/ + ├── content-engineering-app.json + └── content-engineering-run.fixture.json +``` + +主系统只允许极薄入口: + +```ts +if (featureFlags.agentAppHost.labEnabled) { + registerAgentAppLabEntry() +} +``` + +禁止在 P0-P2 出现: + +```text +agent-app 逻辑散落到 Chat 主流程 +agent-app 逻辑散落到 Skill Catalog 主数据结构 +agent-app 逻辑散落到 Artifact 主 schema +agent-app entry 直接进入正式命令面板 +App package 直接 import src/ 内部模块 +``` + +### Tauri / Rust 边界 + +如果 P0 必须增加 Rust 能力,也必须独立目录,且只服务实验岛: + +```text +src-tauri/src/agent_app/ +├── manifest.rs +├── installer.rs +├── projection.rs +├── readiness.rs +├── storage.rs +├── cleanup.rs +└── commands.rs +``` + +原则: + +1. 能先用 TypeScript + local fixture 验证的,不先加 Rust 命令。 +2. 必须加命令时,遵守 Lime command boundary,同步前端 gateway、Rust registration、catalog、mock。 +3. Rust 只暴露安装、读取、清理等本地能力,不承载 App 业务逻辑。 + +## Feature Flag 策略 + +所有能力默认关闭,按层启用: + +```ts +agentAppHost: { + labEnabled: false, + localPackageEnabled: false, + projectionEnabled: false, + readinessEnabled: false, + mockSdkEnabled: false, + localStorageEnabled: false, + realAdapterEnabled: false, + uiRuntimeEnabled: false, + workerRuntimeEnabled: false, + cloudBootstrapEnabled: false +} +``` + +| 开关 | 启用内容 | 默认 | 失败止血 | +|---|---|---|---| +| `labEnabled` | 显示 Agent App Lab 页面。 | off | UI 入口消失。 | +| `localPackageEnabled` | 读取本地 package / fixture。 | off | 不扫描本地 App。 | +| `projectionEnabled` | 生成 projection。 | off | 不产生派生对象。 | +| `readinessEnabled` | 检查 capability / permission / runtime。 | off | 不做启用判断。 | +| `mockSdkEnabled` | 使用 mock capability host。 | off | 不运行 mock action。 | +| `localStorageEnabled` | 创建 App namespace。 | off | 不写本地实验数据。 | +| `realAdapterEnabled` | 接入少量真实 Lime adapter。 | off | 回退 mock。 | +| `uiRuntimeEnabled` | 加载受控 UI bundle。 | off | 回退只读 projection。 | +| `workerRuntimeEnabled` | 执行受控 worker。 | off | 不跑后台任务。 | +| `cloudBootstrapEnabled` | 消费 LimeCore bootstrap。 | off | 只用本地 fixture。 | + +## 数据边界 + +P0 使用独立物理目录,不混入现有主业务表: + +```text +/agent-apps/ +├── installed-apps.json +├── packages/ +│ └── sha256-/ +├── projections/ +│ └── .json +├── readiness/ +│ └── .json +├── storage/ +│ └── / +│ └── app.sqlite 或 data.json +├── artifacts/ +│ └── / +├── evidence/ +│ └── / +├── logs/ +│ └── / +└── exports/ + └── / +``` + +如果进入现有 Artifact / Evidence 系统,必须附带统一 provenance: + +```ts +type AgentAppProvenance = { + sourceKind: 'agent_app' + appId: string + appVersion: string + packageHash: string + manifestHash: string + entryKey?: string + workflowRunId?: string + workspaceId?: string + taskId?: string +} +``` + +禁止: + +```text +App storage 直接写主业务表 +App 产物没有 sourceKind +App package hash 包含用户数据 +App 升级覆盖用户 storage +App 卸载默认删除用户数据 +``` + +## 安装流程 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Lab as Agent App Lab + participant Source as Local Package Source + participant Installer as Installer + participant Parser as Manifest Parser + participant Readiness as Readiness Checker + participant Store as App Store + participant SDK as Capability Host + + User->>Lab: 选择本地 App package / fixture + Lab->>Source: 读取 package metadata + Source-->>Installer: package path + hash + Installer->>Parser: parse APP.md / manifest + Parser-->>Installer: normalized manifest + Installer->>Readiness: capability negotiation + Readiness->>SDK: 查询 host 支持能力 + SDK-->>Readiness: supported / degraded / blocked + Readiness-->>Installer: readiness result + Installer->>Store: 写入 package cache / projection / readiness + Store-->>Lab: installed app state + Lab-->>User: 展示 App card / entries / blockers +``` + +安装器 P0 验收: + +- hash / manifest hash 可计算。 +- projection 是只读派生对象。 +- readiness 可解释缺失能力。 +- 失败不会写入半安装状态。 +- 重复安装同 hash 可幂等。 + +## 运行流程 + +P0-P1 不执行 App 自带代码,只运行内置 mock action: + +```mermaid +sequenceDiagram + participant User as 用户 + participant Lab as Lab UI + participant Runner as Entry Runner + participant SDK as Mock Capability Host + participant Store as App Namespace + participant Artifact as Mock Artifact Store + participant Evidence as Evidence Recorder + + User->>Lab: 点击 fixture entry + Lab->>Runner: runEntry(appId, entryKey) + Runner->>SDK: create scoped sdk context + SDK->>Store: 写入 app run record + SDK->>Artifact: 创建 mock artifact + SDK->>Evidence: 记录 provenance + Evidence-->>Lab: run summary + Lab-->>User: 展示产物、日志、清理入口 +``` + +P2 之后才允许把部分 SDK capability 接到真实 adapter: + +```text +MockCapabilityHost + ↓ contract tests 通过 +Thin Lime Adapter + ↓ feature flag 启用 +真实本地能力 +``` + +## Projection 边界 + +Projection 是安装时生成的只读派生对象,不是全局注册: + +```ts +type AgentAppProjection = { + app: AppSummary + package: PackageIdentity + entries: ProjectedEntry[] + requiredCapabilities: CapabilityRequirement[] + storage?: StorageProjection + artifacts?: ArtifactProjection[] + policies: PolicyProjection[] + readinessHints: ReadinessHint[] + provenance: AgentAppProvenance +} +``` + +允许: + +```text +在 Lab UI 渲染 projection +用 projection 做 readiness 检查 +用 projection 创建 scoped SDK context +按 projection 生成 cleanup plan +``` + +禁止: + +```text +写入 command registry +写入 skill catalog +写入 artifact catalog +写入 workspace routes +自动注册快捷键 +自动注册全局搜索结果 +``` + +## Capability SDK 实施策略 + +P0 SDK 先做 facade、错误码、mock host 和 provenance,不绑定内部实现。 + +```ts +interface LimeAppSdk { + storage: LimeStorageCapability + files: LimeFilesCapability + agent: LimeAgentCapability + knowledge: LimeKnowledgeCapability + tools: LimeToolsCapability + artifacts: LimeArtifactsCapability + workflow: LimeWorkflowCapability + evidence: LimeEvidenceCapability + policy: LimePolicyCapability + secrets: LimeSecretsCapability + events: LimeEventsCapability +} +``` + +所有 capability 必须满足: + +1. 不暴露 Lime internal path。 +2. 所有调用都接收 scoped app context。 +3. 所有错误都走稳定错误码。 +4. 同一接口有 mock host 和真实 adapter 两种实现。 +5. host 自动附加 provenance。 +6. permission、readiness、feature flag 在 bridge 层强制拦截。 +7. contract test 固定接口行为。 + +稳定错误码: + +```text +CAPABILITY_MISSING +VERSION_UNSUPPORTED +PERMISSION_DENIED +READINESS_BLOCKED +HOST_UNAVAILABLE +APP_DISABLED +APP_STORAGE_UNAVAILABLE +APP_RUNTIME_UNSUPPORTED +APP_PACKAGE_INVALID +APP_PACKAGE_HASH_MISMATCH +APP_CLEANUP_FAILED +``` + +## UI Runtime 策略 + +P0-P2 不加载 App UI bundle,只用 Lab projection 展示。 + +P3 才做受控 UI Extension Host: + +| 能力 | P3 范围 | 禁止 | +|---|---|---| +| `page` entry | 在受控容器中展示 App 页面。 | 直接注册主路由。 | +| `panel` entry | 在 Lab 中打开侧栏 / 面板。 | 直接改 Workspace layout。 | +| `settings` entry | 展示 App 自己的设置页。 | 修改全局设置结构。 | +| SDK 注入 | Host 注入 scoped handles。 | App import 内部模块。 | +| 权限提示 | UI 解释权限用途。 | 只靠 UI 提示,不做 runtime 拦截。 | + +UI Host 最小要求: + +- App UI 无法访问 Node / Tauri raw API。 +- App UI 无法直接读写文件、secret、网络。 +- App UI 只能通过 injected SDK bridge 访问能力。 +- 关闭 `uiRuntimeEnabled` 后,所有 App 页面回退到只读 Lab 展示。 + +## Worker / Workflow Runtime 策略 + +Worker 是最高风险项,必须晚于 UI Host: + +| 阶段 | Runtime 能力 | 是否执行 App 代码 | +|---|---|---| +| P0 | manifest / projection / readiness | 否 | +| P1 | mock entry action | 否,只跑内置 mock handler | +| P2 | thin adapter action | 否,仍由 Lime 内置 runner 调 adapter | +| P3 | 受控 UI bundle | 是,仅 UI 容器 | +| P4 | 受控 workflow DSL / worker | 是,必须有 policy、cancel、trace、resource limit | + +Worker 禁止项: + +```text +任意 JS worker +任意 npm dependency install +native binary +未声明网络访问 +未声明文件系统访问 +App 自带模型网关 +明文 secret +不可取消长任务 +无 trace 的工具调用 +``` + +## AI 内容工程化验证切片 + +AI 内容工程化 App 只作为平台验证样板,不进入 Lime Core。 + +P4 最小闭环: + +```text +创建项目 +→ 保存到 app namespace +→ 选择本地 fixture 知识 +→ 生成场景表 mock / local agent result +→ 生成内容资产 +→ 创建内容表 Artifact +→ 记录 Evidence provenance +→ 展示 cleanup plan +→ uninstall delete data 清理 +``` + +最小业务对象: + +| 对象 | 存储位置 | 产物 | +|---|---|---| +| project | App storage namespace | 项目配置。 | +| knowledge binding | App storage + lime.knowledge ref | 三层知识库引用。 | +| scenes | App storage | 场景穷尽表。 | +| content assets | App storage | 文案 / 脚本 / 图片提示词。 | +| content table | Artifact store 或实验 artifact dir | 可导出内容表。 | +| evidence | Evidence store 或实验 evidence dir | 来源、模型、知识版本、App provenance。 | + +停止条件: + +- 为了跑通 P4,需要修改超过 3 个 Lime 核心主路径模块。 +- App 需要绕过 SDK 直接调用 internal store。 +- App 数据无法按 namespace 清理。 +- 产物无法区分 `sourceKind: agent_app`。 + +触发停止条件时,不继续堆业务功能,回到 SDK / Host 边界设计。 + +## 清理与卸载 + +P0 必须同步实现 cleanup plan,不允许“先装上再说”。 + +```ts +type AppCleanupPlan = { + appId: string + packageHash: string + packageCachePaths: string[] + projectionPaths: string[] + readinessPaths: string[] + storageNamespaces: string[] + artifactRefs: string[] + evidenceRefs: string[] + taskRefs: string[] + secretRefs: string[] + logPaths: string[] + exportPaths: string[] +} +``` + +卸载策略: + +| 策略 | 行为 | 用途 | +|---|---|---| +| Disable only | 禁用 App,保留 package 和数据。 | 临时止血。 | +| Uninstall keep data | 删除 package / projection / readiness,保留 storage / artifacts。 | 升级失败或重装。 | +| Uninstall delete data | 删除 package、projection、storage、artifacts、evidence、logs。 | 实验失败或用户明确清理。 | +| Export then delete | 先导出 storage / artifacts,再删除本地数据。 | 数据迁移。 | + +清理流程: + +```mermaid +flowchart TD + Start[用户选择卸载 / 路线失败] --> Plan[生成 Cleanup Plan] + Plan --> Preview[展示将删除 / 保留对象] + Preview --> Choice{用户选择策略} + Choice --> Disable[Disable only] + Choice --> Keep[Uninstall keep data] + Choice --> Delete[Uninstall delete data] + Choice --> Export[Export then delete] + Delete --> Verify[校验残留] + Keep --> Verify + Disable --> Verify + Export --> Verify + Verify --> Done[写入 uninstall record] +``` + +失败清理验收: + +```text +rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp +``` + +路线失败时允许保留历史文档,但运行时代码、feature flag、数据目录、mock fixture 必须能被完整删除。 + +## 分期实施 + +### P0:只读 App Host 骨架 + +目标:证明客户端能读取 Agent App v0.2 package,并生成稳定 projection / readiness。 + +交付: + +- `AppManifest` / `NormalizedAppManifest` 类型。 +- `parseManifest` / `normalizeManifest`。 +- `projectApp`。 +- `checkReadiness`。 +- `InstalledAppState`。 +- `AgentAppProjection`。 +- `AgentAppLabPage` 只读展示。 +- 本地 fixture:`content-engineering-app`。 +- `cleanupPlan` 类型和 dry-run。 + +验收: + +- Lab 页面展示 App 卡片、entries、capability requirements、readiness blockers。 +- 不执行 App UI / worker。 +- 不写全局 registry。 +- 关闭 `labEnabled` 后 UI 完全消失。 +- dry-run cleanup 能列出 package / projection / readiness / storage 路径。 + +### P1:Mock Capability Host 与实验产物 + +目标:证明 App entry 可以通过 mock capability host 运行最小动作,并生成可追溯实验产物。 + +交付: + +- `LimeAppSdk` 类型。 +- `CapabilityHost` 接口。 +- `MockCapabilityHost`。 +- `lime.storage` mock namespace。 +- `lime.artifacts.create` mock。 +- `lime.evidence.record` mock。 +- `uninstallApp`。 +- SDK contract tests。 + +验收: + +- 点击 fixture entry 生成 mock Artifact。 +- Artifact / Evidence 带 `sourceKind: agent_app` provenance。 +- uninstall delete data 删除 package / projection / readiness / storage / artifact / evidence。 +- mock SDK 不依赖真实 Lime internal store。 + +### P2:少量真实 Adapter + +目标:用最小真实能力验证 SDK facade 是否能隔离内部实现。 + +允许接入: + +| Capability | Adapter | 原则 | +|---|---|---| +| `lime.artifacts.create` | Artifact 创建 adapter。 | 自动附加 provenance。 | +| `lime.evidence.record` | Evidence 记录 adapter。 | 可按 appId 查询。 | +| `lime.knowledge.search` | Knowledge resolver adapter。 | 只读检索。 | +| `lime.agent.startTask` | 本地 Agent Runtime adapter。 | 可 cancel / trace。 | + +禁止: + +- 修改 AgentChat 主流程。 +- 修改 Skill Catalog 主结构。 +- 修改 Artifact 主 schema。 +- 把 App entry 放进正式命令面板。 + +验收: + +- 所有 adapter 由 `realAdapterEnabled` 控制。 +- 关闭真实 adapter 后可回退 mock。 +- Adapter 删除后主路径仍可编译。 +- Agent App 产物可按 provenance 查询和清理。 + +### P3:受控 UI Extension Host + +目标:验证 App 可以拥有自己的 UI 表现形式,而不是只能作为专家对话框。 + +交付: + +- `uiExtensionHost`。 +- `page` entry 受控容器。 +- SDK bridge 注入。 +- UI permission guard。 +- runtime sandbox smoke test。 + +验收: + +- App UI 无法 import Lime internal module。 +- App UI 无法直接访问 Tauri raw API、文件、secret、网络。 +- 权限在 bridge 层强制拦截。 +- 关闭 `uiRuntimeEnabled` 后回退 Lab projection 展示。 + +### P4:AI 内容工程化最小业务闭环 + +目标:验证 Product-level App 是否值得继续投入。 + +交付: + +- AI 内容工程化 App fixture / demo package。 +- 项目创建 UI。 +- App namespace storage。 +- 知识 fixture binding。 +- 场景穷尽 mock / local agent result。 +- 内容资产表。 +- Artifact + Evidence。 +- cleanup / uninstall UI。 + +验收: + +- 业务 UI 来自 Agent App 实验岛,不写进 Lime Core。 +- 数据在 app namespace 下。 +- Artifact 和 Evidence 可追溯、可清理。 +- 完成一次从项目创建到内容表生成再到卸载清理的闭环。 + +### P5:Cloud Bootstrap 接入 + +目标:客户端 P0-P4 成立后,再消费 LimeCore 控制面。 + +范围: + +- Cloud 下发 catalog、release metadata、package hash、tenant enablement、license、policy defaults。 +- Desktop 仍负责本地 install、projection、readiness、runtime。 +- 本地 JSON source 和 Cloud payload 使用同一 adapter。 + +验收: + +- 断网时已安装 App 仍可用。 +- Cloud disable 后客户端禁用 App,但不删除用户数据。 +- package hash / manifest hash 校验失败时拒绝启用。 +- tenant enablement 不绕过本地 readiness / permission guard。 + +## 任务拆分 + +| 阶段 | 任务 | 主要文件 | 验证 | +|---|---|---|---| +| P0.1 | 建立类型和 feature flag。 | `types.ts`、`featureFlag.ts` | typecheck。 | +| P0.2 | manifest parser / normalizer。 | `manifest/*` | parser unit tests。 | +| P0.3 | projection / readiness。 | `projection/*`、`readiness/*` | projection tests。 | +| P0.4 | Lab 只读 UI。 | `ui/*` | UI test / GUI smoke。 | +| P0.5 | cleanup dry-run。 | `install/cleanupPlan.ts` | cleanup tests。 | +| P1.1 | SDK facade。 | `sdk/LimeAppSdk.ts` | type contract。 | +| P1.2 | Mock host。 | `sdk/MockCapabilityHost.ts` | contract tests。 | +| P1.3 | mock artifact / evidence。 | `sdk/*`、`install/*` | run entry test。 | +| P2.1 | artifact / evidence adapter。 | `adapters/*` | adapter tests。 | +| P2.2 | knowledge / agent adapter。 | `adapters/*` | targeted integration test。 | +| P3.1 | UI host。 | `runtime/uiExtensionHost.ts` | sandbox smoke。 | +| P4.1 | 内容工程化切片。 | `fixtures/*`、`ui/*` | end-to-end demo。 | + +## 验证策略 + +最低验证层级: + +| 改动 | 必跑 | +|---|---| +| 类型 / parser / projection | 定向单测 + `npm run typecheck` 或项目等价命令。 | +| Capability SDK | contract tests。 | +| Tauri command | `npm run test:contracts`。 | +| 用户可见 UI | 相关 `*.test.tsx` + GUI smoke。 | +| 主路径接入 | `npm run verify:local` + `npm run verify:gui-smoke`。 | + +进入正式主路径前必须满足: + +1. `npm run verify:local` 通过。 +2. `npm run verify:gui-smoke` 覆盖 Lab 入口和关闭 flag 场景。 +3. SDK contract tests 覆盖 P0 capabilities。 +4. cleanup / uninstall tests 覆盖 keep data、delete data、export then delete。 +5. Artifact / Evidence provenance 查询和删除通过。 + +## 主路径接入门槛 + +只有满足以下条件,Agent App 才能从 Lab 进入正式产品入口: + +1. 至少一个 Product-level App 完成 P4 闭环。 +2. cleanup plan 经验证能删除所有实验产物。 +3. Capability SDK contract tests 覆盖核心 capability。 +4. 所有 App 产物都有 provenance。 +5. 不需要修改 AgentChat、Skill Catalog、Artifact 主 schema。 +6. UI / worker runtime 安全评审通过。 +7. 用户数据保留、导出和删除策略明确。 +8. Cloud bootstrap 失败不影响本地已安装 App。 + +不满足时,保持 Lab 实验,不进入正式入口。 + +## 失败退出方案 + +如果 Agent App 路线停止,按以下顺序清理: + +```text +1. 关闭 agentAppHost.* feature flags +2. 删除主系统极薄入口 registerAgentAppLabEntry +3. 删除 src/features/agent-app/ +4. 删除 src-tauri/src/agent_app/ 如果存在 +5. 删除 /agent-apps/ +6. 删除 docs/roadmap/agentapp/ 或保留为 archived roadmap +7. 删除 .gitignore 中 docs/roadmap/agentapp 例外 +8. 清理 sourceKind = agent_app 的 Artifact / Evidence / Task / Log +9. 删除 mock fixture 和 test harness +10. 运行验证,确认 Chat / Skill / Artifact / Workspace 主流程无行为变化 +``` + +失败退出成功标准: + +- 普通 Chat、Skill、Artifact、Workspace 流程无行为变化。 +- 非 Agent App 用户数据未被删除。 +- 运行时代码中不再有 Agent App 入口。 +- 只剩历史文档或 archived roadmap。 + +## 决策点 + +| 决策点 | 时间 | 判断问题 | Go 条件 | No-Go 动作 | +|---|---|---|---|---| +| D0 | P0 结束 | Manifest / projection 是否稳定? | fixture 可稳定 project。 | 停止 runtime 设计,收缩标准。 | +| D1 | P1 结束 | SDK facade 是否足够表达业务? | mock entry 生成可追溯产物。 | 重写 SDK,不接真实 adapter。 | +| D2 | P2 结束 | Adapter 是否足够薄? | 删除 adapter 不影响主路径。 | 回退 mock,重画边界。 | +| D3 | P3 结束 | UI Host 是否安全可控? | App UI 不能越权访问资源。 | 暂停 UI bundle,保留 projection UI。 | +| D4 | P4 结束 | Product-level App 是否有平台价值? | 内容工程化闭环跑通。 | 清理实验岛,不进入正式入口。 | +| D5 | P5 结束 | Cloud bootstrap 是否只是控制面? | 断网已安装 App 可用。 | Cloud 回退为 catalog-only。 | + +## 下一刀 + +P0 技术设计已落到 [p0-technical-design.md](./p0-technical-design.md)。下一刀只按该设计落最小只读闭环,不做市场页、不做 Cloud、不做完整 AI 内容工程化业务: + +```text +Feature flags +→ AppManifest / NormalizedAppManifest +→ PackageIdentity +→ AgentAppProjection +→ ReadinessResult +→ CapabilityRequirement +→ AgentAppProvenance +→ AppCleanupPlan +→ content-engineering local fixture +→ Lab readonly projection UI +``` + +完成 P0 后再决定是否进入 Mock SDK。没有 P0 的 cleanup dry-run,不进入 P1。 diff --git a/docs/roadmap/agentapp/p0-technical-design.md b/docs/roadmap/agentapp/p0-technical-design.md new file mode 100644 index 000000000..ba2be783f --- /dev/null +++ b/docs/roadmap/agentapp/p0-technical-design.md @@ -0,0 +1,584 @@ +# Agent App P0 技术设计 + +更新时间:2026-05-15 + +## 一句话目标 + +P0 只做“只读 App Host 骨架”:客户端能读取一个 Agent App v0.2 本地 package / fixture,得到稳定的 manifest、projection、readiness、cleanup dry-run,并在 Lab 页面展示出来;不执行 App UI,不执行 App worker,不写入主路径 registry。 + +P0 成功后,才允许进入 P1 Mock Capability Host。没有 cleanup dry-run,不进入 P1。 + +## P0 范围 + +| 范围 | 做 | 不做 | +|---|---|---| +| Package source | 读取本地 fixture / local package metadata。 | 不接 Cloud 下载,不做 marketplace。 | +| Manifest | parse、normalize、校验核心字段。 | 不执行 APP.md 中引用的 runtime 文件。 | +| Projection | 生成只读派生对象。 | 不注册到 command / skill / artifact / route 全局 registry。 | +| Readiness | 检查 host capability、runtime target、权限声明、storage 声明。 | 不弹真实授权,不执行 migration。 | +| Cleanup | 生成 dry-run cleanup plan。 | P0 不真实删除用户数据,除非后续进入卸载功能。 | +| UI | Lab 只读展示 App、entries、readiness、cleanup plan。 | 不加载 App UI bundle。 | + +## 设计原则 + +1. **只读优先**:P0 不执行 App 代码,只处理声明和派生对象。 +2. **显式降级**:缺 capability 时给出 `blocked` / `degraded` / `ready`,不静默失败。 +3. **派生对象可重建**:projection、readiness、cleanup plan 都可由 package + host profile 重新生成。 +4. **主路径零污染**:不写 global registry,不修改 Chat / Skill / Artifact 主模型。 +5. **可删除优先**:每个 P0 产物都有 appId、packageHash、路径或 ref,方便后续清理。 + +## P0 数据流 + +```mermaid +flowchart TD + Fixture[Local Package / Fixture] --> Identity[compute PackageIdentity] + Fixture --> Parse[parseManifest] + Parse --> Normalize[normalizeManifest] + Normalize --> Validate[validateManifest] + Validate --> Project[projectApp] + Project --> Readiness[checkReadiness] + Project --> Cleanup[buildCleanupPlan] + Readiness --> State[InstalledAppPreview] + Cleanup --> State + State --> Lab[AgentAppLabPage] +``` + +P0 不产生正式安装状态,只产生 `InstalledAppPreview`。 + +## 文件边界 + +```text +src/features/agent-app/ +├── featureFlag.ts +├── types.ts +├── manifest/ +│ ├── parseManifest.ts +│ ├── normalizeManifest.ts +│ ├── validateManifest.ts +│ └── parseManifest.test.ts +├── projection/ +│ ├── projectApp.ts +│ ├── projectionGuards.ts +│ └── projectApp.test.ts +├── readiness/ +│ ├── hostCapabilityProfile.ts +│ ├── checkReadiness.ts +│ ├── capabilityNegotiation.ts +│ └── checkReadiness.test.ts +├── install/ +│ ├── packageIdentity.ts +│ ├── packageSource.ts +│ ├── installedAppPreview.ts +│ ├── cleanupPlan.ts +│ └── cleanupPlan.test.ts +├── ui/ +│ ├── AgentAppLabPage.tsx +│ ├── AgentAppCard.tsx +│ ├── AgentAppEntriesPanel.tsx +│ ├── AgentAppReadinessPanel.tsx +│ └── AgentAppCleanupPanel.tsx +└── fixtures/ + └── content-engineering-app.json +``` + +P0 如果能全部在前端完成,就不增加 Tauri command。只有本地 package hash、文件枚举或 app data 路径必须走系统能力时,才增加 `src-tauri/src/agent_app/`。 + +## Feature Flag + +P0 只需要前五个开关: + +```ts +type AgentAppHostFlags = { + labEnabled: boolean + localPackageEnabled: boolean + projectionEnabled: boolean + readinessEnabled: boolean + cleanupDryRunEnabled: boolean + mockSdkEnabled: false + localStorageEnabled: false + realAdapterEnabled: false + uiRuntimeEnabled: false + workerRuntimeEnabled: false + cloudBootstrapEnabled: false +} +``` + +P0 默认: + +```ts +const defaultAgentAppHostFlags: AgentAppHostFlags = { + labEnabled: false, + localPackageEnabled: false, + projectionEnabled: false, + readinessEnabled: false, + cleanupDryRunEnabled: false, + mockSdkEnabled: false, + localStorageEnabled: false, + realAdapterEnabled: false, + uiRuntimeEnabled: false, + workerRuntimeEnabled: false, + cloudBootstrapEnabled: false +} +``` + +验收要求:关闭 `labEnabled` 后,所有 Agent App UI 入口消失。 + +## 核心类型 + +### PackageIdentity + +```ts +type PackageSourceKind = 'fixture' | 'local_folder' | 'local_archive' | 'cloud_release' + +type PackageIdentity = { + sourceKind: PackageSourceKind + sourceUri: string + appId: string + appVersion: string + packageHash: string + manifestHash: string + loadedAt: string +} +``` + +P0 只实现 `fixture`,可预留 `local_folder` 类型但不接 UI。 + +### AppManifest + +`AppManifest` 对齐 Agent App v0.2 声明,P0 只解析核心字段: + +```ts +type AppManifest = { + manifestVersion: string + name: string + version: string + status?: 'draft' | 'preview' | 'stable' | 'deprecated' + appType?: 'domain-app' | 'tool-app' | 'expert-pack' | 'workflow-app' + description?: string + runtimeTargets?: RuntimeTarget[] + requires?: AppRequires + runtimePackage?: RuntimePackageDeclaration + capabilities?: string[] + permissions?: PermissionDeclaration[] + entries: AppEntry[] + storage?: StorageDeclaration + knowledgeTemplates?: KnowledgeTemplateDeclaration[] + artifacts?: ArtifactDeclaration[] + policies?: PolicyDeclaration[] +} +``` + +P0 校验规则: + +| 字段 | P0 规则 | +|---|---| +| `manifestVersion` | 必须存在,支持 `0.2.x`。 | +| `name` | 必须存在,作为 appId 原始来源。 | +| `version` | 必须存在,语义化版本或标准允许的版本字符串。 | +| `runtimeTargets` | 缺省按 `local` 处理;P0 只允许 `local`。 | +| `requires.capabilities` | 可以为空;为空时 readiness 显示无额外能力需求。 | +| `entries` | 必须至少一个;P0 只投影,不执行。 | +| `storage` | 可选;存在时只生成 storage projection,不创建真实 namespace。 | + +### NormalizedAppManifest + +normalize 的目标是消除可选字段和命名差异,让后续 projection 不重复判断: + +```ts +type NormalizedAppManifest = { + manifestVersion: '0.2' + appId: string + displayName: string + version: string + status: 'draft' | 'preview' | 'stable' | 'deprecated' + appType: 'domain-app' | 'tool-app' | 'expert-pack' | 'workflow-app' + description: string + runtimeTargets: RuntimeTarget[] + requires: NormalizedRequires + runtimePackage: NormalizedRuntimePackage + permissions: PermissionDeclaration[] + entries: NormalizedAppEntry[] + storage?: NormalizedStorageDeclaration + knowledgeTemplates: KnowledgeTemplateDeclaration[] + artifacts: ArtifactDeclaration[] + policies: PolicyDeclaration[] +} +``` + +normalize 不做 readiness 判断,只做结构归一化。 + +### AppEntry + +```ts +type AppEntryKind = + | 'page' + | 'panel' + | 'expert-chat' + | 'command' + | 'workflow' + | 'artifact-viewer' + | 'background-task' + | 'settings' + +type NormalizedAppEntry = { + key: string + kind: AppEntryKind + title: string + description?: string + route?: string + workflow?: string + persona?: string + requiredCapabilities: string[] + permissions: string[] + enabledByDefault: boolean +} +``` + +P0 entry 只展示,不注册、不执行。 + +## Projection 设计 + +Projection 是只读派生对象: + +```ts +type AgentAppProjection = { + app: AppSummary + package: PackageIdentity + entries: ProjectedEntry[] + requiredCapabilities: CapabilityRequirement[] + runtimePackage: RuntimePackageProjection + storage?: StorageProjection + knowledgeBindings: KnowledgeBindingProjection[] + artifactTypes: ArtifactProjection[] + policies: PolicyProjection[] + readinessHints: ReadinessHint[] + provenance: AgentAppProvenance +} +``` + +### AppSummary + +```ts +type AppSummary = { + appId: string + displayName: string + version: string + status: string + appType: string + description: string +} +``` + +### ProjectedEntry + +```ts +type ProjectedEntry = { + appId: string + key: string + kind: AppEntryKind + title: string + description?: string + presentation: 'lab-only' | 'eligible-for-main-entry' + readiness: 'unknown' | 'ready' | 'degraded' | 'blocked' + requiredCapabilities: CapabilityRequirement[] + provenance: AgentAppProvenance +} +``` + +P0 所有 entry 的 `presentation` 都是 `lab-only`。 + +### CapabilityRequirement + +```ts +type CapabilityRequirement = { + capability: string + requestedRange: string + required: boolean + declaredBy: 'requires' | 'entry' | 'storage' | 'policy' | 'runtimePackage' +} +``` + +Projection 生成规则: + +1. 从 `requires.capabilities` 收集全局能力。 +2. 从 entry 收集入口级能力。 +3. 从 `storage` 自动推导 `lime.storage`。 +4. 从 `runtimePackage.ui` 自动推导 `lime.ui`。 +5. 从 `runtimePackage.worker` / workflow 自动推导 `lime.workflow` 或 `lime.agent`,但 P0 只标记,不运行。 +6. 去重后保留来源信息;相同 capability 多来源时合并 `declaredBy` 或保留多条 detail。 + +## Readiness 设计 + +Readiness 只回答“当前 Host 能否启用这个 App / entry”,不执行修复动作。 + +```ts +type ReadinessStatus = 'ready' | 'degraded' | 'blocked' + +type ReadinessResult = { + appId: string + status: ReadinessStatus + checkedAt: string + blockers: ReadinessIssue[] + warnings: ReadinessIssue[] + supportedCapabilities: CapabilitySupport[] + missingCapabilities: CapabilityRequirement[] + entryReadiness: EntryReadiness[] +} +``` + +### ReadinessIssue + +```ts +type ReadinessIssue = { + code: + | 'MANIFEST_VERSION_UNSUPPORTED' + | 'RUNTIME_TARGET_UNSUPPORTED' + | 'CAPABILITY_MISSING' + | 'CAPABILITY_VERSION_UNSUPPORTED' + | 'PERMISSION_REQUIRED' + | 'STORAGE_DECLARED_BUT_DISABLED' + | 'UI_RUNTIME_DISABLED' + | 'WORKER_RUNTIME_DISABLED' + | 'PACKAGE_HASH_MISSING' + | 'PACKAGE_HASH_MISMATCH' + severity: 'blocker' | 'warning' + message: string + capability?: string + entryKey?: string +} +``` + +### HostCapabilityProfile + +P0 使用静态 profile,不读取真实 runtime: + +```ts +type HostCapabilityProfile = { + appRuntimeVersion: string + runtimeTargets: RuntimeTarget[] + capabilities: Record + featureFlags: AgentAppHostFlags +} +``` + +P0 默认 profile: + +```ts +const p0HostCapabilityProfile: HostCapabilityProfile = { + appRuntimeVersion: '0.2.0', + runtimeTargets: ['local'], + capabilities: { + 'lime.ui': { version: '0.1.0', enabled: false, implementation: 'none' }, + 'lime.storage': { version: '0.1.0', enabled: false, implementation: 'none' }, + 'lime.agent': { version: '0.1.0', enabled: false, implementation: 'none' }, + 'lime.knowledge': { version: '0.1.0', enabled: false, implementation: 'none' }, + 'lime.artifacts': { version: '0.1.0', enabled: false, implementation: 'none' }, + 'lime.evidence': { version: '0.1.0', enabled: false, implementation: 'none' } + }, + featureFlags: defaultAgentAppHostFlags +} +``` + +Readiness 判定: + +| 情况 | 状态 | +|---|---| +| manifest 版本不支持 | `blocked` | +| runtime target 不含 `local` | `blocked` | +| 必需 capability 缺失 | `blocked` | +| 可选 capability 缺失 | `degraded` | +| storage 声明存在但 `localStorageEnabled=false` | `degraded`,P0 不创建 namespace。 | +| UI entry 存在但 `uiRuntimeEnabled=false` | `degraded`,Lab 只读展示。 | +| worker / background-task 存在但 `workerRuntimeEnabled=false` | `degraded`,Lab 只读展示。 | +| 仅有 expert-chat 但 mock SDK 未启用 | `degraded`,不运行。 | + +## Cleanup Dry-run 设计 + +P0 只生成清理计划,不删除文件。 + +```ts +type AppCleanupPlan = { + mode: 'dry-run' + appId: string + packageHash: string + generatedAt: string + packageCachePaths: CleanupTarget[] + projectionPaths: CleanupTarget[] + readinessPaths: CleanupTarget[] + storageNamespaces: CleanupTarget[] + artifactRefs: CleanupTarget[] + evidenceRefs: CleanupTarget[] + taskRefs: CleanupTarget[] + secretRefs: CleanupTarget[] + logPaths: CleanupTarget[] + exportPaths: CleanupTarget[] + warnings: CleanupWarning[] +} + +type CleanupTarget = { + kind: 'path' | 'namespace' | 'ref' + value: string + exists: boolean | 'unknown' + safeToDelete: boolean + reason: string +} +``` + +P0 cleanup plan 来源: + +| 目标 | 来源 | +|---|---| +| package cache | `PackageIdentity.packageHash`。 | +| projection | `appId`。 | +| readiness | `appId`。 | +| storage namespace | `manifest.storage.namespace` 或 `appId`。 | +| artifacts | P0 无真实 artifact,返回空数组。 | +| evidence | P0 无真实 evidence,返回空数组。 | +| secrets | P0 不申请 secret,返回空数组。 | +| logs | `/agent-apps/logs/`。 | + +P0 Lab 必须展示 cleanup dry-run,让用户和开发者知道未来失败时会删哪些东西。 + +## InstalledAppPreview + +P0 不写正式 installed app registry,只生成 preview: + +```ts +type InstalledAppPreview = { + identity: PackageIdentity + manifest: NormalizedAppManifest + projection: AgentAppProjection + readiness: ReadinessResult + cleanupPlan: AppCleanupPlan +} +``` + +后续 P1 / P2 若需要持久化,再引入 `InstalledAppState`: + +```ts +type InstalledAppState = InstalledAppPreview & { + installedAt: string + enabled: boolean + source: PackageSourceKind + installState: 'installed' | 'disabled' | 'blocked' | 'uninstalling' +} +``` + +## Lab UI 设计 + +P0 Lab 页面只做开发者 / 内测入口: + +```text +Agent App Lab +├── Package Source +│ └── content-engineering-app fixture +├── App Card +│ ├── name / version / status / packageHash +│ └── readiness badge +├── Entries +│ ├── page / expert-chat / workflow / background-task +│ └── lab-only 标记 +├── Capability Requirements +│ ├── supported +│ ├── missing +│ └── degraded +├── Readiness Issues +│ ├── blockers +│ └── warnings +└── Cleanup Dry-run + ├── package / projection / storage / logs + └── safe-to-delete 标记 +``` + +P0 UI 不出现“运行 App”“打开 App 页面”“启用 App 到主导航”等按钮。最多可以出现 disabled button,并说明需要 P1/P3。 + +## Fixture 要求 + +`content-engineering-app.json` 应该覆盖足够多字段,避免 P0 只验证玩具样例: + +```json +{ + "manifestVersion": "0.2.0", + "name": "shenlan-content-engineering", + "version": "0.1.0", + "status": "draft", + "appType": "domain-app", + "description": "AI 内容工程化 App fixture", + "runtimeTargets": ["local"], + "requires": { + "lime": { + "appRuntime": ">=0.2.0 <1.0.0" + }, + "capabilities": { + "lime.ui": "^0.1.0", + "lime.storage": "^0.1.0", + "lime.agent": "^0.1.0", + "lime.knowledge": "^0.1.0", + "lime.artifacts": "^0.1.0", + "lime.evidence": "^0.1.0" + } + }, + "runtimePackage": { + "ui": { "path": "./dist/ui" }, + "worker": { "path": "./dist/worker" } + }, + "storage": { + "namespace": "shenlan-content-engineering", + "schema": "./storage/schema.json", + "migrations": "./storage/migrations" + }, + "entries": [ + { "key": "dashboard", "kind": "page", "title": "项目首页", "route": "/dashboard" }, + { "key": "content_strategist", "kind": "expert-chat", "title": "内容策略专家", "persona": "./agents/content-strategist.md" }, + { "key": "scene_exhaustion", "kind": "workflow", "title": "场景穷尽", "workflow": "./workflows/exhaust-scenes.workflow.md" } + ] +} +``` + +注意:fixture 是 manifest fixture,不是业务实现;P4 才需要 demo package。 + +## P0 测试用例 + +| 测试 | 输入 | 期望 | +|---|---|---| +| parse valid fixture | content engineering fixture | 返回 `AppManifest`。 | +| reject missing entries | 无 entries | manifest validation error。 | +| normalize defaults | 缺 status / runtimeTargets | 默认 draft / local。 | +| project entries | page + expert-chat + workflow | 生成 3 个 `ProjectedEntry`。 | +| derive storage capability | 有 storage 声明 | requiredCapabilities 包含 `lime.storage`。 | +| readiness blocked | 不支持 manifestVersion | status `blocked`。 | +| readiness degraded | UI runtime 关闭但有 page entry | status `degraded`。 | +| cleanup dry-run | appId + packageHash | 返回 package / projection / storage / logs targets。 | +| flag off hides UI | `labEnabled=false` | 不展示 Lab entry。 | + +## P0 验收标准 + +P0 完成必须同时满足: + +1. 本地 fixture 可以 parse / normalize / project。 +2. Lab 页面展示 app card、entries、capability requirements、readiness、cleanup dry-run。 +3. 关闭 `labEnabled` 后没有任何可见入口。 +4. Projection 不写入任何全局 registry。 +5. 不执行 App UI bundle / worker / workflow。 +6. 不创建真实 App storage namespace。 +7. cleanup dry-run 能列出未来会删除的 package / projection / readiness / storage / logs。 +8. 单测覆盖 parser、projection、readiness、cleanup plan。 + +## 进入 P1 的门槛 + +只有 P0 验收通过后,才能进入 P1: + +```text +LimeAppSdk types +→ CapabilityHost interface +→ MockCapabilityHost +→ mock storage / artifact / evidence +→ runEntry 内置 mock action +→ uninstall delete data +``` + +P1 之前不接真实 Agent Runtime,不接真实 Artifact Store,不接 UI bundle。 diff --git a/docs/roadmap/i18n/prd.md b/docs/roadmap/i18n/prd.md index 5a974052f..d72a08bc5 100644 --- a/docs/roadmap/i18n/prd.md +++ b/docs/roadmap/i18n/prd.md @@ -1318,3 +1318,12 @@ Patch Layer 不能无限期作为事实源。建议退出条件: 306. 2026-05-13 Agent Chat QueuedTurnsPanel 排队消息 chrome source fallback 收口续测:`src/components/agent/chat/components/Inputbar/components/inputbarQueuedTurnsCopy.ts` 新增 `InputbarQueuedTurnsCopy` / `InputbarQueuedTurnsCopyKey` / `buildInputbarQueuedTurnsCopy()` 作为常规输入区排队消息面板文案事实源,`src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx` 通过 `useTranslation("agent")` 把稍后处理数量、依次开始提示、空白输入 fallback、查看 / 收起、附图数量、纯文本请求、立即执行 / 切换中、移除 / 正在移除等按钮与 aria chrome 从组件内中文 literal / template literal 改为 `agentChat.inputbar.queuedTurns.*` key-based 读取。同步 5 locale `agent.json` 新增 12 个 `agentChat.inputbar.queuedTurns.*` 正式资源,`src/i18n/__tests__/loadNamespace.test.ts` / `types.test.ts` 补充 header / action / meta 主键与 `count` 插值覆盖;`src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx` 接入真实 locale 初始化,并新增 en-US 回归验证 `Handle later 1`、`Starts in order`、`Blank input`、`2 images attached`、`Run queued message now` 与 `Remove queued message` 均来自资源,同时保留 promote / remove / loading 状态行为断言。queued turn 的 message preview/text、position、image_count 与 queued_turn_id 继续作为 runtime 数据保留,不迁入资源。验证:目标 fallback 扫描 `rg -n "稍后处理|会依次开始|空白输入|收起|查看|附图|纯文本请求|插队立即执行|切换中|立即执行|移除排队消息|正在移除排队消息|defaultValue|t\([^\n]*,\s*[\"']" "src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx" "src/components/agent/chat/components/Inputbar/components/inputbarQueuedTurnsCopy.ts"` 无命中;5 个 `agent.json` 的 `python3 -m json.tool` 解析通过;`npm test -- "src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npx eslint --max-warnings 0 "src/components/agent/chat/components/Inputbar/components/inputbarQueuedTurnsCopy.ts" "src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx" "src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 与 `npm run detect-translations -- --verbose`(5 locale、6 namespace、6546 source keys)通过。`npm run typecheck` 仍被非本轮 dirty `src/components/image-gen/useImageGen.ts:1773` 的 `input` 类型错误,以及非本轮 `src/lib/api/asrProvider.test.ts` 引用已不存在导出 `closeVoiceWindow` / `openInputWithText` / `openVoiceWindow` 阻断;`npm run verify:local` 已通过 `verify:app-version`,随后仍在 `npm run lint` 被非本轮 dirty `src/components/agent/chat/hooks/useAsterAgentChat.ts` 未使用导入 `DEFAULT_AGENT_MODEL` / `DEFAULT_AGENT_PROVIDER` 阻断,因此本条不宣称新的完整 typecheck / verify:local / GUI smoke 绿色。本条属于 Agent Chat 常规输入区排队消息面板普通前端 i18n chrome 收口,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;后续仍需继续知识资料中枢、TeamSelectorPanel、InputbarModelExtra、InputbarWorkflowStatusPanel / ExecutionStrategySelect、Agent Chat runtime / projection / helper 路径、Workspace / Browser Runtime 长尾、P3 自动化治理和 legacy Patch 删除门禁。 + +307. 2026-05-14 Agent Chat Inputbar 知识资料中枢 source fallback 收口续测:`src/components/agent/chat/components/Inputbar/knowledge/InputbarKnowledgeControl.tsx` 接入 `useTranslation("agent")` 与 `formatList()`,把项目资料 toggle 的 aria / title / label、资料中枢标题说明、主资料 / 协同资料分区、默认 / 协同 badge、已确认 meta、自动搭配人设资料提示、待确认数量提示、关闭 / 检查 / 补充资料操作文案全部收口到 `agentChat.inputbar.knowledge.*`;`src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.ts` 不再返回中文 presentation 文案,而是返回稳定 key + interpolation values,由组件统一翻译。项目资料名称、packName、runtimeMode、companionPacks、选择状态与发送 request metadata 继续作为 runtime / API 数据保留,不迁入静态资源。同步 5 locale `agent.json` 复用既有 state/action 资源并补齐 22 个知识资料 chrome / menu / meta key;`src/i18n/__tests__/loadNamespace.test.ts` / `types.test.ts` 覆盖 fallback label、enabledWithCompanions、state.using、pendingNotice、check action 与 companion meta;`src/components/agent/chat/components/Inputbar/index.test.tsx` 新增 en-US 真实资源回归,验证 Knowledge: Brand knowledge +1、Open project knowledge、Main knowledge、Default、Reviewed and ready for generation、Persona knowledge added automatically、Companion knowledge、Close knowledge、Add to knowledge 与 Check knowledge 均来自 `agent` namespace;`knowledgeHubState.test.ts` 改为断言 copy key / values,防止 state helper 重新沉淀源码中文。验证:`npm test -- "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、77 个用例;`npx eslint --max-warnings 0 "src/components/agent/chat/components/Inputbar/knowledge/InputbarKnowledgeControl.tsx" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.ts" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、6511 个 source key;5 个 `agent.json` 的 `python3 -m json.tool` 解析通过;目标文件中文 / `defaultValue` / `t(key, "中文")` fallback 扫描无命中;`npm run typecheck` 与定向 `git diff --check` 通过。额外尝试 `npm run verify:local`,已通过 `verify:app-version`、`lint`、`typecheck`、smart Vitest 前端批次、Rust 测试以及 GUI smoke 的 workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page,但最终 `smoke:at-command-registry` 在 `stage=submit-image-command` 后超过 630000ms 超时并退出 124,因此本条不宣称完整统一入口绿色。本条属于 Agent Chat 常规输入区知识资料中枢普通前端 i18n chrome 收口,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;执行时工作区仍存在多组非本轮 Rust / Agent Chat / 图片任务 dirty 改动与 Playwright snapshot 删除,本条未接手、未回滚,后续仍需继续 TeamSelectorPanel、InputbarModelExtra、InputbarWorkflowStatusPanel / ExecutionStrategySelect、Agent Chat runtime / projection / helper 路径、Workspace / Browser Runtime 长尾、P3 自动化治理和 legacy Patch 删除门禁。 + +308. 2026-05-14 Agent Chat Inputbar TeamSelector / TeamSelectorPanel source fallback 收口续测:新增 `src/components/agent/chat/components/Inputbar/components/inputbarTeamSelectorCopy.ts` 作为常规输入区分工选择器文案事实源,`TeamSelector.tsx` 与 `TeamSelectorPanel.tsx` 通过 `useTranslation("agent")` 构建 copy,把触发按钮、Suspense loading、header / scope、当前选择、搜索 placeholder、推荐 / 自定义 / 系统模板区、卡片 badge / 详情 / aria-title、inspector 操作、editor 标题 / 输入 placeholder / role 控制 / profile 与 skill 帮助、toast 成功失败与 summary prefix 全部迁移到 `agentChat.inputbar.teamSelector.*` key-based 读取;自定义空白 draft 的默认角色 label / summary 也进入资源。团队模板 label / description / role summary、内置 profile / skill label、roleKey / skillId、用户自定义团队名称描述、底层 error message 与输入内容继续作为 runtime / builtin data / 用户数据边界保留,不误迁为静态资源。同步 5 locale `agent.json` 新增 81 个 `agentChat.inputbar.teamSelector.*` 正式资源,`src/i18n/__tests__/loadNamespace.test.ts` / `types.test.ts` 补充 trigger / toast / editor / section 等 key 与 interpolation 覆盖;新增 `src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx`,并扩展 `TeamSelectorPanel.test.tsx`,用真实 en-US / zh-CN locale 回归触发按钮、Panel chrome、editor placeholder、推荐分工区、保存自定义分工 profileId / roleKey / skillIds 与项目级删除持久化。验证:`npm exec prettier -- --write ...` 已格式化本刀文件;`npm test -- "src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、13 个用例;`npx eslint --max-warnings 0 "src/components/agent/chat/components/Inputbar/components/inputbarTeamSelectorCopy.ts" "src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、6717 个 source key;5 个 `agent.json` 的 `python3 -m json.tool` 解析通过;目标文件中文 / `defaultValue` / `t(key, "中文")` fallback 扫描无命中;`npm run typecheck` 与本刀定向 `git diff --check` 通过。本轮同时复核了用户提出的 `agent.json` 规模问题:当前 5 个 locale 的 `agent.json` 均为 2542 行,`src/i18n/loadNamespace.ts` 仍以 `import.meta.glob("./resources/*/*.json", { eager: true, import: "default" })` 只加载平级 namespace 文件;结合 i18next namespace、react-i18next multiple translation files、Vite glob import 与 i18next TypeScript 大资源指导,建议下一刀专门做平级 namespace split,而不是在本条 TeamSelector 小刀里顺手深改 loader:优先新增 `agentInputbar.json`、`agentHome.json`、`agentMessageList.json`、`agentSkills.json`、`agentRuntime.json`、`agentTeamWorkspace.json`,`agent.json` 只保留共享 / 过渡 key;第一阶段仍 eager,待资源分布稳定后再评估 lazy chunk 与 selector API。本条属于 Agent Chat 常规输入区分工选择器普通前端 i18n chrome 收口,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;未重复执行 `npm run verify:local` / GUI smoke,后续仍需继续 InputbarModelExtra、InputbarWorkflowStatusPanel / ExecutionStrategySelect、Agent Chat runtime / projection / helper 路径,以及专门的 `agent` namespace split 设计 / 实施刀。 + +309. 2026-05-15 Agent namespace 物理拆分 / 逻辑兼容收口:按记录 308 的 Context7 / 官方资料判断,完成 `agent.json` 首阶段 namespace split,但采用 KISS / YAGNI 的兼容实现:业务组件继续 `useTranslation("agent")`,不大面积改成多个逻辑 namespace;`src/i18n/loadNamespace.ts` 新增 `NAMESPACE_RESOURCE_PARTS`,把平级物理 shard `agentHome.json`、`agentInputbar.json`、`agentMessageList.json`、`agentRuntime.json`、`agentSkills.json`、`agentTeamWorkspace.json` 与保留的 `agent.json` 合并为逻辑 `agent` namespace。`src/i18n/types.d.ts` 同步把 7 个 zh-CN agent shard 合并为 `AgentResources`,新增 `src/i18n/agentResources.ts` 作为需要 source resource 的测试 / helper 的唯一合并事实源,避免继续直接 import 拆薄后的 `resources/zh-CN/agent.json` 并误以为它包含全量 agent key;相关 source-resource helper / 测试已改为引用 `agentZhCNResource` / `agentEnUSResource` 或 `AgentI18nResource`。拆分后 5 个 locale 的逻辑 `agent` 合并 key 数均为 2533,物理 `zh-CN` 行数从单个 `agent.json` 2542 行降为:`agent.json` 181、`agentInputbar.json` 202、`agentHome.json` 148、`agentMessageList.json` 73、`agentRuntime.json` 603、`agentSkills.json` 934、`agentTeamWorkspace.json` 406;`agentSkills` 与 `agentRuntime` 后续如继续超过维护阈值,可再按 Skills workspace / curated task / reliability / image workbench 二级职责拆,但本刀不继续扩大拆分。验证:`npm test -- "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" "src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx" "src/components/agent/chat/utils/imageWorkbenchPresentation.test.ts"` 通过,覆盖 7 个文件、69 个用例;`npx eslint --max-warnings 0` 覆盖 loader / types / agentResources 与所有改过的 source-resource helper / 测试通过;`npm run typecheck` 通过;`npm run detect-translations -- --verbose` 通过,当前物理资源口径为 5 locale、12 namespace、6710 source keys;全部 `src/i18n/resources/*/*.json` 通过 `python3 -m json.tool`;合并后 5 locale 的逻辑 `agent` key 计数均为 2533 且无重复;定向 `git diff --check` 通过。本条只拆资源物理组织和 loader/type 事实源,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;未切 lazy loading,后续等资源布局稳定后再单独评估 Vite lazy chunk 与 i18next selector API。 + + +310. 2026-05-15 Agent Chat Inputbar workflow chrome / execution strategy source fallback 收口续测:`src/components/agent/chat/components/Inputbar/inputbarWorkflowCopy.ts` 新增输入区 workflow / execution strategy copy 事实源,`InputbarExecutionStrategySelect.tsx`、`InputbarWorkflowStatusPanel.tsx`、`InputbarComposerSection.tsx`、`Inputbar/index.tsx` 与 `useInputbarController.ts` 改为从 `useTranslation("agent")` 注入 `agentChat.inputbar.workflow.*` / `agentChat.inputbar.executionStrategy.*` copy,把计划执行开关、当前进展、任务队列、停止生成、队列展开 / 折叠、空态任务节点、进度与状态 label,以及输入区 workflow quick actions 全部迁出源码中文 literal;`workflowInputState.ts` 改为接收 caller 注入 copy,避免在 current 主路径上保留新的中文 fallback,`workflowStepPresentation.ts` 补齐 workflow presentation copy 接口并允许 progress / summary 注入。同步 5 locale `agentInputbar.json` 新增 40 个 `agentChat.inputbar.workflow.*` / `agentChat.inputbar.executionStrategy.*` 正式资源;`src/i18n/__tests__/loadNamespace.test.ts` / `types.test.ts` 补充 execution strategy、workflow summary / progress / queue / quick action key 覆盖;`src/components/agent/chat/components/Inputbar/index.test.tsx` 新增 en-US 回归验证 Plan、Turn on planned execution、Current progress、Task queue、Stop generation、Collapse task queue 等均来自资源;`EmptyStateComposerPanel.tsx` 也同步接入 execution strategy copy,避免首页输入区复用时留下旧中文。验证:`npm test -- "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx" "src/components/agent/chat/utils/workflowInputState.test.ts" "src/components/agent/chat/utils/workflowStepPresentation.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 6 个文件、111 个用例;`npx eslint --max-warnings 0 "src/components/agent/chat/components/Inputbar/inputbarWorkflowCopy.ts" "src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx" "src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts" "src/components/agent/chat/components/Inputbar/index.tsx" "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx" "src/components/agent/chat/utils/workflowInputState.ts" "src/components/agent/chat/utils/workflowStepPresentation.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/utils/workflowInputState.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"`、`npm run detect-translations -- --verbose`(5 locale、12 namespace、6798 source keys)、`for f in src/i18n/resources/*/*.json; do python3 -m json.tool "$f" >/dev/null || exit 1; done`、`git diff --check` 通过;`npm run typecheck` 第三轮在全仓并行脏改环境下持续运行超过验证窗口,最终仍只暴露非本轮 `src/features/agent-app/manifest/parseManifest.ts` 以及本刀已修的 `src/components/agent/chat/components/Inputbar/index.tsx` 类型问题,因此本条不把 typecheck 计为绿色证据,后续可在更干净的工作区再复核。本条属于 Agent Chat 常规输入区 workflow / execution strategy 邻接 chrome 普通前端 i18n 收口,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;后续仍需继续 InputbarModelExtra、TeamSelectorPanel、Agent Chat runtime / projection / helper 路径、Workspace / Browser Runtime 长尾、P3 自动化治理和 legacy Patch 删除门禁。 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f9deee19f..cb56a774a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.38.0", + "version": "1.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.38.0", + "version": "1.39.0", "license": "GPL-3.0-only", "dependencies": { "@babel/standalone": "^7.29.0", diff --git a/package.json b/package.json index bdcf22244..dff341862 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.38.0", + "version": "1.39.0", "description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.", "keywords": [ "ai", diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index d66fb3056..89b1002a8 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.38.0", + "version": "1.39.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/playwright-active-history-snapshot.json b/playwright-active-history-snapshot.json deleted file mode 100644 index ec747fa47..000000000 --- a/playwright-active-history-snapshot.json +++ /dev/null @@ -1 +0,0 @@ -null \ No newline at end of file diff --git a/playwright-after-reload-new-chat-boundary.json b/playwright-after-reload-new-chat-boundary.json deleted file mode 100644 index fbe8efd92..000000000 --- a/playwright-after-reload-new-chat-boundary.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "oldPrompt": false, - "oldLimePrompt": false, - "imagePreviewCount": 0, - "errorsText": false -} \ No newline at end of file diff --git a/playwright-current-session-debug.json b/playwright-current-session-debug.json deleted file mode 100644 index 7a6002450..000000000 --- a/playwright-current-session-debug.json +++ /dev/null @@ -1,183 +0,0 @@ -{ - "url": "http://127.0.0.1:1420/?e2e=1&ts=1778691100", - "body": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n好的,我来生成一张青柠极简插画。\n2分钟前\n新对话\n5分钟前\n新对话\n8分钟前\n国际形势分析无法执行\n23分钟前\n新对话\n45分钟前\n新对话\n46分钟前\n新对话\n50分钟前\n新对话\n51分钟前\nsmoke harness\n52分钟前\n\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Forge E2E 1778592514150\nHarness\n新对话\n工作台\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置", - "sessionIds": [ - [ - "agent_session_workspace_8f16920f-b0f5-4198-9e20-bc643910be73", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_a4828a71-abed-4e31-9e43-791c58d644e1", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_bd1f156d-5d37-41f5-9d59-d80363da7928", - "\"full-access\"" - ], - [ - "agent_session_workspace_eed7439e-273e-4b49-97a2-df6c96be5fa3", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_3239e159-dacd-4a27-b5fa-cfd0aea11d28", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_8d383c48-80e2-42e7-9bf3-c2bf5c4ef864", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_4d6b14fa-d4cb-4fe5-b3fa-6f1827b19f5e", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_43e5ac59-422c-465a-9312-d86ce678e317", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_3fe77ab3-e1d3-4f47-9515-02404d70d1e5", - "\"full-access\"" - ], - [ - "agent_session_workspace_700543e4-9b1f-42d3-811a-0b1f0601fad2", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_8d383c48-80e2-42e7-9bf3-c2bf5c4ef864", - "\"full-access\"" - ], - [ - "agent_session_workspace_e2545a39-417c-4ba4-a1d7-d5a7b5517b28", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_3fe77ab3-e1d3-4f47-9515-02404d70d1e5", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_d9fd2dca-4f1c-4fc9-9ab1-57a8584a365b", - "\"full-access\"" - ], - [ - "aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_8f16920f-b0f5-4198-9e20-bc643910be73", - "\"full-access\"" - ], - [ - "aster_last_sessionId_global", - "null" - ], - [ - "agent_session_workspace_9ec7e2c6-01c3-4654-9881-cead648710a1", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_31cc9300-7e26-4c85-8a0d-3f00e33168c2", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_a2738669-04c1-40db-9c88-251e1328771b", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_1b215641-00bc-41fa-8bb6-0ee64bd87819", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_2123a0bd-445f-4ded-84ee-0bdff0c86284", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_31cc9300-7e26-4c85-8a0d-3f00e33168c2", - "\"full-access\"" - ], - [ - "agent_session_workspace_2b64278a-aa4a-442e-846c-6113dd1d4153", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_a0e782ef-a050-4126-8e8f-6bc258e13de9", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_d9fd2dca-4f1c-4fc9-9ab1-57a8584a365b", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_9ec7e2c6-01c3-4654-9881-cead648710a1", - "\"full-access\"" - ], - [ - "aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_69c81ba7-782a-47e1-92d5-cfec94ea7760", - "\"full-access\"" - ], - [ - "aster_last_sessionId_240ed157-3e7a-456c-a2c2-a05bd852b71c", - "\"8f16920f-b0f5-4198-9e20-bc643910be73\"" - ], - [ - "agent_session_workspace_6a7d0ff5-0cae-4edd-a396-9411cfede225", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_6a7d0ff5-0cae-4edd-a396-9411cfede225", - "\"full-access\"" - ], - [ - "agent_session_workspace_614c5e60-9b49-4a53-aba6-1c7b7301cb26", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_dc7fba43-1c2f-4251-8d9b-05c93943307a", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_69c81ba7-782a-47e1-92d5-cfec94ea7760", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "agent_session_workspace_bd1f156d-5d37-41f5-9d59-d80363da7928", - "\"240ed157-3e7a-456c-a2c2-a05bd852b71c\"" - ], - [ - "aster_session_snapshots_persisted_240ed157-3e7a-456c-a2c2-a05bd852b71c", - "{\"8d383c48-80e2-42e7-9bf3-c2bf5c4ef864\":{\"messages\":[{\"id\":\"2b28ee8f-2588-4721-94f8-d9f9f31ffe24\",\"role\":\"user\",\"content\":\"@analysis 帮我分析一下今天的国际形势\",\"timestamp\":\"2026-05-13T16:31:22.532Z\"},{\"id\":\"9ddd7" - ] - ], - "activeButtons": [ - { - "tag": "DIV", - "text": "新对话8分钟前", - "testid": null, - "active": "true", - "ariaCurrent": null - }, - { - "tag": "BUTTON", - "text": "新对话8分钟前", - "testid": null, - "active": null, - "ariaCurrent": "page" - }, - { - "tag": "BUTTON", - "text": "Skill Forge E2E 1778592514150", - "testid": null, - "active": null, - "ariaCurrent": null - }, - { - "tag": "DIV", - "text": "新对话", - "testid": "task-center-tab-8f16920f-b0f5-4198-9e20-bc643910be73", - "active": "true", - "ariaCurrent": null - }, - { - "tag": "BUTTON", - "text": "新对话", - "testid": null, - "active": null, - "ariaCurrent": "page" - } - ] -} \ No newline at end of file diff --git a/playwright-history-buttons.json b/playwright-history-buttons.json deleted file mode 100644 index 2b86f8102..000000000 --- a/playwright-history-buttons.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "i": 8, - "text": "", - "aria": "新建对话", - "testid": null - }, - { - "i": 9, - "text": "新对话3分钟前", - "aria": null, - "testid": null - }, - { - "i": 11, - "text": "新对话5分钟前", - "aria": null, - "testid": null - }, - { - "i": 15, - "text": "新对话42分钟前", - "aria": null, - "testid": null - }, - { - "i": 17, - "text": "新对话43分钟前", - "aria": null, - "testid": null - }, - { - "i": 19, - "text": "新对话47分钟前", - "aria": null, - "testid": null - }, - { - "i": 21, - "text": "新对话49分钟前", - "aria": null, - "testid": null - }, - { - "i": 27, - "text": "新对话1小时前", - "aria": null, - "testid": null - }, - { - "i": 38, - "text": "新对话", - "aria": null, - "testid": null - }, - { - "i": 39, - "text": "", - "aria": "新建对话", - "testid": "task-center-tab-create-button" - } -] \ No newline at end of file diff --git a/playwright-history-first-image-check.json b/playwright-history-first-image-check.json deleted file mode 100644 index 21a1122d6..000000000 --- a/playwright-history-first-image-check.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n5分钟前\n新对话\n8分钟前\n国际形势分析无法执行\n23分钟前\n新对话\n44分钟前\n新对话\n45分钟前\n新对话\n49分钟前\n新对话\n51分钟前\nsmoke harness\n52分钟前\n\n1小时前\n新对话\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Forge E2E 1778592514150\nHarness\n新对话\n工作台\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置", - "guangzhou": true, - "lime": false, - "tags": [], - "intros": [ - "好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成" - ], - "imageCards": [ - { - "testid": "image-workbench-message-preview-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "图片生成|GPT Images 2搞定,图已经生成好了\n要调整的话直接说,我继续改", - "imgCount": 1 - }, - { - "testid": "image-workbench-message-preview-toolbar-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "图片生成|GPT Images 2", - "imgCount": 0 - }, - { - "testid": "image-workbench-message-preview-single-media-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "", - "imgCount": 1 - }, - { - "testid": "image-workbench-message-preview-caption-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "搞定,图已经生成好了\n要调整的话直接说,我继续改", - "imgCount": 0 - } - ], - "failureText": false -} \ No newline at end of file diff --git a/playwright-image-flow-state-after-wait.json b/playwright-image-flow-state-after-wait.json deleted file mode 100644 index 5045cb4c8..000000000 --- a/playwright-image-flow-state-after-wait.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n17分钟前\n新对话\n38分钟前\n新对话\n39分钟前\n新对话\n43分钟前\n新对话\n45分钟前\nsmoke harness\n46分钟前\n\n54分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n3小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n生成一张广州塔,从花城汇看过去的春天照片\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置", - "imageCards": [ - { - "testid": "image-workbench-message-preview-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "图片生成|GPT Images 2搞定,图已经生成好了\n要调整的话直接说,我继续改", - "imgCount": 1, - "retryCount": 0 - }, - { - "testid": "image-workbench-message-preview-toolbar-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "图片生成|GPT Images 2", - "imgCount": 0, - "retryCount": 0 - }, - { - "testid": "image-workbench-message-preview-single-media-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "", - "imgCount": 1, - "retryCount": 0 - }, - { - "testid": "image-workbench-message-preview-caption-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "搞定,图已经生成好了\n要调整的话直接说,我继续改", - "imgCount": 0, - "retryCount": 0 - } - ], - "retryButtons": [], - "messageCount": 2 -} \ No newline at end of file diff --git a/playwright-image-flow-state.json b/playwright-image-flow-state.json deleted file mode 100644 index 4961303ef..000000000 --- a/playwright-image-flow-state.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n16分钟前\n新对话\n37分钟前\n新对话\n38分钟前\n新对话\n43分钟前\n新对话\n44分钟前\nsmoke harness\n45分钟前\n\n54分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n2小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n生成一张广州塔,从花城汇看过去的春天照片\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n正在生成图片\n添加资料\n模型\ndeepseek-v4-pro\n高级设置", - "imageCards": [ - { - "testid": "image-workbench-message-preview-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "图片生成|GPT Images 2正在生成图片", - "imgCount": 0, - "buttons": [] - }, - { - "testid": "image-workbench-message-preview-toolbar-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "图片生成|GPT Images 2", - "imgCount": 0, - "buttons": [] - }, - { - "testid": "image-workbench-message-preview-single-media-e05d557e-60b5-4d47-b9e4-f825b3150df5", - "text": "正在生成图片", - "imgCount": 0, - "buttons": [] - } - ], - "retryButtons": [], - "userTags": [ - "@配图" - ], - "intros": [ - "好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成" - ] -} \ No newline at end of file diff --git a/playwright-new-chat-no-leak.json b/playwright-new-chat-no-leak.json deleted file mode 100644 index 7426583f9..000000000 --- a/playwright-new-chat-no-leak.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n17分钟前\n新对话\n39分钟前\n新对话\n40分钟前\n新对话\n44分钟前\n新对话\n45分钟前\nsmoke harness\n46分钟前\n\n55分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n3小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\n新对话\n创作\n\n青柠一下,灵感即来\n\n说一句目标,Lime 就接着帮你做。\n\n文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。\n\n帮我梳理一下需求分析\ntab\n添加资料\n高级设置\n当前模型\ndeepseek-v4-pro\n引导帮助\n写作\n添加资料\nPPT\n调研报告\n需求分析\n视频\n设计\nExcel\n编程\n更多做法\n回看最近结果 · 国际形势分析无法执行\n向下滑,看看 Lime 可以帮你做什么\n↓\n你可以从这些任务开始\n\n往下看更多任务样例;真正执行仍会回到生成里继续补充。\n\n拆解一条爆款内容\n把一条高表现内容拆成标题钩子、结构节奏、素材手法和可复用套路,方便快速复刻。\n复盘这个账号/项目\n围绕目标、已有结果和下一步动作判断当前该怎么推进,适合内容账号、项目推进和运营回看。\n脚本转口播/字幕稿\n把现有脚本整理成更适合口播和字幕的版本,补齐停顿、节奏和适合出镜表达的语气。\n每日趋势摘要\n先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。\n内容主稿生成\n围绕目标受众、表达结构和关键信息,先生成一版可继续迭代的内容首稿。\n长文转多平台发布稿\n把长文、资料或文章整理成多平台可直接发布的版本,不用再自己拆标题和结构。\n复制轮播帖\n拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。\n视频配音\n围绕视频素材、语言要求与旁白意图,先在本地工作区整理一版可继续加工的配音稿。\n视频配音成其他语言\n先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。\n文章转 Slide 视频提纲\n把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。\n账号增长跟踪\n围绕参考账号和目标平台先整理一版增长打法,再持续跟踪内容节奏、关键指标和提醒条件。", - "oldPrompt": false, - "generating": false, - "imagePreviewCount": 0, - "userTags": [] -} \ No newline at end of file diff --git a/playwright-second-image-after-reload-final.json b/playwright-second-image-after-reload-final.json deleted file mode 100644 index 6e4a93c14..000000000 --- a/playwright-second-image-after-reload-final.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n3分钟前\n新对话\n5分钟前\n国际形势分析无法执行\n20分钟前\n新对话\n42分钟前\n新对话\n43分钟前\n新对话\n47分钟前\n新对话\n49分钟前\nsmoke harness\n49分钟前\n\n58分钟前\n新对话\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n再生成一张青柠极简插画\n好啊,用 GPT Images 2 生成:再生成一张青柠极简插画\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置", - "oldPrompt": false, - "newPrompt": true, - "imageCards": [ - { - "testid": "image-workbench-message-preview-968e9568-1dc4-4fba-be89-d3df274a4adb", - "text": "图片生成|GPT Images 2搞定,图已经生成好了\n要调整的话直接说,我继续改", - "imgCount": 1 - }, - { - "testid": "image-workbench-message-preview-toolbar-968e9568-1dc4-4fba-be89-d3df274a4adb", - "text": "图片生成|GPT Images 2", - "imgCount": 0 - }, - { - "testid": "image-workbench-message-preview-single-media-968e9568-1dc4-4fba-be89-d3df274a4adb", - "text": "", - "imgCount": 1 - }, - { - "testid": "image-workbench-message-preview-caption-968e9568-1dc4-4fba-be89-d3df274a4adb", - "text": "搞定,图已经生成好了\n要调整的话直接说,我继续改", - "imgCount": 0 - } - ], - "retryButtons": [], - "forbidden": [] -} \ No newline at end of file diff --git a/playwright-second-image-after-reload-start.json b/playwright-second-image-after-reload-start.json deleted file mode 100644 index e03f9fff2..000000000 --- a/playwright-second-image-after-reload-start.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n3分钟前\n新对话\n5分钟前\n国际形势分析无法执行\n20分钟前\n新对话\n42分钟前\n新对话\n43分钟前\n新对话\n47分钟前\n新对话\n49分钟前\nsmoke harness\n49分钟前\n\n58分钟前\n新对话\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n再生成一张青柠极简插画\n好啊,用 GPT Images 2 生成:再生成一张青柠极简插画\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n正在生成图片\n添加资料\n模型\ndeepseek-v4-pro\n高级设置", - "oldPrompt": false, - "newPrompt": true, - "tags": [ - "@配图" - ], - "intros": [ - "好啊,用 GPT Images 2 生成:再生成一张青柠极简插画\n先获取下工具参数\n马上生成" - ], - "imageCards": [ - { - "testid": "image-workbench-message-preview-968e9568-1dc4-4fba-be89-d3df274a4adb", - "text": "图片生成|GPT Images 2正在生成图片", - "imgCount": 0 - }, - { - "testid": "image-workbench-message-preview-toolbar-968e9568-1dc4-4fba-be89-d3df274a4adb", - "text": "图片生成|GPT Images 2", - "imgCount": 0 - }, - { - "testid": "image-workbench-message-preview-single-media-968e9568-1dc4-4fba-be89-d3df274a4adb", - "text": "正在生成图片", - "imgCount": 0 - } - ] -} \ No newline at end of file diff --git a/playwright-second-image-start.json b/playwright-second-image-start.json deleted file mode 100644 index 18a24c319..000000000 --- a/playwright-second-image-start.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n18分钟前\n新对话\n39分钟前\n新对话\n40分钟前\n新对话\n45分钟前\n新对话\n46分钟前\nsmoke harness\n47分钟前\n\n56分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n3小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\n新对话\n创作\n\n青柠一下,灵感即来\n\n说一句目标,Lime 就接着帮你做。\n\n文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。\n\n帮我整理项目资料\ntab\n添加资料\n高级设置\n当前模型\ndeepseek-v4-pro\n引导帮助\n写作\n添加资料\nPPT\n调研报告\n需求分析\n视频\n设计\nExcel\n编程\n更多做法\n回看最近结果 · 国际形势分析无法执行\n向下滑,看看 Lime 可以帮你做什么\n↓\n你可以从这些任务开始\n\n往下看更多任务样例;真正执行仍会回到生成里继续补充。\n\n拆解一条爆款内容\n把一条高表现内容拆成标题钩子、结构节奏、素材手法和可复用套路,方便快速复刻。\n复盘这个账号/项目\n围绕目标、已有结果和下一步动作判断当前该怎么推进,适合内容账号、项目推进和运营回看。\n脚本转口播/字幕稿\n把现有脚本整理成更适合口播和字幕的版本,补齐停顿、节奏和适合出镜表达的语气。\n每日趋势摘要\n先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。\n内容主稿生成\n围绕目标受众、表达结构和关键信息,先生成一版可继续迭代的内容首稿。\n长文转多平台发布稿\n把长文、资料或文章整理成多平台可直接发布的版本,不用再自己拆标题和结构。\n短视频编排\n把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。\n复制轮播帖\n拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。\n视频配音\n围绕视频素材、语言要求与旁白意图,先在本地工作区整理一版可继续加工的配音稿。\n视频配音成其他语言\n先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。\n文章转 Slide 视频提纲\n把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。\n我来读取已导出的 Markdown 文件,然后翻...\n沉淀自一次成功结果:我来读取已导出的 Markdown 文件,然后翻译成中文。 文件已读取完毕。现在我将正文翻译为中文,保留所有 Markdown 结构、代码块、链...\n加载对话历史失败: normalizeUserVisibleContent is not defined", - "oldPrompt": false, - "newPrompt": false, - "tags": [], - "intros": [], - "imagePreviewCount": 0 -} \ No newline at end of file diff --git a/scripts/at-command-registry-e2e.mjs b/scripts/at-command-registry-e2e.mjs index 4460cceee..ed238988a 100644 --- a/scripts/at-command-registry-e2e.mjs +++ b/scripts/at-command-registry-e2e.mjs @@ -202,6 +202,17 @@ function readImageLaunch(metadata) { return { launch, imageTask }; } +function isImageGenerateSkillExecution(invoke) { + return ( + invoke?.cmd === "execute_skill" && + invoke.args?.skillName === "image_generate" + ); +} + +function isLegacyImageRuntimeSubmit(invoke) { + return invoke?.cmd === "agent_runtime_submit_turn"; +} + function parseInvokeRequest(request, invokeUrl) { const requestUrl = request.url(); const normalizedInvokeUrl = invokeUrl.replace(/\/$/, ""); @@ -507,59 +518,110 @@ async function main() { }); const submitInvoke = await waitForCondition( - "等待 @配图 提交 agent_runtime_submit_turn", + "等待 @配图 提交 image_generate 执行请求", () => invokes .slice(submitStart) - .find((item) => item.cmd === "agent_runtime_submit_turn") || null, + .find( + (item) => + isImageGenerateSkillExecution(item) || + isLegacyImageRuntimeSubmit(item), + ) || null, options.timeoutMs, options.intervalMs, ); - const request = submitInvoke.args?.request || {}; - const turnConfig = request.turn_config || {}; - const metadata = turnConfig.metadata || {}; - const { launch, imageTask } = readImageLaunch(metadata); - const runtimeContract = readNestedObject(imageTask, [ - "runtime_contract", - "runtimeContract", - ]); - const contractKey = - imageTask?.modality_contract_key || - imageTask?.modalityContractKey || - runtimeContract?.contract_key || - runtimeContract?.contractKey; - assert(launch, "@配图 提交缺少 harness.image_skill_launch"); - assert(imageTask, "@配图 提交缺少 image_task"); - assert( - String(imageTask.prompt || "").includes(IMAGE_PROMPT), - "@配图 image_task.prompt 未保留用户输入", - ); - assert( - contractKey === "image_generation", - `@配图 image_task 合同应为 image_generation,实际为 ${String(contractKey)}`, - ); - assert( - turnConfig.provider_preference == null, - "@配图 不应把当前聊天 provider 提交为 request provider_preference", - ); - assert( - turnConfig.model_preference == null, - "@配图 不应把当前聊天 model 提交为 request model_preference", - ); + if (isImageGenerateSkillExecution(submitInvoke)) { + const args = submitInvoke.args || {}; + const userInput = String(args.userInput || ""); + assert( + userInput.includes("@配图"), + "@配图 execute_skill.userInput 应保留命令标签", + ); + assert( + userInput.includes(IMAGE_PROMPT), + "@配图 execute_skill.userInput 未保留用户输入", + ); + assert( + typeof args.executionId === "string" && args.executionId.trim(), + "@配图 execute_skill 缺少 executionId", + ); + assert( + typeof args.sessionId === "string" && args.sessionId.trim(), + "@配图 execute_skill 缺少 sessionId", + ); - summary.assertions.imageSkillLaunchSubmitted = true; - summary.assertions.imageGenerationContractPreserved = true; - summary.assertions.chatModelPreferenceSuppressed = true; - summary.submitRequest = { - message: request.message, - sessionId: request.session_id || null, - turnId: request.turn_id || null, - providerPreference: turnConfig.provider_preference ?? null, - modelPreference: turnConfig.model_preference ?? null, - contractKey, - imagePrompt: imageTask.prompt, - }; + await page + .getByText("先执行技能 image_generate", { exact: false }) + .first() + .waitFor({ state: "visible", timeout: options.timeoutMs }); + await page + .getByText("图片生成", { exact: false }) + .first() + .waitFor({ state: "visible", timeout: options.timeoutMs }); + + summary.assertions.imageSkillExecutionSubmitted = true; + summary.assertions.imageCommandTagPreserved = true; + summary.assertions.imagePromptPreserved = true; + summary.assertions.imageGenerateProcessVisible = true; + summary.submitRequest = { + routeMode: "skill_execution", + command: submitInvoke.cmd, + skillName: args.skillName, + userInput, + sessionId: args.sessionId || null, + executionId: args.executionId || null, + providerOverride: args.providerOverride ?? null, + modelOverride: args.modelOverride ?? null, + }; + } else { + const request = submitInvoke.args?.request || {}; + const turnConfig = request.turn_config || {}; + const metadata = turnConfig.metadata || {}; + const { launch, imageTask } = readImageLaunch(metadata); + const runtimeContract = readNestedObject(imageTask, [ + "runtime_contract", + "runtimeContract", + ]); + const contractKey = + imageTask?.modality_contract_key || + imageTask?.modalityContractKey || + runtimeContract?.contract_key || + runtimeContract?.contractKey; + + assert(launch, "@配图 提交缺少 harness.image_skill_launch"); + assert(imageTask, "@配图 提交缺少 image_task"); + assert( + String(imageTask.prompt || "").includes(IMAGE_PROMPT), + "@配图 image_task.prompt 未保留用户输入", + ); + assert( + contractKey === "image_generation", + `@配图 image_task 合同应为 image_generation,实际为 ${String(contractKey)}`, + ); + assert( + turnConfig.provider_preference == null, + "@配图 不应把当前聊天 provider 提交为 request provider_preference", + ); + assert( + turnConfig.model_preference == null, + "@配图 不应把当前聊天 model 提交为 request model_preference", + ); + + summary.assertions.imageSkillLaunchSubmitted = true; + summary.assertions.imageGenerationContractPreserved = true; + summary.assertions.chatModelPreferenceSuppressed = true; + summary.submitRequest = { + routeMode: "agent_runtime_submit_turn", + message: request.message, + sessionId: request.session_id || null, + turnId: request.turn_id || null, + providerPreference: turnConfig.provider_preference ?? null, + modelPreference: turnConfig.model_preference ?? null, + contractKey, + imagePrompt: imageTask.prompt, + }; + } await page.screenshot({ path: path.join( @@ -569,7 +631,17 @@ async function main() { fullPage: true, }); - summary.cleanup = await interruptSubmittedTurn(options, request); + if (summary.submitRequest?.routeMode === "agent_runtime_submit_turn") { + summary.cleanup = await interruptSubmittedTurn( + options, + submitInvoke.args?.request || {}, + ); + } else { + summary.cleanup = { + attempted: false, + reason: "skill-execution-route", + }; + } const summaryPath = path.join( options.evidenceDir, diff --git a/scripts/check-command-contracts.mjs b/scripts/check-command-contracts.mjs index f20720cd4..01506dffe 100644 --- a/scripts/check-command-contracts.mjs +++ b/scripts/check-command-contracts.mjs @@ -312,10 +312,68 @@ function collectDefaultMockCommands() { for (const match of objectBody.matchAll(/^ ([A-Za-z0-9_]+)\s*:/gm)) { mockCommands.add(match[1]); } + for (const spreadMatch of objectBody.matchAll( + /^ \.\.\.([A-Za-z0-9_]+),/gm, + )) { + const registryName = spreadMatch[1]; + for (const command of collectSpreadMockRegistryCommands( + sourceCode, + registryName, + )) { + mockCommands.add(command); + } + } return mockCommands; } +function collectSpreadMockRegistryCommands(sourceCode, registryName) { + const importPattern = + /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+["'`]([^"'`]+)["'`]/g; + let importSource; + for (const importMatch of sourceCode.matchAll(importPattern)) { + const namedImports = importMatch[1]; + if (new RegExp(`\\b${registryName}\\b`).test(namedImports)) { + importSource = importMatch[2]; + break; + } + } + if (!importSource?.startsWith("./")) { + return []; + } + + const registrySourcePath = path.resolve( + repoRoot, + "src/lib/tauri-mock", + `${importSource.replace(/^\.\//, "")}.ts`, + ); + if (!fs.existsSync(registrySourcePath)) { + throw new Error(`未找到 spread mock registry 文件: ${registrySourcePath}`); + } + + const registrySourceCode = fs.readFileSync(registrySourcePath, "utf8"); + const markerPattern = new RegExp( + `export\\s+const\\s+${registryName}\\b[\\s\\S]*?=\\s*\\{`, + ); + const markerMatch = markerPattern.exec(registrySourceCode); + if (!markerMatch) { + throw new Error(`未找到 spread mock registry 定义: ${registryName}`); + } + + const braceStart = markerMatch.index + markerMatch[0].length - 1; + const registryBody = extractBalancedBlock( + registrySourceCode, + braceStart, + "{", + "}", + ); + const commands = []; + for (const match of registryBody.matchAll(/^ ([A-Za-z0-9_]+)\s*:/gm)) { + commands.push(match[1]); + } + return commands; +} + function readAgentCommandCatalog() { const catalogPath = path.join( repoRoot, diff --git a/scripts/release-updater-manifest.mjs b/scripts/release-updater-manifest.mjs index e0b4731ce..49eb8794a 100644 --- a/scripts/release-updater-manifest.mjs +++ b/scripts/release-updater-manifest.mjs @@ -17,6 +17,28 @@ const PLATFORM_BY_TARGET_TRIPLE = { "x86_64-pc-windows-msvc": "windows-x86_64", }; +const TARGET_TRIPLE_BY_PLATFORM = Object.fromEntries( + Object.entries(PLATFORM_BY_TARGET_TRIPLE).map(([targetTriple, platform]) => [ + platform, + targetTriple, + ]), +); + +const INSTALLER_ASSET_BY_PLATFORM = { + "darwin-aarch64": { + required: true, + assetName: (version) => `Lime_${version}_aarch64.dmg`, + }, + "darwin-x86_64": { + required: true, + assetName: (version) => `Lime_${version}_x64.dmg`, + }, + "windows-x86_64": { + required: false, + assetName: (version) => `Lime_${version}_x64-setup.exe`, + }, +}; + function parseArgs(argv) { const args = {}; for (let index = 0; index < argv.length; index += 1) { @@ -176,6 +198,86 @@ function platformKeyFromAssetFile(assetsDir, assetFile) { return PLATFORM_BY_TARGET_TRIPLE[targetTriple] || ""; } +function targetTripleFromAssetFile(assetsDir, assetFile) { + const relativeDir = path.relative(assetsDir, path.dirname(assetFile)); + return relativeDir.split(path.sep).find(Boolean) || ""; +} + +function resolveInstallerAssetFile({ + assetsDir, + filesByBasename, + platformKey, + referenceFile, + version, +}) { + const spec = INSTALLER_ASSET_BY_PLATFORM[platformKey]; + if (!spec) { + return null; + } + + const assetName = spec.assetName(version); + const candidates = filesByBasename.get(assetName) || []; + if (candidates.length === 0) { + if (spec.required) { + throw new Error( + `platform ${platformKey} is missing installer asset: ${assetName}`, + ); + } + return null; + } + + const expectedTargetTriple = TARGET_TRIPLE_BY_PLATFORM[platformKey]; + return ( + (referenceFile && + candidates.find( + (candidate) => path.dirname(candidate) === path.dirname(referenceFile), + )) || + candidates.find( + (candidate) => + targetTripleFromAssetFile(assetsDir, candidate) === expectedTargetTriple, + ) || + candidates[0] + ); +} + +function addReferencedFile(referencedFiles, item) { + referencedFiles.set(item.targetPath, item); +} + +function addInstallerUrlIfAvailable({ + assetsDir, + baseUrl, + channel, + filesByBasename, + platform, + platformKey, + referenceFile, + referencedFiles, + version, + versionTag, +}) { + const installerFile = resolveInstallerAssetFile({ + assetsDir, + filesByBasename, + platformKey, + referenceFile, + version, + }); + if (!installerFile) { + return; + } + + const installerName = path.basename(installerFile); + const installerAssetPath = buildPlatformAssetPath(platformKey, installerName); + platform.installer_url = `${baseUrl}/lime/${channel}/${versionTag}/${installerAssetPath}`; + addReferencedFile(referencedFiles, { + assetName: installerName, + file: installerFile, + platformKey, + targetPath: installerAssetPath, + }); +} + function collectSignedUpdaterArtifacts(files, assetsDir) { const fileSet = new Set(files); return files @@ -277,12 +379,24 @@ function collectUpdaterManifest(options) { signature, url: targetUrl, }; - referencedFiles.set(platformAssetPath, { + addReferencedFile(referencedFiles, { assetName, file: assetFile, platformKey, targetPath: platformAssetPath, }); + addInstallerUrlIfAvailable({ + assetsDir, + baseUrl, + channel, + filesByBasename, + platform: platforms[platformKey], + platformKey, + referenceFile: manifestFile, + referencedFiles, + version, + versionTag, + }); } } } else { @@ -306,12 +420,23 @@ function collectUpdaterManifest(options) { signature: artifact.signature, url: targetUrl, }; - referencedFiles.set(platformAssetPath, { + addReferencedFile(referencedFiles, { assetName: artifact.assetName, file: artifact.assetFile, platformKey: artifact.platformKey, targetPath: platformAssetPath, }); + addInstallerUrlIfAvailable({ + assetsDir, + baseUrl, + channel, + filesByBasename, + platform: platforms[artifact.platformKey], + platformKey: artifact.platformKey, + referencedFiles, + version, + versionTag, + }); } } diff --git a/scripts/release-updater-manifest.test.mjs b/scripts/release-updater-manifest.test.mjs index 530139dbe..7d7a68530 100644 --- a/scripts/release-updater-manifest.test.mjs +++ b/scripts/release-updater-manifest.test.mjs @@ -26,9 +26,15 @@ describe("release updater manifest", () => { const assetsDir = path.join(root, "release-assets"); writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz")); + writeFile( + path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.20.0_aarch64.dmg"), + ); writeFile( path.join(assetsDir, "x86_64-apple-darwin", "Lime-x64.app.tar.gz"), ); + writeFile( + path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.20.0_x64.dmg"), + ); writeFile(path.join(assetsDir, "x86_64-pc-windows-msvc", "Lime.nsis.zip")); writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), { @@ -78,7 +84,19 @@ describe("release updater manifest", () => { expect(result.manifest.platforms["darwin-aarch64"].url).toBe( "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz", ); - expect(result.r2UploadPlan).toHaveLength(3); + expect(result.manifest.platforms["darwin-aarch64"].installer_url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg", + ); + expect(result.manifest.platforms["darwin-x86_64"].installer_url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg", + ); + expect(result.r2UploadPlan.map((item) => item.key).sort()).toEqual([ + "lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz", + "lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg", + "lime/stable/v1.20.0/darwin-x86_64/Lime-x64.app.tar.gz", + "lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg", + "lime/stable/v1.20.0/windows-x86_64/Lime.nsis.zip", + ]); const output = writeOutputs(result, path.join(root, "out"), "stable"); expect(fs.existsSync(output.latestPath)).toBe(true); @@ -91,6 +109,9 @@ describe("release updater manifest", () => { ); const assetsDir = path.join(root, "release-assets"); writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz")); + writeFile( + path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.20.0_aarch64.dmg"), + ); writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), { version: "1.20.0", platforms: { @@ -147,10 +168,18 @@ describe("release updater manifest", () => { path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"), "arm", ); + writeFile( + path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.20.0_aarch64.dmg"), + "arm-dmg", + ); writeFile( path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz"), "x64", ); + writeFile( + path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.20.0_x64.dmg"), + "x64-dmg", + ); writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), { version: "1.20.0", @@ -185,15 +214,23 @@ describe("release updater manifest", () => { expect(result.manifest.platforms["darwin-x86_64"].url).toBe( "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz", ); + expect(result.manifest.platforms["darwin-aarch64"].installer_url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg", + ); + expect(result.manifest.platforms["darwin-x86_64"].installer_url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg", + ); expect(result.r2UploadPlan.map((item) => item.key).sort()).toEqual([ "lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz", + "lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg", "lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz", + "lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg", ]); expect( result.r2UploadPlan .map((item) => fs.readFileSync(item.file, "utf8")) .sort(), - ).toEqual(["arm", "x64"]); + ).toEqual(["arm", "arm-dmg", "x64", "x64-dmg"]); }); it("没有 latest.json 时应从 Tauri 签名产物生成清单", () => { @@ -216,11 +253,17 @@ describe("release updater manifest", () => { path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz.sig"), encodedSignature, ); + writeFile( + path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.20.0_aarch64.dmg"), + ); writeFile(path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz")); writeFile( path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz.sig"), rawSignature, ); + writeFile( + path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.20.0_x64.dmg"), + ); writeFile( path.join( assetsDir, @@ -257,12 +300,49 @@ describe("release updater manifest", () => { expect(result.manifest.platforms["windows-x86_64"].url).toBe( "https://updates.limecloud.com/lime/stable/v1.20.0/windows-x86_64/Lime_1.20.0_x64-setup.exe", ); + expect(result.manifest.platforms["darwin-aarch64"].installer_url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg", + ); + expect(result.manifest.platforms["darwin-x86_64"].installer_url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg", + ); + expect(result.manifest.platforms["windows-x86_64"].installer_url).toBe( + "https://updates.limecloud.com/lime/stable/v1.20.0/windows-x86_64/Lime_1.20.0_x64-setup.exe", + ); expect(result.r2UploadPlan.map((item) => item.key).sort()).toEqual([ "lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz", + "lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg", "lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz", + "lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg", "lime/stable/v1.20.0/windows-x86_64/Lime_1.20.0_x64-setup.exe", ]); }); + + it("macOS 平台缺少 DMG 时失败,避免官网回退下载 updater 包", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "lime-release-missing-dmg-"), + ); + const assetsDir = path.join(root, "release-assets"); + writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz")); + writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), { + version: "1.20.0", + platforms: { + "darwin-aarch64": { + signature: "sig-arm", + url: "https://example.com/Lime.app.tar.gz", + }, + }, + }); + + expect(() => + collectUpdaterManifest({ + assetsDir, + baseUrl: "https://updates.limecloud.com", + requiredPlatforms: ["darwin-aarch64"], + version: "v1.20.0", + }), + ).toThrow(/missing installer asset/); + }); }); describe("R2 release cleanup", () => { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1b2909c44..1b688baf5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5131,7 +5131,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.38.0" +version = "1.39.0" dependencies = [ "anyhow", "arboard", @@ -5238,7 +5238,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.38.0" +version = "1.39.0" dependencies = [ "anyhow", "aster-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.38.0" +version = "1.39.0" dependencies = [ "chrono", "futures", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.38.0" +version = "1.39.0" dependencies = [ "clap", "lime-core", @@ -5296,7 +5296,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.38.0" +version = "1.39.0" dependencies = [ "async-trait", "lime-core", @@ -5312,7 +5312,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.38.0" +version = "1.39.0" dependencies = [ "aster-models", "async-trait", @@ -5365,7 +5365,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.38.0" +version = "1.39.0" dependencies = [ "aes", "axum 0.7.9", @@ -5395,7 +5395,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.38.0" +version = "1.39.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5415,7 +5415,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.38.0" +version = "1.39.0" dependencies = [ "chrono", "hex", @@ -5428,7 +5428,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.38.0" +version = "1.39.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5444,7 +5444,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.38.0" +version = "1.39.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5477,7 +5477,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.38.0" +version = "1.39.0" dependencies = [ "async-trait", "lime-core", @@ -5496,7 +5496,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.38.0" +version = "1.39.0" dependencies = [ "anyhow", "async-stream", @@ -5551,7 +5551,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.38.0" +version = "1.39.0" dependencies = [ "aster-core", "async-stream", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.38.0" +version = "1.39.0" dependencies = [ "axum 0.7.9", "futures", @@ -5610,7 +5610,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.38.0" +version = "1.39.0" dependencies = [ "anyhow", "aster-core", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.38.0" +version = "1.39.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5673,7 +5673,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.38.0" +version = "1.39.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 41f213f76..f1fc636c5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,7 +9,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.38.0" +version = "1.39.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -198,7 +198,7 @@ version = "2.4.9" [package] name = "lime" -version = "1.38.0" +version = "1.39.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/skill_execution.rs b/src-tauri/crates/agent/src/skill_execution.rs index 6c6698c13..5e3d311b9 100644 --- a/src-tauri/crates/agent/src/skill_execution.rs +++ b/src-tauri/crates/agent/src/skill_execution.rs @@ -169,9 +169,8 @@ fn build_skill_turn_context( .allowed_tools .as_ref() .filter(|tools| !tools.is_empty()); - let user_visible_input_text = user_visible_input.and_then(|input| { - (!input.trim().is_empty()).then(|| input.trim().to_string()) - }); + let user_visible_input_text = user_visible_input + .and_then(|input| (!input.trim().is_empty()).then(|| input.trim().to_string())); if allowed_tools.is_none() && user_visible_input_text.is_none() { return None; @@ -194,6 +193,40 @@ fn build_skill_turn_context( }) } +fn build_prompt_session_config( + session_id: &str, + skill: &LoadedSkillDefinition, + user_visible_input: Option<&str>, + memory_prompt: Option<&str>, +) -> SessionConfig { + let mut session_config_builder = SessionConfigBuilder::new(session_id) + .system_prompt(build_prompt_system_prompt( + &skill.markdown_content, + memory_prompt, + )) + .system_prompt_override(true) + .include_context_trace(true); + if let Some(turn_context) = build_skill_turn_context(skill, user_visible_input) { + session_config_builder = session_config_builder.turn_context(turn_context); + } + session_config_builder.build() +} + +fn build_step_session_config( + step_session_id: &str, + step_system_prompt: String, + skill_turn_context: Option, +) -> SessionConfig { + let mut session_config_builder = SessionConfigBuilder::new(step_session_id) + .system_prompt(step_system_prompt) + .system_prompt_override(true) + .include_context_trace(true); + if let Some(turn_context) = skill_turn_context { + session_config_builder = session_config_builder.turn_context(turn_context); + } + session_config_builder.build() +} + async fn stream_skill_session( aster_state: &AsterAgentState, session_id: &str, @@ -310,13 +343,11 @@ pub async fn execute_skill_workflow( memory_prompt, ); let step_session_id = format!("{session_id}-step-{}", step.id); - let mut session_config_builder = SessionConfigBuilder::new(&step_session_id) - .system_prompt(step_system_prompt) - .include_context_trace(true); - if let Some(turn_context) = skill_turn_context.clone() { - session_config_builder = session_config_builder.turn_context(turn_context); - } - let session_config = session_config_builder.build(); + let session_config = build_step_session_config( + &step_session_id, + step_system_prompt, + skill_turn_context.clone(), + ); let step_input = build_step_input(user_input, &accumulated_context, idx == 0); let user_message = build_user_message(&step_input, user_visible_input, images); @@ -407,16 +438,8 @@ pub async fn execute_skill_prompt( emitter, } = request; let event_name = format!("skill-exec-{execution_id}"); - let mut session_config_builder = SessionConfigBuilder::new(session_id) - .system_prompt(build_prompt_system_prompt( - &skill.markdown_content, - memory_prompt, - )) - .include_context_trace(true); - if let Some(turn_context) = build_skill_turn_context(skill, user_visible_input) { - session_config_builder = session_config_builder.turn_context(turn_context); - } - let session_config = session_config_builder.build(); + let session_config = + build_prompt_session_config(session_id, skill, user_visible_input, memory_prompt); let user_message = build_user_message(user_input, user_visible_input, images); let reply = stream_skill_session( aster_state, @@ -461,7 +484,10 @@ pub async fn execute_skill_prompt( #[cfg(test)] mod tests { - use super::{build_skill_turn_context, build_user_message}; + use super::{ + build_prompt_session_config, build_skill_turn_context, build_step_session_config, + build_step_system_prompt, build_user_message, + }; use lime_skills::LoadedSkillDefinition; use std::collections::HashMap; @@ -523,9 +549,8 @@ mod tests { fn build_skill_turn_context_keeps_user_visible_input_without_allowed_tools() { let skill = build_loaded_skill(None); - let turn_context = - build_skill_turn_context(&skill, Some(" @analysis 帮我分析一下 ")) - .expect("turn context"); + let turn_context = build_skill_turn_context(&skill, Some(" @analysis 帮我分析一下 ")) + .expect("turn context"); assert_eq!( turn_context.user_visible_input_text.as_deref(), @@ -553,4 +578,64 @@ mod tests { assert!(message.is_user_visible()); assert!(message.is_agent_visible()); } + + #[test] + fn prompt_skill_session_uses_skill_markdown_as_full_system_prompt() { + let mut skill = build_loaded_skill(Some(vec!["read_file"])); + skill.skill_name = "analysis".to_string(); + skill.markdown_content = + "你是 Lime 的分析助手。\n\n## 输出格式(固定)\n# 分析结果".to_string(); + + let session_config = build_prompt_session_config( + "skill-session", + &skill, + Some("@analysis 帮我分析一下今天的国际形势"), + Some("记忆补充"), + ); + + assert_eq!(session_config.system_prompt_override, Some(true)); + assert_eq!(session_config.include_context_trace, Some(true)); + let system_prompt = session_config.system_prompt.expect("system prompt"); + assert!(system_prompt.contains("你是 Lime 的分析助手")); + assert!(system_prompt.contains("## 输出格式(固定)")); + assert!(system_prompt.contains("记忆补充")); + assert_eq!( + session_config + .turn_context + .as_ref() + .and_then(|context| context.user_visible_input_text.as_deref()), + Some("@analysis 帮我分析一下今天的国际形势") + ); + assert_eq!( + session_config + .turn_context + .as_ref() + .and_then(|context| context.metadata.get("subagent")) + .and_then(|metadata| metadata.get("allowed_tools")), + Some(&serde_json::json!(["read_file"])) + ); + } + + #[test] + fn workflow_step_session_uses_step_skill_prompt_as_full_system_prompt() { + let mut skill = build_loaded_skill(None); + skill.markdown_content = "Skill 主体指令".to_string(); + let step_prompt = build_step_system_prompt( + &skill.markdown_content, + "分析", + 1, + 2, + "执行当前分析步骤", + None, + ); + + let session_config = build_step_session_config("skill-step", step_prompt, None); + + assert_eq!(session_config.system_prompt_override, Some(true)); + assert_eq!(session_config.include_context_trace, Some(true)); + let system_prompt = session_config.system_prompt.expect("system prompt"); + assert!(system_prompt.contains("Skill 主体指令")); + assert!(system_prompt.contains("## 当前步骤: 分析 (1/2)")); + assert!(system_prompt.contains("执行当前分析步骤")); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs index 6336325fd..859873f7f 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs @@ -1585,6 +1585,30 @@ where } impl Agent { + fn build_user_visible_context_message( + user_message: &Message, + session_config: &SessionConfig, + ) -> Option { + if user_message.is_user_visible() { + return None; + } + + let visible_text = session_config + .turn_context + .as_ref() + .and_then(|context| context.user_visible_input_text.as_deref()) + .map(str::trim) + .filter(|text| !text.is_empty())?; + + let mut visible_message = Message::user().with_text(visible_text).user_only(); + for content in &user_message.content { + if matches!(content, MessageContent::Image(_)) { + visible_message = visible_message.with_content(content.clone()); + } + } + Some(visible_message) + } + pub fn new() -> Self { // Create channels with buffer size 32 (adjust if needed) let (confirm_tx, confirm_rx) = mpsc::channel(32); @@ -3525,6 +3549,12 @@ impl Agent { .await?; } Ok(None) => { + if let Some(visible_message) = + Self::build_user_visible_context_message(&user_message, &session_config) + { + self.store_add_message(&session_config.id, &visible_message) + .await?; + } self.store_add_message(&session_config.id, &user_message) .await?; } @@ -5112,9 +5142,7 @@ mod tests { "thread-1", Some("内部结构化执行输入".to_string()), Some(TurnContextOverride { - user_visible_input_text: Some( - "@analysis 帮我分析一下今天的国际形势".to_string(), - ), + user_visible_input_text: Some("@analysis 帮我分析一下今天的国际形势".to_string()), ..TurnContextOverride::default() }), ); @@ -5124,18 +5152,51 @@ mod tests { .project_user_input(&turn) .expect("user input event"); + let AgentEvent::ItemCompleted { item } = event else { + panic!("expected item completed event"); + }; + assert_eq!(item.id, "user:turn-skill-visible"); assert!(matches!( - event, - AgentEvent::ItemStarted { item } - if item.id == "user_input:turn-skill-visible" - && matches!( - item.payload, - ItemRuntimePayload::UserInput { ref text } - if text == "@analysis 帮我分析一下今天的国际形势" - ) + item.payload, + ItemRuntimePayload::UserMessage { ref content } + if content == "@analysis 帮我分析一下今天的国际形势" )); } + #[test] + fn test_build_user_visible_context_message_keeps_visible_skill_input_only_for_user() { + let hidden_user_message = Message::user() + .with_text("{\"analysis_request\":{\"content\":\"内部结构化输入\"}}") + .with_image("base64-image", "image/png") + .agent_only(); + let session_config = SessionConfig { + id: "session-skill-visible".to_string(), + turn_context: Some(TurnContextOverride { + user_visible_input_text: Some("@analysis 帮我分析一下今天的国际形势".to_string()), + ..TurnContextOverride::default() + }), + ..SessionConfig::default() + }; + + let visible_message = + Agent::build_user_visible_context_message(&hidden_user_message, &session_config) + .expect("visible user message"); + + assert!(visible_message.is_user_visible()); + assert!(!visible_message.is_agent_visible()); + assert_eq!( + visible_message.as_concat_text(), + "@analysis 帮我分析一下今天的国际形势" + ); + assert!(visible_message + .content + .iter() + .any(|content| matches!(content, MessageContent::Image(_)))); + assert!(!visible_message + .as_concat_text() + .contains("analysis_request")); + } + #[test] fn test_project_tool_response_emits_file_artifact_runtime_item() { let turn = TurnRuntime::new( diff --git a/src-tauri/resources/default-skills/analysis/SKILL.md b/src-tauri/resources/default-skills/analysis/SKILL.md index 7e303c817..51f999da1 100644 --- a/src-tauri/resources/default-skills/analysis/SKILL.md +++ b/src-tauri/resources/default-skills/analysis/SKILL.md @@ -5,7 +5,7 @@ allowed-tools: list_directory, read_file metadata: lime_argument_hint: 输入待分析内容、重点、风格与输出格式要求。 lime_when_to_use: 用户需要对文本、对话或文件内容做拆解、判断、评估或风险分析时使用。 - lime_version: 1.0.0 + lime_version: 1.0.1 lime_execution_mode: prompt lime_surface: chat lime_category: reasoning @@ -21,10 +21,12 @@ metadata: - 优先分析 `analysis_request.content` 或用户明确给出的正文内容。 - 如果用户明确提供本地文件路径或资料目录,可先最小化使用 `list_directory` / `read_file` 读取必要内容,再进行分析。 -- 若未提供显式内容,则默认分析当前对话里与请求最相关的内容。 +- 若未提供显式内容,但用户问题本身有明确分析对象,则基于当前可见对话与用户问题做可用范围内的分析;不要仅因没有外部材料就输出“无法分析”。 +- 面对“今天”“最新”“当前局势”等实时性请求,必须先说明实时信息边界:只能基于已知背景与当前可见上下文给出结构化判断,并把需要实时核验的新闻、数据或出处列入待确认项。 +- 只有在完全没有分析对象,或用户明确要求必须依赖某份缺失材料/精确实时数据时,才最多追问 1 个关键问题;追问前仍应说明当前可先分析的范围。 - 分析必须区分“原文事实”“你的判断”“待确认项”,不要把推断写成已确认事实。 - 用户要求分析重点、风格、输出格式时要显式遵循;未指定时默认给出简洁结论与依据。 -- 信息不足时最多追问 1 个关键问题,不要假装已经读到不存在的内容。 +- 不要假装已经读到不存在的文件、网页、实时新闻或私有数据。 ## 输出格式(固定) diff --git a/src-tauri/resources/default-skills/image_generate/SKILL.md b/src-tauri/resources/default-skills/image_generate/SKILL.md index f526025df..40a755751 100644 --- a/src-tauri/resources/default-skills/image_generate/SKILL.md +++ b/src-tauri/resources/default-skills/image_generate/SKILL.md @@ -5,7 +5,7 @@ allowed-tools: lime_create_image_generation_task metadata: lime_argument_hint: 输入主题、画面主体、风格、构图、数量、尺寸。 lime_when_to_use: 用户显式使用 @配图/@修图/@重绘/@image,或在普通对话中已确认“调用画图/现在生成”时使用;封面需求优先交给 cover_generate。 - lime_version: 1.3.3 + lime_version: 1.3.5 lime_execution_mode: prompt lime_surface: workbench lime_category: media @@ -20,11 +20,18 @@ metadata: ## 执行规则 - 先判断是否属于封面需求;封面需求请转 `cover_generate`。 -- 普通聊天里如果用户只是给出视觉提示词、设计海报 brief、完善画面描述,但没有显式 `@配图/@修图/@重绘/@image` 或明确确认“现在调用画图功能”,不要调用本 Skill;也不要改写成 HTML/CSS/SVG/Markdown 草图或长方案,应先用一句话询问是否开始生成。 +- 普通聊天里如果用户只是给出视觉提示词、设计海报 brief、完善画面描述,但没有显式 `@配图/@修图/@重绘/@image` 或明确确认“现在调用画图功能”,不要调用本 Skill;也不要改写成 HTML/CSS/SVG/Markdown 草图或长方案,应先按用户当前语言和上下文自然询问是否开始生成,不要使用固定确认模板。 - 当前已经进入 `@配图/@修图/@重绘 -> image_skill_launch -> Skill(image_generate)` 主链,不要先调用 `ToolSearch`、`WebSearch`、`Read`、`Glob`、`Grep` 去“找技能”或“确认工具”。 +- 如果发现人物肖像、相似公众人物、深度伪造、版权、商标、未成年人、裸露、暴力、仇恨、违法等图片安全或合规风险,可以最多用一句自然短句提示风险或建议安全改写;不要展开长篇审查说明。 +- 已进入 `@配图/@修图/@重绘` 图片主链时,外层 Agent 和本 Skill 不得以安全审查、政策不确定或“不能生成”为由只输出拒绝说明、停止任务或跳过工具调用;风险提示不是任务拦截。 +- 给出必要风险提示后,仍必须直接调用 `lime_create_image_generation_task`,并把原始需求、必要的风险提示或安全替代建议放入任务参数;最终是否拒绝、改写或降级由图片生成执行器/图片模型自己的策略决定,外层 Agent 和本 Skill 不要自行替图片模型下最终拒绝结论。 +- 聊天区禁止输出“非常抱歉,我无法完成这个请求”“我无法为此类请求提供帮助”“这些内容违反原则”“禁止生成,所以不能继续”这类拒绝话术;这类话术即使任务卡继续生成,也会被用户理解为外层模型阻止了图片执行。 +- 可见风险提示应改写为非阻断句式,例如“这类真人肖像/代言请求可能有合规风险,我会把风险提示一并交给图片模型处理。”随后立即继续工具调用。 - 不要搜索 “Skill image_generate”、“lime media image generate --json”、“lime_create_image_generation_task” 之类目录信息;当前上下文已经明确要求执行图片任务。 - 提示词必须包含主体、场景、风格,不要空泛。 - 如果结构化上下文提供 `persona_context`,聊天输出必须服从其中的单条消息、短确认和隐藏运行时细节约束。 +- 如果结构化上下文提供 `presentation.assistant_intro_request`,调用工具前要在 `presentation.assistant_intro` 中补一条按用户目标、当前语言和品味上下文自然生成的开场承接;这条文案必须短,不能套固定模板,也不能包含任务 ID、任务文件、排队说明或内部工具名。 +- 如果结构化上下文提供 `presentation.completion_caption_request`,调用工具前要在 `presentation.completion_caption` 中补一条按用户目标、当前语言和品味上下文自然生成的结果收尾;这条文案必须短、可继续迭代,不能套固定模板,也不能包含任务 ID、任务文件、排队说明或内部工具名。 - 如果结构化上下文提供 `taste_context`,应结合其中的 `memory_sources`、`style_keywords`、`reference_summaries`、`avoid_keywords` 和 `cold_start_policy` 优化工具参数;这层只影响 prompt/style/reference,不要在聊天区解释内部来源。 - 若调用方在结构化上下文里提供了 `image_task`,必须优先复用其中的 `mode`、`reference_images`、`target_output_*`、`session_id`、`project_id`、`content_id`、`entry_source`、`requested_target`、`executor_mode`、`outer_model`、`runtime_contract`、`modality_contract_key`、`routing_slot`、`slot_id`、`anchor_*` 等字段,不要擅自丢失。 - 若 `image_task.runtime_contract.layered_design` 存在,说明这是 `LayeredDesignDocument -> canvas:design -> DesignCanvas` 的图层生成任务;必须原样透传 `runtime_contract.layered_design`、`target_output_id`、`target_output_ref_id` 和 `slot_id`,不要改写成 `poster_generate`、`canvas:poster`、脚本生成或 markdown 配图流程。 @@ -45,6 +52,6 @@ metadata: ## 输出规则(固定) -工具调用成功后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类递交摘要;Lime 会在同一条 assistant 消息里展示工具调用、任务进度和图片结果。若确实需要补充文字,只用一句自然说明,不重复 task_id、path 或排队模板。 +工具调用成功后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类递交摘要;Lime 会在同一条 assistant 消息里展示工具调用、任务进度和图片结果。若任务仍是 pending / queued / running,只保留一句自然承接,不重复 task_id、path 或排队模板;若已经拿到真实图片结果,可以用一两句自然收尾,引导用户继续调整,不要复述固定模板。 -聊天输出必须极简自然:工具前最多保留 `presentation.assistant_intro` 和 `presentation.process_lines` 这类短过程;结果态优先使用 `presentation.completion_caption` / `presentation.result_captions`;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。 +聊天输出必须极简自然:工具前只用贴合上下文的一句短承接,然后直接调用工具;不要复述固定过程句,也不要把 `presentation` 当成可见模板。结果态优先使用你写入任务参数的 `presentation.completion_caption` 或自己按图片结果自然收尾;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。 diff --git a/src-tauri/src/agent_tools/execution.rs b/src-tauri/src/agent_tools/execution.rs index f56cef25d..19b6cc4f5 100644 --- a/src-tauri/src/agent_tools/execution.rs +++ b/src-tauri/src/agent_tools/execution.rs @@ -81,6 +81,7 @@ pub struct WorkspaceExecutionPermissionInput<'a> { pub surface: WorkspaceToolSurface, pub workspace_root: &'a str, pub auto_mode: bool, + pub bypass_restrictions: bool, pub execution_policy_input: ToolExecutionResolverInput<'a>, } @@ -203,20 +204,22 @@ pub fn should_auto_approve_tool_warnings( pub fn build_workspace_execution_permissions( input: WorkspaceExecutionPermissionInput<'_>, ) -> Vec { - let patterns = build_workspace_permission_patterns(input.workspace_root, input.auto_mode); + let unrestricted = input.auto_mode || input.bypass_restrictions; + let patterns = build_workspace_permission_patterns(input.workspace_root, unrestricted); let mut permissions = tool_catalog_entries_for_surface(input.surface) .into_iter() .filter_map(|entry| { build_parameter_restricted_permission( entry.name, input.auto_mode, + input.bypass_restrictions, &patterns, input.execution_policy_input, ) }) .collect::>(); - if input.auto_mode { + if unrestricted { permissions.push(ToolPermission { tool: "*".to_string(), allowed: true, @@ -224,7 +227,11 @@ pub fn build_workspace_execution_permissions( conditions: Vec::new(), parameter_restrictions: Vec::new(), scope: PermissionScope::Session, - reason: Some("Auto 模式:允许所有工具与参数".to_string()), + reason: Some(if input.bypass_restrictions { + "full-access:允许所有工具与参数".to_string() + } else { + "Auto 模式:允许所有工具与参数".to_string() + }), expires_at: None, metadata: HashMap::new(), }); @@ -493,6 +500,7 @@ fn build_workspace_permission_patterns( fn build_parameter_restricted_permission( tool_name: &str, auto_mode: bool, + bypass_restrictions: bool, patterns: &WorkspacePermissionPatterns, execution_policy_input: ToolExecutionResolverInput<'_>, ) -> Option { @@ -502,7 +510,8 @@ fn build_parameter_restricted_permission( } let policy = resolve_tool_execution_policy(tool_name, execution_policy_input); - let parameter_restrictions = if auto_mode { + let unrestricted = auto_mode || bypass_restrictions; + let parameter_restrictions = if unrestricted { Vec::new() } else { build_parameter_restrictions(tool_name, policy.restriction_profile, patterns) @@ -519,6 +528,7 @@ fn build_parameter_restricted_permission( tool_name, policy.restriction_profile, auto_mode, + bypass_restrictions, )), expires_at: None, metadata: HashMap::new(), @@ -627,7 +637,30 @@ fn permission_reason( tool_name: &str, profile: ToolExecutionRestrictionProfile, auto_mode: bool, + bypass_restrictions: bool, ) -> String { + if bypass_restrictions { + return match profile { + ToolExecutionRestrictionProfile::WorkspaceShellCommand => { + format!("full-access:允许 {tool_name} 执行任意命令") + } + ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => { + format!("full-access:允许 {tool_name} 访问任意 URL") + } + ToolExecutionRestrictionProfile::AnalyzeImageInput => { + format!("full-access:允许 {tool_name} 分析任意图片路径或 base64") + } + ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => { + format!("full-access:允许 {tool_name} 访问任意绝对路径") + } + ToolExecutionRestrictionProfile::WorkspacePathRequired + | ToolExecutionRestrictionProfile::WorkspacePathOptional => { + format!("full-access:允许 {tool_name} 访问任意路径") + } + ToolExecutionRestrictionProfile::None => format!("full-access:允许工具 {tool_name}"), + }; + } + if auto_mode { return match profile { ToolExecutionRestrictionProfile::WorkspaceShellCommand => { @@ -685,6 +718,26 @@ mod tests { }; use serde_json::json; + fn assert_bash_allows_restricted_command(permissions: Vec) { + let mut manager = aster::permission::ToolPermissionManager::new(None); + for permission in permissions { + manager.add_permission(permission, PermissionScope::Session); + } + let params = HashMap::from([( + "command".to_string(), + json!("test -f .baoyu-skills/baoyu-xhs-images/EXTEND.md"), + )]); + let context = aster::permission::PermissionContext { + working_directory: std::path::PathBuf::from("/tmp/workspace"), + session_id: "session-full-access".to_string(), + timestamp: chrono::Utc::now().timestamp(), + user: None, + environment: HashMap::new(), + metadata: HashMap::new(), + }; + assert!(manager.is_allowed("Bash", ¶ms, &context).allowed); + } + #[test] fn test_tool_execution_policy_marks_bash_as_sandboxed_shell_risk() { let policy = tool_execution_policy("Bash"); @@ -709,6 +762,7 @@ mod tests { surface: WorkspaceToolSurface::core(), workspace_root: "/tmp/workspace", auto_mode: false, + bypass_restrictions: false, execution_policy_input: ToolExecutionResolverInput::default(), }); @@ -744,6 +798,7 @@ mod tests { surface: WorkspaceToolSurface::core(), workspace_root: "/tmp/workspace", auto_mode: true, + bypass_restrictions: false, execution_policy_input: ToolExecutionResolverInput::default(), }); @@ -755,6 +810,31 @@ mod tests { assert!(permissions .iter() .any(|permission| permission.tool == "*" && permission.allowed)); + + assert_bash_allows_restricted_command(permissions); + } + + #[test] + fn test_build_workspace_execution_permissions_full_access_bypasses_shell_restrictions() { + let permissions = + build_workspace_execution_permissions(WorkspaceExecutionPermissionInput { + surface: WorkspaceToolSurface::core(), + workspace_root: "/tmp/workspace", + auto_mode: false, + bypass_restrictions: true, + execution_policy_input: ToolExecutionResolverInput::default(), + }); + + let bash = permissions + .iter() + .find(|permission| permission.tool == "Bash") + .expect("Bash permission should exist"); + assert!(bash.parameter_restrictions.is_empty()); + assert!(permissions + .iter() + .any(|permission| permission.tool == "*" && permission.allowed)); + + assert_bash_allows_restricted_command(permissions); } #[test] @@ -933,6 +1013,7 @@ mod tests { surface: WorkspaceToolSurface::core(), workspace_root: "/tmp/workspace", auto_mode: false, + bypass_restrictions: false, execution_policy_input: ToolExecutionResolverInput { persisted_policy: None, request_metadata: Some(&request_metadata), diff --git a/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs index 5247c7b3c..214b1bd0c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs @@ -456,7 +456,7 @@ fn build_image_generation_confirmation_guard_prompt() -> String { "- 只有当用户显式使用 @配图/@修图/@重绘/@image,或在你询问后明确确认“调用画图/现在生成/开始画”时,才允许进入图片生成主链。", "- 如果用户没有 @ 命令但内容明显像配图提示词、海报 brief、封面 brief 或视觉设计 brief,必须先用 1 句简洁确认:是否要调用画图功能生成图片。", "- 上述普通视觉 brief 确认回合不要输出 HTML/CSS/SVG/Markdown 草图,不要写完整设计方案,不要生成提示词全文,不要输出任务详情表、排队状态或任务已提交模板。", - "- 推荐确认句式:这看起来是图片生成需求,要我直接调用画图功能生成吗?", + "- 确认句必须按用户当前语言和上下文自然表达,不要机械复述固定句式。", ] .join("\n") } @@ -600,6 +600,11 @@ fn build_image_skill_launch_system_prompt( "- 当前底层运行合同:modality_contract_key={modality_contract_key}, modality={modality}, routing_slot={routing_slot};后续创建任务必须原样保留 contract 字段。" ), "- 先快速归纳用户目标,然后立刻把任务交给 Skill 工具;不要停留在泛泛解释。".to_string(), + "- 如果发现人物肖像、相似公众人物、深度伪造、版权、商标、未成年人、裸露、暴力、仇恨、违法等图片安全或合规风险,可以最多用一句自然短句提示风险或建议安全改写;不要展开长篇审查说明。".to_string(), + "- 当前已经进入 @配图/@修图/@重绘 -> image_skill_launch 图片主链时,外层 Agent 不得以安全审查、政策不确定或“不能生成”为由拒绝、停止、跳过或延迟图片任务;风险提示不是任务拦截。".to_string(), + "- 给出必要风险提示后,仍必须继续调用 Skill(image_generate),并把原始需求、必要的风险提示或安全替代建议放入 image_task/prompt 上下文;最终是否拒绝、改写或降级由图片生成执行器/图片模型自己的策略决定,外层 Agent 不要自行替图片模型下最终拒绝结论。".to_string(), + "- 可见聊天区禁止输出“非常抱歉,我无法完成这个请求”“我无法为此类请求提供帮助”“这些内容违反原则”“禁止生成,所以不能继续”这类拒绝话术;这类话术即使任务卡继续生成,也会被用户理解为外层模型阻止了图片执行。".to_string(), + "- 可见风险提示应改写为非阻断句式,例如“这类真人肖像/代言请求可能有合规风险,我会把风险提示一并交给图片模型处理。”随后立即继续工具调用。".to_string(), format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), @@ -622,8 +627,8 @@ fn build_image_skill_launch_system_prompt( "- Skill 内部调用 lime_create_image_generation_task 时,必须只提交标准 image task 参数;不要传 outputPath,不要把任务写成 markdown 文稿。".to_string(), "- 不要伪造“图片已生成完成”;在 task file 真正返回结果前,只能让工具轨迹展示任务已提交、排队或执行中,不要额外输出递交模板。".to_string(), "- 如果当前回合已经拿到任何图片任务结果,且结果里含 task_id、path,或 status=pending_submit/queued/running/partial/succeeded,说明任务已提交;不要再次调用 Skill(image_generate) 或重复创建第二个图片任务。".to_string(), - "- 拿到上述任务结果后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类提交摘要;让同一条 assistant 消息内的工具调用和图片任务轻卡继续展示进度与结果。".to_string(), - "- 聊天输出必须服从 persona_context 与 presentation:工具前最多保留短确认和短过程,结果态优先使用 presentation.completion_caption / presentation.result_captions;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。".to_string(), + "- 拿到上述任务结果后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类提交摘要;让同一条 assistant 消息内的工具调用和图片任务轻卡继续展示进度与结果。任务仍在 pending/queued/running 时只保留一句自然承接,不要写递交流程说明。".to_string(), + "- 聊天输出必须服从 persona_context 与 presentation:工具前只用贴合上下文的一句自然短承接,然后直接调用 Skill(image_generate);不要复述固定过程句,也不要把 presentation 当成可见模板。若 presentation 只有 assistant_intro_request/completion_caption_request/result_caption_policy 而没有具体 assistant_intro/completion_caption,应让 Skill 在工具参数里补充按用户目标、当前语言和品味上下文自然生成的 assistant_intro 与 completion_caption;不要在前端或协议里套固定模板。结果态按真实图片结果自然收尾并提示可以继续调整;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。".to_string(), format!("- 当前图片任务上下文(JSON):{image_task_json}"), format!("- 当前模式:{mode}。"), format!("- 当前入口来源:{entry_source}。"), @@ -643,7 +648,7 @@ fn build_image_skill_launch_system_prompt( } if let Some(value) = presentation_json.as_deref() { lines.push(format!( - "- 当前聊天展示契约(JSON):{value}。这只用于同一条对话里的自然展示,不要改写成任务摘要。" + "- 当前聊天展示契约(JSON):{value}。这只提供自然展示约束,不是可见文案模板;不要机械复述其中字段,也不要改写成任务摘要。" )); } if let Some(value) = taste_context_json.as_deref() { diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs index 05de45330..0b4d61fd9 100644 --- a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs @@ -5,8 +5,8 @@ use crate::config::GlobalConfigManagerState; mod fast_response; mod responsive_chat; use fast_response::{ - extract_fast_response_routing, extract_fast_response_routing_slot, - extract_fast_response_service_model_slot, + extract_fast_response_fallback_preference, extract_fast_response_routing, + extract_fast_response_routing_slot, extract_fast_response_service_model_slot, }; use lime_core::database::dao::api_key_provider::{ApiProviderType, ProviderGroup}; use lime_core::models::model_registry::{ @@ -2990,6 +2990,7 @@ enum RequestPreferenceSource { ServiceSceneLaunch, Session, ServiceModelSetting, + FastResponseFallback, ResponsiveChatAuto, } @@ -3930,6 +3931,99 @@ pub(super) async fn resolve_runtime_request_provider_resolution( } } + if request_targets_responsive_chat { + if let Some(fallback_preference) = + extract_fast_response_fallback_preference(request.metadata.as_ref()) + { + match build_runtime_request_provider_config_from_preference( + app, + db, + api_key_provider_service, + request, + &task_profile, + &fallback_preference.provider_selector, + &fallback_preference.model_name, + RequestPreferenceSource::FastResponseFallback, + true, + ) + .await + { + Ok(selection) => { + tracing::info!( + "[AsterAgent] 快速响应后端候选不可用,回退当前工作区 provider/model: session={}, provider={}, model={}", + request.session_id, + fallback_preference.provider_selector, + fallback_preference.model_name + ); + let mut notes = vec![ + "快速响应后端候选不可用,已回退到当前工作区 provider/model。".to_string(), + ]; + if let Some(note) = fallback_note.clone() { + notes.push(note); + } + let limit_state = build_limit_state( + runtime_limit_status_for_selection(&selection), + selection.candidate_count, + true, + false, + oem_locked, + selection.capability_gap.clone(), + notes, + ); + let routing_decision = build_routing_decision( + &task_profile, + "fast_response_fallback", + compose_routing_decision_reason( + "当前回合命中快速响应路由,但后端服务模型或自动候选不可用;运行时回退到前端当前工作区模型。", + Some(&selection), + oem_locked, + fallback_note.as_deref(), + ), + Some(&selection), + Some(fallback_preference.provider_selector), + Some(fallback_preference.model_name), + Some("fast_response_routing.fallback".to_string()), + ); + let cost_state = build_cost_state(Some(&selection), None, "estimated"); + let runtime_summary = build_runtime_summary( + &routing_decision, + &limit_state, + &cost_state, + &permission_state, + oem_limit_event.as_ref(), + ); + + return Ok(RuntimeRequestProviderResolution { + provider_config: Some(selection.provider_config), + task_profile, + routing_decision, + limit_state, + cost_state, + permission_state: permission_state.clone(), + limit_event: oem_limit_event, + oem_policy: oem_policy.clone(), + runtime_summary, + }); + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 快速响应 fallback provider/model 不可用,继续回退会话默认: session={}, provider={}, model={}, error={}", + request.session_id, + fallback_preference.provider_selector, + fallback_preference.model_name, + error + ); + if fallback_note.is_none() { + fallback_note = Some( + "快速响应 fallback provider/model 不可用,已继续回退会话默认。" + .to_string(), + ); + } + } + } + } + } + let Some((provider_selector, provider_preference_source)) = resolve_provider_preference_with_session_fallback(None, session_context.as_ref()) else { diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/fast_response.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/fast_response.rs index 120921bf4..a2625e374 100644 --- a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/fast_response.rs +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/fast_response.rs @@ -3,6 +3,12 @@ use super::{ responsive_chat::{RESPONSIVE_CHAT_ROUTING_SLOT, RESPONSIVE_CHAT_SERVICE_MODEL_SLOT}, }; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct FastResponseFallbackPreference { + pub(super) provider_selector: String, + pub(super) model_name: String, +} + pub(super) fn extract_fast_response_routing( request_metadata: Option<&serde_json::Value>, ) -> Option<&serde_json::Map> { @@ -29,3 +35,38 @@ pub(super) fn extract_fast_response_routing_slot( .filter(|slot| normalize_identifier(slot) == RESPONSIVE_CHAT_ROUTING_SLOT) .or_else(|| Some(RESPONSIVE_CHAT_ROUTING_SLOT.to_string())) } + +pub(super) fn extract_fast_response_fallback_preference( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let routing = extract_fast_response_routing(request_metadata)?; + let provider_selector = extract_metadata_text( + routing, + &[ + "fallback_provider_preference", + "fallbackProviderPreference", + "fallback_provider", + "fallbackProvider", + "provider_preference", + "providerPreference", + "provider", + ], + )?; + let model_name = extract_metadata_text( + routing, + &[ + "fallback_model_preference", + "fallbackModelPreference", + "fallback_model", + "fallbackModel", + "model_preference", + "modelPreference", + "model", + ], + )?; + + Some(FastResponseFallbackPreference { + provider_selector, + model_name, + }) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/tests.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/tests.rs index 189c543fe..64b5879a3 100644 --- a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/tests.rs @@ -2414,6 +2414,26 @@ fn runtime_task_profile_maps_fast_response_to_responsive_chat_slot() { .any(|value| value == "fast_response_routing")); } +#[test] +fn fast_response_fallback_preference_reads_workspace_selection() { + let metadata = serde_json::json!({ + "harness": { + "fast_response_routing": { + "service_model_slot": "responsive_chat", + "routing_slot": "responsive_chat_model", + "fallback_provider_preference": "deepseek", + "fallback_model_preference": "deepseek-v4-pro" + } + } + }); + + let preference = + extract_fast_response_fallback_preference(Some(&metadata)).expect("fallback preference"); + + assert_eq!(preference.provider_selector, "deepseek"); + assert_eq!(preference.model_name, "deepseek-v4-pro"); +} + #[test] fn runtime_task_profile_marks_oem_runtime_from_harness_oem_routing() { let request = AsterChatRequest { 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 69dcd732b..87a057fcf 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -1874,6 +1874,10 @@ async fn prepare_runtime_turn_submit_bootstrap( .set_provider_continuation(provider_continuation_state); let request_metadata = request.metadata.clone(); + let bypass_workspace_restrictions = matches!( + resolve_runtime_access_mode_from_request(request), + Some(lime_agent::SessionExecutionRuntimeAccessMode::FullAccess) + ); let sandbox_outcome = apply_workspace_sandbox_permissions( state, config_manager, @@ -1890,6 +1894,7 @@ async fn prepare_runtime_turn_submit_bootstrap( execution_profile, request_tool_policy, requested_strategy, + bypass_workspace_restrictions, ) .await .map_err(|error| format!("注入 workspace 安全策略失败: {error}"))?; diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 50e34df17..cca5c893d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -5068,7 +5068,11 @@ mod tests { }, "presentation": { "version": "lime-image-chat-v1", - "assistant_intro": "好啊,用 GPT Image 2 处理:把这张海报改成更清爽的青柠风格\n先获取下工具参数\n马上生成" + "opening_guidance": { + "source": "model_stream", + "avoid_fixed_templates": true, + "avoid_visible_process_lines": true + } }, "taste_context": { "version": "lime-image-taste-v1", @@ -5095,6 +5099,20 @@ mod tests { assert!(merged.contains("skill=\"image_generate\"")); assert!(merged.contains("Skill.args 的 JSON")); assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); + assert!(merged.contains("可以最多用一句自然短句提示风险")); + assert!(merged.contains( + "不得以安全审查、政策不确定或“不能生成”为由拒绝、停止、跳过或延迟图片任务" + )); + assert!(merged.contains("仍必须继续调用 Skill(image_generate)")); + assert!(merged.contains( + "最终是否拒绝、改写或降级由图片生成执行器/图片模型自己的策略决定" + )); + assert!(merged.contains("外层 Agent 不要自行替图片模型下最终拒绝结论")); + assert!(merged.contains( + "禁止输出“非常抱歉,我无法完成这个请求”“我无法为此类请求提供帮助”" + )); + assert!(merged.contains("这类话术即使任务卡继续生成,也会被用户理解为外层模型阻止了图片执行")); + assert!(merged.contains("可见风险提示应改写为非阻断句式")); assert!(merged.contains("\"image_task\":")); assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); assert!(merged.contains("当前主会话第一刀必须先调用 Skill(image_generate)")); @@ -5113,6 +5131,9 @@ mod tests { assert!(merged.contains("不要伪造“图片已生成完成”")); assert!(merged.contains("不要再次调用 Skill(image_generate) 或重复创建第二个图片任务")); assert!(merged.contains("不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类提交摘要")); + assert!(merged.contains("assistant_intro_request")); + assert!(merged.contains("completion_caption_request")); + assert!(merged.contains("不要在前端或协议里套固定模板")); assert!(!merged.contains("给出提交摘要")); assert!(merged.contains("provider_id / model")); assert!(merged.contains( @@ -5126,6 +5147,8 @@ mod tests { assert!(merged.contains("当前任务已经显式进入图片技能主链")); assert!(merged.contains("当前图片生成人设契约(JSON)")); assert!(merged.contains("当前聊天展示契约(JSON)")); + assert!(merged.contains("不要复述固定过程句")); + assert!(merged.contains("不是可见文案模板")); assert!(merged.contains("当前品味/记忆规划上下文(JSON)")); assert!(merged.contains("不要在聊天区暴露内部来源或参考站名称")); let reference_site_name = ["ri", "bbi"].concat(); @@ -5152,7 +5175,9 @@ mod tests { assert!(merged.contains("不要因此自动调用 Skill(image_generate)")); assert!(merged.contains("是否要调用画图功能生成图片")); assert!(merged.contains("不要输出 HTML/CSS/SVG/Markdown 草图")); - assert!(merged.contains("这看起来是图片生成需求,要我直接调用画图功能生成吗?")); + assert!(merged.contains("按用户当前语言和上下文自然表达")); + assert!(merged.contains("不要机械复述固定句式")); + assert!(!merged.contains("这看起来是图片生成需求,要我直接调用画图功能生成吗?")); assert!(!merged.contains(super::image_skill_launch::IMAGE_SKILL_LAUNCH_PROMPT_MARKER)); } @@ -6920,7 +6945,11 @@ mod tests { "provider_id": "fal", "model": "fal-ai/nano-banana-pro", "presentation": { - "assistant_intro": "好啊\n先获取下工具参数\n马上生成" + "opening_guidance": { + "source": "model_stream", + "avoid_fixed_templates": true, + "avoid_visible_process_lines": true + } }, "taste_context": { "version": "lime-image-taste-v1" diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs index d3a51ee03..124a73a84 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -343,6 +343,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions( execution_profile: TurnExecutionProfile, request_tool_policy: &RequestToolPolicy, execution_strategy: AsterExecutionStrategy, + bypass_workspace_restrictions: bool, ) -> Result { let workspace_root = workspace_root.trim(); if workspace_root.is_empty() { @@ -356,6 +357,8 @@ pub(crate) async fn apply_workspace_sandbox_permissions( persisted_policy: Some(¤t_config.agent.tool_execution), request_metadata, }; + let auto_approve_bash_warnings = bypass_workspace_restrictions + || should_auto_approve_tool_warnings("Bash", auto_mode, execution_policy_input); let lock_service_skill_launch_to_site_tools = should_lock_service_skill_launch_to_site_tools(request_metadata); let tool_surface = WorkspaceToolSurface { @@ -363,12 +366,12 @@ pub(crate) async fn apply_workspace_sandbox_permissions( browser_assist: is_browser_assist_enabled(request_metadata), }; let mut sandboxed_bash_tool: Option = None; - let apply_outcome = if !sandbox_policy.enabled { + let apply_outcome = if bypass_workspace_restrictions || !sandbox_policy.enabled { WorkspaceSandboxApplyOutcome::DisabledByConfig } else { match WorkspaceSandboxedBashTool::new( workspace_root, - should_auto_approve_tool_warnings("Bash", auto_mode, execution_policy_input), + auto_approve_bash_warnings, app_handle.clone(), ) { Ok(tool) => { @@ -395,6 +398,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions( surface: tool_surface, workspace_root, auto_mode, + bypass_restrictions: bypass_workspace_restrictions, execution_policy_input, }); @@ -503,7 +507,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions( workspace_tools::register_workspace_runtime_tools( &mut registry, task_manager, - should_auto_approve_tool_warnings("Bash", auto_mode, execution_policy_input), + auto_approve_bash_warnings, app_handle.clone(), sandboxed_bash_tool, ); diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs index 46aba1407..bb6887bcd 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs @@ -2192,7 +2192,11 @@ mod tests { })), presentation: Some(serde_json::json!({ "version": "lime-image-chat-v1", - "assistant_intro": "好啊,生成:未来感青柠实验室", + "opening_guidance": { + "source": "model_stream", + "avoid_fixed_templates": true, + "avoid_visible_process_lines": true + }, "completion_caption": "青柠实验室已生成" })), taste_context: Some(serde_json::json!({ diff --git a/src-tauri/src/commands/media_task_cmd.rs b/src-tauri/src/commands/media_task_cmd.rs index 8ac2a0a0f..b30b98c3f 100644 --- a/src-tauri/src/commands/media_task_cmd.rs +++ b/src-tauri/src/commands/media_task_cmd.rs @@ -5758,7 +5758,11 @@ mod tests { })), presentation: Some(json!({ "version": "lime-image-chat-v1", - "assistant_intro": "好啊,生成:未来感青柠实验室", + "opening_guidance": { + "source": "model_stream", + "avoid_fixed_templates": true, + "avoid_visible_process_lines": true + }, "completion_caption": "青柠实验室已生成" })), taste_context: Some(json!({ @@ -5837,7 +5841,11 @@ mod tests { })), presentation: Some(json!({ "version": "lime-image-chat-v1", - "assistant_intro": "好啊,生成:未来感青柠实验室", + "opening_guidance": { + "source": "model_stream", + "avoid_fixed_templates": true, + "avoid_visible_process_lines": true + }, "completion_caption": "青柠实验室已生成" })), taste_context: Some(json!({ diff --git a/src-tauri/src/services/agent_timeline_service.rs b/src-tauri/src/services/agent_timeline_service.rs index 01c684f8a..fe7b34b94 100644 --- a/src-tauri/src/services/agent_timeline_service.rs +++ b/src-tauri/src/services/agent_timeline_service.rs @@ -144,6 +144,40 @@ impl AgentTimelineRecorder { }) } + pub fn from_started_turn(db: DbConnection, turn: AgentThreadTurn) -> Result { + let mut sequence_counter = 0; + let mut item_sequences = HashMap::new(); + let mut item_statuses = HashMap::new(); + let mut plan_text = None; + + { + let conn = lock_db(&db)?; + AgentTimelineDao::upsert_turn(&conn, &turn) + .map_err(|e| format!("同步 turn 启动态失败: {e}"))?; + let items = AgentTimelineDao::list_items_by_thread(&conn, &turn.thread_id) + .map_err(|e| format!("读取 turn item 失败: {e}"))?; + for item in items.into_iter().filter(|item| item.turn_id == turn.id) { + sequence_counter = sequence_counter.max(item.sequence); + item_sequences.insert(item.id.clone(), item.sequence); + item_statuses.insert(item.id.clone(), item.status.clone()); + if let AgentThreadItemPayload::Plan { text } = item.payload { + plan_text = Some(text); + } + } + } + + Ok(Self { + db, + thread_id: turn.thread_id.clone(), + turn_id: turn.id.clone(), + turn, + sequence_counter, + item_sequences, + item_statuses, + plan_text, + }) + } + pub fn thread_id(&self) -> &str { &self.thread_id } @@ -394,6 +428,37 @@ impl AgentTimelineRecorder { Ok(events) } + pub fn record_synthetic_item( + &mut self, + app: &AppHandle, + event_name: &str, + id: String, + status: AgentThreadItemStatus, + completed_at: Option, + payload: AgentThreadItemPayload, + ) -> Result { + let event = self.record_synthetic_item_event(id, status, completed_at, payload)?; + let item = match &event { + RuntimeAgentEvent::ItemStarted { item } + | RuntimeAgentEvent::ItemUpdated { item } + | RuntimeAgentEvent::ItemCompleted { item } => item.clone(), + _ => return Err("synthetic item 写入后产生了非 item 事件".to_string()), + }; + emit_event(app, event_name, &event); + Ok(item) + } + + pub fn record_synthetic_item_event( + &mut self, + id: String, + status: AgentThreadItemStatus, + completed_at: Option, + payload: AgentThreadItemPayload, + ) -> Result { + let item = self.build_item(id, status, completed_at, payload); + self.persist_item_and_build_event(item) + } + fn complete_projection_items( &mut self, status: AgentThreadItemStatus, @@ -496,9 +561,22 @@ impl AgentTimelineRecorder { &mut self, app: &AppHandle, event_name: &str, - item: AgentThreadItem, + mut item: AgentThreadItem, event: RuntimeAgentEvent, ) -> Result<(), String> { + self.normalize_runtime_item_sequence(&mut item); + let event = match event { + RuntimeAgentEvent::ItemStarted { .. } => { + RuntimeAgentEvent::ItemStarted { item: item.clone() } + } + RuntimeAgentEvent::ItemUpdated { .. } => { + RuntimeAgentEvent::ItemUpdated { item: item.clone() } + } + RuntimeAgentEvent::ItemCompleted { .. } => { + RuntimeAgentEvent::ItemCompleted { item: item.clone() } + } + other => other, + }; self.sync_runtime_item_state(&item); { let conn = lock_db(&self.db)?; @@ -509,6 +587,41 @@ impl AgentTimelineRecorder { Ok(()) } + fn normalize_runtime_item_sequence(&mut self, item: &mut AgentThreadItem) { + if let Some(existing_sequence) = self.item_sequences.get(&item.id) { + item.sequence = *existing_sequence; + return; + } + + self.sequence_counter = self.sequence_counter.max(item.sequence); + if !self.sequence_is_used_by_other_item(&item.id, item.sequence) { + return; + } + + item.sequence = self.next_available_sequence(); + } + + fn sequence_is_used_by_other_item(&self, item_id: &str, sequence: i64) -> bool { + self.item_sequences + .iter() + .any(|(existing_id, existing_sequence)| { + existing_id != item_id && *existing_sequence == sequence + }) + } + + fn next_available_sequence(&mut self) -> i64 { + loop { + self.sequence_counter += 1; + if !self + .item_sequences + .values() + .any(|sequence| *sequence == self.sequence_counter) + { + return self.sequence_counter; + } + } + } + fn sync_runtime_item_state(&mut self, item: &AgentThreadItem) { self.thread_id = item.thread_id.clone(); self.turn_id = item.turn_id.clone(); @@ -532,6 +645,7 @@ mod tests { }; use lime_core::database::schema::create_tables; use rusqlite::Connection; + use serde_json::json; use std::sync::{Arc, Mutex}; fn setup_db() -> DbConnection { @@ -550,6 +664,253 @@ mod tests { Arc::new(Mutex::new(conn)) } + fn running_turn(id: &str) -> AgentThreadTurn { + AgentThreadTurn { + id: id.to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "@analysis 帮我分析一下今天的国际形势".to_string(), + status: AgentThreadTurnStatus::Running, + started_at: "2026-03-13T00:00:00Z".to_string(), + completed_at: None, + error_message: None, + created_at: "2026-03-13T00:00:00Z".to_string(), + updated_at: "2026-03-13T00:00:00Z".to_string(), + } + } + + #[test] + fn from_started_turn_should_upsert_turn_and_seed_existing_items() { + let db = setup_db(); + { + let conn = lock_db(&db).expect("获取数据库锁"); + AgentTimelineDao::upsert_turn(&conn, &running_turn("turn-skill-1")) + .expect("写入测试 turn"); + AgentTimelineDao::upsert_item( + &conn, + &AgentThreadItem { + id: "reasoning:assistant-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-skill-1".to_string(), + sequence: 3, + status: AgentThreadItemStatus::InProgress, + started_at: "2026-03-13T00:00:01Z".to_string(), + completed_at: None, + updated_at: "2026-03-13T00:00:01Z".to_string(), + payload: AgentThreadItemPayload::Reasoning { + text: "先确认可用范围".to_string(), + summary: Some(vec!["先确认可用范围".to_string()]), + }, + }, + ) + .expect("写入测试 item"); + } + + let recorder = + AgentTimelineRecorder::from_started_turn(db.clone(), running_turn("turn-skill-1")) + .expect("应从 turn_started 创建 recorder"); + + assert_eq!(recorder.thread_id(), "thread-1"); + assert_eq!(recorder.turn_id(), "turn-skill-1"); + + let conn = lock_db(&db).expect("获取数据库锁"); + let turns = AgentTimelineDao::list_turns_by_thread(&conn, "thread-1").expect("读取 turn"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].id, "turn-skill-1"); + assert_eq!(turns[0].status, AgentThreadTurnStatus::Running); + } + + #[test] + fn synthetic_skill_item_should_keep_recorder_sequence_and_payload() { + let db = setup_db(); + { + let conn = lock_db(&db).expect("获取数据库锁"); + AgentTimelineDao::upsert_turn(&conn, &running_turn("turn-skill-1")) + .expect("写入测试 turn"); + AgentTimelineDao::upsert_item( + &conn, + &AgentThreadItem { + id: "reasoning:assistant-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-skill-1".to_string(), + sequence: 3, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-13T00:00:01Z".to_string(), + completed_at: Some("2026-03-13T00:00:02Z".to_string()), + updated_at: "2026-03-13T00:00:02Z".to_string(), + payload: AgentThreadItemPayload::Reasoning { + text: "已完成思考".to_string(), + summary: Some(vec!["已完成思考".to_string()]), + }, + }, + ) + .expect("写入测试 item"); + } + + let mut recorder = + AgentTimelineRecorder::from_started_turn(db.clone(), running_turn("turn-skill-1")) + .expect("应从 turn_started 创建 recorder"); + let item_id = "skill:exec-1".to_string(); + let start_event = recorder + .record_synthetic_item_event( + item_id.clone(), + AgentThreadItemStatus::InProgress, + None, + AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "analysis", + "source": "SKILL.md" + })), + output: Some("正在从 SKILL.md 读取并执行 Skill:Analysis".to_string()), + success: None, + error: None, + metadata: Some(json!({ + "tool_family": "skill", + "skill_source": "SKILL.md", + "markdown_content_bytes": 1024 + })), + }, + ) + .expect("应记录 synthetic Skill item"); + + let RuntimeAgentEvent::ItemStarted { item } = start_event else { + panic!("首次 synthetic item 应发出 ItemStarted"); + }; + assert_eq!(item.sequence, 4); + assert!(matches!(item.status, AgentThreadItemStatus::InProgress)); + match &item.payload { + AgentThreadItemPayload::ToolCall { + tool_name, + arguments, + metadata, + .. + } => { + assert_eq!(tool_name, "Skill"); + assert_eq!( + arguments + .as_ref() + .and_then(|v| v.get("source")) + .and_then(serde_json::Value::as_str), + Some("SKILL.md") + ); + assert_eq!( + metadata + .as_ref() + .and_then(|v| v.get("markdown_content_bytes")) + .and_then(serde_json::Value::as_u64), + Some(1024) + ); + } + _ => panic!("Skill invocation 应保存为 tool_call item"), + } + + let completed_event = recorder + .record_synthetic_item_event( + item_id, + AgentThreadItemStatus::Completed, + Some("2026-03-13T00:00:03Z".to_string()), + AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "analysis", + "source": "SKILL.md" + })), + output: Some("已从 SKILL.md 读取并执行 Skill:Analysis".to_string()), + success: Some(true), + error: None, + metadata: Some(json!({ + "tool_family": "skill", + "skill_source": "SKILL.md", + "markdown_content_bytes": 1024 + })), + }, + ) + .expect("应完成 synthetic Skill item"); + + let RuntimeAgentEvent::ItemCompleted { item } = completed_event else { + panic!("完成 synthetic item 应发出 ItemCompleted"); + }; + assert_eq!(item.sequence, 4); + assert_eq!(item.completed_at.as_deref(), Some("2026-03-13T00:00:03Z")); + assert!(matches!(item.status, AgentThreadItemStatus::Completed)); + } + + #[test] + fn runtime_items_after_synthetic_skill_should_not_reuse_sequence() { + let db = setup_db(); + { + let conn = lock_db(&db).expect("获取数据库锁"); + AgentTimelineDao::upsert_turn(&conn, &running_turn("turn-skill-1")) + .expect("写入测试 turn"); + AgentTimelineDao::upsert_item( + &conn, + &AgentThreadItem { + id: "user:1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-skill-1".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-13T00:00:01Z".to_string(), + completed_at: Some("2026-03-13T00:00:01Z".to_string()), + updated_at: "2026-03-13T00:00:01Z".to_string(), + payload: AgentThreadItemPayload::UserMessage { + content: "@analysis 请分析".to_string(), + }, + }, + ) + .expect("写入测试 user item"); + } + + let mut recorder = + AgentTimelineRecorder::from_started_turn(db.clone(), running_turn("turn-skill-1")) + .expect("应从 turn_started 创建 recorder"); + recorder + .record_synthetic_item_event( + "skill:exec-1".to_string(), + AgentThreadItemStatus::InProgress, + None, + AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "analysis", + "source": "SKILL.md" + })), + output: Some("正在从 SKILL.md 读取并执行 Skill:analysis".to_string()), + success: None, + error: None, + metadata: Some(json!({ + "tool_family": "skill", + "skill_source": "SKILL.md", + "markdown_content_bytes": 1024 + })), + }, + ) + .expect("应记录 synthetic Skill item"); + + let mut reasoning_item = AgentThreadItem { + id: "reasoning:1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-skill-1".to_string(), + sequence: 2, + status: AgentThreadItemStatus::InProgress, + started_at: "2026-03-13T00:00:02Z".to_string(), + completed_at: None, + updated_at: "2026-03-13T00:00:02Z".to_string(), + payload: AgentThreadItemPayload::Reasoning { + text: "先读取 Skill".to_string(), + summary: Some(vec!["先读取 Skill".to_string()]), + }, + }; + + recorder.normalize_runtime_item_sequence(&mut reasoning_item); + + assert_eq!(reasoning_item.sequence, 3); + let conn = lock_db(&db).expect("获取数据库锁"); + let items = AgentTimelineDao::list_items_by_thread(&conn, "thread-1").expect("读取 item"); + let skill_item = items.iter().find(|item| item.id == "skill:exec-1").unwrap(); + assert_eq!(skill_item.sequence, 2); + } + #[test] fn fail_turn_should_persist_failed_turn_before_emitting_events() { let db = setup_db(); diff --git a/src-tauri/src/skills/catalog.rs b/src-tauri/src/skills/catalog.rs index 7a8ba6f9f..289ee70ac 100644 --- a/src-tauri/src/skills/catalog.rs +++ b/src-tauri/src/skills/catalog.rs @@ -534,4 +534,31 @@ Invalid content assert!(!skill.disable_model_invocation); assert!(skill.standard_compliance.is_standard); } + + #[test] + fn test_bundled_analysis_skill_loads_full_instruction_body() { + let skill_file = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources/default-skills/analysis/SKILL.md"); + + assert!(skill_file.exists()); + let skill = load_skill_from_file("analysis", &skill_file).unwrap(); + + assert_eq!(skill.display_name, "analysis"); + assert_eq!(skill.execution_mode, "prompt"); + assert_eq!( + skill.allowed_tools, + Some(vec!["list_directory".to_string(), "read_file".to_string()]) + ); + assert_eq!( + skill.metadata.get("lime_version").map(String::as_str), + Some("1.0.1") + ); + assert!(skill.markdown_content.contains("你是 Lime 的分析助手")); + assert!(skill + .markdown_content + .contains("不要仅因没有外部材料就输出“无法分析”")); + assert!(skill.markdown_content.contains("## 输出格式(固定)")); + assert!(!skill.markdown_content.contains("lime_execution_mode")); + assert!(skill.standard_compliance.is_standard); + } } diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index 984cbfe6c..f35ef8929 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -846,18 +846,23 @@ mod tests { .contains("必须直接调用 `lime_create_cover_generation_task` 创建真实封面任务记录")); assert!(MODAL_RESOURCE_SEARCH_SKILL_CONTENT.contains("name: modal_resource_search")); assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("name: image_generate")); + assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("lime_version: 1.3.5")); assert!(IMAGE_GENERATE_SKILL_CONTENT .contains("allowed-tools: lime_create_image_generation_task")); assert!(IMAGE_GENERATE_SKILL_CONTENT .contains("必须直接调用 `lime_create_image_generation_task` 创建真实图片任务")); assert!(IMAGE_GENERATE_SKILL_CONTENT .contains("必须直接传扁平任务对象参数;不要包成 `{\"image_task\": ...}`")); + assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("assistant_intro_request")); + assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("completion_caption_request")); assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("runtime_contract.layered_design")); assert!( IMAGE_GENERATE_SKILL_CONTENT.contains("不要改写成 `poster_generate`、`canvas:poster`") ); assert!(IMAGE_GENERATE_SKILL_CONTENT .contains("不要通过 `Bash` 拼接 `lime media image generate --json`")); + assert!(!IMAGE_GENERATE_SKILL_CONTENT.contains("任务 ID:{task_id}")); + assert!(!IMAGE_GENERATE_SKILL_CONTENT.contains("任务文件:{path}")); assert!(!IMAGE_GENERATE_SKILL_CONTENT .contains("allowed-tools: Bash, lime_create_image_generation_task")); assert!(!IMAGE_GENERATE_SKILL_CONTENT @@ -875,6 +880,10 @@ mod tests { assert!(FORM_GENERATE_SKILL_CONTENT.contains("```a2ui")); assert!(SUMMARY_SKILL_CONTENT.contains("name: summary")); assert!(SUMMARY_SKILL_CONTENT.contains("allowed-tools: list_directory, read_file")); + assert!(ANALYSIS_SKILL_CONTENT.contains("name: analysis")); + assert!(ANALYSIS_SKILL_CONTENT.contains("lime_version: 1.0.1")); + assert!(ANALYSIS_SKILL_CONTENT.contains("不要仅因没有外部材料就输出“无法分析”")); + assert!(ANALYSIS_SKILL_CONTENT.contains("实时信息边界")); assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`github/search`")); assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`zhihu/hot`")); assert!(TYPESETTING_SKILL_CONTENT.contains("name: typesetting")); diff --git a/src-tauri/src/skills/execution.rs b/src-tauri/src/skills/execution.rs index 24160d875..519eada45 100644 --- a/src-tauri/src/skills/execution.rs +++ b/src-tauri/src/skills/execution.rs @@ -8,12 +8,13 @@ use lime_agent::{ artifact_protocol::extend_unique_artifact_protocol_paths, execute_skill_prompt as execute_agent_skill_prompt, execute_skill_workflow as execute_agent_skill_workflow, AgentEvent as RuntimeAgentEvent, - AsterAgentState, SkillEventEmitter, SkillExecutionError, SkillInputImage, SkillPromptExecution, - SkillWorkflowExecution, + AgentToolResult, AsterAgentState, SkillEventEmitter, SkillExecutionError, SkillInputImage, + SkillPromptExecution, SkillWorkflowExecution, }; +use lime_core::database::dao::agent_timeline::{AgentThreadItemPayload, AgentThreadItemStatus}; use lime_skills::{ExecutionCallback, LoadedSkillDefinition}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -26,6 +27,7 @@ use crate::commands::skill_error::{ }; use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; +use crate::services::agent_timeline_service::AgentTimelineRecorder; use crate::services::execution_tracker_service::{ExecutionTracker, RunSource}; use super::execution_callback::TauriExecutionCallback; @@ -266,20 +268,458 @@ impl ExecutionCallback for TauriExecutionCallbackAdapter<'_> { } } -fn create_skill_event_emitter(app_handle: &AppHandle) -> SkillEventEmitter { - let app_handle = app_handle.clone(); - Arc::new(move |event_name: String, event: RuntimeAgentEvent| { - if let Err(error) = app_handle.emit(&event_name, &event) { - tracing::error!("[execute_skill_workflow] 发送事件失败: {}", error); - } - }) +#[derive(Debug, Clone)] +struct SkillInvocationTrace { + execution_id: String, + skill_name: String, + display_name: String, + execution_mode: String, + version: Option, + is_standard: bool, + markdown_content: String, + markdown_content_bytes: usize, } -fn emit_skill_final_done(app_handle: &AppHandle, execution_id: &str) { - let event_name = format!("skill-exec-{execution_id}"); - if let Err(error) = app_handle.emit(&event_name, RuntimeAgentEvent::FinalDone { usage: None }) { - tracing::error!("[execute_skill] 发送完成事件失败: {}", error); +impl SkillInvocationTrace { + fn new(skill: &LoadedSkillDefinition, execution_id: &str) -> Self { + Self { + execution_id: execution_id.to_string(), + skill_name: skill.skill_name.clone(), + display_name: skill.display_name.clone(), + execution_mode: skill.execution_mode.clone(), + version: skill + .metadata + .get("lime_version") + .or_else(|| skill.metadata.get("version")) + .cloned(), + is_standard: skill.standard_compliance.is_standard, + markdown_content: skill.markdown_content.clone(), + markdown_content_bytes: skill.markdown_content.len(), + } } + + fn tool_id(&self) -> String { + format!("skill:{}", self.execution_id) + } + + fn arguments(&self) -> Value { + let mut args = json!({ + "skill": self.skill_name, + "name": self.skill_name, + "display_name": self.display_name, + "source": "SKILL.md", + "execution_mode": self.execution_mode, + }); + if let Some(version) = self.version.as_deref() { + if let Some(record) = args.as_object_mut() { + record.insert("version".to_string(), json!(version)); + } + } + args + } + + fn metadata(&self) -> Value { + json!({ + "tool_family": "skill", + "skill_name": self.skill_name, + "skill_display_name": self.display_name, + "skill_source": "SKILL.md", + "agent_skills_standard": self.is_standard, + "skill_markdown_content": self.markdown_content.as_str(), + "skill_markdown_content_format": "text/markdown", + "markdown_content_bytes": self.markdown_content_bytes, + "version": self.version, + }) + } + + fn success_output(&self) -> String { + format!("已从 SKILL.md 读取并执行 Skill:{}", self.display_name) + } + + fn running_output(&self) -> String { + format!("正在从 SKILL.md 读取并执行 Skill:{}", self.display_name) + } +} + +#[derive(Clone)] +struct SkillRuntimeEventBridge { + app_handle: AppHandle, + db: DbConnection, + recorder: Arc>>, + invocation_trace: Arc, + tool_start_emitted: Arc>, + invocation_item_started: Arc>, +} + +impl SkillRuntimeEventBridge { + fn new( + app_handle: &AppHandle, + db: &DbConnection, + skill: &LoadedSkillDefinition, + execution_id: &str, + ) -> Self { + Self { + app_handle: app_handle.clone(), + db: db.clone(), + recorder: Arc::new(Mutex::new(None)), + invocation_trace: Arc::new(SkillInvocationTrace::new(skill, execution_id)), + tool_start_emitted: Arc::new(Mutex::new(false)), + invocation_item_started: Arc::new(Mutex::new(false)), + } + } + + fn emitter(&self) -> SkillEventEmitter { + let bridge = self.clone(); + Arc::new(move |event_name: String, event: RuntimeAgentEvent| { + bridge.emit_runtime_event(event_name, event); + }) + } + + fn emit_final_done(&self, execution_id: &str) { + let event_name = format!("skill-exec-{execution_id}"); + self.emit_runtime_event(event_name, RuntimeAgentEvent::FinalDone { usage: None }); + } + + fn finish_turn_success(&self, event_name: &str) { + let events = { + let mut recorder_guard = match self.recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + let Some(recorder) = recorder_guard.as_mut() else { + return; + }; + match recorder.complete_turn_success() { + Ok(events) => events, + Err(error) => { + tracing::warn!( + "[execute_skill] 标记 Skill runtime turn 完成失败,已降级继续: {}", + error + ); + return; + } + } + }; + + for event in events { + self.emit_runtime_event_directly(event_name, &event); + } + } + + fn finish_turn_failed(&self, event_name: &str, message: &str) { + let events = { + let mut recorder_guard = match self.recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + let Some(recorder) = recorder_guard.as_mut() else { + return; + }; + match recorder.fail_turn(message) { + Ok(events) => events, + Err(error) => { + tracing::warn!( + "[execute_skill] 标记 Skill runtime turn 失败状态失败,已降级继续: {}", + error + ); + return; + } + } + }; + + for event in events { + self.emit_runtime_event_directly(event_name, &event); + } + } + + fn finish_current_turn(&self, event_name: &str, success: bool, error_message: Option<&str>) { + self.finish_skill_invocation(event_name, success, error_message); + if success { + self.finish_turn_success(event_name); + } else { + self.finish_turn_failed( + event_name, + error_message.unwrap_or("Skill 执行失败,未返回可用结果。"), + ); + } + } + + fn emit_runtime_event(&self, event_name: String, event: RuntimeAgentEvent) { + let recorder_emitted_equivalent = self.record_runtime_event(&event_name, &event); + if !recorder_emitted_equivalent { + self.emit_runtime_event_directly(&event_name, &event); + } + } + + fn emit_runtime_event_directly(&self, event_name: &str, event: &RuntimeAgentEvent) { + if let Err(error) = self.app_handle.emit(event_name, event) { + tracing::error!("[execute_skill] 发送事件失败: {}", error); + } + } + + fn emit_skill_invocation_start(&self, event_name: &str) { + let should_emit = { + let mut started_guard = match self.tool_start_emitted.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if *started_guard { + false + } else { + *started_guard = true; + true + } + }; + if !should_emit { + return; + } + + let arguments = self.invocation_trace.arguments(); + let tool_start = RuntimeAgentEvent::ToolStart { + tool_name: "Skill".to_string(), + tool_id: self.invocation_trace.tool_id(), + arguments: Some(arguments.to_string()), + }; + self.emit_runtime_event_directly(event_name, &tool_start); + } + + fn start_skill_invocation_item(&self, event_name: &str) { + let should_record = { + let mut started_guard = match self.invocation_item_started.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if *started_guard { + false + } else { + *started_guard = true; + true + } + }; + if !should_record { + return; + } + + self.record_skill_invocation_item( + event_name, + AgentThreadItemStatus::InProgress, + None, + None, + ); + } + + fn start_skill_invocation(&self, event_name: &str) { + self.emit_skill_invocation_start(event_name); + self.start_skill_invocation_item(event_name); + } + + fn finish_skill_invocation( + &self, + event_name: &str, + success: bool, + error_message: Option<&str>, + ) { + let started = match self.invocation_item_started.lock() { + Ok(guard) => *guard, + Err(error) => *error.into_inner(), + }; + if !started { + self.start_skill_invocation(event_name); + } + + let output = if success { + Some(self.invocation_trace.success_output()) + } else { + None + }; + let error = if success { + None + } else { + Some( + error_message + .unwrap_or("Skill 执行失败,未返回可用结果。") + .to_string(), + ) + }; + self.record_skill_invocation_item( + event_name, + if success { + AgentThreadItemStatus::Completed + } else { + AgentThreadItemStatus::Failed + }, + output.clone(), + error.clone(), + ); + + let tool_end = RuntimeAgentEvent::ToolEnd { + tool_id: self.invocation_trace.tool_id(), + result: AgentToolResult { + success, + output: output.unwrap_or_default(), + error, + images: None, + metadata: Some( + serde_json::from_value(self.invocation_trace.metadata()).unwrap_or_default(), + ), + }, + }; + self.emit_runtime_event_directly(event_name, &tool_end); + } + + fn build_skill_invocation_payload( + &self, + status: AgentThreadItemStatus, + output: Option, + error: Option, + ) -> AgentThreadItemPayload { + let success = match status { + AgentThreadItemStatus::Completed => Some(true), + AgentThreadItemStatus::Failed => Some(false), + AgentThreadItemStatus::InProgress => None, + }; + let visible_output = if matches!(status, AgentThreadItemStatus::InProgress) { + Some(self.invocation_trace.running_output()) + } else { + output + }; + + AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(self.invocation_trace.arguments()), + output: visible_output, + success, + error, + metadata: Some(self.invocation_trace.metadata()), + } + } + + fn record_skill_invocation_item( + &self, + event_name: &str, + status: AgentThreadItemStatus, + output: Option, + error: Option, + ) { + let completed_at = if matches!(status, AgentThreadItemStatus::InProgress) { + None + } else { + Some(chrono::Utc::now().to_rfc3339()) + }; + let payload = self.build_skill_invocation_payload(status.clone(), output, error); + let mut recorder_guard = match self.recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + let Some(recorder) = recorder_guard.as_mut() else { + return; + }; + if let Err(error) = recorder.record_synthetic_item( + &self.app_handle, + event_name, + self.invocation_trace.tool_id(), + status, + completed_at, + payload, + ) { + tracing::warn!("[execute_skill] 记录 Skill invocation item 失败,已降级继续: {error}"); + } + } + + fn record_runtime_event(&self, event_name: &str, event: &RuntimeAgentEvent) -> bool { + match event { + RuntimeAgentEvent::TurnStarted { turn } => { + let mut recorder_guard = match self.recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Some(recorder) = recorder_guard.as_mut() { + if recorder.turn_id() != turn.id { + match recorder.complete_turn_success() { + Ok(events) => { + for event in events { + self.emit_runtime_event_directly(event_name, &event); + } + } + Err(error) => { + tracing::warn!( + "[execute_skill] 结束上一段 Skill runtime turn 失败,已降级继续: {}", + error + ); + } + } + } + } + match AgentTimelineRecorder::from_started_turn(self.db.clone(), turn.clone()) { + Ok(recorder) => { + *recorder_guard = Some(recorder); + drop(recorder_guard); + self.emit_skill_invocation_start(event_name); + } + Err(error) => { + tracing::warn!( + "[execute_skill] 创建 Skill runtime timeline 失败,已降级继续: {}", + error + ); + } + } + false + } + RuntimeAgentEvent::ItemStarted { .. } + | RuntimeAgentEvent::ItemUpdated { .. } + | RuntimeAgentEvent::ItemCompleted { .. } => { + self.record_with_existing_recorder(event_name, event) + } + RuntimeAgentEvent::ArtifactSnapshot { .. } + | RuntimeAgentEvent::ContextCompactionStarted { .. } + | RuntimeAgentEvent::ContextCompactionCompleted { .. } + | RuntimeAgentEvent::Warning { .. } + | RuntimeAgentEvent::Error { .. } => { + self.record_with_existing_recorder(event_name, event); + false + } + _ => false, + } + } + + fn record_with_existing_recorder(&self, event_name: &str, event: &RuntimeAgentEvent) -> bool { + let should_record_skill_item_after_event = matches!( + event, + RuntimeAgentEvent::ItemStarted { item } + | RuntimeAgentEvent::ItemUpdated { item } + | RuntimeAgentEvent::ItemCompleted { item } + if matches!(item.payload, AgentThreadItemPayload::UserMessage { .. }) + ); + + let mut recorder_guard = match self.recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + let Some(recorder) = recorder_guard.as_mut() else { + return false; + }; + if let Err(error) = recorder.record_runtime_event(&self.app_handle, event_name, event, "") { + tracing::warn!( + "[execute_skill] 记录 Skill runtime timeline 事件失败,已降级继续: {}", + error + ); + return false; + } + drop(recorder_guard); + if should_record_skill_item_after_event { + self.start_skill_invocation_item(event_name); + } + true + } +} + +fn create_skill_event_bridge( + app_handle: &AppHandle, + db: &DbConnection, + skill: &LoadedSkillDefinition, + execution_id: &str, +) -> SkillRuntimeEventBridge { + SkillRuntimeEventBridge::new(app_handle, db, skill, execution_id) } fn map_execution_error(error: SkillExecutionError) -> String { @@ -396,6 +836,7 @@ pub async fn execute_named_skill( execute_skill_definition( &app_handle, + &db, &aster_state, &skill, &user_input_for_run, @@ -428,6 +869,7 @@ pub async fn execute_named_skill( pub async fn execute_skill_prompt( app_handle: &AppHandle, + db: &DbConnection, aster_state: &AsterAgentState, skill: &LoadedSkillDefinition, user_input: &str, @@ -440,6 +882,8 @@ pub async fn execute_skill_prompt( ) -> Result { let callback_adapter = TauriExecutionCallbackAdapter::new(callback); callback_adapter.on_step_start("main", &skill.display_name, 1, 1); + let event_bridge = create_skill_event_bridge(app_handle, db, skill, execution_id); + let event_name = format!("skill-exec-{execution_id}"); let mut result = map_execution_result( execute_agent_skill_prompt(SkillPromptExecution { @@ -451,7 +895,7 @@ pub async fn execute_skill_prompt( execution_id, session_id, memory_prompt, - emitter: create_skill_event_emitter(app_handle), + emitter: event_bridge.emitter(), }) .await .map_err(map_execution_error)?, @@ -464,7 +908,8 @@ pub async fn execute_skill_prompt( .unwrap_or_else(|| format_skill_error(SKILL_ERR_EXECUTE_FAILED, "Unknown error")); callback_adapter.on_step_error("main", &error_message, false); callback_adapter.on_complete(false, None, Some(&error_message)); - emit_skill_final_done(app_handle, execution_id); + event_bridge.finish_current_turn(&event_name, false, Some(&error_message)); + event_bridge.emit_final_done(execution_id); return Ok(result); } @@ -483,12 +928,14 @@ pub async fn execute_skill_prompt( callback_adapter.on_step_complete("main", &finalized.final_output); callback_adapter.on_complete(true, Some(&finalized.final_output), None); - emit_skill_final_done(app_handle, execution_id); + event_bridge.finish_current_turn(&event_name, true, None); + event_bridge.emit_final_done(execution_id); Ok(result) } pub async fn execute_skill_workflow( app_handle: &AppHandle, + db: &DbConnection, aster_state: &AsterAgentState, skill: &LoadedSkillDefinition, user_input: &str, @@ -500,7 +947,9 @@ pub async fn execute_skill_workflow( memory_prompt: Option<&str>, ) -> Result { let callback_adapter = TauriExecutionCallbackAdapter::new(callback); - execute_agent_skill_workflow(SkillWorkflowExecution { + let event_bridge = create_skill_event_bridge(app_handle, db, skill, execution_id); + let event_name = format!("skill-exec-{execution_id}"); + let result = execute_agent_skill_workflow(SkillWorkflowExecution { aster_state, skill, user_input, @@ -510,15 +959,19 @@ pub async fn execute_skill_workflow( session_id, callback: &callback_adapter, memory_prompt, - emitter: create_skill_event_emitter(app_handle), + emitter: event_bridge.emitter(), }) .await .map(map_execution_result) - .map_err(map_execution_error) + .map_err(map_execution_error)?; + + event_bridge.finish_current_turn(&event_name, result.success, result.error.as_deref()); + Ok(result) } pub async fn execute_skill_definition( app_handle: &AppHandle, + db: &DbConnection, aster_state: &AsterAgentState, skill: &LoadedSkillDefinition, user_input: &str, @@ -532,6 +985,7 @@ pub async fn execute_skill_definition( if skill.execution_mode == "workflow" && !skill.workflow_steps.is_empty() { execute_skill_workflow( app_handle, + db, aster_state, skill, user_input, @@ -546,6 +1000,7 @@ pub async fn execute_skill_definition( } else { execute_skill_prompt( app_handle, + db, aster_state, skill, user_input, @@ -560,4 +1015,69 @@ pub async fn execute_skill_definition( } } +#[cfg(test)] +mod tests { + use super::*; + use lime_core::models::SkillStandardCompliance; + use std::collections::HashMap; + + fn test_skill(markdown_content: &str) -> LoadedSkillDefinition { + let mut metadata = HashMap::new(); + metadata.insert("lime_version".to_string(), "1.0.1".to_string()); + + LoadedSkillDefinition { + skill_name: "analysis".to_string(), + display_name: "analysis".to_string(), + description: "分析任务".to_string(), + markdown_content: markdown_content.to_string(), + license: None, + compatibility: None, + metadata, + allowed_tools: None, + argument_hint: None, + when_to_use: None, + when_to_use_config: None, + model: None, + provider: None, + disable_model_invocation: false, + execution_mode: "prompt".to_string(), + workflow_ref: None, + workflow_steps: Vec::new(), + standard_compliance: SkillStandardCompliance { + is_standard: true, + validation_errors: Vec::new(), + deprecated_fields: Vec::new(), + }, + } + } + + #[test] + fn skill_invocation_trace_metadata_should_include_skill_markdown_snapshot() { + let markdown_content = "---\nname: analysis\n---\n\n# Analysis Skill\n\n必须先读取本文件。"; + let skill = test_skill(markdown_content); + let trace = SkillInvocationTrace::new(&skill, "exec-1"); + + let metadata = trace.metadata(); + + assert_eq!( + metadata + .get("skill_source") + .and_then(serde_json::Value::as_str), + Some("SKILL.md") + ); + assert_eq!( + metadata + .get("skill_markdown_content") + .and_then(serde_json::Value::as_str), + Some(markdown_content) + ); + assert_eq!( + metadata + .get("markdown_content_bytes") + .and_then(serde_json::Value::as_u64), + Some(markdown_content.len() as u64) + ); + } +} + pub use lime_agent::{SkillExecutionResult, StepResult}; diff --git a/src-tauri/src/skills/runtime.rs b/src-tauri/src/skills/runtime.rs index 121445065..a3f4ed607 100644 --- a/src-tauri/src/skills/runtime.rs +++ b/src-tauri/src/skills/runtime.rs @@ -109,6 +109,7 @@ fn build_skill_workspace_permissions( surface: WorkspaceToolSurface::workbench(), workspace_root, auto_mode: false, + bypass_restrictions: false, execution_policy_input: ToolExecutionResolverInput { persisted_policy: Some(&config.agent.tool_execution), request_metadata: metadata.as_ref(), diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index a3b38636d..3df13ca87 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": "1.38.0", + "version": "1.39.0", "identifier": "com.limecloud.lime.headless", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c9cde7f04..b3039a8c3 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": "1.38.0", + "version": "1.39.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", diff --git a/src/components/AppPageContent.test.tsx b/src/components/AppPageContent.test.tsx index 65739ade1..a44cd9de9 100644 --- a/src/components/AppPageContent.test.tsx +++ b/src/components/AppPageContent.test.tsx @@ -36,6 +36,12 @@ const latestKnowledgePageProps = vi.hoisted( value: null as Record | null, }) as { value: Record | null }, ); +const agentAppLabLifecycle = vi.hoisted( + () => + ({ + mounts: 0, + }) as { mounts: number }, +); const latestSceneAppsPageProps = vi.hoisted( () => ({ @@ -74,6 +80,10 @@ vi.mock("./settings-v2", () => ({ SettingsPageV2: () =>
, })); +vi.mock("./automation", () => ({ + AutomationPage: () =>
, +})); + vi.mock("./skills", () => ({ SkillsWorkspacePage: (props: Record) => { latestSkillsWorkspaceProps.value = props; @@ -95,6 +105,16 @@ vi.mock("@/features/knowledge", () => ({ }, })); +vi.mock("@/features/agent-app", () => ({ + AgentAppLabPage: () => { + useEffect(() => { + agentAppLabLifecycle.mounts += 1; + }, []); + + return
; + }, +})); + vi.mock("./sceneapps", () => ({ SceneAppsPage: (props: Record) => { latestSceneAppsPageProps.value = props; @@ -194,6 +214,7 @@ describe("AppPageContent", () => { latestSkillsWorkspaceProps.value = null; latestMemoryPageProps.value = null; latestKnowledgePageProps.value = null; + agentAppLabLifecycle.mounts = 0; latestSceneAppsPageProps.value = null; sceneAppsLifecycle.mounts = 0; sceneAppsLifecycle.unmounts = 0; @@ -715,6 +736,16 @@ describe("AppPageContent", () => { }); }); + it("agent-app-lab 页面应渲染 P0 只读实验入口", async () => { + const { container } = renderContent("agent-app-lab"); + await flushEffects(); + + expect( + container.querySelector('[data-testid="agent-app-lab-page"]'), + ).not.toBeNull(); + expect(agentAppLabLifecycle.mounts).toBe(1); + }); + it("sceneapps 页面在持续流程往返后不应重新挂载", async () => { const { rerender } = renderContent("sceneapps", { sceneappId: "story-video-suite", diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index b52d55d20..0911a2cca 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -64,6 +64,10 @@ const loadKnowledgePage = () => import("@/features/knowledge").then((module) => ({ default: module.KnowledgePage, })); +const loadAgentAppLabPage = () => + import("@/features/agent-app").then((module) => ({ + default: module.AgentAppLabPage, + })); const loadBrowserRuntimeWorkspace = () => import("@/features/browser-runtime").then((module) => ({ default: module.BrowserRuntimeWorkspace, @@ -122,6 +126,7 @@ const ResourcesPage = lazy(loadResourcesPage); const MemoryPage = lazy(loadMemoryPage); const SkillsWorkspacePage = lazy(loadSkillsWorkspacePage); const KnowledgePage = lazy(loadKnowledgePage); +const AgentAppLabPage = lazy(loadAgentAppLabPage); const BrowserRuntimeWorkspace = lazy(loadBrowserRuntimeWorkspace); const AgentChatPage = lazy(loadAgentChatPage); @@ -398,6 +403,16 @@ export function AppPageContent({ return wrapWithSceneApps(content); } + if (activePage === "agent-app-lab") { + const content = ( +
+ +
+ ); + + return wrapWithSceneApps(content); + } + if (activePage === "knowledge") { const content = (
diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index e5b140f87..ddf0e7ce3 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -182,6 +182,7 @@ const SIDEBAR_CONVERSATION_NAVIGATION_DEFER_MS = SIDEBAR_SESSION_ENTRY_REFRESH_DEFER_MS; const SIDEBAR_SEARCH_HOVER_PREFETCH_DELAY_MS = 900; const SIDEBAR_NAV_LABEL_KEYS: Record = { + "agent-app-lab": "navigation.sidebar.items.agentAppLab", automation: "navigation.sidebar.items.automation", channels: "navigation.sidebar.items.channels", companion: "navigation.sidebar.items.companion", diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 4d0c050c6..8dd1e8f50 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -26,6 +26,7 @@ import { settleLiveArtifactAfterStreamStops, useArtifactDisplayState, } from "./hooks/useArtifactDisplayState"; +import { clearAgentSessionCachedSnapshot } from "./hooks/agentSessionScopedStorage"; import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard"; import { useSessionFiles } from "./hooks/useSessionFiles"; import { useContentSync } from "./hooks/useContentSync"; @@ -237,6 +238,7 @@ import { subscribeTaskCenterDraftTaskRequests, subscribeTaskCenterTaskPrefetchRequests, subscribeTaskCenterTaskOpenRequests, + type TaskCenterTaskEventSource, } from "./taskCenterDraftTaskEvents"; import type { GeneralWorkbenchFollowUpActionPayload } from "./components/generalWorkbenchSidebarContract"; import { RuntimeReviewDecisionDialog } from "./components/RuntimeReviewDecisionDialog"; @@ -289,6 +291,9 @@ import { loadSessionImageWorkbenchCachedState, saveSessionImageWorkbenchCachedState, } from "./workspace/imageWorkbenchStateCache"; +import { resolveImageWorkbenchStateForPreviewSelection } from "./workspace/imageWorkbenchPreviewSelection"; +import { buildImageWorkbenchPreviewResourceManagerInput } from "./workspace/imageWorkbenchResourceManager"; +import { openResourceManager } from "@/features/resource-manager"; import { SOCIAL_ARTICLE_SKILL_KEY, GENERAL_WORKBENCH_HISTORY_PAGE_SIZE, @@ -384,6 +389,16 @@ const TASK_CENTER_DRAFT_SESSION_WARMUP_DELAY_MS = 120; const NOOP_SET_CHAT_MESSAGES: Dispatch> = () => undefined; +function shouldForceRefreshTaskOpenSource( + source?: TaskCenterTaskEventSource, +): boolean { + return ( + source === "sidebar" || + source === "sidebar_search" || + source === "conversation_shelf" + ); +} + function loadFileManagerSidebarOpen(): boolean { return false; } @@ -2341,6 +2356,67 @@ export function AgentChatWorkspace({ : undefined, getSyncedSessionRecentPreferences, }); + const activeSessionKey = sessionId?.trim() || null; + const restoredInteractiveMessageSnapshotRef = useRef<{ + sessionId: string | null; + ids: Set; + capturedInitial: boolean; + pendingRestoreCapture: boolean; + }>({ + sessionId: null, + ids: new Set(), + capturedInitial: false, + pendingRestoreCapture: false, + }); + const readOnlyInteractiveMessageIds = useMemo>(() => { + const snapshot = restoredInteractiveMessageSnapshotRef.current; + if (!activeSessionKey) { + snapshot.sessionId = null; + snapshot.ids = new Set(); + snapshot.capturedInitial = false; + snapshot.pendingRestoreCapture = false; + return snapshot.ids; + } + + if (snapshot.sessionId !== activeSessionKey) { + snapshot.sessionId = activeSessionKey; + snapshot.ids = new Set(); + snapshot.capturedInitial = false; + snapshot.pendingRestoreCapture = false; + } + + if (isAutoRestoringSession || isSessionHydrating) { + snapshot.pendingRestoreCapture = true; + } + + const shouldCaptureInitialSessionMessages = + normalizedInitialSessionId === activeSessionKey && + !snapshot.capturedInitial && + messages.length > 0; + const shouldCaptureRestoredMessages = + snapshot.pendingRestoreCapture || + shouldCaptureInitialSessionMessages || + sessionHistoryWindow?.isLoadingFull === true; + let didCaptureRestoredMessages = false; + + if (shouldCaptureRestoredMessages && messages.length > 0) { + for (const message of messages) { + snapshot.ids.add(message.id); + } + snapshot.capturedInitial = true; + snapshot.pendingRestoreCapture = false; + didCaptureRestoredMessages = true; + } + + return didCaptureRestoredMessages ? new Set(snapshot.ids) : snapshot.ids; + }, [ + activeSessionKey, + isAutoRestoringSession, + isSessionHydrating, + messages, + normalizedInitialSessionId, + sessionHistoryWindow?.isLoadingFull, + ]); const topicById = useMemo( () => new Map(topics.map((topic) => [topic.id, topic])), [topics], @@ -3237,6 +3313,7 @@ export function AgentChatWorkspace({ resolvePendingA2UISubmit, } = useWorkspaceA2UIRuntime({ messages, + readOnlyInteractiveMessageIds, }); const pendingServiceSkillLaunchForm = workspaceServiceSkillEntryActions.pendingServiceSkillLaunchForm; @@ -3867,7 +3944,7 @@ export function AgentChatWorkspace({ const topic = topicById.get(topicId); return { allowDetachedSession: true, - forceRefresh: topic?.statusReason === "workspace_error", + forceRefresh: true, ...(shouldResumeTaskSession(topic) ? { resumeSessionStartHooks: true } : {}), @@ -3931,6 +4008,7 @@ export function AgentChatWorkspace({ const taskCenterDraftMaterializedSessionIdsRef = useRef>( new Map(), ); + const taskCenterDraftSurfaceActiveRef = useRef(false); const homePendingPreviewPaintedRequestIdsRef = useRef>(new Set()); const [taskCenterLocalSessionOverride, setTaskCenterLocalSessionOverride] = useState<{ @@ -3976,6 +4054,7 @@ export function AgentChatWorkspace({ useEffect(() => { if (agentEntry !== "claw") { + taskCenterDraftSurfaceActiveRef.current = false; setTaskCenterTransitionTopicId(null); return; } @@ -3996,6 +4075,7 @@ export function AgentChatWorkspace({ ); setTaskCenterDraftTabs((current) => (current.length > 0 ? [] : current)); setActiveTaskCenterDraftTabId(null); + taskCenterDraftSurfaceActiveRef.current = false; if (agentEntry !== "new-task") { setTaskCenterDraftSendRequest(null); setHomePendingPreviewRequest(null); @@ -4227,6 +4307,7 @@ export function AgentChatWorkspace({ prepareActiveContextPrompt: contextWorkspace.prepareActiveContextPrompt, }, projectId, + projectRootPath: project?.rootPath || null, sessionId, executionStrategy, accessMode, @@ -4738,6 +4819,7 @@ export function AgentChatWorkspace({ status: "draft", }; + taskCenterDraftSurfaceActiveRef.current = true; resetLocalImageWorkbenchSessionScope(); clearMessages({ showToast: false }); startTransition(() => { @@ -4780,6 +4862,7 @@ export function AgentChatWorkspace({ newSessionId: string, options?: { preserveInput?: boolean }, ) => { + taskCenterDraftSurfaceActiveRef.current = false; startTransition(() => { setTaskCenterDraftTabs((current) => current.filter((tab) => tab.id !== draftTabId), @@ -5012,11 +5095,17 @@ export function AgentChatWorkspace({ ); }; + taskCenterDraftSurfaceActiveRef.current = false; resetLocalImageWorkbenchSessionScope(); setTaskCenterTransitionTopicId(topicId); setTaskCenterDetachedTopicId(null); setActiveTaskCenterDraftTabId(null); clearEntryPendingA2UI(); + setChatMessages([]); + const targetSnapshotWorkspaceId = topicWorkspaceId ?? taskCenterWorkspaceId; + if (targetSnapshotWorkspaceId) { + clearAgentSessionCachedSnapshot(targetSnapshotWorkspaceId, topicId); + } if (options?.replaceOpenTabs === true) { replaceTaskCenterOpenTabs(topicId, topicWorkspaceId); } else if (shouldMaintainTaskCenterTab) { @@ -5064,6 +5153,7 @@ export function AgentChatWorkspace({ replaceTaskCenterOpenTabs, resetLocalImageWorkbenchSessionScope, setActiveTaskCenterDraftTabId, + setChatMessages, switchTopic, taskCenterWorkspaceId, setTaskCenterDetachedTopicId, @@ -5075,11 +5165,16 @@ export function AgentChatWorkspace({ const handleOpenArchivedTaskTopic = useCallback( async (topicId: string) => { + taskCenterDraftSurfaceActiveRef.current = false; resetLocalImageWorkbenchSessionScope(); setActiveTaskCenterDraftTabId(null); setTaskCenterDetachedTopicId(topicId); setTaskCenterTransitionTopicId(topicId); clearEntryPendingA2UI(); + setChatMessages([]); + if (taskCenterWorkspaceId) { + clearAgentSessionCachedSnapshot(taskCenterWorkspaceId, topicId); + } markTaskCenterLocalSessionOverride(topicId); rememberInitialSessionNavigationStart(topicId); const switchResult = await switchTopic(topicId, { @@ -5103,7 +5198,9 @@ export function AgentChatWorkspace({ clearEntryPendingA2UI, markTaskCenterLocalSessionOverride, resetLocalImageWorkbenchSessionScope, + setChatMessages, switchTopic, + taskCenterWorkspaceId, ], ); @@ -5116,6 +5213,7 @@ export function AgentChatWorkspace({ return; } + taskCenterDraftSurfaceActiveRef.current = true; resetLocalImageWorkbenchSessionScope(); clearMessages({ showToast: false }); startTransition(() => { @@ -5154,7 +5252,7 @@ export function AgentChatWorkspace({ const handleOpenSidebarTaskTopic = useCallback( async (topicId: string) => { - await handleOpenTaskTopic(topicId); + await handleOpenTaskTopic(topicId, { forceRefresh: true }); }, [handleOpenTaskTopic], ); @@ -5301,8 +5399,10 @@ export function AgentChatWorkspace({ } return subscribeTaskCenterTaskOpenRequests( - ({ sessionId: requestedSessionId, workspaceId }) => { + ({ sessionId: requestedSessionId, source, workspaceId }) => { const requestedWorkspaceId = normalizeProjectId(workspaceId); + const shouldForceRefresh = + shouldForceRefreshTaskOpenSource(source); if ( requestedWorkspaceId && requestedWorkspaceId !== normalizeProjectId(taskCenterWorkspaceId) @@ -5312,11 +5412,18 @@ export function AgentChatWorkspace({ setActiveTaskCenterDraftTabId(null); markTaskCenterLocalSessionOverride(requestedSessionId); upsertTaskCenterOpenTab(requestedSessionId, requestedWorkspaceId); - deferTopicSwitch(requestedSessionId, requestedWorkspaceId); + deferTopicSwitch( + requestedSessionId, + requestedWorkspaceId, + shouldForceRefresh ? { forceRefresh: true } : undefined, + ); return; } - void handleOpenTaskTopic(requestedSessionId); + void handleOpenTaskTopic( + requestedSessionId, + shouldForceRefresh ? { forceRefresh: true } : undefined, + ); }, ); }, [ @@ -5469,6 +5576,8 @@ export function AgentChatWorkspace({ !taskCenterWorkspaceId || isAutoRestoringSession || isSessionHydrating || + taskCenterDraftSurfaceActiveRef.current || + isTaskCenterDraftTabActive || initialPendingServiceSkillLaunchSignature ) { return; @@ -5543,6 +5652,7 @@ export function AgentChatWorkspace({ initialPendingServiceSkillLaunchSignature, isAutoRestoringSession, isSessionHydrating, + isTaskCenterDraftTabActive, normalizedInitialSessionId, sessionId, shouldHideDetachedTaskCenterTabs, @@ -5855,15 +5965,27 @@ export function AgentChatWorkspace({ const handleOpenMessagePreview = useCallback( (target: MessagePreviewTarget, message: Message) => { if (target.kind === "image_workbench") { - updateCurrentImageWorkbenchState((current) => { - if (current.active) { - return current; - } - return { - ...current, - active: true, - }; - }); + const resourceManagerInput = + buildImageWorkbenchPreviewResourceManagerInput({ + message, + preview: target.preview, + selection: target.selection, + threadId: sessionId ?? null, + }); + + if (resourceManagerInput) { + void openResourceManager(resourceManagerInput); + return; + } + + updateCurrentImageWorkbenchState((current) => + resolveImageWorkbenchStateForPreviewSelection({ + current, + messages, + preview: target.preview, + selection: target.selection, + }), + ); openCanvasForReason("user_open_message_preview", setLayoutMode); return; } @@ -5917,6 +6039,8 @@ export function AgentChatWorkspace({ [ handleWorkspaceArtifactClick, handleWorkspaceFileClick, + messages, + sessionId, setCanvasState, setLayoutMode, taskFiles, @@ -7801,7 +7925,11 @@ export function AgentChatWorkspace({ sendOptions?: HandleSendOptions, ) => { const normalizedText = text.trim(); - const activeDraftTabId = activeTaskCenterDraftTabIdRef.current; + const activeDraftTabId = + activeTaskCenterDraftTabIdRef.current || + (agentEntry === "claw" && shouldRenderTaskCenterEmbeddedHome + ? openTaskCenterDraftTab() + : null); if (agentEntry === "claw" && activeDraftTabId) { const submittedAt = Date.now(); const requestId = createTaskCenterDraftSendRequestId(); @@ -7923,7 +8051,9 @@ export function AgentChatWorkspace({ effectiveThreadItems.length, handleSend, hasDisplayMessages, + openTaskCenterDraftTab, sessionId, + shouldRenderTaskCenterEmbeddedHome, taskCenterWorkspaceId, turns.length, ], diff --git a/src/components/agent/chat/components/A2UITaskCard.tsx b/src/components/agent/chat/components/A2UITaskCard.tsx index 1c3cc6e0a..83d74714e 100644 --- a/src/components/agent/chat/components/A2UITaskCard.tsx +++ b/src/components/agent/chat/components/A2UITaskCard.tsx @@ -61,9 +61,11 @@ function getRendererClassName( surface: A2UITaskCardSurface, ): string { return cn( - compact ? "space-y-3" : "space-y-4", + compact + ? "space-y-2.5 text-[13px] leading-5" + : "space-y-3 text-sm leading-6", surface === "embedded" && - "space-y-2.5 text-[13px] leading-5 [&_.a2ui-text-h3]:text-[15px] [&_.a2ui-text-h3]:leading-6 [&_.a2ui-text-h3]:font-semibold [&_.a2ui-text-h4]:text-[13px] [&_.a2ui-text-h4]:leading-5 [&_.a2ui-text-h4]:font-medium [&_.a2ui-text-body]:text-[13px] [&_.a2ui-text-body]:leading-5 [&_.a2ui-text-body]:text-slate-700 [&_.a2ui-field-stack]:space-y-1.5 [&_.a2ui-field-label]:text-[12px] [&_.a2ui-field-label]:leading-5 [&_.a2ui-helper-text]:text-[11px] [&_.a2ui-helper-text]:leading-4 [&_.a2ui-option-list]:gap-2.5 [&_.a2ui-choice-option]:rounded-[16px] [&_.a2ui-choice-option]:px-3.5 [&_.a2ui-choice-option]:py-3 [&_.a2ui-choice-option]:text-[13px] [&_.a2ui-choice-option-title]:text-[13px] [&_.a2ui-choice-option-title]:leading-5 [&_.a2ui-option-description]:mt-1 [&_.a2ui-option-description]:text-[11px] [&_.a2ui-option-description]:leading-4 [&_.a2ui-text-input]:h-10 [&_.a2ui-text-input]:rounded-[16px] [&_.a2ui-text-input]:px-3 [&_.a2ui-text-input]:text-[13px] [&_.a2ui-textarea]:min-h-[84px] [&_.a2ui-textarea]:rounded-[16px] [&_.a2ui-textarea]:px-3 [&_.a2ui-textarea]:py-2.5 [&_.a2ui-textarea]:text-[13px] [&_.a2ui-textarea]:leading-5 [&_.a2ui-card-shell]:rounded-[16px] [&_.a2ui-card-shell]:border-slate-200/90 [&_.a2ui-card-shell]:bg-white [&_.a2ui-card-shell]:p-3 [&_.a2ui-card-shell]:shadow-none", + "space-y-2.5 text-[13px] leading-5 [&_.a2ui-text-h3]:text-[15px] [&_.a2ui-text-h3]:leading-6 [&_.a2ui-text-h3]:font-semibold [&_.a2ui-text-h4]:text-[13px] [&_.a2ui-text-h4]:leading-5 [&_.a2ui-text-h4]:font-medium [&_.a2ui-text-body]:text-[13px] [&_.a2ui-text-body]:leading-5 [&_.a2ui-field-stack]:space-y-1.5 [&_.a2ui-field-label]:text-[12px] [&_.a2ui-field-label]:leading-5 [&_.a2ui-helper-text]:text-[11px] [&_.a2ui-helper-text]:leading-4 [&_.a2ui-option-list]:gap-2 [&_.a2ui-choice-option]:rounded-[14px] [&_.a2ui-choice-option]:px-3 [&_.a2ui-choice-option]:py-2.5 [&_.a2ui-choice-option]:text-[13px] [&_.a2ui-choice-option-title]:text-[13px] [&_.a2ui-choice-option-title]:leading-5 [&_.a2ui-option-description]:mt-1 [&_.a2ui-option-description]:text-[11px] [&_.a2ui-option-description]:leading-4 [&_.a2ui-text-input]:h-9 [&_.a2ui-text-input]:rounded-[14px] [&_.a2ui-text-input]:px-3 [&_.a2ui-text-input]:text-[13px] [&_.a2ui-textarea]:min-h-[76px] [&_.a2ui-textarea]:rounded-[14px] [&_.a2ui-textarea]:px-3 [&_.a2ui-textarea]:py-2.5 [&_.a2ui-textarea]:text-[13px] [&_.a2ui-textarea]:leading-5 [&_.a2ui-card-shell]:rounded-[14px] [&_.a2ui-card-shell]:p-2.5 [&_.a2ui-card-shell]:shadow-none", ); } @@ -124,7 +126,9 @@ export function A2UITaskCard({ {footerText ? ( -
{footerText}
+
+ {footerText} +
) : null} ); diff --git a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx index 00fba9602..83d5066bc 100644 --- a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx +++ b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx @@ -47,9 +47,9 @@ import { buildSceneAppExecutionReviewPrefillHighlights, buildSceneAppExecutionReviewPrefillSnapshot, } from "@/components/agent/chat/utils/sceneAppCuratedTaskReference"; -import type agentResource from "@/i18n/resources/zh-CN/agent.json"; +import type { AgentI18nResource } from "@/i18n/agentResources"; -type AgentI18nKey = keyof typeof agentResource; +type AgentI18nKey = keyof AgentI18nResource; interface CuratedTaskLauncherDialogProps { open: boolean; diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 31d967c3a..3d0a9c7e1 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -79,7 +79,7 @@ import { type InputCapabilitySelection, } from "../skill-selection/inputCapabilitySelection"; import type { AgentInitialInputCapabilityParams } from "@/types/page"; -import type agentResource from "@/i18n/resources/zh-CN/agent.json"; +import type { AgentI18nResource } from "@/i18n/agentResources"; import type { InputbarKnowledgePackOption, InputbarKnowledgePackSelection, @@ -114,13 +114,14 @@ import { } from "../home/buildHomeSkillSurface"; import { buildHomeSurfaceCopy } from "../home/homeSurfaceCopy"; import { buildInputbarCoreCopy } from "./Inputbar/components/inputbarCoreCopy"; +import { buildInputbarExecutionStrategyCopy } from "./Inputbar/inputbarWorkflowCopy"; import type { HomeGuideCard, HomeSkillSurfaceItem, HomeStarterChip, } from "../home/homeSurfaceTypes"; -type AgentI18nKey = keyof typeof agentResource; +type AgentI18nKey = keyof AgentI18nResource; const contentReveal = keyframes` from { @@ -541,6 +542,10 @@ export const EmptyState: React.FC = ({ () => buildInputbarCoreCopy(translateAgentCopyKey), [translateAgentCopyKey], ); + const executionStrategyCopy = useMemo( + () => buildInputbarExecutionStrategyCopy(translateAgentCopyKey), + [translateAgentCopyKey], + ); const pageContainerRef = useRef(null); const handledInitialInputCapabilitySignatureRef = useRef(""); const [activeCapability, setActiveCapability] = @@ -1620,6 +1625,7 @@ export const EmptyState: React.FC = ({ onManageKnowledgePacks={onManageKnowledgePacks} copy={homeSurfaceCopy.composer} inputbarCopy={inputbarCoreCopy} + executionStrategyCopy={executionStrategyCopy} showCreationModeSelector={showCreationModeSelector} creationMode={creationMode} onCreationModeChange={onCreationModeChange} diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index ade4c7a33..e00b0c25f 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -7,8 +7,7 @@ import { createSkillSelectionProps, type SkillSelectionProps, } from "../skill-selection/skillSelectionBindings"; -import agentResource from "@/i18n/resources/zh-CN/agent.json"; -import enAgentResource from "@/i18n/resources/en-US/agent.json"; +import { agentEnUSResource, agentZhCNResource } from "@/i18n/agentResources"; import { buildHomeSurfaceCopy, type HomeSurfaceCopyKey, @@ -17,6 +16,10 @@ import { buildInputbarCoreCopy, type InputbarCoreCopyKey, } from "./Inputbar/components/inputbarCoreCopy"; +import { + buildInputbarExecutionStrategyCopy, + type InputbarWorkflowCopyKey, +} from "./Inputbar/inputbarWorkflowCopy"; import { changeLimeLocale } from "@/i18n/createI18n"; vi.mock("./ChatModelSelector", () => ({ @@ -57,31 +60,43 @@ vi.mock("./Inputbar/components/TeamSelector", () => ({ })); function translateResource( - resource: Partial>, - key: HomeSurfaceCopyKey | InputbarCoreCopyKey, + resource: Partial< + Record< + HomeSurfaceCopyKey | InputbarCoreCopyKey | InputbarWorkflowCopyKey, + string + > + >, + key: HomeSurfaceCopyKey | InputbarCoreCopyKey | InputbarWorkflowCopyKey, values?: Record, ) { return Object.entries(values ?? {}).reduce( - (text, [name, value]) => - text.split(`{{${name}}}`).join(String(value)), + (text, [name, value]) => text.split(`{{${name}}}`).join(String(value)), resource[key] ?? key, ); } const TEST_COMPOSER_COPY = buildHomeSurfaceCopy((key, values) => - translateResource(agentResource, key, values), + translateResource(agentZhCNResource, key, values), ).composer; const TEST_EN_COMPOSER_COPY = buildHomeSurfaceCopy((key, values) => - translateResource(enAgentResource, key, values), + translateResource(agentEnUSResource, key, values), ).composer; const TEST_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) => - translateResource(agentResource, key, values), + translateResource(agentZhCNResource, key, values), ); const TEST_EN_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) => - translateResource(enAgentResource, key, values), + translateResource(agentEnUSResource, key, values), +); + +const TEST_EXECUTION_STRATEGY_COPY = buildInputbarExecutionStrategyCopy( + (key, values) => translateResource(agentZhCNResource, key, values), +); + +const TEST_EN_EXECUTION_STRATEGY_COPY = buildInputbarExecutionStrategyCopy( + (key, values) => translateResource(agentEnUSResource, key, values), ); const mockSelectedTeam = { @@ -204,6 +219,7 @@ function renderPanel( skillSelection: createSkillSelection(), copy: TEST_COMPOSER_COPY, inputbarCopy: TEST_INPUTBAR_CORE_COPY, + executionStrategyCopy: TEST_EXECUTION_STRATEGY_COPY, showCreationModeSelector: false, creationMode: "guided", onCreationModeChange: vi.fn(), @@ -262,6 +278,7 @@ function renderStatefulPanel( skillSelection={createSkillSelection()} copy={TEST_COMPOSER_COPY} inputbarCopy={TEST_INPUTBAR_CORE_COPY} + executionStrategyCopy={TEST_EXECUTION_STRATEGY_COPY} showCreationModeSelector={false} creationMode="guided" onCreationModeChange={vi.fn()} @@ -408,6 +425,7 @@ describe("EmptyStateComposerPanel", () => { const container = renderPanel({ copy: TEST_EN_COMPOSER_COPY, inputbarCopy: TEST_EN_INPUTBAR_CORE_COPY, + executionStrategyCopy: TEST_EN_EXECUTION_STRATEGY_COPY, isGeneralTheme: true, guideHelpActive: true, onClearGuideHelp: vi.fn(), diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx index 5a6e3d96e..50800ce8b 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx @@ -66,6 +66,7 @@ import type { InputbarKnowledgePackSelection, } from "./Inputbar/types"; import type { InputbarCoreCopy } from "./Inputbar/components/inputbarCoreCopy"; +import type { InputbarExecutionStrategyCopy } from "./Inputbar/inputbarWorkflowCopy"; interface EmptyStateComposerPanelProps { input: string; @@ -108,6 +109,7 @@ interface EmptyStateComposerPanelProps { onManageKnowledgePacks?: () => void; copy: HomeSurfaceComposerCopy; inputbarCopy: InputbarCoreCopy; + executionStrategyCopy: InputbarExecutionStrategyCopy; showCreationModeSelector: boolean; creationMode: CreationMode; onCreationModeChange?: (mode: CreationMode) => void; @@ -234,6 +236,7 @@ export function EmptyStateComposerPanel({ onManageKnowledgePacks, copy, inputbarCopy, + executionStrategyCopy, showCreationModeSelector, creationMode, onCreationModeChange, @@ -629,6 +632,7 @@ export function EmptyStateComposerPanel({ ({ })); vi.mock("react-i18next", async () => { - const agentZhCN = (await import("@/i18n/resources/zh-CN/agent.json")) - .default as Record; + const { agentZhCNResource } = await import("@/i18n/agentResources"); + const agentZhCN = agentZhCNResource as Record; const interpolate = (template: string, values: Record) => template.replace(/{{\s*([^}]+?)\s*}}/g, (_, name: string) => { diff --git a/src/components/agent/chat/components/ImageTaskViewer.test.tsx b/src/components/agent/chat/components/ImageTaskViewer.test.tsx index 14c214f5a..9589b9f87 100644 --- a/src/components/agent/chat/components/ImageTaskViewer.test.tsx +++ b/src/components/agent/chat/components/ImageTaskViewer.test.tsx @@ -2,6 +2,7 @@ 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 { changeLimeLocale } from "@/i18n/createI18n"; const { mockOpenResourceManager } = vi.hoisted(() => ({ mockOpenResourceManager: vi.fn(), @@ -49,6 +50,7 @@ function createProps( createdAt: 2, }, ], + selectedTaskId: "task-1", selectedOutputId: "output-1", viewport: { x: 0, y: 0, scale: 1 }, preferenceSummary: null, @@ -89,7 +91,8 @@ function renderComponent( }; } -beforeEach(() => { +beforeEach(async () => { + await changeLimeLocale("zh-CN"); ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -146,7 +149,7 @@ describe("ImageTaskViewer", () => { expect(mockOpenResourceManager).toHaveBeenCalledWith( expect.objectContaining({ - sourceLabel: "Image Generation", + sourceLabel: "图片生成", sourceContext: expect.objectContaining({ kind: "image_task", projectId: "project-1", @@ -262,6 +265,64 @@ describe("ImageTaskViewer", () => { expect(outputGrid?.textContent).toContain("2"); }); + it("多图结果应按任务 outputIds 计算选中位次,而不是按缓存数组顺序", () => { + const { container } = renderComponent({ + tasks: [ + { + id: "task-ordered", + mode: "generate", + status: "complete", + prompt: "三张章节配图", + rawText: "@配图 生成三张章节配图", + expectedCount: 3, + outputIds: ["output-1", "output-2", "output-3"], + createdAt: 1, + }, + ], + outputs: [ + { + id: "output-3", + refId: "img-3", + taskId: "task-ordered", + url: "https://example.com/image-3.png", + prompt: "第三张", + createdAt: 3, + }, + { + id: "output-2", + refId: "img-2", + taskId: "task-ordered", + url: "https://example.com/image-2.png", + prompt: "第二张", + createdAt: 2, + }, + { + id: "output-1", + refId: "img-1", + taskId: "task-ordered", + url: "https://example.com/image-1.png", + prompt: "第一张", + createdAt: 1, + }, + ], + selectedTaskId: "task-ordered", + selectedOutputId: "output-3", + }); + + const outputGrid = container.querySelector( + '[data-testid="image-task-viewer-output-grid"]', + ); + const selectedThumb = Array.from( + outputGrid?.querySelectorAll("button") || [], + ).find((button) => + button.querySelector('img[src="https://example.com/image-3.png"]'), + ); + + expect(container.textContent).toContain("已选第 3 张"); + expect(selectedThumb?.textContent).toContain("3"); + expect(selectedThumb?.textContent).toContain("当前选中"); + }); + it("任务 viewer 应展示已按多模态运行合同路由的状态", () => { const { container } = renderComponent({ tasks: [ @@ -350,6 +411,56 @@ describe("ImageTaskViewer", () => { ); }); + it("选中无结果的失败任务时不回退展示上一张成功图", () => { + const { container } = renderComponent({ + tasks: [ + { + id: "task-failed-2", + mode: "generate", + status: "error", + prompt: "青柠极简插画", + rawText: "@配图 青柠极简插画", + expectedCount: 1, + outputIds: [], + createdAt: 2, + failureMessage: + '默认图片服务调用失败: Fal HTTP 403: {"detail":"User is locked."}', + }, + { + id: "task-success-1", + mode: "generate", + status: "complete", + prompt: "广州塔春天照片", + rawText: "@配图 广州塔春天照片", + expectedCount: 1, + outputIds: ["output-success-1"], + createdAt: 1, + }, + ], + outputs: [ + { + id: "output-success-1", + refId: "img-success-1", + taskId: "task-success-1", + url: "https://example.com/success.png", + prompt: "广州塔春天照片", + createdAt: 1, + }, + ], + selectedTaskId: "task-failed-2", + selectedOutputId: null, + }); + + expect(container.textContent).toContain("青柠极简插画"); + expect(container.textContent).toContain("生成失败"); + expect(container.textContent).toContain("这次生成没有拿到可用图片结果。"); + expect(container.textContent).not.toContain("广州塔春天照片"); + expect(container.textContent).not.toContain("Fal HTTP 403"); + expect( + container.querySelector('[data-testid="image-task-viewer-open-image"]'), + ).toBeNull(); + }); + it("3x3 分镜任务应使用九宫格缩略布局并展示编号", () => { const outputs = Array.from({ length: 9 }, (_, index) => ({ id: `output-storyboard-${index + 1}`, @@ -576,7 +687,7 @@ describe("ImageTaskViewer", () => { const sourcePanel = container.querySelector( '[data-testid="image-task-viewer-source"]', ); - expect(container.textContent).toContain("Image Editing"); + expect(container.textContent).toContain("图片编辑"); expect(container.textContent).toContain("已修图"); expect(sourcePanel?.textContent).toContain("来源图"); expect(sourcePanel?.textContent).toContain("原始海报"); @@ -701,7 +812,7 @@ describe("ImageTaskViewer", () => { const sourcePanel = container.querySelector( '[data-testid="image-task-viewer-source"]', ); - expect(container.textContent).toContain("Image Redraw"); + expect(container.textContent).toContain("图片重绘"); expect(container.textContent).toContain("已重绘"); expect(sourcePanel?.textContent).toContain("参考图"); expect(sourcePanel?.textContent).toContain("原始海报"); diff --git a/src/components/agent/chat/components/ImageTaskViewer.tsx b/src/components/agent/chat/components/ImageTaskViewer.tsx index 8bead85a0..5e5d8012b 100644 --- a/src/components/agent/chat/components/ImageTaskViewer.tsx +++ b/src/components/agent/chat/components/ImageTaskViewer.tsx @@ -1,47 +1,65 @@ import { useMemo } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { LoaderCircle, Sparkles, X } from "lucide-react"; import { openResourceManager } from "@/features/resource-manager"; -import type { ResourceManagerSourceContext } from "@/features/resource-manager"; import { cn } from "@/lib/utils"; import { RenderableTaskImage } from "./RenderableTaskImage"; import type { ImageTaskViewerProps } from "./imageWorkbenchTypes"; import type { ImageRuntimeContractSnapshot } from "../types"; import { buildLimeCorePolicyEvaluationMetaItem } from "../workspace/mediaTaskPolicyEvaluation"; +import { buildImageTaskResourceSourceContext } from "../workspace/imageWorkbenchResourceManager"; const IMAGE_TASK_PRIMARY_BUTTON_CLASSNAME = "inline-flex items-center justify-center rounded-full border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-4 py-2 text-sm font-medium text-white shadow-sm shadow-emerald-950/15 transition hover:opacity-95"; -function resolveModeEyebrow(mode?: string): string { +type AgentTranslate = TFunction<"agent", undefined>; + +function resolveModeEyebrow( + mode: string | undefined, + t: AgentTranslate, +): string { switch ((mode || "").trim().toLowerCase()) { case "edit": - return "Image Editing"; + return t("agentChat.imageWorkbenchPreview.tool.editing"); case "variation": - return "Image Redraw"; + return t("agentChat.imageWorkbenchPreview.tool.redraw"); case "generate": default: - return "Image Generation"; + return t("agentChat.imageWorkbenchPreview.tool.generation"); } } -function resolveSourceLabel(mode?: string): string { +function resolveSourceLabel( + mode: string | undefined, + t: AgentTranslate, +): string { return (mode || "").trim().toLowerCase() === "variation" - ? "参考图" - : "来源图"; + ? t("agentChat.imageTaskViewer.source.reference") + : t("agentChat.imageTaskViewer.source.source"); } -function resolveFollowUpLabel(mode?: string): string { +function resolveFollowUpLabel( + mode: string | undefined, + t: AgentTranslate, +): string { const normalizedMode = (mode || "").trim().toLowerCase(); if (normalizedMode === "edit") { - return "继续修图"; + return t("agentChat.imageTaskViewer.action.continueEdit"); } if (normalizedMode === "variation") { - return "继续重绘"; + return t("agentChat.imageTaskViewer.action.continueVariation"); } - return "基于此图重绘"; + return t("agentChat.imageTaskViewer.action.redrawFromImage"); } -function resolveLayoutLabel(layoutHint?: string | null): string | null { - return layoutHint === "storyboard_3x3" ? "3x3 分镜" : null; +function resolveLayoutLabel( + layoutHint: string | null | undefined, + t: AgentTranslate, +): string | null { + return layoutHint === "storyboard_3x3" + ? t("agentChat.imageTaskViewer.layout.storyboard3x3") + : null; } function resolveOutputGridClassName(params: { @@ -68,14 +86,19 @@ function resolveSelectedOutputLabel(params: { selectedIndex: number; outputCount: number; layoutHint?: string | null; + t: AgentTranslate; }): string | null { if (params.selectedIndex < 0 || params.outputCount <= 1) { return null; } return params.layoutHint === "storyboard_3x3" - ? `已选第 ${params.selectedIndex + 1} 格` - : `已选第 ${params.selectedIndex + 1} 张`; + ? params.t("agentChat.imageTaskViewer.selected.storyboardSlot", { + index: params.selectedIndex + 1, + }) + : params.t("agentChat.imageTaskViewer.selected.image", { + index: params.selectedIndex + 1, + }); } function resolveOutputDisplayIndex( @@ -91,6 +114,7 @@ function resolveStoryboardSlotLabel(params: { slotIndex?: number | null; slotLabel?: string | null; taskSlotLabel?: string | null; + t: AgentTranslate; }): string | null { if (params.layoutHint !== "storyboard_3x3") { return null; @@ -99,7 +123,9 @@ function resolveStoryboardSlotLabel(params: { return ( params.slotLabel?.trim() || params.taskSlotLabel?.trim() || - `第 ${resolveOutputDisplayIndex(params.outputIndex, params.slotIndex)} 格` + params.t("agentChat.imageTaskViewer.storyboard.slotFallback", { + index: resolveOutputDisplayIndex(params.outputIndex, params.slotIndex), + }) ); } @@ -127,48 +153,52 @@ function buildFollowUpCommand(params: { return `@重绘 ${referenceToken} `; } -function resolveStatusLabel(status?: string, mode?: string): string { +function resolveStatusLabel( + status: string | undefined, + mode: string | undefined, + t: AgentTranslate, +): string { const normalizedMode = (mode || "").trim().toLowerCase(); switch ((status || "").trim().toLowerCase()) { case "complete": switch (normalizedMode) { case "edit": - return "已修图"; + return t("agentChat.imageTaskViewer.status.complete.edit"); case "variation": - return "已重绘"; + return t("agentChat.imageTaskViewer.status.complete.variation"); case "generate": default: - return "已生成"; + return t("agentChat.imageTaskViewer.status.complete.generate"); } case "partial": - return "部分完成"; + return t("agentChat.imageTaskViewer.status.partial"); case "cancelled": - return "已取消"; + return t("agentChat.imageTaskViewer.status.cancelled"); case "error": switch (normalizedMode) { case "edit": - return "修图失败"; + return t("agentChat.imageTaskViewer.status.error.edit"); case "variation": - return "重绘失败"; + return t("agentChat.imageTaskViewer.status.error.variation"); case "generate": default: - return "生成失败"; + return t("agentChat.imageTaskViewer.status.error.generate"); } case "queued": - return "等待队列"; + return t("agentChat.imageTaskViewer.status.queued"); case "running": case "routing": switch (normalizedMode) { case "edit": - return "修图中"; + return t("agentChat.imageTaskViewer.status.running.edit"); case "variation": - return "重绘中"; + return t("agentChat.imageTaskViewer.status.running.variation"); case "generate": default: - return "生成中"; + return t("agentChat.imageTaskViewer.status.running.generate"); } default: - return "准备中"; + return t("agentChat.imageTaskViewer.status.preparing"); } } @@ -192,7 +222,8 @@ function resolveStatusTone(status?: string): string { } function resolveRuntimeContractBadge( - runtimeContract?: ImageRuntimeContractSnapshot | null, + runtimeContract: ImageRuntimeContractSnapshot | null | undefined, + t: AgentTranslate, ): { label: string; tone: string } | null { if (!runtimeContract) { return null; @@ -202,40 +233,43 @@ function resolveRuntimeContractBadge( const outcome = (runtimeContract.routingOutcome || "").trim().toLowerCase(); if (outcome === "blocked") { return { - label: `运行合同阻止 · ${ - runtimeContract.failureCode?.trim() || contractKey - }`, + label: t("agentChat.imageTaskViewer.runtimeContract.blocked", { + reason: runtimeContract.failureCode?.trim() || contractKey, + }), tone: "border-amber-200 bg-amber-50 text-amber-800", }; } if (outcome === "failed") { return { - label: `运行合同失败 · ${ - runtimeContract.failureCode?.trim() || contractKey - }`, + label: t("agentChat.imageTaskViewer.runtimeContract.failed", { + reason: runtimeContract.failureCode?.trim() || contractKey, + }), tone: "border-rose-200 bg-rose-50 text-rose-700", }; } return { - label: `运行合同 · 已按 ${contractKey} 路由`, + label: t("agentChat.imageTaskViewer.runtimeContract.accepted", { + contractKey, + }), tone: "border-emerald-200 bg-emerald-50 text-emerald-700", }; } function resolveRuntimeContractRegistryLabel( - runtimeContract?: ImageRuntimeContractSnapshot | null, + runtimeContract: ImageRuntimeContractSnapshot | null | undefined, + t: AgentTranslate, ): string | null { if (runtimeContract?.modelCapabilityAssessmentSource !== "model_registry") { return null; } if (runtimeContract.modelSupportsImageGeneration === false) { - return "模型能力来自 model_registry · 不支持图片生成"; + return t("agentChat.imageTaskViewer.runtimeContract.registry.unsupported"); } if (runtimeContract.modelSupportsImageGeneration === true) { - return "模型能力来自 model_registry · 支持图片生成"; + return t("agentChat.imageTaskViewer.runtimeContract.registry.supported"); } - return "模型能力来自 model_registry"; + return t("agentChat.imageTaskViewer.runtimeContract.registry.unknown"); } function resolveRuntimeContractPolicyLabel( @@ -257,100 +291,105 @@ function resolveRuntimeContractPolicyLabel( } function resolveEmptyStateDescription( - status?: string, - failureMessage?: string, - mode?: string, + status: string | undefined, + _failureMessage: string | undefined, + mode: string | undefined, + t: AgentTranslate, ): string { - if (failureMessage?.trim()) { - return failureMessage.trim(); - } - switch ((status || "").trim().toLowerCase()) { case "cancelled": - return "这次任务已经取消,当前不会继续生成新的图片结果。"; + return t("agentChat.imageTaskViewer.empty.cancelled"); case "error": - return "这次生成没有拿到可用图片结果。"; + return t("agentChat.imageTaskViewer.empty.error"); case "queued": - return "图片任务已经提交,正在等待服务分配执行槽位。"; + return t("agentChat.imageTaskViewer.empty.queued"); case "running": case "routing": switch ((mode || "").trim().toLowerCase()) { case "edit": - return "图片编辑中,完成后会直接在这里展示修图结果。"; + return t("agentChat.imageTaskViewer.empty.running.edit"); case "variation": - return "图片重绘中,完成后会直接在这里展示结果。"; + return t("agentChat.imageTaskViewer.empty.running.variation"); case "generate": default: - return "图片生成中,完成后会直接在这里展示结果。"; + return t("agentChat.imageTaskViewer.empty.running.generate"); } default: - return "图片任务已创建,结果准备好后会展示在这里。"; + return t("agentChat.imageTaskViewer.empty.preparing"); } } -function resolveImageUnavailableTitle(status?: string): string { +function resolveImageUnavailableTitle( + status: string | undefined, + mode: string | undefined, + t: AgentTranslate, +): string { switch ((status || "").trim().toLowerCase()) { case "complete": case "partial": - return "图片暂时无法显示"; + return t("agentChat.imageTaskViewer.unavailable.title"); default: - return resolveStatusLabel(status); + return resolveStatusLabel(status, mode, t); } } -function resolveImageUnavailableDescription(mode?: string): string { +function resolveImageUnavailableDescription( + mode: string | undefined, + t: AgentTranslate, +): string { switch ((mode || "").trim().toLowerCase()) { case "edit": - return "修图结果已经返回,但当前预览地址暂时无法加载。"; + return t("agentChat.imageTaskViewer.unavailable.edit"); case "variation": - return "重绘结果已经返回,但当前预览地址暂时无法加载。"; + return t("agentChat.imageTaskViewer.unavailable.variation"); case "generate": default: - return "图片结果已经返回,但当前预览地址暂时无法加载。"; + return t("agentChat.imageTaskViewer.unavailable.generate"); } } function resolveSourcePlaceholderLabel( - mode?: string, - reason?: "empty" | "error", + mode: string | undefined, + reason: "empty" | "error", + t: AgentTranslate, ) { if (reason === "error") { return (mode || "").trim().toLowerCase() === "variation" - ? "参考图暂时无法显示" - : "来源图暂时无法显示"; + ? t("agentChat.imageTaskViewer.source.referenceUnavailable") + : t("agentChat.imageTaskViewer.source.sourceUnavailable"); } return (mode || "").trim().toLowerCase() === "variation" - ? "参考图待同步" - : "来源图待同步"; + ? t("agentChat.imageTaskViewer.source.referencePending") + : t("agentChat.imageTaskViewer.source.sourcePending"); } -function normalizeSourceContextText(value?: string | null): string | null { - const normalized = value?.trim(); - return normalized ? normalized : null; -} +function orderTaskOutputsByTaskOutputIds( + task: ImageTaskViewerProps["tasks"][number] | null, + outputs: ImageTaskViewerProps["outputs"], +): ImageTaskViewerProps["outputs"] { + if (!task?.outputIds?.length) { + return outputs; + } -function buildImageTaskResourceSourceContext(params: { - taskId?: string | null; - outputId?: string | null; - projectId?: string | null; - contentId?: string | null; - threadId?: string | null; -}): ResourceManagerSourceContext { - return { - kind: "image_task", - projectId: normalizeSourceContextText(params.projectId), - contentId: normalizeSourceContextText(params.contentId), - taskId: normalizeSourceContextText(params.taskId), - outputId: normalizeSourceContextText(params.outputId), - threadId: normalizeSourceContextText(params.threadId), - sourcePage: "image-task-viewer", - }; + const outputById = new Map(outputs.map((output) => [output.id, output])); + const ordered = task.outputIds + .map((outputId) => outputById.get(outputId)) + .filter((output): output is ImageTaskViewerProps["outputs"][number] => + Boolean(output), + ); + const orderedIds = new Set(ordered.map((output) => output.id)); + + return [ + ...ordered, + ...outputs.filter((output) => !orderedIds.has(output.id)), + ]; } export function ImageTaskViewer({ tasks, outputs, + selectedTaskId, selectedOutputId, sourceProjectId, sourceContentId, @@ -363,16 +402,32 @@ export function ImageTaskViewer({ onSelectOutput, onClose, }: ImageTaskViewerProps) { + const { t } = useTranslation("agent"); + const selectedTaskById = selectedTaskId + ? (tasks.find((item) => item.id === selectedTaskId) ?? null) + : null; + const selectedOutputById = selectedOutputId + ? (outputs.find((item) => item.id === selectedOutputId) ?? null) + : null; const selectedOutput = - outputs.find((item) => item.id === selectedOutputId) ?? outputs[0] ?? null; + selectedOutputById && + (!selectedTaskById || selectedOutputById.taskId === selectedTaskById.id) + ? selectedOutputById + : selectedTaskById + ? (outputs.find((item) => item.taskId === selectedTaskById.id) ?? null) + : (selectedOutputById ?? outputs[0] ?? null); const selectedTask = + selectedTaskById ?? (selectedOutput ? tasks.find((item) => item.id === selectedOutput.taskId) : null) ?? tasks[0] ?? null; const selectedTaskOutputs = selectedTask - ? outputs.filter((item) => item.taskId === selectedTask.id) + ? orderTaskOutputsByTaskOutputIds( + selectedTask, + outputs.filter((item) => item.taskId === selectedTask.id), + ) : outputs; const expectedOutputCount = Math.max( selectedTask?.expectedCount ?? 0, @@ -404,6 +459,7 @@ export function ImageTaskViewer({ slotIndex: selectedSlotIndex, slotLabel: selectedOutput?.slotLabel, taskSlotLabel: taskSlot?.label, + t, }), prompt: selectedOutput?.slotPrompt || taskSlot?.prompt || null, }; @@ -413,30 +469,35 @@ export function ImageTaskViewer({ selectedOutput?.slotPrompt, selectedOutputIndex, selectedTask, + t, ]); const statusLabel = resolveStatusLabel( selectedTask?.status, selectedTask?.mode, + t, ); const runtimeContractBadge = resolveRuntimeContractBadge( selectedTask?.runtimeContract, + t, ); const runtimeContractRegistryLabel = resolveRuntimeContractRegistryLabel( selectedTask?.runtimeContract, + t, ); const runtimeContractPolicyLabel = resolveRuntimeContractPolicyLabel( selectedTask?.runtimeContract, ); - const layoutLabel = resolveLayoutLabel(selectedTask?.layoutHint); + const layoutLabel = resolveLayoutLabel(selectedTask?.layoutHint, t); const selectedOutputLabel = resolveSelectedOutputLabel({ selectedIndex: selectedOutputIndex, outputCount: expectedOutputCount, layoutHint: selectedTask?.layoutHint, + t, }); const prompt = selectedOutput?.prompt?.trim() || selectedTask?.prompt?.trim() || - "当前图片任务未提供提示词。"; + t("agentChat.imageTaskViewer.promptFallback"); const sourceOutputId = selectedTask?.targetOutputId ?? selectedOutput?.parentOutputId ?? null; const sourceOutput = sourceOutputId @@ -463,10 +524,12 @@ export function ImageTaskViewer({ const sourceSummary = sourceImagePrompt ? sourceImagePrompt : sourceImageRef - ? `已引用 ${sourceImageRef}` + ? t("agentChat.imageTaskViewer.source.refSummary", { + ref: sourceImageRef, + }) : selectedTask?.mode === "variation" - ? "当前任务会基于参考图继续生成新的重绘结果。" - : "当前任务会基于已有图片结果继续完成修图。"; + ? t("agentChat.imageTaskViewer.source.variationSummary") + : t("agentChat.imageTaskViewer.source.editSummary"); const followUpCommand = buildFollowUpCommand({ mode: selectedTask?.mode, outputRef: selectedOutput?.refId, @@ -479,13 +542,14 @@ export function ImageTaskViewer({ } void openResourceManager({ - sourceLabel: resolveModeEyebrow(selectedTask?.mode), + sourceLabel: resolveModeEyebrow(selectedTask?.mode, t), sourceContext: buildImageTaskResourceSourceContext({ taskId: selectedTask?.id ?? selectedOutput.taskId, outputId: selectedOutput.id, projectId: sourceProjectId, contentId: sourceContentId, threadId: sourceThreadId, + sourcePage: "image-task-viewer", }), initialIndex: selectedOutputIndex >= 0 ? selectedOutputIndex : 0, items: selectedTaskOutputs.map((output, index) => { @@ -500,6 +564,7 @@ export function ImageTaskViewer({ slotIndex: output.slotIndex, slotLabel: output.slotLabel, taskSlotLabel, + t, }); return { @@ -521,6 +586,7 @@ export function ImageTaskViewer({ projectId: sourceProjectId, contentId: sourceContentId, threadId: sourceThreadId, + sourcePage: "image-task-viewer", }), }; }), @@ -533,7 +599,7 @@ export function ImageTaskViewer({
- {resolveModeEyebrow(selectedTask?.mode)} + {resolveModeEyebrow(selectedTask?.mode, t)}
{prompt} @@ -560,7 +626,7 @@ export function ImageTaskViewer({ type="button" onClick={onClose} className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-400 transition hover:border-slate-300 hover:text-slate-700" - aria-label="关闭图片查看" + aria-label={t("agentChat.imageTaskViewer.action.close")} data-testid="image-task-viewer-close" > @@ -579,7 +645,10 @@ export function ImageTaskViewer({ {selectedOutput ? ( (
@@ -656,6 +732,7 @@ export function ImageTaskViewer({ selectedTask?.status, selectedTask?.failureMessage, selectedTask?.mode, + t, )}
@@ -670,7 +747,7 @@ export function ImageTaskViewer({ className="mt-4 rounded-[20px] border border-slate-200 bg-slate-50 p-4" >
- {resolveSourceLabel(selectedTask?.mode)} + {resolveSourceLabel(selectedTask?.mode, t)}
@@ -678,7 +755,8 @@ export function ImageTaskViewer({ src={sourceImageUrl} data-testid="image-task-viewer-source-image" alt={ - sourceImagePrompt || resolveSourceLabel(selectedTask?.mode) + sourceImagePrompt || + resolveSourceLabel(selectedTask?.mode, t) } className="h-full w-full object-cover" renderFallback={(reason) => ( @@ -686,6 +764,7 @@ export function ImageTaskViewer({ {resolveSourcePlaceholderLabel( selectedTask?.mode, reason, + t, )} )} @@ -703,7 +782,9 @@ export function ImageTaskViewer({ ) : null} {sourceImageCount && sourceImageCount > 0 ? ( - {sourceImageCount} 张 + {t("agentChat.imageTaskViewer.source.imageCount", { + count: sourceImageCount, + })} ) : null}
@@ -773,8 +854,13 @@ export function ImageTaskViewer({ {expectedOutputCount > 0 ? ( {expectedOutputCount > selectedTaskOutputs.length - ? `${selectedTaskOutputs.length} / ${expectedOutputCount} 张结果` - : `${selectedTaskOutputs.length} 张结果`} + ? t("agentChat.imageTaskViewer.resultCount.partial", { + current: selectedTaskOutputs.length, + total: expectedOutputCount, + }) + : t("agentChat.imageTaskViewer.resultCount.complete", { + count: selectedTaskOutputs.length, + })} ) : null}
@@ -795,7 +881,7 @@ export function ImageTaskViewer({ }} className={IMAGE_TASK_PRIMARY_BUTTON_CLASSNAME} > - {resolveFollowUpLabel(selectedTask?.mode)} + {resolveFollowUpLabel(selectedTask?.mode, t)} ) : null} {selectedOutput && onSaveSelectedToLibrary ? ( @@ -820,8 +906,8 @@ export function ImageTaskViewer({ ) : null} {selectedOutput?.resourceSaved - ? "已保存到素材库" - : "保存到素材库"} + ? t("agentChat.imageTaskViewer.action.savedToLibrary") + : t("agentChat.imageTaskViewer.action.saveToLibrary")} ) : null} {selectedOutput && @@ -863,6 +949,7 @@ export function ImageTaskViewer({ slotIndex: output?.slotIndex, slotLabel: output?.slotLabel, taskSlotLabel, + t, }); return (
)} /> @@ -926,10 +1016,10 @@ export function ImageTaskViewer({ )} {selectedTask?.status === "error" - ? "本格失败" + ? t("agentChat.imageTaskViewer.slot.failed") : selectedTask?.status === "cancelled" - ? "已取消" - : "等待生成"} + ? t("agentChat.imageTaskViewer.slot.cancelled") + : t("agentChat.imageTaskViewer.slot.pending")}
)} @@ -952,7 +1042,7 @@ export function ImageTaskViewer({ ) : null} {active && output ? ( - 当前选中 + {t("agentChat.imageTaskViewer.selected.current")} ) : null}
diff --git a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.test.tsx b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.test.tsx index 0893c9328..b2e69205e 100644 --- a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.test.tsx +++ b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.test.tsx @@ -1,4 +1,5 @@ import { act } from "react"; +import type { ComponentProps } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { changeLimeLocale } from "@/i18n/createI18n"; @@ -8,13 +9,18 @@ import { ImageWorkbenchMessagePreview } from "./ImageWorkbenchMessagePreview"; const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; const IMAGE_WORKBENCH_TASK_ACTION_EVENT = "lime:image-workbench-task-action"; -function renderPreview(preview: MessageImageWorkbenchPreview) { +function renderPreview( + preview: MessageImageWorkbenchPreview, + props?: Partial< + Omit, "preview"> + >, +) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); act(() => { - root.render(); + root.render(); }); mountedRoots.push({ root, container }); @@ -130,15 +136,16 @@ describe("ImageWorkbenchMessagePreview", () => { }; window.addEventListener(IMAGE_WORKBENCH_TASK_ACTION_EVENT, handleAction); - const openButton = container.querySelector( + const openCard = container.querySelector( '[data-testid="image-workbench-message-preview-image-preview-failed"]', - ) as HTMLButtonElement | null; + ) as HTMLDivElement | null; const retryButton = container.querySelector( '[data-testid="image-workbench-message-preview-action-image-preview-failed-retry"]', ) as HTMLButtonElement | null; - expect(openButton?.getAttribute("aria-label")).toBe("Open image result"); + expect(openCard).not.toBeNull(); expect(retryButton?.textContent).toContain("Retry"); + expect(container.textContent).not.toContain("Provider timeout"); act(() => { retryButton?.click(); @@ -151,9 +158,44 @@ describe("ImageWorkbenchMessagePreview", () => { contentId: "content-1", }); - window.removeEventListener( - IMAGE_WORKBENCH_TASK_ACTION_EVENT, - handleAction, + window.removeEventListener(IMAGE_WORKBENCH_TASK_ACTION_EVENT, handleAction); + }); + + it("passes the clicked grid image so the viewer does not fall back to the first output", () => { + const onOpen = vi.fn(); + const { container } = renderPreview( + { + taskId: "image-preview-storyboard", + prompt: "九张章节配图", + mode: "generate", + status: "complete", + imageUrl: "https://example.com/chapter-1.png", + previewImages: [ + "https://example.com/chapter-1.png", + "https://example.com/chapter-2.png", + "https://example.com/chapter-3.png", + ], + imageCount: 3, + }, + { onOpen }, + ); + + const secondImageButton = container.querySelector( + '[data-testid="image-workbench-message-preview-media-image-preview-storyboard-2"]', + ) as HTMLButtonElement | null; + + act(() => { + secondImageButton?.click(); + }); + + expect(onOpen).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: "image-preview-storyboard", + }), + { + imageUrl: "https://example.com/chapter-2.png", + imageIndex: 1, + }, ); }); }); diff --git a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx index 2249ecf44..103d07bf3 100644 --- a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx +++ b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx @@ -1,19 +1,24 @@ import React from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { Leaf, LoaderCircle, RotateCcw } from "lucide-react"; +import { Leaf, RotateCcw } from "lucide-react"; import { emitImageWorkbenchFocus, emitImageWorkbenchTaskAction, } from "@/lib/imageWorkbenchEvents"; -import { cn } from "@/lib/utils"; -import type { MessageImageWorkbenchPreview } from "../types"; +import type { + MessageImageWorkbenchPreview, + MessageImageWorkbenchPreviewSelection, +} from "../types"; import { resolveImageWorkbenchPreviewModelLabel } from "../utils/imageWorkbenchPresentation"; -import { RenderableTaskImage } from "./RenderableTaskImage"; +import { ImageWorkbenchPreviewMedia } from "./ImageWorkbenchPreviewMedia"; interface ImageWorkbenchMessagePreviewProps { preview: MessageImageWorkbenchPreview; - onOpen?: (preview: MessageImageWorkbenchPreview) => void; + onOpen?: ( + preview: MessageImageWorkbenchPreview, + selection?: MessageImageWorkbenchPreviewSelection, + ) => void; } type AgentTranslate = TFunction<"agent", undefined>; @@ -33,175 +38,6 @@ function resolveToolLabel( } } -function resolvePlaceholderLabel( - preview: MessageImageWorkbenchPreview, - t: AgentTranslate, -): string { - switch (preview.status) { - case "cancelled": - return t("agentChat.imageWorkbenchPreview.placeholder.cancelled"); - case "failed": - return t("agentChat.imageWorkbenchPreview.placeholder.failed"); - case "complete": - case "partial": - return t("agentChat.imageWorkbenchPreview.placeholder.imageUnavailable"); - case "running": - default: - switch (preview.mode) { - case "edit": - return t("agentChat.imageWorkbenchPreview.placeholder.editing"); - case "variation": - return t("agentChat.imageWorkbenchPreview.placeholder.redrawing"); - case "generate": - default: - return t("agentChat.imageWorkbenchPreview.placeholder.generating"); - } - } -} - -function renderPlaceholder( - preview: MessageImageWorkbenchPreview, - reason: string, - t: AgentTranslate, -) { - const detail = - preview.status === "failed" ? preview.statusMessage?.trim() : ""; - - return ( -
-
- {reason === "empty" && preview.status === "running" ? ( - - ) : null} -
- {resolvePlaceholderLabel(preview, t)} -
- {detail ? ( -
- {detail} -
- ) : null} -
-
- ); -} - -function resolvePreviewImages(preview: MessageImageWorkbenchPreview): string[] { - const urls: string[] = []; - (preview.previewImages || []).forEach((value) => { - const normalized = value.trim(); - if (!normalized || urls.includes(normalized)) { - return; - } - urls.push(normalized); - }); - const primaryUrl = preview.imageUrl?.trim(); - if (primaryUrl && !urls.includes(primaryUrl)) { - urls.unshift(primaryUrl); - } - return urls.slice(0, 9); -} - -function resolvePreviewGridAspectClass( - preview: MessageImageWorkbenchPreview, - imageCount: number, -): string { - if (preview.layoutHint === "storyboard_3x3" && imageCount >= 4) { - return "aspect-square"; - } - return "aspect-[16/10]"; -} - -function renderPreviewMedia( - preview: MessageImageWorkbenchPreview, - t: AgentTranslate, -) { - const previewImages = resolvePreviewImages(preview); - const expectedImageCount = Math.max( - preview.expectedImageCount ?? 0, - preview.imageCount ?? 0, - previewImages.length, - ); - const isStoryboardGrid = preview.layoutHint === "storyboard_3x3"; - const totalSlotCount = isStoryboardGrid - ? Math.max(expectedImageCount, 9) - : previewImages.length; - const aspectClass = resolvePreviewGridAspectClass(preview, totalSlotCount); - - if (!isStoryboardGrid && previewImages.length <= 1) { - return ( -
- renderPlaceholder(preview, reason, t)} - /> -
- ); - } - - const visibleCount = isStoryboardGrid - ? Math.min(totalSlotCount, 9) - : Math.min(previewImages.length, previewImages.length <= 4 ? 4 : 6); - const extraCount = Math.max(0, previewImages.length - visibleCount); - const columnsClass = isStoryboardGrid - ? "grid-cols-3" - : visibleCount <= 4 - ? "grid-cols-2" - : "grid-cols-3"; - - return ( -
- {Array.from({ length: visibleCount }, (_, index) => { - const url = previewImages[index]; - const isLastWithOverflow = extraCount > 0 && index === visibleCount - 1; - return ( -
- {url ? ( - ( -
- {t("agentChat.imageWorkbenchPreview.media.previewFailed")} -
- )} - /> - ) : ( -
- {preview.status === "running" ? ( - - ) : null} -
- )} - {isLastWithOverflow ? ( -
- +{extraCount} -
- ) : null} -
- ); - })} -
- ); -} - export const ImageWorkbenchMessagePreview: React.FC< ImageWorkbenchMessagePreviewProps > = ({ preview, onOpen }) => { @@ -211,9 +47,9 @@ export const ImageWorkbenchMessagePreview: React.FC< const caption = preview.caption?.trim(); const showRetryAction = preview.status === "failed"; - const openPreview = () => { + const openPreview = (selection?: MessageImageWorkbenchPreviewSelection) => { if (onOpen) { - onOpen(preview); + onOpen(preview, selection); return; } emitImageWorkbenchFocus({ @@ -234,12 +70,10 @@ export const ImageWorkbenchMessagePreview: React.FC< return (
- +
{showRetryAction ? (
+ ); + } + + const visibleCount = isStoryboardGrid + ? Math.min(totalSlotCount, 9) + : Math.min(previewImages.length, previewImages.length <= 4 ? 4 : 6); + const extraCount = Math.max(0, previewImages.length - visibleCount); + const columnsClass = isStoryboardGrid + ? "grid-cols-3" + : visibleCount <= 4 + ? "grid-cols-2" + : "grid-cols-3"; + + return ( +
+ {Array.from({ length: visibleCount }, (_, index) => { + const url = previewImages[index]; + const isLastWithOverflow = extraCount > 0 && index === visibleCount - 1; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/agent/chat/components/InlineToolProcessStep.test.tsx b/src/components/agent/chat/components/InlineToolProcessStep.test.tsx index bcd805c55..52c2be3e4 100644 --- a/src/components/agent/chat/components/InlineToolProcessStep.test.tsx +++ b/src/components/agent/chat/components/InlineToolProcessStep.test.tsx @@ -224,6 +224,75 @@ describe("InlineToolProcessStep", () => { expect(container.textContent).toContain("<typeof schema>"); }); + it("Skill 过程步骤应能展开查看本次执行读取的 SKILL.md", () => { + const { container } = renderTool({ + id: "skill:analysis-run-1", + name: "Skill", + arguments: JSON.stringify({ + skill: "analysis", + display_name: "analysis", + source: "SKILL.md", + }), + status: "completed", + result: { + success: true, + output: "已从 SKILL.md 读取并执行 Skill:analysis", + metadata: { + tool_family: "skill", + skill_name: "analysis", + skill_display_name: "analysis", + skill_source: "SKILL.md", + agent_skills_standard: true, + markdown_content_bytes: 86, + skill_markdown_content: + "---\nname: analysis\ndescription: 分析任务\n---\n\n# Analysis Skill\n\n必须先确认可见上下文。", + }, + }, + startTime: new Date("2026-05-14T04:30:00.000Z"), + endTime: new Date("2026-05-14T04:30:02.000Z"), + }); + + expect(container.textContent).toContain("已执行技能 analysis"); + expect(container.textContent).toContain("SKILL.md"); + expect(container.textContent).not.toContain("skill_markdown_content"); + + act(() => { + const skillButton = container.querySelector( + 'button[title="查看 SKILL.md"]', + ) as HTMLButtonElement | null; + skillButton?.click(); + }); + + expect( + container.querySelector( + '[data-testid="inline-tool-skill-content-panel"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain("执行时读取的 SKILL.md"); + expect(container.textContent).toContain("随本次执行记录保存"); + expect(container.textContent).toContain("Agent Skills 标准"); + expect(container.textContent).toContain("展开 SKILL.md 内容"); + expect(container.textContent).not.toContain("Analysis Skill"); + + act(() => { + const expandBodyButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("展开 SKILL.md 内容")) as + | HTMLButtonElement + | undefined; + expandBodyButton?.click(); + }); + + expect( + container.querySelector('[data-testid="inline-tool-skill-content-body"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("收起 SKILL.md 内容"); + expect(container.textContent).toContain("Analysis Skill"); + expect(container.textContent).toContain("必须先确认可见上下文。"); + expect(container.textContent).not.toContain("tool_family"); + expect(container.textContent).not.toContain("skill_markdown_content"); + }); + it("ToolSearch 展开后应展示结构化工具摘要,而不是原始 JSON", () => { const { container } = renderTool({ id: "tool-search-1", @@ -357,6 +426,48 @@ describe("InlineToolProcessStep", () => { ); }); + it("图片生成任务失败时不应展示内部错误码、工具名或长提示词", () => { + const { container } = renderTool({ + id: "tool-image-generate-failed-1", + name: "lime_create_image_generation_task", + arguments: JSON.stringify({ + prompt: "A comic book style illustration of a formal statue", + }), + status: "failed", + result: { + success: false, + error: "-32603: -32002: lime_create_image_generation_task", + output: "", + }, + startTime: new Date("2026-05-14T10:22:00.000Z"), + endTime: new Date("2026-05-14T10:22:01.000Z"), + }); + + expect(container.textContent).toContain("生成失败"); + expect(container.textContent).not.toContain("开始失败"); + expect(container.textContent).not.toContain("-32603"); + expect(container.textContent).not.toContain("-32002"); + expect(container.textContent).not.toContain( + "lime_create_image_generation_task", + ); + expect(container.textContent).not.toContain( + "A comic book style illustration", + ); + + act(() => { + const toggle = container.querySelector( + 'button[title="展开过程详情"]', + ) as HTMLButtonElement | null; + toggle?.click(); + }); + + expect(container.textContent).toContain("生成失败"); + expect(container.textContent).not.toContain("-32603"); + expect(container.textContent).not.toContain( + "lime_create_image_generation_task", + ); + }); + it("完成态过程卡不应重复展示执行完成与原始工具名", () => { const { container } = renderTool({ id: "tool-inline-ask-user-1", diff --git a/src/components/agent/chat/components/InlineToolProcessStep.tsx b/src/components/agent/chat/components/InlineToolProcessStep.tsx index b1fcc7138..152005943 100644 --- a/src/components/agent/chat/components/InlineToolProcessStep.tsx +++ b/src/components/agent/chat/components/InlineToolProcessStep.tsx @@ -1,7 +1,9 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { open as openExternal } from "@tauri-apps/plugin-shell"; import { ChevronDown, ExternalLink, FileText, Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; +import { skillsApi } from "@/lib/api/skills"; import { MarkdownRenderer } from "./MarkdownRenderer"; import { SearchResultPreviewList } from "./SearchResultPreviewList"; import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel"; @@ -38,6 +40,7 @@ import { resolveToolErrorDetailText, resolveToolProcessNarrative, } from "../utils/toolProcessSummary"; +import { isImageGenerationProtocolFailure } from "../utils/limeTaskProtocolNoise"; interface InlineToolProcessStepProps { toolCall: ToolCallState; @@ -71,6 +74,38 @@ function readString( return null; } +function readNumber( + record: Record | null, + keys: string[], +): number | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return null; +} + +function readBoolean( + record: Record | null, + keys: string[], +): boolean | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + } + return null; +} + function summarizeResultText(value: string): string | null { const trimmed = value.trim(); if (!trimmed) { @@ -182,12 +217,24 @@ export const InlineToolProcessStep: React.FC = ({ onFileClick, onOpenSavedSiteContent, }) => { + const { t } = useTranslation("agent"); const [expanded, setExpanded] = useState(false); + const [skillContentExpanded, setSkillContentExpanded] = useState(false); + const [fetchedSkillContent, setFetchedSkillContent] = useState( + null, + ); + const [skillContentLoading, setSkillContentLoading] = useState(false); + const [skillContentError, setSkillContentError] = useState( + null, + ); + const [skillMarkdownBodyExpanded, setSkillMarkdownBodyExpanded] = + useState(false); const parsedArgs = useMemo( () => parseToolCallArguments(toolCall.arguments), [toolCall.arguments], ); + const argsRecord = useMemo(() => asRecord(parsedArgs), [parsedArgs]); const toolDisplay = useMemo( () => getToolDisplayInfo(toolCall.name, toolCall.status), [toolCall.name, toolCall.status], @@ -206,19 +253,30 @@ export const InlineToolProcessStep: React.FC = ({ () => resolveToolPrimarySubject(toolCall.name, parsedArgs, filePath), [filePath, parsedArgs, toolCall.name], ); - const headline = useMemo( - () => - buildToolHeadline({ - toolDisplay, - subject, - toolName: toolCall.name, - }), - [subject, toolCall.name, toolDisplay], - ); const rawResultText = useMemo(() => { const rawText = toolCall.result?.error || toolCall.result?.output || ""; return extractLimeToolMetadataBlock(rawText).text.trim(); }, [toolCall.result?.error, toolCall.result?.output]); + const isImageGenerationFailureProcess = useMemo( + () => + toolCall.status === "failed" && + isImageGenerationProtocolFailure({ + toolName: toolCall.name, + text: rawResultText, + }), + [rawResultText, toolCall.name, toolCall.status], + ); + const headline = useMemo( + () => + isImageGenerationFailureProcess + ? t("agentChat.imageWorkbenchPreview.placeholder.failed") + : buildToolHeadline({ + toolDisplay, + subject, + toolName: toolCall.name, + }), + [isImageGenerationFailureProcess, subject, t, toolCall.name, toolDisplay], + ); const resultText = useMemo(() => { if (toolCall.status !== "failed") { return rawResultText; @@ -285,11 +343,50 @@ export const InlineToolProcessStep: React.FC = ({ [toolCall], ); const skillTitle = - readString(asRecord(parsedArgs), ["skill_title", "skillTitle"]) || + readString(argsRecord, ["skill_title", "skillTitle"]) || readString(metadata, ["skill_title", "skillTitle"]); const isPreload = metadata?.execution_origin === "preload" || metadata?.preload === true; const hasOpenableFile = Boolean(filePath && onFileClick); + const skillSource = + readString(metadata, ["skill_source", "skillSource"]) || + readString(argsRecord, ["source"]); + const isSkillInvocation = + toolDisplay.family === "skill" || + metadata?.tool_family === "skill" || + skillSource === "SKILL.md"; + const skillName = + readString(metadata, ["skill_name", "skillName"]) || + readString(argsRecord, ["skill", "name"]); + const skillDisplayName = + readString(metadata, ["skill_display_name", "skillDisplayName"]) || + readString(argsRecord, ["display_name", "displayName"]) || + skillName; + const skillSnapshotContent = readString(metadata, [ + "skill_markdown_content", + "skillMarkdownContent", + "markdown_content", + "markdownContent", + ]); + const skillMarkdownContentBytes = readNumber(metadata, [ + "markdown_content_bytes", + "markdownContentBytes", + ]); + const isStandardSkillSnapshot = readBoolean(metadata, [ + "agent_skills_standard", + "agentSkillsStandard", + ]); + const hasSkillContentAccess = + isSkillInvocation && + Boolean(skillSnapshotContent || fetchedSkillContent || skillName); + const resolvedSkillContent = + skillSnapshotContent || fetchedSkillContent || ""; + const skillContentSourceLabel = skillSnapshotContent + ? t("agentChat.toolCall.skillContent.source.snapshot") + : t("agentChat.toolCall.skillContent.source.current"); + const skillContentTitle = skillSnapshotContent + ? t("agentChat.toolCall.skillContent.title.snapshot") + : t("agentChat.toolCall.skillContent.title.current"); const processSummary = useMemo(() => { const streamingOutputSummary = toolCall.status === "running" && structuredResultPreview @@ -356,6 +453,61 @@ export const InlineToolProcessStep: React.FC = ({ toolSearchSummary, ]); + useEffect(() => { + setSkillContentExpanded(false); + setFetchedSkillContent(null); + setSkillContentLoading(false); + setSkillContentError(null); + setSkillMarkdownBodyExpanded(false); + }, [toolCall.id]); + + const handleToggleSkillContent = useCallback(async () => { + if (skillContentExpanded) { + setSkillContentExpanded(false); + setSkillMarkdownBodyExpanded(false); + return; + } + + setSkillContentExpanded(true); + setSkillMarkdownBodyExpanded(false); + if (skillSnapshotContent || fetchedSkillContent) { + return; + } + + const normalizedSkillName = skillName?.trim(); + if (!normalizedSkillName) { + setSkillContentError( + t("agentChat.toolCall.skillContent.error.unavailable"), + ); + return; + } + + setSkillContentLoading(true); + setSkillContentError(null); + try { + const inspection = await skillsApi.inspectLocalSkill(normalizedSkillName); + setFetchedSkillContent(inspection.content || ""); + } catch (error) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : t("agentChat.toolCall.skillContent.error.unknown"); + setSkillContentError( + t("agentChat.toolCall.skillContent.error.loadFailed", { message }), + ); + } finally { + setSkillContentLoading(false); + } + }, [ + fetchedSkillContent, + skillContentExpanded, + skillName, + skillSnapshotContent, + t, + ]); + const detailBadges = [ isPreload ? "系统预执行" : null, skillTitle && skillTitle !== subject ? `技能:${skillTitle}` : null, @@ -429,6 +581,33 @@ export const InlineToolProcessStep: React.FC = ({
+ {hasSkillContentAccess ? ( + + ) : null} {hasOpenableFile ? (
+ {skillContentExpanded ? ( +
+
+ + {skillContentTitle} +
+
+ {skillContentSourceLabel} + {skillDisplayName ? {skillDisplayName} : null} + {skillMarkdownContentBytes !== null ? ( + + {t("agentChat.toolCall.skillContent.meta.bytes", { + count: skillMarkdownContentBytes, + })} + + ) : null} + {isStandardSkillSnapshot === true ? ( + + {t("agentChat.toolCall.skillContent.meta.standard")} + + ) : null} +
+ + {skillMarkdownBodyExpanded ? ( +
+ {skillContentLoading ? ( +
+ + {t("agentChat.toolCall.skillContent.loading")} +
+ ) : skillContentError ? ( +
+ {skillContentError} +
+ ) : resolvedSkillContent.trim() ? ( + + ) : ( +
+ {t("agentChat.toolCall.skillContent.empty")} +
+ )} +
+ ) : null} +
+ ) : null} + {expanded && hasDetails ? (
{siteNoticeLines.length > 0 ? ( diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index bb71b0d46..fd66ef3d6 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -36,6 +36,10 @@ import type { import { InputbarKnowledgeControl } from "../knowledge/InputbarKnowledgeControl"; import type { InputbarComposerSectionCopy } from "./inputbarComposerSectionCopy"; import type { InputbarCoreCopy } from "./inputbarCoreCopy"; +import type { + InputbarExecutionStrategyCopy, + InputbarWorkflowPanelCopy, +} from "../inputbarWorkflowCopy"; import type { WorkflowGateState, WorkflowQuickAction, @@ -116,6 +120,8 @@ interface InputbarComposerSectionProps { inputCompletionEnabled?: boolean; copy: InputbarComposerSectionCopy; inputbarCopy: InputbarCoreCopy; + executionStrategyCopy: InputbarExecutionStrategyCopy; + workflowPanelCopy: InputbarWorkflowPanelCopy; } export const InputbarComposerSection: React.FC< @@ -183,6 +189,8 @@ export const InputbarComposerSection: React.FC< inputCompletionEnabled = true, copy, inputbarCopy, + executionStrategyCopy, + workflowPanelCopy, }) => { const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState< number | null @@ -369,6 +377,7 @@ export const InputbarComposerSection: React.FC< isFullscreen={isFullscreen} executionStrategy={executionStrategy} setExecutionStrategy={setExecutionStrategy} + copy={executionStrategyCopy} /> {shouldShowModelControls ? ( ); } @@ -426,6 +436,7 @@ export const InputbarComposerSection: React.FC< renderGeneratingPanel={false} onQuickAction={inputAdapter.actions.setText} onStop={inputAdapter.actions.stop} + copy={workflowPanelCopy} /> , ) { return Object.entries(values ?? {}).reduce( - (text, [name, value]) => - text.split(`{{${name}}}`).join(String(value)), + (text, [name, value]) => text.split(`{{${name}}}`).join(String(value)), resource[key] ?? key, ); } const TEST_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) => - translateResource(agentResource, key, values), + translateResource(agentZhCNResource, key, values), ); const TEST_EN_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) => - translateResource(enAgentResource, key, values), + translateResource(agentEnUSResource, key, values), ); beforeEach(async () => { @@ -164,9 +162,7 @@ afterEach(() => { }); const renderInputbarCore = async ( - props?: Partial< - Omit, "uiCopy"> - > & { + props?: Partial, "uiCopy">> & { uiCopy?: React.ComponentProps["uiCopy"]; }, ) => { @@ -272,13 +268,9 @@ describe("InputbarCore", () => { expect( container.querySelector('button[aria-label="Expand input"]'), ).toBeTruthy(); - expect( - container.querySelector('button[aria-label="Send"]'), - ).toBeNull(); + expect(container.querySelector('button[aria-label="Send"]')).toBeNull(); expect(container.textContent).toContain("Handle later"); - expect( - container.querySelector('button[aria-label="Stop"]'), - ).toBeTruthy(); + expect(container.querySelector('button[aria-label="Stop"]')).toBeTruthy(); }); it("添加路径引用时应显示 chip 并允许移除", async () => { diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx index 5d7d7fd8d..b54f2b7f4 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx @@ -6,6 +6,7 @@ import { MetaToggleGlyph, MetaToggleLabel, } from "../styles"; +import type { InputbarExecutionStrategyCopy } from "../inputbarWorkflowCopy"; interface InputbarExecutionStrategySelectProps { isFullscreen?: boolean; @@ -13,6 +14,7 @@ interface InputbarExecutionStrategySelectProps { setExecutionStrategy?: ( strategy: "react" | "code_orchestrated" | "auto", ) => void; + copy: InputbarExecutionStrategyCopy; } export const InputbarExecutionStrategySelect: React.FC< @@ -22,6 +24,7 @@ export const InputbarExecutionStrategySelect: React.FC< isFullscreen = false, executionStrategy, setExecutionStrategy, + copy, } = props; if (isFullscreen || !setExecutionStrategy) { @@ -29,15 +32,16 @@ export const InputbarExecutionStrategySelect: React.FC< } const planEnabled = executionStrategy === "code_orchestrated"; + const toggleLabel = planEnabled ? copy.disable : copy.enable; return ( setExecutionStrategy(planEnabled ? "react" : "code_orchestrated") } @@ -46,7 +50,7 @@ export const InputbarExecutionStrategySelect: React.FC< - 计划执行 + {copy.label} ); }; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx index 402e7bbc8..b9775c643 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx @@ -6,6 +6,7 @@ import type { WorkflowQuickAction, WorkflowStep, } from "../../../utils/workflowInputState"; +import type { InputbarWorkflowPanelCopy } from "../inputbarWorkflowCopy"; interface InputbarWorkflowStatusPanelProps { gate?: WorkflowGateState | null; @@ -20,6 +21,7 @@ interface InputbarWorkflowStatusPanelProps { renderGeneratingPanel: boolean; onQuickAction: (prompt: string) => void; onStop?: () => void; + copy: InputbarWorkflowPanelCopy; } type WorkflowTone = "active" | "pending" | "error"; @@ -302,14 +304,17 @@ function resolveWorkflowTone( return "active"; } -function getStatusLabel(tone: WorkflowTone): string { +function getStatusLabel( + tone: WorkflowTone, + copy: InputbarWorkflowPanelCopy, +): string { if (tone === "error") { - return "异常"; + return copy.status.error; } if (tone === "pending") { - return "待处理"; + return copy.status.pending; } - return "进行中"; + return copy.status.active; } function renderToneIcon(tone: WorkflowTone) { @@ -335,6 +340,7 @@ export function InputbarWorkflowStatusPanel({ renderGeneratingPanel, onQuickAction, onStop, + copy, }: InputbarWorkflowStatusPanelProps) { const [queueCollapsed, setQueueCollapsed] = useState(false); @@ -346,12 +352,12 @@ export function InputbarWorkflowStatusPanel({ ? resolveWorkflowTone(activeItem?.status) : resolveWorkflowTone(gate?.status); const summaryTitle = renderGeneratingPanel - ? activeItem?.title || "正在编排任务节点" - : gate?.title || "等待继续"; + ? activeItem?.title || copy.summary.defaultActiveTitle + : gate?.title || copy.summary.defaultGateTitle; const queueHiddenCount = Math.max(queueTotalCount - queueItems.length, 0); const queueHintLabel = queueHiddenCount > 0 - ? `${progressLabel} · 另有 ${queueHiddenCount} 项收纳` + ? copy.queue.hiddenSuffix(progressLabel, queueHiddenCount) : progressLabel; return ( @@ -362,7 +368,7 @@ export function InputbarWorkflowStatusPanel({ {renderToneIcon(summaryTone)} - 当前进展 + {copy.summary.currentProgress} {summaryTitle} @@ -371,19 +377,19 @@ export function InputbarWorkflowStatusPanel({ {renderGeneratingPanel - ? getStatusLabel(summaryTone) + ? getStatusLabel(summaryTone, copy) : gate?.status === "waiting" - ? "等待决策" + ? copy.status.waitingDecision : gate?.status === "running" - ? "自动执行中" - : "待启动"} + ? copy.status.autoRunning + : copy.status.ready} {renderGeneratingPanel ? ( onStop?.()} - aria-label="停止生成" + aria-label={copy.action.stopGeneration} > @@ -396,9 +402,11 @@ export function InputbarWorkflowStatusPanel({ setQueueCollapsed((previous) => !previous)} - aria-label={queueCollapsed ? "展开任务队列" : "折叠任务队列"} + aria-label={ + queueCollapsed ? copy.queue.expand : copy.queue.collapse + } > - 任务队列 + {copy.queue.title} {queueTotalCount} {queueHintLabel} @@ -413,8 +421,8 @@ export function InputbarWorkflowStatusPanel({ - 正在编排任务节点... - 进行中 + {copy.queue.placeholderTitle} + {copy.status.active} ) : ( @@ -430,10 +438,13 @@ export function InputbarWorkflowStatusPanel({ {item.title} - {getStatusLabel(tone)} {totalCount > 0 - ? ` · ${completedCount}/${totalCount}` - : ""} + ? copy.queue.itemMetaWithProgress( + getStatusLabel(tone, copy), + completedCount, + totalCount, + ) + : getStatusLabel(tone, copy)} diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx new file mode 100644 index 000000000..d1da1feb3 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx @@ -0,0 +1,74 @@ +import { act, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { changeLimeLocale } from "@/i18n/createI18n"; +import { + createTeamDefinitionFromPreset, + type TeamDefinition, +} from "../../../utils/teamDefinitions"; +import { TeamSelector } from "./TeamSelector"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function renderTeamSelector( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const defaultProps: ComponentProps = { + onSelectTeam: vi.fn(), + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return container; +} + +describe("TeamSelector", () => { + beforeEach(async () => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("zh-CN"); + }); + + afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); + }); + + it("触发按钮 chrome 文案应跟随 en-US 资源", async () => { + await changeLimeLocale("en-US"); + const selectedTeam = createTeamDefinitionFromPreset( + "code-triage-team", + ) as TeamDefinition; + const container = renderTeamSelector({ selectedTeam }); + + expect( + container.querySelector('[data-testid="team-selector-trigger"]') + ?.textContent, + ).toContain("Team · 代码排障团队"); + expect(container.textContent).not.toContain("分工 ·"); + }); + + it("未选择分工时应展示 zh-CN 默认入口", async () => { + const container = renderTeamSelector(); + + expect(container.textContent).toContain("配置分工"); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx index a4db7c435..128cf82ef 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx @@ -1,10 +1,12 @@ import React, { Suspense, lazy, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Users } from "lucide-react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { WorkspaceSettings } from "@/types/workspace"; import type { TeamDefinition } from "../../../utils/teamDefinitions"; import { useIdleModulePreload } from "../../../skill-selection/useIdleModulePreload"; +import { buildInputbarTeamSelectorCopy } from "./inputbarTeamSelectorCopy"; const preloadTeamSelectorPanel = () => import("./TeamSelectorPanel"); @@ -32,6 +34,11 @@ export const TeamSelector: React.FC = ({ workspaceSettings, onPersistCustomTeams, }) => { + const { t } = useTranslation("agent"); + const copy = React.useMemo( + () => buildInputbarTeamSelectorCopy((key, values) => t(key, values ?? {})), + [t], + ); const [open, setOpen] = useState(false); useIdleModulePreload(() => { @@ -46,8 +53,8 @@ export const TeamSelector: React.FC = ({ }, [autoOpenToken]); const resolvedLabel = selectedTeam?.label?.trim() - ? `分工 · ${selectedTeam.label.trim()}` - : "配置分工"; + ? copy.triggerSelected(selectedTeam.label.trim()) + : copy.triggerDefault; const selectedRoleCount = selectedTeam?.roles.length || 0; @@ -81,7 +88,7 @@ export const TeamSelector: React.FC = ({ - 加载中... + {copy.loading}
} > 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 f14c25956..b0f63842e 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx @@ -2,6 +2,7 @@ import { act, type ComponentProps } from "react"; import { createRoot, type Root } from "react-dom/client"; import type { WorkspaceSettings } from "@/types/workspace"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { changeLimeLocale } from "@/i18n/createI18n"; import { TeamSelectorPanel } from "./TeamSelectorPanel"; import { createTeamDefinitionFromPreset, @@ -70,12 +71,13 @@ function setInputValue( } describe("TeamSelectorPanel", () => { - beforeEach(() => { + beforeEach(async () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("zh-CN"); localStorage.clear(); }); @@ -103,6 +105,60 @@ describe("TeamSelectorPanel", () => { expect(container.textContent).not.toContain("模型生成分工"); }); + it("TeamSelectorPanel chrome 文案应跟随 en-US 资源", async () => { + await changeLimeLocale("en-US"); + const selectedTeam = createTeamDefinitionFromPreset( + "code-triage-team", + ) as TeamDefinition; + const { container } = renderPanel({ + activeTheme: "general", + selectedTeam, + input: + "请帮我分析这个前端问题,给出实现方案,完成修复,补测试并最终汇总结论。", + }); + + await flushEffects(); + + expect(container.textContent).toContain("Task team setup"); + expect(container.textContent).toContain("Selected team"); + expect(container.textContent).toContain("Create custom team"); + expect(container.textContent).toContain("Recommended team"); + expect(container.textContent).toContain("My teams"); + expect(container.textContent).toContain("System templates"); + expect(container.textContent).toContain("Team overview"); + expect(container.textContent).toContain("Role split"); + expect(container.textContent).toContain("Roles:"); + expect(container.textContent).not.toContain("任务分工配置"); + + const createButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Create custom team"), + ); + + expect(createButton).toBeTruthy(); + + act(() => { + createButton?.click(); + }); + + await flushEffects(); + + expect(container.textContent).toContain("Create custom team"); + expect(container.textContent).toContain("Team name"); + expect(container.textContent).toContain("Add role"); + expect(container.textContent).toContain("Save team"); + expect( + container.querySelector( + 'input[placeholder="Example: Frontend integration team"]', + ), + ).toBeTruthy(); + expect( + container.querySelector( + 'textarea[placeholder="Describe which tasks this team is best for."]', + ), + ).toBeTruthy(); + expect(mockToast.success).not.toHaveBeenCalled(); + }); + it("应保存自定义分工的 profileId、roleKey 与 skillIds", async () => { const onSelectTeam = vi.fn(); const selectedTeam = createTeamDefinitionFromPreset( diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx index c4f78b420..8b2745df7 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Check, ChevronDown, @@ -24,7 +25,6 @@ import { getBuiltinTeamSkillOption, } from "../../../utils/teamPresets"; import { - buildTeamDefinitionSummary, cloneTeamDefinitionAsCustom, createTeamDefinitionFromPreset, listBuiltinTeamDefinitions, @@ -37,6 +37,10 @@ import { resolveCustomTeams, saveCustomTeams, } from "../../../utils/teamStorage"; +import { + buildInputbarTeamSelectorCopy, + type InputbarTeamSelectorCopy, +} from "./inputbarTeamSelectorCopy"; interface TeamSelectorPanelProps { activeTheme?: string; @@ -60,7 +64,10 @@ interface TeamDraft { roles: TeamRoleDefinition[]; } -function createBlankDraft(theme?: string): TeamDraft { +function createBlankDraft( + copy: InputbarTeamSelectorCopy, + theme?: string, +): TeamDraft { return { label: "", description: "", @@ -69,18 +76,37 @@ function createBlankDraft(theme?: string): TeamDraft { roles: [ { id: "planner", - label: "分析", - summary: "负责拆解任务、澄清边界与输出第一轮事实。", + label: copy.defaultRole.plannerLabel, + summary: copy.defaultRole.plannerSummary, }, { id: "executor", - label: "执行", - summary: "负责在明确范围内推进实现或产出草稿。", + label: copy.defaultRole.executorLabel, + summary: copy.defaultRole.executorSummary, }, ], }; } +function formatTeamDefinitionSummary( + team: TeamDefinition | null | undefined, + copy: InputbarTeamSelectorCopy, +): string { + if (!team) { + return ""; + } + + const roleSummary = team.roles + .map((role) => `${role.label}: ${role.summary}`) + .join(copy.summary.roleSeparator); + const description = team.description.trim(); + + if (description && roleSummary) { + return `${description} ${copy.summary.rolesPrefix}${roleSummary}`; + } + return description || roleSummary; +} + function buildDraftFromTeam(team: TeamDefinition): TeamDraft { return { id: team.source === "custom" ? team.id : undefined, @@ -138,6 +164,7 @@ function TeamCard({ onCopy, onEdit, onDelete, + copy, }: { team: TeamDefinition; selected: boolean; @@ -149,6 +176,7 @@ function TeamCard({ onCopy?: () => void; onEdit?: () => void; onDelete?: () => void; + copy: InputbarTeamSelectorCopy; }) { return (
- {selectedLabel || "当前选择"} + {selectedLabel || copy.card.selected} ) : null}
@@ -197,9 +225,11 @@ function TeamCard({
{expanded ? (
-
详细分工
+
+ {copy.card.detailTitle} +
- {buildTeamDefinitionSummary(team)} + {formatTeamDefinitionSummary(team, copy)}
{team.roles.map((role) => ( @@ -212,7 +242,7 @@ function TeamCard({
{role.profileId ? ( - 画像 ·{" "} + {copy.card.profilePrefix} ·{" "} {getBuiltinTeamProfileOption(role.profileId) ?.label || role.profileId} @@ -227,7 +257,7 @@ function TeamCard({ key={`${team.id}-${role.id}-${skillId}`} className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] text-slate-500" > - 技能 ·{" "} + {copy.card.skillPrefix} ·{" "} {getBuiltinTeamSkillOption(skillId)?.label || skillId} @@ -246,8 +276,10 @@ function TeamCard({ type="button" className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700" onClick={onToggleDetail} - title={expanded ? "收起详情" : "查看详情"} - aria-label={expanded ? "收起详情" : "查看详情"} + title={expanded ? copy.card.collapseDetail : copy.card.viewDetail} + aria-label={ + expanded ? copy.card.collapseDetail : copy.card.viewDetail + } > {expanded ? ( @@ -261,8 +293,8 @@ function TeamCard({ type="button" className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700" onClick={onCopy} - title="复制为自定义分工" - aria-label="复制为自定义分工" + title={copy.card.copyCustom} + aria-label={copy.card.copyCustom} > @@ -272,8 +304,8 @@ function TeamCard({ type="button" className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700" onClick={onEdit} - title="编辑分工" - aria-label="编辑分工" + title={copy.card.edit} + aria-label={copy.card.edit} > @@ -283,8 +315,8 @@ function TeamCard({ type="button" className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-rose-50 hover:text-rose-600" onClick={onDelete} - title="删除分工" - aria-label="删除分工" + title={copy.card.delete} + aria-label={copy.card.delete} > @@ -304,6 +336,11 @@ export const TeamSelectorPanel: React.FC = ({ onPersistCustomTeams, onClose, }) => { + const { t } = useTranslation("agent"); + const copy = useMemo( + () => buildInputbarTeamSelectorCopy((key, values) => t(key, values ?? {})), + [t], + ); const [query, setQuery] = useState(""); const [customTeams, setCustomTeams] = useState([]); const [draft, setDraft] = useState(null); @@ -347,7 +384,10 @@ export const TeamSelectorPanel: React.FC = ({ [customTeams, query], ); - const currentSelectionSummary = buildTeamDefinitionSummary(selectedTeam); + const currentSelectionSummary = formatTeamDefinitionSummary( + selectedTeam, + copy, + ); const updateDraftRole = ( roleIndex: number, @@ -382,8 +422,15 @@ export const TeamSelectorPanel: React.FC = ({ const handleStartCreate = (base?: TeamDefinition | null) => { setDraft( base - ? buildDraftFromTeam(cloneTeamDefinitionAsCustom(base)) - : createBlankDraft(activeTheme), + ? buildDraftFromTeam( + cloneTeamDefinitionAsCustom(base, { + label: + base.source === "builtin" + ? copy.editor.customCloneLabel(base.label) + : base.label, + }), + ) + : createBlankDraft(copy, activeTheme), ); }; @@ -415,7 +462,7 @@ export const TeamSelectorPanel: React.FC = ({ }); if (!normalized) { - toast.error("请至少填写分工名称和 1 个角色"); + toast.error(copy.toast.invalidDraft); return; } @@ -440,12 +487,14 @@ export const TeamSelectorPanel: React.FC = ({ onClose?.(); toast.success( isProjectScopedCustomTeam - ? `已将分工「${nextTeam.label}」保存到当前项目` - : `已保存分工「${nextTeam.label}」`, + ? copy.toast.saveProjectSuccess(nextTeam.label) + : copy.toast.saveLocalSuccess(nextTeam.label), ); } catch (error) { toast.error( - `保存分工失败:${error instanceof Error ? error.message : String(error)}`, + copy.toast.saveFailed( + error instanceof Error ? error.message : String(error), + ), ); } }; @@ -464,12 +513,14 @@ export const TeamSelectorPanel: React.FC = ({ } toast.success( isProjectScopedCustomTeam - ? `已从当前项目删除分工「${team.label}」` - : `已删除分工「${team.label}」`, + ? copy.toast.deleteProjectSuccess(team.label) + : copy.toast.deleteLocalSuccess(team.label), ); } catch (error) { toast.error( - `删除分工失败:${error instanceof Error ? error.message : String(error)}`, + copy.toast.deleteFailed( + error instanceof Error ? error.message : String(error), + ), ); } }; @@ -526,8 +577,7 @@ export const TeamSelectorPanel: React.FC = ({ if (!inspectorTeam) { return (
- 还没有可预览的分工。你可以从左侧模板中查看详情,或新建一个自定义 - 分工。 + {copy.emptyInspector}
); } @@ -536,10 +586,10 @@ export const TeamSelectorPanel: React.FC = ({ const isCustom = inspectorTeam.source === "custom"; const badgeLabel = inspectorTeam.id === recommendedTeam?.id - ? "按任务推荐" + ? copy.badge.recommended : isCustom - ? "自定义" - : "系统模板"; + ? copy.badge.custom + : copy.badge.systemTemplate; return (
= ({ {isCurrent ? ( - 当前选择 + {copy.inspector.current} ) : null}
- {inspectorTeam.description || - "这套分工会作为主代理拆分子任务时的分工参考。"} + {inspectorTeam.description || copy.inspector.defaultDescription}
@@ -574,7 +623,7 @@ export const TeamSelectorPanel: React.FC = ({ className={TEAM_SELECTOR_PRIMARY_BUTTON_CLASSNAME} onClick={() => handleSelect(inspectorTeam)} > - 采用这套分工 + {copy.inspector.select} ) : null} {isCustom ? ( ) : null}
@@ -602,16 +651,16 @@ export const TeamSelectorPanel: React.FC = ({
- 分工概览 + {copy.inspector.summaryTitle}
- {buildTeamDefinitionSummary(inspectorTeam)} + {formatTeamDefinitionSummary(inspectorTeam, copy)}
- 角色分工 + {copy.inspector.rolesTitle}
{inspectorTeam.roles.map((role) => ( @@ -625,7 +674,7 @@ export const TeamSelectorPanel: React.FC = ({ {role.profileId ? ( - 画像 ·{" "} + {copy.card.profilePrefix} ·{" "} {getBuiltinTeamProfileOption(role.profileId)?.label || role.profileId} @@ -646,7 +695,7 @@ export const TeamSelectorPanel: React.FC = ({ key={`${inspectorTeam.id}-${role.id}-${skillId}`} className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500" > - 技能 ·{" "} + {copy.card.skillPrefix} ·{" "} {getBuiltinTeamSkillOption(skillId)?.label || skillId} ))} @@ -668,7 +717,7 @@ export const TeamSelectorPanel: React.FC = ({ }} > - 删除这套分工 + {copy.inspector.delete}
) : null} @@ -689,17 +738,17 @@ export const TeamSelectorPanel: React.FC = ({
- {draft.id ? "编辑自定义分工" : "新建自定义分工"} + {draft.id ? copy.editor.editTitle : copy.editor.createTitle}
- 在右侧完整配置分工角色、画像和技能,左侧列表用于浏览与切换模板。 + {copy.editor.description}
@@ -708,7 +757,7 @@ export const TeamSelectorPanel: React.FC = ({
= ({ : current, ) } - placeholder="例如:前端联调团队" + placeholder={copy.editor.teamNamePlaceholder} className="border-slate-200 bg-white" />