From 0398fc8c8e611b7397fec4ecb3043c35c7c92c79 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 1 Apr 2026 07:09:52 +0800 Subject: [PATCH] chore: release v1.0.1 --- RELEASE_NOTES.md | 52 +- docs/aiprompts/README.md | 1 - docs/aiprompts/commands.md | 27 +- docs/aiprompts/content-creator.md | 28 +- docs/aiprompts/overview.md | 3 +- docs/aiprompts/playwright-e2e.md | 14 +- docs/aiprompts/quality-workflow.md | 11 + docs/aiprompts/workspace.md | 2 +- .../02.user-guide/4.configuration-example.md | 2 +- docs/prd/tools/README.md | 10 +- docs/prd/tools/architecture.md | 2 +- docs/prd/tools/development-plan.md | 2 +- docs/prd/tools/inventory.md | 8 +- .../artifacts/architecture-blueprint.md | 5 +- .../roadmap/artifacts/artifact-document-v1.md | 7 +- docs/roadmap/artifacts/roadmap.md | 17 +- .../system-prompt-and-schema-contract.md | 51 +- package.json | 2 +- scripts/check-harness-contracts.mjs | 3 + src-tauri/Cargo.lock | 62 +- src-tauri/Cargo.toml | 8 +- src-tauri/crates/agent/src/ask_bridge.rs | 385 ++- src-tauri/crates/agent/src/event_converter.rs | 136 +- .../crates/agent/src/prompt/templates.rs | 23 +- .../crates/agent/src/request_tool_policy.rs | 157 + src-tauri/crates/core/src/config/mod.rs | 48 +- src-tauri/crates/core/src/config/tests.rs | 8 +- src-tauri/crates/core/src/config/types.rs | 30 +- src-tauri/crates/core/src/config/yaml.rs | 118 +- src-tauri/crates/core/src/content/manager.rs | 5 +- .../crates/core/src/database/dao/chat.rs | 11 +- ...aterial_dao.rs => gallery_material_dao.rs} | 157 +- src-tauri/crates/core/src/database/dao/mod.rs | 2 +- .../crates/core/src/database/migration_v5.rs | 155 + src-tauri/crates/core/src/database/mod.rs | 1 + src-tauri/crates/core/src/database/schema.rs | 232 +- .../core/src/database/startup_migrations.rs | 40 +- .../crates/core/src/models/project_model.rs | 44 +- .../crates/core/src/session_files/types.rs | 2 +- .../crates/core/src/workspace/manager.rs | 147 +- src-tauri/crates/core/src/workspace/types.rs | 58 +- .../broadcast_generate/SKILL.md | 2 +- .../default-skills/cover_generate/SKILL.md | 2 +- .../default-skills/image_generate/SKILL.md | 2 +- .../modal_resource_search/SKILL.md | 2 +- .../social_post_with_cover/SKILL.md | 2 +- .../default-skills/typesetting/SKILL.md | 2 +- .../default-skills/video_generate/SKILL.md | 2 +- .../resources/music/chord-progressions.json | 251 -- .../resources/music/guofeng-patterns.json | 266 -- .../resources/music/midi-parser-rules.json | 251 -- .../resources/music/pentatonic-rules.json | 104 - src-tauri/resources/music/rhyme-patterns.json | 183 -- src-tauri/resources/scripts/audio_to_midi.py | 367 --- src-tauri/resources/scripts/midi_analyzer.py | 677 ---- src-tauri/src/agent_tools/catalog.rs | 166 +- src-tauri/src/agent_tools/execution.rs | 6 - src-tauri/src/agent_tools/inventory.rs | 30 +- src-tauri/src/app/runner.rs | 37 +- .../aster_agent_cmd/action_runtime.rs | 88 +- .../aster_agent_cmd/browser_assist.rs | 6 +- .../commands/aster_agent_cmd/command_api.rs | 2 +- .../command_api/provider_api.rs | 3 +- .../command_api/runtime_api.rs | 10 +- src-tauri/src/commands/aster_agent_cmd/dto.rs | 2 +- src-tauri/src/commands/aster_agent_cmd/mod.rs | 21 +- .../aster_agent_cmd/prompt_context.rs | 70 + .../run_metadata/request_metadata.rs | 2 + .../commands/aster_agent_cmd/runtime_turn.rs | 85 +- .../src/commands/aster_agent_cmd/tests.rs | 160 +- .../commands/aster_agent_cmd/tool_runtime.rs | 24 +- .../tool_runtime/mcp_resource_tools.rs | 302 ++ .../tool_runtime/search_bridge.rs | 4 +- .../tool_runtime/subagent_tools.rs | 596 +--- .../tool_runtime/workspace_tools.rs | 16 +- ...aterial_cmd.rs => gallery_material_cmd.rs} | 103 +- src-tauri/src/commands/mcp_cmd.rs | 98 +- src-tauri/src/commands/mod.rs | 4 +- src-tauri/src/commands/music_cmd.rs | 270 -- src-tauri/src/commands/novel_cmd.rs | 120 - src-tauri/src/commands/webview_cmd.rs | 133 +- .../dev_bridge/dispatcher/agent_sessions.rs | 2 + src-tauri/src/services/README.md | 1 - .../src/services/artifact_document_service.rs | 467 ++- .../services/artifact_document_validator.rs | 867 +++++- .../src/services/artifact_ops_service.rs | 769 ++++- .../artifact_output_schema_service.rs | 327 +- .../src/services/artifact_prompt_service.rs | 41 +- src-tauri/src/services/mod.rs | 1 - src-tauri/src/services/novel_service.rs | 2719 ----------------- src-tauri/src/skills/catalog.rs | 4 + src-tauri/src/skills/default_skills.rs | 11 + src-tauri/src/theme/mod.rs | 1 - src-tauri/src/theme/novel/command.rs | 100 - src-tauri/src/theme/novel/mod.rs | 3 - src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 3 - .../agent/chat/AgentChatHomeShell.test.tsx | 194 ++ .../agent/chat/AgentChatHomeShell.tsx | 141 +- .../agent/chat/AgentChatWorkspace.tsx | 109 +- .../AgentThreadTimelineArtifactCard.test.tsx | 4 +- .../components/CanvasWorkbenchLayout.test.tsx | 9 +- .../agent/chat/components/ChatNavbar.tsx | 57 +- .../chat/components/DecisionPanel.test.tsx | 100 +- .../agent/chat/components/DecisionPanel.tsx | 261 +- .../agent/chat/components/EmptyState.tsx | 64 +- .../EmptyStateComposerPanel.test.tsx | 16 - .../components/EmptyStateComposerPanel.tsx | 161 +- .../components/HarnessStatusPanel.test.tsx | 12 +- .../chat/components/HarnessStatusPanel.tsx | 7 +- .../chat/components/MarkdownRenderer.test.tsx | 38 + .../chat/components/MarkdownRenderer.tsx | 181 +- .../components/TeamMemoryShadowCard.test.tsx | 90 + .../chat/components/TeamMemoryShadowCard.tsx | 136 + .../TeamWorkbenchSummaryPanel.test.tsx | 81 + .../components/TeamWorkbenchSummaryPanel.tsx | 8 + .../components/ThemeWorkbenchSkillsPanel.tsx | 6 +- .../agent/chat/homeShellEntry.test.ts | 8 +- .../agent/chat/hooks/agentChatCoreUtils.ts | 12 + .../chat/hooks/agentStreamEventProcessor.ts | 6 +- .../chat/hooks/useAsterAgentChat.test.tsx | 756 +++-- .../hooks/useSelectedTeamPreference.test.tsx | 101 + .../chat/hooks/useSelectedTeamPreference.ts | 76 +- .../hooks/useTeamMemoryShadowSync.test.tsx | 225 ++ .../chat/hooks/useTeamMemoryShadowSync.ts | 411 +++ .../hooks/useThemeContextWorkspace.test.tsx | 20 +- .../chat/hooks/useThemeContextWorkspace.ts | 7 +- .../useThemeScopedChatToolPreferences.ts | 15 + .../agent/chat/hooks/useTrayModelShortcuts.ts | 3 - src/components/agent/chat/index.test.tsx | 172 +- .../service-skills/workspaceLaunch.test.ts | 37 + .../chat/service-skills/workspaceLaunch.ts | 26 +- .../utils/actionRequestGovernance.test.ts | 35 + .../chat/utils/actionRequestGovernance.ts | 40 +- .../chat/utils/artifactToolSources.test.ts | 40 +- .../agent/chat/utils/artifactToolSources.ts | 69 +- .../chat/utils/compatSubagentRuntime.test.ts | 2 + .../agent/chat/utils/compatSubagentRuntime.ts | 3 + .../chat/utils/contextualRecommendations.ts | 66 - .../agent/chat/utils/entryPromptComposer.ts | 3 - .../chat/utils/generalAgentPrompt.test.ts | 6 +- .../agent/chat/utils/generalAgentPrompt.ts | 10 +- .../chat/utils/harnessRequestMetadata.test.ts | 36 +- .../chat/utils/harnessRequestMetadata.ts | 4 + .../agent/chat/utils/harnessState.test.ts | 10 +- .../agent/chat/utils/harnessState.ts | 55 +- .../agent/chat/utils/messageArtifacts.test.ts | 15 +- .../agent/chat/utils/modelThemePolicy.test.ts | 53 - .../agent/chat/utils/modelThemePolicy.ts | 85 +- .../agent/chat/utils/styleRuntime.ts | 26 - .../utils/submitOpRuntimeCompaction.test.ts | 90 + .../chat/utils/taskFileCanvasSync.test.ts | 2 +- .../agent/chat/utils/teamPresets.ts | 3 - .../chat/utils/teamRequestMetadata.test.ts | 79 + .../agent/chat/utils/teamRequestMetadata.ts | 53 + .../agent/chat/utils/teamStorage.ts | 6 + .../agent/chat/utils/teamSuggestion.ts | 2 +- .../agent/chat/utils/toolDisplayInfo.ts | 192 +- .../agent/chat/utils/workflowMapping.test.ts | 343 +-- .../agent/chat/utils/workflowMapping.ts | 27 - .../workspace/ArtifactWorkbenchShell.test.tsx | 728 ++++- .../chat/workspace/ArtifactWorkbenchShell.tsx | 48 + .../ArtifactWorkbenchToolbarActions.test.tsx | 8 + .../ArtifactWorkbenchToolbarActions.tsx | 12 +- .../workspace/WorkspaceConversationScene.tsx | 3 - .../workspace/WorkspaceHarnessDialogs.tsx | 44 +- .../artifactWorkbenchActions.test.ts | 77 +- .../workspace/artifactWorkbenchActions.ts | 452 ++- .../workspace/artifactWorkbenchDocument.tsx | 1040 ++++++- .../artifactWorkbenchEditableDraft.ts | 462 +++ .../artifactWorkbenchRewrite.test.ts | 293 ++ .../workspace/artifactWorkbenchRewrite.ts | 522 ++++ .../artifactWorkbenchRewriteConfig.ts | 2 + .../agent/chat/workspace/chatSurfaceProps.ts | 9 +- .../chat/workspace/imageWorkbenchHelpers.ts | 12 - .../workspace/teamWorkbenchPresentation.tsx | 12 +- .../chat/workspace/themeWorkbenchHelpers.ts | 32 +- .../useWorkspaceA2UIRuntime.test.tsx | 46 + .../chat/workspace/useWorkspaceA2UIRuntime.ts | 53 +- ...seWorkspaceArtifactPreviewActions.test.tsx | 1 - .../useWorkspaceArtifactPreviewActions.ts | 34 +- ...WorkspaceArtifactWorkbenchActions.test.tsx | 108 + .../useWorkspaceArtifactWorkbenchActions.tsx | 126 +- .../useWorkspaceCanvasLayoutRuntime.test.tsx | 1 - .../useWorkspaceCanvasLayoutRuntime.ts | 53 +- ...WorkspaceCanvasMessageSyncRuntime.test.tsx | 2 +- .../useWorkspaceCanvasMessageSyncRuntime.ts | 88 +- .../useWorkspaceCanvasPreviewPresentation.tsx | 20 +- .../useWorkspaceCanvasSceneRuntime.tsx | 12 +- .../useWorkspaceCanvasTaskFileSync.test.tsx | 1 - .../useWorkspaceCanvasTaskFileSync.ts | 26 - ...useWorkspaceCanvasWorkflowActions.test.tsx | 291 ++ .../useWorkspaceCanvasWorkflowActions.ts | 88 +- ...WorkspaceConversationScenePresentation.tsx | 24 +- .../useWorkspaceConversationSceneRuntime.tsx | 23 +- ...eWorkspaceHarnessInventoryRuntime.test.tsx | 2 +- .../useWorkspaceHarnessInventoryRuntime.ts | 4 +- .../useWorkspaceInputbarSceneRuntime.tsx | 3 + .../useWorkspaceSendActions.test.tsx | 108 + .../chat/workspace/useWorkspaceSendActions.ts | 5 + ...WorkspaceServiceSkillEntryActions.test.tsx | 124 + .../useWorkspaceServiceSkillEntryActions.ts | 53 +- .../workspace/useWorkspaceSessionRestore.ts | 6 +- .../useWorkspaceShellChromeRuntime.ts | 6 +- ...eWorkspaceThemeWorkbenchScaffoldRuntime.ts | 5 +- ...useWorkspaceThemeWorkbenchShellRuntime.tsx | 3 + .../useWorkspaceWriteFileAction.test.tsx | 3 +- .../workspace/useWorkspaceWriteFileAction.ts | 42 +- .../agent/chat/workspace/workbenchPreview.tsx | 9 + .../chat/workspace/workspaceSendHelpers.ts | 165 +- src/components/artifact/ArtifactList.tsx | 15 +- .../artifact/ArtifactRenderer.test.ts | 8 +- .../artifact/ArtifactRenderer.ui.test.tsx | 179 +- .../artifact/ArtifactToolbar.test.ts | 5 +- src/components/artifact/ArtifactToolbar.tsx | 5 +- src/components/artifact/CanvasAdapter.tsx | 2 +- src/components/artifact/README.md | 9 +- src/components/artifact/canvasAdapterUtils.ts | 119 +- .../renderers/ArtifactDocumentRenderer.tsx | 1074 ++----- .../ArtifactDocumentBlocks.tsx | 1101 +++++++ .../renderers/artifact-document/blockUtils.ts | 365 +++ .../content-creator/a2ui/A2uiSurface.tsx | 31 - .../content-creator/a2ui/adapter.tsx | 20 - .../a2ui/catalog/basic/utils.ts | 36 - .../content-creator/canvas/README.md | 121 - .../content-creator/canvas/canvasUtils.ts | 97 - .../canvas/document/DocumentEditor.tsx | 87 - .../content-creator/canvas/document/README.md | 140 - .../canvas/document/VersionSelector.tsx | 168 - .../canvas/document/editor/index.ts | 2 - .../document/hooks/useDocumentCanvas.ts | 167 - .../canvas/document/hooks/useVersions.ts | 136 - .../content-creator/canvas/document/index.tsx | 48 - .../canvas/document/platforms/index.ts | 10 - .../canvas/document/registerDocumentCanvas.ts | 37 - .../canvas/music/MusicCanvas.tsx | 639 ---- .../canvas/music/MusicToolbar.tsx | 263 -- .../music/analysis/MelodyAnalysisReport.tsx | 208 -- .../canvas/music/analysis/TrackSelector.tsx | 188 -- .../canvas/music/analysis/index.ts | 15 - .../content-creator/canvas/music/api/index.ts | 6 - .../content-creator/canvas/music/api/tauri.ts | 149 - .../canvas/music/editors/LyricsEditor.tsx | 435 --- .../canvas/music/editors/index.ts | 6 - .../canvas/music/export/ExportDialog.tsx | 409 --- .../canvas/music/export/index.ts | 6 - .../canvas/music/hooks/index.ts | 43 - .../canvas/music/hooks/useLyricsEditor.ts | 246 -- .../canvas/music/hooks/useMelodyMimic.ts | 359 --- .../canvas/music/hooks/useMidiPlayback.ts | 401 --- .../canvas/music/hooks/useMusicExport.ts | 214 -- .../canvas/music/hooks/useRhyme.ts | 123 - .../content-creator/canvas/music/index.ts | 102 - .../canvas/music/player/MidiPlayer.tsx | 329 -- .../canvas/music/player/PlaybackControls.tsx | 100 - .../canvas/music/player/ProgressBar.tsx | 224 -- .../canvas/music/player/TempoControl.tsx | 201 -- .../canvas/music/player/VolumeControl.tsx | 191 -- .../canvas/music/player/index.ts | 13 - .../canvas/music/registerMusicCanvas.ts | 37 - .../canvas/music/renderers/ChordDiagram.tsx | 222 -- .../music/renderers/GuitarTabRenderer.tsx | 264 -- .../renderers/NumberedNotationRenderer.tsx | 309 -- .../music/renderers/PianoRollRenderer.tsx | 350 --- .../canvas/music/renderers/index.ts | 8 - .../content-creator/canvas/music/types.ts | 719 ----- .../canvas/music/utils/chordDatabase.ts | 277 -- .../canvas/music/utils/exportFormatters.ts | 198 -- .../canvas/music/utils/index.ts | 51 - .../canvas/music/utils/lyricsParser.ts | 292 -- .../music/utils/numberedNotationParser.ts | 364 --- .../canvas/music/utils/rhymeDetector.ts | 601 ---- .../canvas/novel/NovelCanvas.tsx | 643 ---- .../content-creator/canvas/novel/index.ts | 7 - .../content-creator/canvas/novel/types.ts | 204 -- .../canvas/poster/AlignmentToolbar.tsx | 253 -- .../canvas/poster/ElementToolbar.tsx | 262 -- .../canvas/poster/ExportDialog.tsx | 520 ---- .../canvas/poster/LayerPanel.tsx | 474 --- .../canvas/poster/PageList.tsx | 344 --- .../canvas/poster/PosterCanvas.tsx | 510 ---- .../canvas/poster/PosterToolbar.tsx | 402 --- .../content-creator/canvas/poster/README.md | 304 -- .../canvas/poster/SizeSelector.tsx | 435 --- .../poster/elements/BackgroundElement.tsx | 259 -- .../canvas/poster/elements/ImageElement.tsx | 248 -- .../canvas/poster/elements/README.md | 63 - .../canvas/poster/elements/ShapeElement.tsx | 191 -- .../canvas/poster/elements/TextElement.tsx | 150 - .../canvas/poster/elements/index.ts | 10 - .../canvas/poster/hooks/README.md | 60 - .../canvas/poster/hooks/index.ts | 45 - .../poster/hooks/posterCanvasHotkeys.test.ts | 63 - .../poster/hooks/posterCanvasHotkeys.ts | 115 - .../canvas/poster/hooks/useAlignment.ts | 327 -- .../poster/hooks/useElementOperations.test.ts | 794 ----- .../poster/hooks/useElementOperations.ts | 796 ----- .../canvas/poster/hooks/useExport.test.ts | 358 --- .../canvas/poster/hooks/useExport.ts | 371 --- .../poster/hooks/useFabricCanvas.test.ts | 193 -- .../canvas/poster/hooks/useFabricCanvas.ts | 399 --- .../canvas/poster/hooks/useHistory.test.ts | 332 -- .../canvas/poster/hooks/useHistory.ts | 339 -- .../poster/hooks/useLayerManager.test.ts | 518 ---- .../canvas/poster/hooks/useLayerManager.ts | 282 -- .../poster/hooks/usePageOperations.test.ts | 505 --- .../canvas/poster/hooks/usePageOperations.ts | 333 -- .../content-creator/canvas/poster/index.tsx | 95 - .../canvas/poster/platforms/douyin.ts | 92 - .../canvas/poster/platforms/index.test.ts | 239 -- .../canvas/poster/platforms/index.ts | 101 - .../canvas/poster/platforms/juejin.ts | 72 - .../canvas/poster/platforms/taobao.ts | 99 - .../canvas/poster/platforms/types.ts | 158 - .../canvas/poster/platforms/wechat.ts | 98 - .../canvas/poster/platforms/xiaohongshu.ts | 84 - .../canvas/poster/platforms/zhihu.ts | 71 - .../canvas/poster/registerPosterCanvas.ts | 39 - .../content-creator/canvas/poster/types.ts | 383 --- .../canvas/poster/utils/README.md | 37 - .../poster/utils/alignmentGuides.test.ts | 564 ---- .../canvas/poster/utils/alignmentGuides.ts | 536 ---- .../canvas/poster/utils/index.ts | 52 - .../canvas/poster/utils/layerUtils.ts | 144 - .../canvas/poster/utils/safeZone.test.ts | 271 -- .../canvas/poster/utils/safeZone.ts | 317 -- .../canvas/poster/utils/smartCrop.test.ts | 251 -- .../canvas/poster/utils/smartCrop.ts | 265 -- .../canvas/poster/utils/styleUtils.test.ts | 459 --- .../canvas/poster/utils/styleUtils.ts | 402 --- .../canvas/script/ScriptCanvas.tsx | 471 --- .../content-creator/canvas/script/index.ts | 7 - .../content-creator/canvas/script/types.ts | 142 - .../content-creator/canvas/video/index.ts | 2 - .../core/CanvasContainer/CanvasRegistry.ts | 127 - .../core/CanvasContainer/index.ts | 7 - .../core/LayoutTransition/index.ts | 8 - .../content-creator/hooks/useWorkflow.test.ts | 356 --- .../content-creator/hooks/useWorkflow.ts | 697 ----- .../content-creator/material/index.ts | 8 - .../content-creator/utils/systemPrompt.ts | 2161 ------------- .../image-gen/RecentImageInsertFloating.tsx | 4 - .../image-gen/tabs/ImageSearchTab.tsx | 28 +- .../image-gen/tabs/MyGalleryTab.test.tsx | 2 +- .../image-gen/tabs/MyGalleryTab.tsx | 30 +- src/components/input-kit/ModelSelector.tsx | 3 - src/components/mcp/McpPanel.tsx | 4 + src/components/mcp/McpServerList.tsx | 49 +- src/components/projects/ContentListPage.tsx | 11 - .../projects/CreateProjectDialog.test.tsx | 6 +- src/components/projects/ProjectCategories.tsx | 15 - src/components/projects/ProjectDetailPage.tsx | 26 +- src/components/projects/ProjectsPage.tsx | 9 - src/components/projects/tabs/ContentTab.tsx | 17 +- .../projects/tabs/NovelPublishTab.tsx | 506 --- .../projects/tabs/NovelSettingsPanel.tsx | 1036 ------- src/components/projects/tabs/SettingsTab.tsx | 49 +- src/components/projects/tabs/index.ts | 1 - .../tabs/novel-flow/NovelFlowWorkbench.tsx | 1928 ------------ .../novel-settings/NovelSettingsWizard.tsx | 1223 -------- .../agent/image-gen/index.test.tsx | 4 +- .../settings-v2/agent/image-gen/index.tsx | 9 +- .../agent/video-gen/index.test.tsx | 4 +- .../settings-v2/agent/video-gen/index.tsx | 8 +- .../settings-v2/agent/voice/index.test.tsx | 4 +- .../settings-v2/agent/voice/index.tsx | 9 +- .../general/appearance/index.test.tsx | 6 +- .../general/hotkeys/hotkeyCatalog.test.ts | 6 +- .../general/hotkeys/hotkeyCatalog.ts | 12 - .../general/hotkeys/index.test.tsx | 2 +- .../settings-v2/general/hotkeys/index.tsx | 2 - .../workspace/WorkbenchPage.test.tsx | 40 - src/components/workspace/WorkbenchPage.tsx | 22 +- .../a2ui/README.md | 0 .../a2ui/catalog/basic/childList.ts | 0 .../components/A2UIFormControls.test.tsx | 0 .../basic/components/A2UILayout.test.tsx | 0 .../catalog/basic/components/AudioPlayer.tsx | 0 .../a2ui/catalog/basic/components/Button.tsx | 0 .../a2ui/catalog/basic/components/Card.tsx | 0 .../catalog/basic/components/CheckBox.tsx | 0 .../catalog/basic/components/ChildList.tsx | 6 +- .../catalog/basic/components/ChoicePicker.tsx | 0 .../a2ui/catalog/basic/components/Column.tsx | 0 .../basic/components/DateTimeInput.tsx | 0 .../a2ui/catalog/basic/components/Divider.tsx | 0 .../a2ui/catalog/basic/components/Icon.tsx | 0 .../a2ui/catalog/basic/components/Image.tsx | 0 .../a2ui/catalog/basic/components/List.tsx | 0 .../a2ui/catalog/basic/components/Modal.tsx | 0 .../a2ui/catalog/basic/components/Row.tsx | 0 .../a2ui/catalog/basic/components/Slider.tsx | 0 .../a2ui/catalog/basic/components/Tabs.tsx | 0 .../a2ui/catalog/basic/components/Text.tsx | 0 .../catalog/basic/components/TextField.tsx | 0 .../a2ui/catalog/basic/components/Video.tsx | 0 .../a2ui/catalog/basic/index.ts | 0 .../a2ui/catalog/index.ts | 0 .../catalog/minimal/components/Button.tsx | 0 .../catalog/minimal/components/ChildList.tsx | 0 .../catalog/minimal/components/Column.tsx | 0 .../a2ui/catalog/minimal/components/Row.tsx | 0 .../a2ui/catalog/minimal/components/Text.tsx | 0 .../catalog/minimal/components/TextField.tsx | 0 .../a2ui/catalog/minimal/index.ts | 0 .../a2ui/components/A2UIRenderer.test.tsx | 0 .../a2ui/components/ComponentRenderer.tsx | 0 .../a2ui/components/README.md | 2 +- .../a2ui/components/index.tsx | 2 +- .../a2ui/dataModel.ts | 0 .../a2ui/index.ts | 4 +- .../a2ui/layoutTokens.ts | 0 .../a2ui/parser.test.ts | 0 .../a2ui/parser.ts | 2 +- .../a2ui/protocol.ts | 0 .../a2ui/rendererTokens.ts | 0 .../a2ui/taskCardPresets.ts | 0 .../a2ui/taskCardPrimitives.tsx | 0 .../a2ui/taskCardTokens.ts | 0 .../a2ui/taskFormTokens.ts | 0 .../a2ui/types.ts | 2 +- .../canvas/CanvasFactory.tsx | 99 +- .../canvas/canvasUtils.test.ts | 35 +- .../workspace/canvas/canvasUtils.ts | 74 + .../canvas/shared/CanvasBreadcrumbHeader.tsx | 0 .../document/ContentReviewPanel.test.tsx | 0 .../document/ContentReviewPanel.tsx | 8 +- .../document/DocumentCanvas.tsx | 7 +- .../document/DocumentRenderer.tsx | 12 +- .../document/DocumentToolbar.tsx | 2 +- .../document/PlatformTabs.tsx | 2 +- .../document/contentReviewExperts.test.ts | 0 .../document/contentReviewExperts.ts | 2 +- .../document/documentCanvasHotkeys.test.ts | 0 .../document/documentCanvasHotkeys.ts | 0 .../document/documentEditorHotkeys.test.ts | 0 .../document/documentEditorHotkeys.ts | 0 .../document/editor/BubbleToolbar.tsx | 0 .../document/editor/NotionEditor.tsx | 2 +- .../document/editor/SlashCommand.tsx | 0 .../document/editor/editor-styles.css | 0 .../document/editor/extensions.ts | 0 .../document/editor/utils/markdown.ts | 0 .../platforms/CoverImagePlaceholder.tsx | 2 +- .../document/platforms/MarkdownRenderer.tsx | 2 +- .../document/platforms/WechatRenderer.tsx | 2 +- .../platforms/XiaohongshuRenderer.tsx | 2 +- .../document/platforms/ZhihuRenderer.tsx | 2 +- .../document/types.test.ts | 0 .../canvas => workspace}/document/types.ts | 30 +- .../utils/autoContinueSettings.test.ts | 0 .../document/utils/autoContinueSettings.ts | 0 .../document/utils/autoImageInsert.test.ts | 0 .../document/utils/autoImageInsert.ts | 0 .../document/utils/exportDocument.ts | 0 .../hooks/useCreationDialogs.test.tsx | 2 +- .../workspace/hooks/useCreationDialogs.ts | 78 +- .../hooks/useWorkbenchController.test.tsx | 3 +- .../workspace/hooks/useWorkbenchController.ts | 42 +- .../workspace/hooks/useWorkflow.test.ts | 91 + src/components/workspace/hooks/useWorkflow.ts | 402 +++ .../layout}/LayoutTransition.test.tsx | 6 +- .../layout}/LayoutTransition.tsx | 4 +- .../layout}/StepProgress.tsx | 4 +- .../layout}/useLayoutTransition.test.tsx | 4 +- .../layout}/useLayoutTransition.ts | 4 +- .../media}/ImageGallery.tsx | 20 +- .../workspace/panels/WorkbenchMainContent.tsx | 27 +- .../video/PromptInput.tsx | 0 .../video/VideoCanvas.tsx | 0 .../video/VideoSidebar.tsx | 0 .../video/VideoWorkspace.tsx | 0 .../canvas => workspace}/video/types.ts | 0 .../themes/novel/NovelThemeWorkspace.tsx | 227 -- src/features/themes/novel/api/index.ts | 1 - .../novel/components/AIToolsSection.tsx | 76 - .../novel/components/NovelCategoryCard.tsx | 46 - .../novel/components/RecentWorksSection.tsx | 69 - .../themes/novel/constants/aiTools.ts | 71 - src/features/themes/novel/index.ts | 38 - src/features/themes/novel/panelRenderers.tsx | 22 - src/features/themes/novel/types.ts | 60 - src/features/themes/poster/index.ts | 28 - src/features/themes/registry.ts | 4 - src/features/themes/types.ts | 28 - src/hooks/index.ts | 4 +- src/hooks/subAgentSummary.test.ts | 108 + src/hooks/subAgentSummary.ts | 97 + src/hooks/useAppNavigation.test.tsx | 8 +- ...osterMaterial.ts => useGalleryMaterial.ts} | 136 +- src/hooks/useGlobalMediaGenerationDefaults.ts | 2 +- src/hooks/useMcp.ts | 103 +- src/hooks/useSubAgentScheduler.test.tsx | 4 + src/hooks/useSubAgentScheduler.ts | 30 + src/lib/activeContentTarget.ts | 20 +- src/lib/api/agent.test.ts | 14 +- src/lib/api/agentRuntime.ts | 6 +- src/lib/api/appConfig.test.ts | 56 + src/lib/api/appConfig.ts | 2 +- src/lib/api/appConfigTypes.ts | 4 +- ...rials.test.ts => galleryMaterials.test.ts} | 40 +- src/lib/api/galleryMaterials.ts | 69 + src/lib/api/novel.test.ts | 109 - src/lib/api/novel.ts | 263 -- src/lib/api/posterMaterials.ts | 69 - src/lib/api/project-integration.test.ts | 65 +- src/lib/api/project.test.ts | 66 +- src/lib/api/project.ts | 42 +- src/lib/artifact-document/index.ts | 19 + src/lib/artifact-document/ops.test.ts | 129 +- src/lib/artifact-document/ops.ts | 260 ++ src/lib/artifact-document/parser.test.ts | 13 +- src/lib/artifact-document/parser.ts | 702 ++++- src/lib/artifact-document/types.ts | 240 +- src/lib/artifact/parser.ts | 12 +- src/lib/artifact/types.ts | 40 +- src/lib/canvasImageInsertBus.ts | 19 +- src/lib/dev-bridge/http-client.ts | 18 +- src/lib/dev-bridge/mockPriorityCommands.ts | 1 + src/lib/governance/agentCommandCatalog.json | 20 +- src/lib/governance/legacySurfaceCatalog.json | 398 ++- .../governance/legacySurfaceCatalog.test.ts | 68 + src/lib/hotkeys/types.ts | 3 +- src/lib/novel-flow/actions.ts | 71 - src/lib/novel-flow/pipeline.ts | 166 - src/lib/novel-settings/types.ts | 418 --- src/lib/tauri-mock/core.ts | 7 +- src/lib/teamMemorySync.test.ts | 162 + src/lib/teamMemorySync.ts | 240 ++ src/lib/workspace/a2ui.ts | 10 +- .../workspace}/activityLogger.test.ts | 4 +- .../utils => lib/workspace}/activityLogger.ts | 4 +- .../utils => lib/workspace}/projectPrompt.ts | 2 +- .../workspace}/socialMediaHarness.test.ts | 0 .../workspace}/socialMediaHarness.ts | 4 +- src/lib/workspace/systemPrompt.ts | 189 ++ src/lib/workspace/workbenchCanvas.ts | 54 +- src/lib/workspace/workbenchContract.test.ts | 27 +- src/lib/workspace/workbenchContract.ts | 41 +- src/lib/workspace/workbenchPrompt.ts | 8 +- src/lib/workspace/workbenchRuntime.ts | 6 +- src/lib/workspace/workbenchUi.ts | 8 +- src/lib/workspace/workbenchWorkflow.ts | 2 +- .../workspace/workflowTypes.ts} | 75 +- src/types/chat.ts | 4 +- ...poster-material.ts => gallery-material.ts} | 58 +- src/types/index.ts | 4 +- src/types/page.ts | 16 +- src/types/workspace.ts | 6 - 550 files changed, 18901 insertions(+), 47208 deletions(-) rename src-tauri/crates/core/src/database/dao/{poster_material_dao.rs => gallery_material_dao.rs} (83%) create mode 100644 src-tauri/crates/core/src/database/migration_v5.rs delete mode 100644 src-tauri/resources/music/chord-progressions.json delete mode 100644 src-tauri/resources/music/guofeng-patterns.json delete mode 100644 src-tauri/resources/music/midi-parser-rules.json delete mode 100644 src-tauri/resources/music/pentatonic-rules.json delete mode 100644 src-tauri/resources/music/rhyme-patterns.json delete mode 100644 src-tauri/resources/scripts/audio_to_midi.py delete mode 100644 src-tauri/resources/scripts/midi_analyzer.py create mode 100644 src-tauri/src/commands/aster_agent_cmd/tool_runtime/mcp_resource_tools.rs rename src-tauri/src/commands/{poster_material_cmd.rs => gallery_material_cmd.rs} (56%) delete mode 100644 src-tauri/src/commands/music_cmd.rs delete mode 100644 src-tauri/src/commands/novel_cmd.rs delete mode 100644 src-tauri/src/services/novel_service.rs delete mode 100644 src-tauri/src/theme/novel/command.rs delete mode 100644 src-tauri/src/theme/novel/mod.rs create mode 100644 src/components/agent/chat/components/TeamMemoryShadowCard.test.tsx create mode 100644 src/components/agent/chat/components/TeamMemoryShadowCard.tsx create mode 100644 src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx create mode 100644 src/components/agent/chat/hooks/useTeamMemoryShadowSync.test.tsx create mode 100644 src/components/agent/chat/hooks/useTeamMemoryShadowSync.ts create mode 100644 src/components/agent/chat/utils/teamRequestMetadata.test.ts create mode 100644 src/components/agent/chat/utils/teamRequestMetadata.ts create mode 100644 src/components/agent/chat/workspace/artifactWorkbenchEditableDraft.ts create mode 100644 src/components/agent/chat/workspace/artifactWorkbenchRewrite.test.ts create mode 100644 src/components/agent/chat/workspace/artifactWorkbenchRewrite.ts create mode 100644 src/components/agent/chat/workspace/artifactWorkbenchRewriteConfig.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.test.tsx create mode 100644 src/components/artifact/renderers/artifact-document/ArtifactDocumentBlocks.tsx create mode 100644 src/components/artifact/renderers/artifact-document/blockUtils.ts delete mode 100644 src/components/content-creator/a2ui/A2uiSurface.tsx delete mode 100644 src/components/content-creator/a2ui/adapter.tsx delete mode 100644 src/components/content-creator/a2ui/catalog/basic/utils.ts delete mode 100644 src/components/content-creator/canvas/README.md delete mode 100644 src/components/content-creator/canvas/canvasUtils.ts delete mode 100644 src/components/content-creator/canvas/document/DocumentEditor.tsx delete mode 100644 src/components/content-creator/canvas/document/README.md delete mode 100644 src/components/content-creator/canvas/document/VersionSelector.tsx delete mode 100644 src/components/content-creator/canvas/document/editor/index.ts delete mode 100644 src/components/content-creator/canvas/document/hooks/useDocumentCanvas.ts delete mode 100644 src/components/content-creator/canvas/document/hooks/useVersions.ts delete mode 100644 src/components/content-creator/canvas/document/index.tsx delete mode 100644 src/components/content-creator/canvas/document/platforms/index.ts delete mode 100644 src/components/content-creator/canvas/document/registerDocumentCanvas.ts delete mode 100644 src/components/content-creator/canvas/music/MusicCanvas.tsx delete mode 100644 src/components/content-creator/canvas/music/MusicToolbar.tsx delete mode 100644 src/components/content-creator/canvas/music/analysis/MelodyAnalysisReport.tsx delete mode 100644 src/components/content-creator/canvas/music/analysis/TrackSelector.tsx delete mode 100644 src/components/content-creator/canvas/music/analysis/index.ts delete mode 100644 src/components/content-creator/canvas/music/api/index.ts delete mode 100644 src/components/content-creator/canvas/music/api/tauri.ts delete mode 100644 src/components/content-creator/canvas/music/editors/LyricsEditor.tsx delete mode 100644 src/components/content-creator/canvas/music/editors/index.ts delete mode 100644 src/components/content-creator/canvas/music/export/ExportDialog.tsx delete mode 100644 src/components/content-creator/canvas/music/export/index.ts delete mode 100644 src/components/content-creator/canvas/music/hooks/index.ts delete mode 100644 src/components/content-creator/canvas/music/hooks/useLyricsEditor.ts delete mode 100644 src/components/content-creator/canvas/music/hooks/useMelodyMimic.ts delete mode 100644 src/components/content-creator/canvas/music/hooks/useMidiPlayback.ts delete mode 100644 src/components/content-creator/canvas/music/hooks/useMusicExport.ts delete mode 100644 src/components/content-creator/canvas/music/hooks/useRhyme.ts delete mode 100644 src/components/content-creator/canvas/music/index.ts delete mode 100644 src/components/content-creator/canvas/music/player/MidiPlayer.tsx delete mode 100644 src/components/content-creator/canvas/music/player/PlaybackControls.tsx delete mode 100644 src/components/content-creator/canvas/music/player/ProgressBar.tsx delete mode 100644 src/components/content-creator/canvas/music/player/TempoControl.tsx delete mode 100644 src/components/content-creator/canvas/music/player/VolumeControl.tsx delete mode 100644 src/components/content-creator/canvas/music/player/index.ts delete mode 100644 src/components/content-creator/canvas/music/registerMusicCanvas.ts delete mode 100644 src/components/content-creator/canvas/music/renderers/ChordDiagram.tsx delete mode 100644 src/components/content-creator/canvas/music/renderers/GuitarTabRenderer.tsx delete mode 100644 src/components/content-creator/canvas/music/renderers/NumberedNotationRenderer.tsx delete mode 100644 src/components/content-creator/canvas/music/renderers/PianoRollRenderer.tsx delete mode 100644 src/components/content-creator/canvas/music/renderers/index.ts delete mode 100644 src/components/content-creator/canvas/music/types.ts delete mode 100644 src/components/content-creator/canvas/music/utils/chordDatabase.ts delete mode 100644 src/components/content-creator/canvas/music/utils/exportFormatters.ts delete mode 100644 src/components/content-creator/canvas/music/utils/index.ts delete mode 100644 src/components/content-creator/canvas/music/utils/lyricsParser.ts delete mode 100644 src/components/content-creator/canvas/music/utils/numberedNotationParser.ts delete mode 100644 src/components/content-creator/canvas/music/utils/rhymeDetector.ts delete mode 100644 src/components/content-creator/canvas/novel/NovelCanvas.tsx delete mode 100644 src/components/content-creator/canvas/novel/index.ts delete mode 100644 src/components/content-creator/canvas/novel/types.ts delete mode 100644 src/components/content-creator/canvas/poster/AlignmentToolbar.tsx delete mode 100644 src/components/content-creator/canvas/poster/ElementToolbar.tsx delete mode 100644 src/components/content-creator/canvas/poster/ExportDialog.tsx delete mode 100644 src/components/content-creator/canvas/poster/LayerPanel.tsx delete mode 100644 src/components/content-creator/canvas/poster/PageList.tsx delete mode 100644 src/components/content-creator/canvas/poster/PosterCanvas.tsx delete mode 100644 src/components/content-creator/canvas/poster/PosterToolbar.tsx delete mode 100644 src/components/content-creator/canvas/poster/README.md delete mode 100644 src/components/content-creator/canvas/poster/SizeSelector.tsx delete mode 100644 src/components/content-creator/canvas/poster/elements/BackgroundElement.tsx delete mode 100644 src/components/content-creator/canvas/poster/elements/ImageElement.tsx delete mode 100644 src/components/content-creator/canvas/poster/elements/README.md delete mode 100644 src/components/content-creator/canvas/poster/elements/ShapeElement.tsx delete mode 100644 src/components/content-creator/canvas/poster/elements/TextElement.tsx delete mode 100644 src/components/content-creator/canvas/poster/elements/index.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/README.md delete mode 100644 src/components/content-creator/canvas/poster/hooks/index.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/posterCanvasHotkeys.test.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/posterCanvasHotkeys.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useAlignment.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useElementOperations.test.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useElementOperations.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useExport.test.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useExport.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useFabricCanvas.test.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useFabricCanvas.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useHistory.test.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useHistory.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useLayerManager.test.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/useLayerManager.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/usePageOperations.test.ts delete mode 100644 src/components/content-creator/canvas/poster/hooks/usePageOperations.ts delete mode 100644 src/components/content-creator/canvas/poster/index.tsx delete mode 100644 src/components/content-creator/canvas/poster/platforms/douyin.ts delete mode 100644 src/components/content-creator/canvas/poster/platforms/index.test.ts delete mode 100644 src/components/content-creator/canvas/poster/platforms/index.ts delete mode 100644 src/components/content-creator/canvas/poster/platforms/juejin.ts delete mode 100644 src/components/content-creator/canvas/poster/platforms/taobao.ts delete mode 100644 src/components/content-creator/canvas/poster/platforms/types.ts delete mode 100644 src/components/content-creator/canvas/poster/platforms/wechat.ts delete mode 100644 src/components/content-creator/canvas/poster/platforms/xiaohongshu.ts delete mode 100644 src/components/content-creator/canvas/poster/platforms/zhihu.ts delete mode 100644 src/components/content-creator/canvas/poster/registerPosterCanvas.ts delete mode 100644 src/components/content-creator/canvas/poster/types.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/README.md delete mode 100644 src/components/content-creator/canvas/poster/utils/alignmentGuides.test.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/alignmentGuides.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/index.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/layerUtils.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/safeZone.test.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/safeZone.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/smartCrop.test.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/smartCrop.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/styleUtils.test.ts delete mode 100644 src/components/content-creator/canvas/poster/utils/styleUtils.ts delete mode 100644 src/components/content-creator/canvas/script/ScriptCanvas.tsx delete mode 100644 src/components/content-creator/canvas/script/index.ts delete mode 100644 src/components/content-creator/canvas/script/types.ts delete mode 100644 src/components/content-creator/canvas/video/index.ts delete mode 100644 src/components/content-creator/core/CanvasContainer/CanvasRegistry.ts delete mode 100644 src/components/content-creator/core/CanvasContainer/index.ts delete mode 100644 src/components/content-creator/core/LayoutTransition/index.ts delete mode 100644 src/components/content-creator/hooks/useWorkflow.test.ts delete mode 100644 src/components/content-creator/hooks/useWorkflow.ts delete mode 100644 src/components/content-creator/material/index.ts delete mode 100644 src/components/content-creator/utils/systemPrompt.ts delete mode 100644 src/components/projects/tabs/NovelPublishTab.tsx delete mode 100644 src/components/projects/tabs/NovelSettingsPanel.tsx delete mode 100644 src/components/projects/tabs/novel-flow/NovelFlowWorkbench.tsx delete mode 100644 src/components/projects/tabs/novel-settings/NovelSettingsWizard.tsx rename src/components/{content-creator => workspace}/a2ui/README.md (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/childList.ts (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/A2UIFormControls.test.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/A2UILayout.test.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/AudioPlayer.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Button.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Card.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/CheckBox.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/ChildList.tsx (81%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/ChoicePicker.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Column.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/DateTimeInput.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Divider.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Icon.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Image.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/List.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Modal.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Row.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Slider.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Tabs.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Text.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/TextField.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/components/Video.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/basic/index.ts (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/index.ts (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/minimal/components/Button.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/minimal/components/ChildList.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/minimal/components/Column.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/minimal/components/Row.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/minimal/components/Text.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/minimal/components/TextField.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/catalog/minimal/index.ts (100%) rename src/components/{content-creator => workspace}/a2ui/components/A2UIRenderer.test.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/components/ComponentRenderer.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/components/README.md (91%) rename src/components/{content-creator => workspace}/a2ui/components/index.tsx (99%) rename src/components/{content-creator => workspace}/a2ui/dataModel.ts (100%) rename src/components/{content-creator => workspace}/a2ui/index.ts (91%) rename src/components/{content-creator => workspace}/a2ui/layoutTokens.ts (100%) rename src/components/{content-creator => workspace}/a2ui/parser.test.ts (100%) rename src/components/{content-creator => workspace}/a2ui/parser.ts (99%) rename src/components/{content-creator => workspace}/a2ui/protocol.ts (100%) rename src/components/{content-creator => workspace}/a2ui/rendererTokens.ts (100%) rename src/components/{content-creator => workspace}/a2ui/taskCardPresets.ts (100%) rename src/components/{content-creator => workspace}/a2ui/taskCardPrimitives.tsx (100%) rename src/components/{content-creator => workspace}/a2ui/taskCardTokens.ts (100%) rename src/components/{content-creator => workspace}/a2ui/taskFormTokens.ts (100%) rename src/components/{content-creator => workspace}/a2ui/types.ts (99%) rename src/components/{content-creator => workspace}/canvas/CanvasFactory.tsx (62%) rename src/components/{content-creator => workspace}/canvas/canvasUtils.test.ts (76%) create mode 100644 src/components/workspace/canvas/canvasUtils.ts rename src/components/{content-creator => workspace}/canvas/shared/CanvasBreadcrumbHeader.tsx (100%) rename src/components/{content-creator/canvas => workspace}/document/ContentReviewPanel.test.tsx (100%) rename src/components/{content-creator/canvas => workspace}/document/ContentReviewPanel.tsx (98%) rename src/components/{content-creator/canvas => workspace}/document/DocumentCanvas.tsx (99%) rename src/components/{content-creator/canvas => workspace}/document/DocumentRenderer.tsx (93%) rename src/components/{content-creator/canvas => workspace}/document/DocumentToolbar.tsx (99%) rename src/components/{content-creator/canvas => workspace}/document/PlatformTabs.tsx (96%) rename src/components/{content-creator/canvas => workspace}/document/contentReviewExperts.test.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/contentReviewExperts.ts (97%) rename src/components/{content-creator/canvas => workspace}/document/documentCanvasHotkeys.test.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/documentCanvasHotkeys.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/documentEditorHotkeys.test.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/documentEditorHotkeys.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/editor/BubbleToolbar.tsx (100%) rename src/components/{content-creator/canvas => workspace}/document/editor/NotionEditor.tsx (99%) rename src/components/{content-creator/canvas => workspace}/document/editor/SlashCommand.tsx (100%) rename src/components/{content-creator/canvas => workspace}/document/editor/editor-styles.css (100%) rename src/components/{content-creator/canvas => workspace}/document/editor/extensions.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/editor/utils/markdown.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/platforms/CoverImagePlaceholder.tsx (98%) rename src/components/{content-creator/canvas => workspace}/document/platforms/MarkdownRenderer.tsx (98%) rename src/components/{content-creator/canvas => workspace}/document/platforms/WechatRenderer.tsx (97%) rename src/components/{content-creator/canvas => workspace}/document/platforms/XiaohongshuRenderer.tsx (96%) rename src/components/{content-creator/canvas => workspace}/document/platforms/ZhihuRenderer.tsx (97%) rename src/components/{content-creator/canvas => workspace}/document/types.test.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/types.ts (92%) rename src/components/{content-creator/canvas => workspace}/document/utils/autoContinueSettings.test.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/utils/autoContinueSettings.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/utils/autoImageInsert.test.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/utils/autoImageInsert.ts (100%) rename src/components/{content-creator/canvas => workspace}/document/utils/exportDocument.ts (100%) create mode 100644 src/components/workspace/hooks/useWorkflow.test.ts create mode 100644 src/components/workspace/hooks/useWorkflow.ts rename src/components/{content-creator/core/LayoutTransition => workspace/layout}/LayoutTransition.test.tsx (98%) rename src/components/{content-creator/core/LayoutTransition => workspace/layout}/LayoutTransition.tsx (99%) rename src/components/{content-creator/core/StepGuide => workspace/layout}/StepProgress.tsx (97%) rename src/components/{content-creator/core/LayoutTransition => workspace/layout}/useLayoutTransition.test.tsx (96%) rename src/components/{content-creator/core/LayoutTransition => workspace/layout}/useLayoutTransition.ts (97%) rename src/components/{content-creator/material => workspace/media}/ImageGallery.tsx (96%) rename src/components/{content-creator/canvas => workspace}/video/PromptInput.tsx (100%) rename src/components/{content-creator/canvas => workspace}/video/VideoCanvas.tsx (100%) rename src/components/{content-creator/canvas => workspace}/video/VideoSidebar.tsx (100%) rename src/components/{content-creator/canvas => workspace}/video/VideoWorkspace.tsx (100%) rename src/components/{content-creator/canvas => workspace}/video/types.ts (100%) delete mode 100644 src/features/themes/novel/NovelThemeWorkspace.tsx delete mode 100644 src/features/themes/novel/api/index.ts delete mode 100644 src/features/themes/novel/components/AIToolsSection.tsx delete mode 100644 src/features/themes/novel/components/NovelCategoryCard.tsx delete mode 100644 src/features/themes/novel/components/RecentWorksSection.tsx delete mode 100644 src/features/themes/novel/constants/aiTools.ts delete mode 100644 src/features/themes/novel/index.ts delete mode 100644 src/features/themes/novel/panelRenderers.tsx delete mode 100644 src/features/themes/novel/types.ts delete mode 100644 src/features/themes/poster/index.ts create mode 100644 src/hooks/subAgentSummary.test.ts create mode 100644 src/hooks/subAgentSummary.ts rename src/hooks/{usePosterMaterial.ts => useGalleryMaterial.ts} (56%) rename src/lib/api/{posterMaterials.test.ts => galleryMaterials.test.ts} (59%) create mode 100644 src/lib/api/galleryMaterials.ts delete mode 100644 src/lib/api/novel.test.ts delete mode 100644 src/lib/api/novel.ts delete mode 100644 src/lib/api/posterMaterials.ts delete mode 100644 src/lib/novel-flow/actions.ts delete mode 100644 src/lib/novel-flow/pipeline.ts delete mode 100644 src/lib/novel-settings/types.ts create mode 100644 src/lib/teamMemorySync.test.ts create mode 100644 src/lib/teamMemorySync.ts rename src/{components/content-creator/utils/__tests__ => lib/workspace}/activityLogger.test.ts (96%) rename src/{components/content-creator/utils => lib/workspace}/activityLogger.ts (97%) rename src/{components/content-creator/utils => lib/workspace}/projectPrompt.ts (97%) rename src/{components/content-creator/utils => lib/workspace}/socialMediaHarness.test.ts (100%) rename src/{components/content-creator/utils => lib/workspace}/socialMediaHarness.ts (97%) create mode 100644 src/lib/workspace/systemPrompt.ts rename src/{components/content-creator/types.ts => lib/workspace/workflowTypes.ts} (54%) rename src/types/{poster-material.ts => gallery-material.ts} (83%) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0e2283e79..63a955d3c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,41 +1,39 @@ -## Lime v1.0.0-beta +## Lime v1.0.1 ### ✨ 主要更新 -- **首个 1.0 Beta 节点**:这是 Lime 在连续近 100 个迭代版本后的第一个 `1.0 beta` 发布,标志产品主线从 `0.x` 的快速试验期转入以主路径收敛、体验统一和可交付发布为目标的 Beta 阶段 -- **创作主题类分支集中清理**:海报工作流、模板、品牌人设、风格库、创作主题孤岛与相关前后端接口继续成片收口,减少并行分支与历史包袱,让工作台能力重新回到当前主链 -- **`Claw` 成为 Lime 的主方向**:首页、工作区、Agent Chat、Service Skill、Browser Assist / OpenClaw 相关链路继续统一,`Claw` 被明确收敛为 Lime 的主要入口与默认协作方向 -- **导航与设置继续重构**:侧边栏、主导航、设置分组、开发者工具与系统能力入口完成新一轮整理,旧主题/旧分支入口进一步下沉或移除,整体信息架构更聚焦 -- **工程与发布边界同步补强**:版本一致性、发布说明、契约文档、Playwright / GUI smoke 与 Release 工作流同步更新,确保 `v1.0.0-beta` 能按 Beta 语义发布而不是误标成正式版 +- **主工作台与 Agent Chat 持续收敛**:Home Shell、Workspace、Decision Panel、Markdown 渲染、Service Skill 入口与 Workbench 交互继续围绕当前主链统一,减少入口分叉与状态散落 +- **Artifact Document 与工作区运行时继续加固**:Artifact 文档渲染、校验、输出 schema、操作服务、Canvas / A2UI / Preview / Workflow 链路同步整理,让文档型产物、工作台画布和会话侧的连接更稳定 +- **Team / Subagent / MCP 协作链路补强**:Aster Agent 命令运行时、请求元数据、子代理工具、MCP 命令面板与相关测试继续补齐,围绕团队协作和工具编排做进一步收口 +- **历史创作分支继续清退**:`content-creator`、`novel`、`poster`、`music` 等旧主题、旧命令、旧资源与相关接口进一步删除或下沉,治理目录、命令文档与路线图同步更新,减少长期并行 surface -### ⚠️ 兼容性说明 +### ⚠️ 发布与兼容性说明 -- 本次发布 tag 为 `v1.0.0-beta`,应用内版本号保持为 `1.0.0-beta`;`.github/workflows/release.yml` 会按 prerelease 语义创建 GitHub Release,并直接使用 `RELEASE_NOTES.md` 作为正文 -- Homebrew Tap 工作流会跳过 prerelease,避免把这次 Beta 版本误推到稳定通道 -- GUI 冒烟依赖本机可启动 headless Tauri、`DevBridge`、默认 workspace 和系统 Chrome;若目标环境缺少对应条件,Browser Runtime / 站点适配器相关能力会降级或无法完成验证 -- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.23.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 +- 本次发布 tag 为 `v1.0.1`,应用内版本号保持为 `1.0.1`;`.github/workflows/release.yml` 会按稳定版语义创建 GitHub Release +- Homebrew Tap 更新工作流不会再把本次发布视为 prerelease;Release 发布后会继续走稳定通道同步 +- 当前仓库声明的 `aster-rust` 依赖已提升到 `v0.24.0` +- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.24.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 -### 🔗 依赖同步 +### 🔗 依赖与版本同步 -- 应用版本已同步提升到 `1.0.0-beta`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock` -- 当前仓库前端锁文件仍以受控的 `pnpm-lock.yaml` 为准;本次发布未引入新的前端依赖升级 -- 当前仓库声明的 `aster-rust` 依赖保持在 `v0.23.0` -- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `1.0.0-beta` 对齐 +- 应用版本已同步提升到 `1.0.1`,覆盖 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` +- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `1.0.1` 对齐 +- `aster-core` / `aster-models` 的 git tag 已同步切换到 `v0.24.0` -### 🧪 测试 +### 🧪 发布前校验 -- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all` -- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`npm run lint` -- 发布前执行:`npm run verify:app-version` -- 当前结果:`cargo test` 已通过(723 个 Rust 单测通过,2 个集成测试通过,另有 2 个真实联网测试保持忽略),`cargo clippy` 已完成但仍提示少量既有 warning,`npm run lint` 与 `npm run verify:app-version` 已通过 +- `cargo fmt --manifest-path src-tauri/Cargo.toml --all` +- `cargo test --manifest-path src-tauri/Cargo.toml` +- `cargo clippy --manifest-path src-tauri/Cargo.toml` +- `npm run lint` +- `npm run verify:app-version` +- 当前结果:`npm run verify:app-version` 与 `npm run lint` 已通过;`cargo test` 已通过(`739` 个单测通过,`2` 个集成测试通过,另有 `2` 个真实联网测试保持 `ignored`);`cargo clippy` 已完成,但仍有若干既有 warning(如 `lime-browser-runtime` 的 `unnecessary_map_or`、`lime-agent` 的 `large_enum_variant` / `too_many_arguments`、主 crate 的未使用常量) -### 📝 文档 +### 📝 文档同步 -- 发布说明已切换到当前这次 `v1.0.0-beta` Beta 发布内容,供 GitHub Release 直接读取 -- 工程质量、命令边界、Playwright / GUI 冒烟与扩展说明已围绕当前 Beta 主路径持续同步 +- 发布说明已切换到当前这次 `v1.0.1` 稳定版发布内容,供 GitHub Release 直接读取 +- 命令边界、质量流程、路线图与工作区相关文档会随当前主线收敛继续同步 --- -**完整变更**: `v0.99.0` -> `v1.0.0-beta` +**完整变更**: `v1.0.0-beta` -> `v1.0.1` diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 9b43073d9..b9a44d224 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -30,7 +30,6 @@ - `hooks.md` - 自定义 Hooks - `lib.md` - 前端工具库与运行时封装 - `workspace.md` - Workspace 边界与工作区模型 -- `content-creator.md` - 内容创作工作台与画布联动 - `playwright-e2e.md` - GUI 续测、Playwright MCP、交互验证 - `performance-profiling.md` - 性能分析与剖析方法 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index d21e4b5b0..deb3f186c 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -39,6 +39,25 @@ 这些命令属于当前设置主路径,不应再在页面组件里散落裸 `invoke`。 +图库素材链路也遵循同一原则。当前主入口为 `src/lib/api/galleryMaterials.ts`,统一承接: + +- `create_gallery_material_metadata` +- `get_gallery_material_metadata` +- `get_gallery_material` +- `list_gallery_materials_by_image_category` +- `list_gallery_materials_by_layout_category` +- `list_gallery_materials_by_mood` +- `update_gallery_material_metadata` +- `delete_gallery_material_metadata` + +旧 `poster_material_*` 命名只允许停留在 schema 迁移与治理守卫中,不应重新出现在前端网关、Rust 命令模块或运行时代码里。 + +文档导出链路同样遵循这条路径。当前主入口为 `src/lib/api/document-export.ts`,统一承接: + +- `save_exported_document` + +`Artifact Workbench`、文档工作台与其他导出入口如需把内容落到用户选择的本地路径,应继续复用这条主链,不要在业务组件里重新扩散 `Blob + a.download` 式浏览器旁路。 + ## 命令契约的五个事实源 命令边界不是单文件事实,至少要同时看下面五处: @@ -163,6 +182,7 @@ npm run verify:local 如果命令边界改动影响会话运行时恢复语义,例如: - `agent_runtime_submit_turn.turn_config` 新增或调整 `approval_policy / sandbox_policy` +- `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` 新增或调整 repo-scoped Team 协作记忆注入 - `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_access_mode / recent_preferences / recent_team_selection` - `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` - 话题切换时的 provider/model、权限 accessMode、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛 @@ -218,18 +238,23 @@ npm run verify:local - **会话运行阶段上下文主链**:`getSession` 返回的 `execution_runtime.recent_gate_key / recent_run_title` 负责承接最近一次 Theme Workbench 运行阶段上下文;当前端已命中同一 steady-state gate/run 时,不应继续每回合重复携带 `harness.gate_key / harness.run_title` - **会话内容上下文主链**:`getSession` 返回的 `execution_runtime.recent_content_id` 负责承接最近一次运行态 `content_id`;当前端已命中同一 steady-state 内容时,不应继续每回合重复携带 `harness.content_id` - **运行态摘要主链**:Aster `runtime_status` item -> timeline `turn_summary` +- **上下文压缩策略主链**:`workspace.settings.auto_compact` 是运行时自动压缩的唯一 workspace 级开关;`agent_runtime_submit_turn` 与 `agent_runtime_respond_action` 都会把该设置注入 turn context。值为 `false` 时,Lime 不会做发起前自动压缩,并会显式告诉 Aster 关闭当前回合的内部自动压缩 / overflow recovery 自动压缩;此时只允许用户通过 `agent_runtime_compact_session` 手动压缩。 - **旧 `chat_*` 命令**:已停止注册,不应重新回到 `commands::mod` 或 `generate_handler!` - **旧 `general_chat_*` 边界**:前端 compat 网关与 Rust 命令都已移除,不应重新接入 - **记忆系统**:统一沉淀优先走 `unified_memory_*`,runtime / 上下文视图优先走 `memory_runtime_*` - **旧项目风格命令**:`style_guide_get` / `style_guide_update` 已下线,不应再从前端网关、Rust 注册或 mock 中接回 - **旧项目模板命令**:`create_template` / `list_templates` / `get_template` / `update_template` / `delete_template` / `set_default_template` / `get_default_template` 已下线,不应再从前端网关、Rust 注册或 mock 中接回 - **旧品牌人设扩展命令**:`get_brand_persona` / `get_brand_extension` / `save_brand_extension` / `update_brand_extension` / `delete_brand_extension` / `list_brand_persona_templates` 已下线,不应再从前端网关、Rust 注册或 mock 中接回 +- **图库素材主链**:继续收敛到 `gallery_material_*` 命令族与 `src/lib/api/galleryMaterials.ts`;旧 `create_poster_metadata` / `get_poster_material` / `list_by_*` 命名已下线,不应重新接回 这些示例的意义不是列清单,而是提醒: **不要再造第三套入口,优先继续把能力收敛到已存在的主链。** -补充约定: +补充说明: + +- `execution_runtime.recent_team_selection` 继续承接 steady-state 的 Team 选择恢复 +- `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` 只承接当前请求的 repo-scoped Team 协作记忆,例如最近一次 Team 选择、子代理状态与父会话上下文;它是低优先级协作参考,不替代显式 `selected_team_*` 或 session runtime - **站点能力主链**:继续收敛到 `site_list_adapters / site_recommend_adapters / site_search_adapters / site_get_adapter_info / site_get_adapter_launch_readiness / site_get_adapter_catalog_status / site_import_adapter_yaml_bundle / site_run_adapter` - **站点适配器导入主链**:`site_import_adapter_yaml_bundle` 只负责把外部 YAML 来源编译为 Lime 标准并写入 `imported` 目录,不允许带入第二套 runtime、daemon 或自动唤醒浏览器链路 diff --git a/docs/aiprompts/content-creator.md b/docs/aiprompts/content-creator.md index aecd476fb..c00b08d8e 100644 --- a/docs/aiprompts/content-creator.md +++ b/docs/aiprompts/content-creator.md @@ -1,13 +1,14 @@ -# 内容创作系统 +# 历史内容工作台与画布联动(归档) ## 概述 -内容创作系统支持多种主题(社媒内容、图文海报、歌词曲谱等),通过 `` 标签与结构化 A2UI 产物实现 Agent 响应、版本链与右侧画布的联动。 +本说明仅作为历史治理记录保留,现役主入口已经迁到 `workspace.md` 与 `src/components/workspace/**` / `src/lib/workspace/**`。当前内容工作台通过 `` 标签与结构化 A2UI 产物实现 Agent 响应、版本链与右侧画布联动。 当前治理结论: -- `src/components/content-creator/**` 仍是现役 `current` runtime,本轮不应直接拆除 -- 工作台外层主链应统一经 `src/lib/workspace/*` 网关访问共享能力 +- `src/components/workspace/**` 与 `src/lib/workspace/**` 是现役 `current` runtime,后续能力只允许继续向这里收敛 +- 文档导航、实现判断与后续扩展应优先参考 `workspace.md`,不要再把本文件当成现役入口 +- 不要再直接恢复 `src/components/content-creator/**` 旧路径;若需要共享能力,统一经 `src/lib/workspace/*` 网关访问 - 已删除的根级旧入口如 `src/hooks/usePosterWorkflow.ts`、`src/hooks/useMultiPlatformExport.ts` 不应回流 - 已删除的海报 workflow 孤岛 `src/components/content-creator/workflows/poster/**`、`src/lib/workspace/workbenchPoster.ts` 不应回流 @@ -36,11 +37,13 @@ useWorkspaceWriteFileAction → 映射为社媒 harness 产物 / 版本链 ``` src/ ├── components/ -│ ├── content-creator/ +│ ├── workspace/ │ │ ├── canvas/ # 现役画布 runtime │ │ ├── a2ui/ # A2UI 结构与解析实现 -│ │ ├── utils/ # prompt / harness / activity 等内部实现 -│ │ └── hooks/ # 模块内 Hook(仅保留现役实现) +│ │ ├── document/ # 文档画布与编辑器 +│ │ ├── layout/ # 布局过渡与工作台壳 +│ │ ├── media/ # 素材库与媒体面板 +│ │ └── hooks/ # 工作台 Hook │ └── agent/chat/ │ ├── hooks/ │ │ ├── index.ts # useAgentChatUnified 统一入口 @@ -64,15 +67,14 @@ src/ ### 1. workbenchPrompt.ts / systemPrompt.ts - 系统提示词生成 -根据主题和创作模式生成 AI 系统提示词。外层主链统一经 `src/lib/workspace/workbenchPrompt.ts` 访问,底层实现仍位于 `content-creator` 模块内部。 +根据主题和工作台模式生成 AI 系统提示词。外层主链统一经 `src/lib/workspace/workbenchPrompt.ts` 访问,不应再恢复 `content-creator` 内部旧实现路径。 ```typescript -// src/lib/workspace/workbenchPrompt.ts - -export { +// 运行时代码统一从 workspace 网关进入 +import { generateSystemPrompt, generateProjectMemoryPrompt, -} from "@/components/content-creator/utils/*"; +} from "@/lib/workspace/workbenchPrompt"; ``` **关键指令**:系统提示词要求 AI 使用 `` 标签输出内容: @@ -211,7 +213,7 @@ const handleWriteFile = useWorkspaceWriteFileAction(...); - 社媒主题已不再把 `write_file` 仅视为“文件覆盖”,而是映射为带阶段语义的版本链产物 - `brief / draft / polished / platform variant / publish package` 应分别作为不同产物语义处理 - 日志、运行轨迹、正文产物三层分离:`harness` 产生命名事件,日志只做投影,正文仍由画布/产物承载 -- 外层主链不要再直接 import `@/components/content-creator/**`,统一走 `src/lib/workspace/*` +- 外层主链不要再直接 import `@/components/content-creator/**`,统一走 `src/lib/workspace/*` 与 `@/components/workspace/**` - 根级旧 Hook `src/hooks/usePosterWorkflow.ts`、`src/hooks/useMultiPlatformExport.ts` 已删除,不应回流 - 海报 workflow 孤岛 `src/components/content-creator/workflows/poster/**` 与 `src/lib/workspace/workbenchPoster.ts` 已删除,不应回流 diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index 6287644f4..e4f3800c8 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -60,7 +60,7 @@ lime/ |------|------| | `workspace/` | 工作区与项目边界,承载文件、会话与配置上下文 | | `components/agent/` | Agent 对话主入口,负责会话、流式事件与交互 | -| `components/content-creator/` | 主题化创作工作台与画布联动 | +| `components/workspace/` + `lib/workspace/` | 主题工作台、画布联动与共享工作区能力 | | `skills/` | 技能加载、标准校验与经验编排能力;统一遵循 `skill-standard.md` | | `lib/artifact/` | Artifact 解析、状态与轻量渲染器 | | `memory / personas` | 项目记忆与人设沉淀 | @@ -222,7 +222,6 @@ lime/ ### 产品与工作台 - [workspace.md](workspace.md) - Workspace 边界与工作区设计 -- [content-creator.md](content-creator.md) - 主题化创作工作台 - [skill-standard.md](skill-standard.md) - 统一技能标准、目录与运行边界 - [../../src-tauri/src/skills/README.md](../../src-tauri/src/skills/README.md) - Skills 标准与集成 - [terminal.md](terminal.md) - 终端能力 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index e4cb71dc1..b899169a8 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -210,6 +210,17 @@ npm run test:contracts 3. 在两个话题之间来回切换,必要时新建一个空白话题再切回 4. 验证 Team 选择器、摘要区和 Team Workbench 展示恢复的是该话题最近一次 `recent_team_selection`,而不是主题级 localStorage 的旧值 5. 对 custom Team 额外确认:切回后 label / description / roles 没丢;如果本轮是从 fallback 回填,继续切换一次确认第二次开始已优先走 runtime 恢复 +6. 如果当前项目已有子代理或父会话上下文,再发送一条新消息,确认 Team Workbench 的 shadow 卡片与当前 Team 恢复一致,不会退回到全局 theme fallback;本轮如涉及 `harness.team_memory_shadow`,这里就是最小 GUI 续测锚点 + +### 上下文压缩链路验证 + +1. 准备一个长线程,确保能够稳定接近上下文上限 +2. 在 `workspace.settings.auto_compact=true` 时发送普通消息,确认需要时会自动压缩,并且时间线出现 `自动压缩` +3. 再把同一工作区切到 `workspace.settings.auto_compact=false` +4. 分别验证两条链路: + - 普通发送消息 + - ask-user / elicitation 回填后继续执行 +5. 两条链路都不应再静默自动压缩;如果达到上下文上限,页面应出现“请先手动压缩上下文或新建会话后重试”的可见错误 ### 运行时交接制品验证 @@ -289,7 +300,8 @@ npm run test:contracts 1. 从社媒内容项目进入 `素材` 2. 验证素材列表可加载 3. 验证素材计数、列表项或空状态正常显示 -4. 检查控制台无新增 error +4. 如当前环境能查看调试面板或 DevBridge 日志,优先确认素材页读取的是 `gallery_material_*` 命令,而不是旧 `poster_material_*` 命名 +5. 检查控制台无新增 error ## 每一步至少记录什么 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 4eeae3ac1..92a591a98 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -70,6 +70,8 @@ 如果本轮是在下线项目模板或品牌人设扩展旧链路,`create_template` / `list_templates` / `get_template` / `update_template` / `delete_template` / `set_default_template` / `get_default_template`,以及 `get_brand_persona` / `get_brand_extension` / `save_brand_extension` / `update_brand_extension` / `delete_brand_extension` / `list_brand_persona_templates` 也必须同步从前端 API、Rust 注册、services/core 模型、默认 mock 和 GUI 入口中撤掉。 +如果本轮是在清退旧图库素材命名,`create_poster_metadata` / `get_poster_metadata` / `get_poster_material` / `update_poster_metadata` / `delete_poster_metadata` / `list_by_*`,以及 `PosterMaterial*` / `poster_material_*` 表名与模块名也必须同步从前端网关、Rust 注册、DAO 与治理目录册中撤掉;如需保留历史数据,只允许在 schema 迁移中短暂停留旧表名。最低校验至少包含 `npm run test:contracts` 与 `npm run governance:legacy-report`。 + ### 3. 用户可见 UI 改动必须补稳定回归 - 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言 @@ -171,7 +173,9 @@ npm run bridge:health -- --timeout-ms 120000 高频场景: - 修改 `safeInvoke` / `invoke` +- 修改 `src/lib/api/document-export.ts`、`save_exported_document`,或把新的 GUI 导出入口接到本地文件保存主链 - 修改 `agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` +- 修改 `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` - 修改 `agent_runtime_update_session` 或会话 provider/model / recent_access_mode / recent_preferences / recent_team_selection 恢复语义 - 修改 `execution_runtime.recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.access_mode / harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑 - 修改首页 / 工作区进入 `Claw` 时的首条自动发送上下文,例如 `initialUserPrompt`、`initialAutoSendRequestMetadata`、`harness.service_skill_launch` @@ -218,10 +222,17 @@ npm run bridge:health -- --timeout-ms 120000 - 如果这次改动影响 `Claw` 与站点技能的直跑门禁,还应补回归证明:阻断停留在技能入口层,不再把浏览器准备态注入成对话里的继续执行确认。 - 如果这次改动把 `content_id` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: - session 已有 `execution_runtime.recent_content_id` 时,前端不会重复提交相同 `harness.content_id` +- 如果这次改动涉及上下文压缩语义,至少要同时验证两条运行时链路: + - 普通 `agent_runtime_submit_turn` 发消息链路 + - `agent_runtime_respond_action` 的 ask-user / elicitation 恢复链路 + 二者在 `workspace.settings.auto_compact=false` 时都不应再偷偷触发自动压缩,而应把“请手动压缩或新建会话”的错误显式投影到前端。 - 切换到新 content 但 runtime 尚未同步时,前端仍会保留显式 `content_id` - 如果这次改动把 `theme / session_mode` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: - session 已有 `execution_runtime.recent_theme / recent_session_mode` 时,前端不会重复提交相同 `harness.theme / harness.session_mode` - 切换到新 theme 或 `theme_workbench` 但 runtime 尚未同步时,前端仍会保留显式 `theme / session_mode` +- 如果这次改动影响 `harness.team_memory_shadow` 这类 repo-scoped Team 协作上下文,除了契约检查之外,还应补: + - 前端发送边界回归,确认 `team_memory_shadow` 能随当前请求进入 `agent_runtime_submit_turn` + - Rust `prompt_context` 定向测试,确认 shadow 只作为低优先级协作参考,不覆盖显式 `selected_team_*` 或 `recent_team_selection` - 如果这次改动把 `accessMode` steady-state 从“只写 harness metadata”收敛到正式 turn context 与 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: - turn 提交始终携带正式 `approval_policy / sandbox_policy` - session 已有 `execution_runtime.recent_access_mode` 时,切换话题会恢复对应 accessMode,而不是回退到工作区默认值 diff --git a/docs/aiprompts/workspace.md b/docs/aiprompts/workspace.md index fb997b92d..cfb50b669 100644 --- a/docs/aiprompts/workspace.md +++ b/docs/aiprompts/workspace.md @@ -100,7 +100,7 @@ pub struct Workspace { pub struct WorkspaceSettings { pub mcp_config: Option, // workspace 级 MCP 配置 pub default_provider: Option, // 默认 provider - pub auto_compact: bool, // 自动压缩 context + pub auto_compact: bool, // 是否允许运行时自动压缩上下文;关闭后只保留手动压缩 } ``` diff --git a/docs/content/02.user-guide/4.configuration-example.md b/docs/content/02.user-guide/4.configuration-example.md index ecfd5a869..246a006ed 100644 --- a/docs/content/02.user-guide/4.configuration-example.md +++ b/docs/content/02.user-guide/4.configuration-example.md @@ -16,7 +16,7 @@ navigation: 目标:少配置、快开始。 ```yaml -profile: "solo-creator" +profile: "solo-content" navigation: enabled: - agent diff --git a/docs/prd/tools/README.md b/docs/prd/tools/README.md index a0895229c..158dc3357 100644 --- a/docs/prd/tools/README.md +++ b/docs/prd/tools/README.md @@ -8,7 +8,7 @@ - Aster 默认内置工具 - Lime 注入工具 -- Creator 专属工具 +- Workbench 专属工具 - Browser Assist 兼容工具面 - Lime MCP runtime tools - Aster ExtensionManager 注入后的 prefixed tools @@ -111,7 +111,7 @@ Lime 实际已经具备这些能力: - Aster built-ins - Lime 注入工具 -- Creator 工具面 +- Workbench 工具面 - Browser Assist 兼容前缀 并明确了: @@ -236,7 +236,7 @@ Lime 实际已经具备这些能力: - **Core surface catalog total**:26 个 -### 4.2 Creator surface +### 4.2 Workbench surface 在 Core 之上额外增加 8 个: @@ -249,7 +249,7 @@ Lime 实际已经具备这些能力: - `lime_create_url_parse_task` - `lime_create_typesetting_task` -- **Creator surface catalog total**:34 个 +- **Workbench surface catalog total**:34 个 ### 4.3 Browser Assist surface @@ -261,7 +261,7 @@ Lime 实际已经具备这些能力: 参考 Aster 的 `chrome_mcp/tools.rs`,当前浏览器工具定义为 **17 个**。 - **Browser Assist surface catalog total**:27 个 -- **Creator + Browser Assist 全量 surface**:35 个 +- **Workbench + Browser Assist 全量 surface**:35 个 --- diff --git a/docs/prd/tools/architecture.md b/docs/prd/tools/architecture.md index 7c39cd32f..3bedf28b9 100644 --- a/docs/prd/tools/architecture.md +++ b/docs/prd/tools/architecture.md @@ -38,7 +38,7 @@ - 工具目录完整性 - source / lifecycle / capability / permission_plane - 默认 allowlist 子集 -- Creator / Browser Assist surface 裁剪 +- Workbench / Browser Assist surface 裁剪 - MCP extension surface 聚合 ### 2.3 执行权限事实源 diff --git a/docs/prd/tools/development-plan.md b/docs/prd/tools/development-plan.md index 6c9a11350..7f5b563ee 100644 --- a/docs/prd/tools/development-plan.md +++ b/docs/prd/tools/development-plan.md @@ -34,7 +34,7 @@ - [x] 引入 `ToolSourceKind` - [x] 引入 `ToolPermissionPlane` - [x] 引入 `ToolLifecycle` -- [x] 补全 core / creator / browser assist tools +- [x] 补全 core / workbench / browser assist tools - [x] 形成默认 allowlist 子集 ### 验收标准 diff --git a/docs/prd/tools/inventory.md b/docs/prd/tools/inventory.md index b23961940..14a4a5cad 100644 --- a/docs/prd/tools/inventory.md +++ b/docs/prd/tools/inventory.md @@ -42,7 +42,7 @@ --- -## 1.2 Creator surface 增量(8) +## 1.2 Workbench surface 增量(8) - `social_generate_cover_image` - `lime_create_video_generation_task` @@ -53,7 +53,7 @@ - `lime_create_url_parse_task` - `lime_create_typesetting_task` -### Creator 总数 +### Workbench 总数 - **34 个 catalog entries** @@ -72,7 +72,7 @@ - **27 个 catalog entries** -### Creator + Browser Assist 总数 +### Workbench + Browser Assist 总数 - **35 个 catalog entries** @@ -130,7 +130,7 @@ import { getAgentRuntimeToolInventory } from "@/lib/api/agentRuntime"; const snapshot = await getAgentRuntimeToolInventory({ caller: "assistant", - creator: true, + workbench: true, browserAssist: true, metadata: { harness: { diff --git a/docs/roadmap/artifacts/architecture-blueprint.md b/docs/roadmap/artifacts/architecture-blueprint.md index 75b609d9e..d64bbceaa 100644 --- a/docs/roadmap/artifacts/architecture-blueprint.md +++ b/docs/roadmap/artifacts/architecture-blueprint.md @@ -56,7 +56,7 @@ - `src/components/agent/chat/workspace/workbenchPreview.tsx` - `src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx` - `src/components/artifact/ArtifactRenderer.tsx` -- `src/components/content-creator/canvas/document/editor/NotionEditor.tsx` +- `src/components/workspace/document/editor/NotionEditor.tsx` - `src-tauri/src/services/agent_timeline_service.rs` 从这些事实源可以确认: @@ -531,7 +531,8 @@ Stage 2 的职责是生成正式结构化交付物。 必须输出: - `artifact_document_draft` -- 或增量 `artifact ops` +- 或正式单条 incremental op +- `artifact_ops` 只作为兼容回退 必须遵守: diff --git a/docs/roadmap/artifacts/artifact-document-v1.md b/docs/roadmap/artifacts/artifact-document-v1.md index 318781322..7d44d0c1e 100644 --- a/docs/roadmap/artifacts/artifact-document-v1.md +++ b/docs/roadmap/artifacts/artifact-document-v1.md @@ -1,7 +1,7 @@ # ArtifactDocument v1 协议草案 -> 状态:提案 -> 更新时间:2026-03-24 +> 状态:进行中,block renderer 映射、current-first 协议读取、Markdown / HTML / JSON 桌面导出主链已落地,完整导出态仍未完成 +> 更新时间:2026-03-31 > 运行时边界:turn metadata、prompt 组装入口、runtime output schema 注入链以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义 `ArtifactDocument v1` 的产品层协议与校验映射 > 依赖文档:`docs/roadmap/artifacts/roadmap.md` > 架构蓝图:`docs/roadmap/artifacts/architecture-blueprint.md` @@ -555,8 +555,9 @@ export type ArtifactOpEnvelope = - 判断是否需要 Artifact - 判断文档 kind - 判断是否需要 sources -2. `artifact_document_draft` 或 `artifact ops` +2. `artifact_document_draft`、正式单条 incremental op,或兼容态 `artifact_ops` - 正式生成结构化内容 + - 运行时内部按 current-first action apply;`artifact_ops` 只保留 compat 输入回退 这样比“一次自然语言长回复”更稳定。 diff --git a/docs/roadmap/artifacts/roadmap.md b/docs/roadmap/artifacts/roadmap.md index 75a5da8d6..604913845 100644 --- a/docs/roadmap/artifacts/roadmap.md +++ b/docs/roadmap/artifacts/roadmap.md @@ -1,7 +1,7 @@ # Lime 高配版 Artifacts 路线图 -> 状态:进行中,P1 / P2 已落地,P3 已闭环,rewrite typed patch 合同已落地 -> 更新时间:2026-03-25 +> 状态:进行中,P1 / P2 已落地,P3 已闭环,rewrite typed patch 与 current incremental 合同已落地 +> 更新时间:2026-03-31 > 运行时边界:发送边界、runtime metadata、Team 委派、协议瘦身以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义 Artifact 产品层与 Workbench 主线 > 目标:把 Lime 从“能显示文件/画布的聊天工作台”升级为“交付物优先的 Artifact Workbench”,让回复不再只是普通 Markdown,而是可扫描、可编辑、可版本化、可复用的正式产物 > @@ -29,8 +29,8 @@ - `src/components/agent/chat/workspace/workbenchPreview.tsx` - `src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx` - `src/lib/workspace/workbenchCanvas.ts` -- `src/components/content-creator/canvas/document/DocumentRenderer.tsx` -- `src/components/content-creator/canvas/document/editor/NotionEditor.tsx` +- `src/components/workspace/document/DocumentRenderer.tsx` +- `src/components/workspace/document/editor/NotionEditor.tsx` - `src-tauri/src/services/agent_timeline_service.rs` 从这些事实源可以确认: @@ -49,13 +49,16 @@ 3. 后端已具备 `ArtifactDocument v1` 的 validator / repair / fallback / workspace 落盘能力。 4. Timeline snapshot metadata 已可回灌 `artifactDocument`,前端在 `content` 为空时也能直接渲染结构化文档。 5. 前端已落地最小 `artifact-protocol` 壳层,用于统一 runtime metadata 中的 `artifactDocument` 与 `artifact_path(s)` 读取合同。 -6. 后端已支持最小 `artifact_ops` 增量协议,可把 `artifact.upsert_block / attach_source / finalize_version` 等操作应用到已有 `ArtifactDocument` 并生成新版本。 +6. 后端已支持最小 current-first 增量应用链,并兼容 ingest `artifact_ops`;可把 `artifact.upsert_block / attach_source / finalize_version` 等操作应用到已有 `ArtifactDocument` 并生成新版本。 7. 右侧已接入最小 `ArtifactWorkbenchShell`,包含阅读面与 `概览 / 来源 / 版本 / 差异` inspector。 8. 当前版本已支持最小 block diff 摘要,以及来源项 / 差异项到文档 block 的 Workbench 内跳转。 9. `rewrite` 已把 `artifact_target_block_id` 贯通到 prompt / output schema / ops apply / persist 链路,非目标 block 的 op 会在运行时被忽略并记录 issue。 -10. `rewrite` 现已支持专用 `artifact_rewrite_patch` envelope,并保留 `artifact_ops` 兼容回退,用于逐步收紧模型输出合同。 +10. `rewrite` 现已支持专用 `artifact_rewrite_patch` envelope,并接受正式单条 incremental op;`artifact_ops` 只保留兼容回退,用于逐步收紧模型输出合同。 +11. 前端 `src/lib/artifact-document/*` 已补齐 current-first operation candidate 读取边界,可统一识别正式单条 incremental op、`artifact_rewrite_patch` 与 `artifact_ops` compat 回退。 +12. Rust `artifact_ops_service` 内部 apply 已切到 normalized action 列表;`current incremental` 与 `artifact_rewrite_patch` 不再先包成 `artifact_ops`,后者只保留 compat 输入壳。 +13. Artifact Workbench 的 Markdown / HTML / Artifact JSON 导出已接入统一桌面导出链,复用保存对话框与 `save_exported_document` 主路径,不再走浏览器下载旁路。 -这意味着当前主线已经从“只有路线图”推进到“结构合同 + 版本快照 + Workbench inspector 闭环”。当前仍然属于后续阶段的,主要是更细粒度的 typed rewrite patch 合同,以及编辑态 / 展示态 / 导出态的进一步同源。 +这意味着当前主线已经从“只有路线图”推进到“结构合同 + 版本快照 + Workbench inspector 闭环”。当前仍然属于后续阶段的,主要是编辑态 / 展示态 / 导出态的进一步同源、更完整的导出格式与分享链,以及在模型稳定后进一步收紧 rewrite 的 `artifact_ops` compat 分支。 ## 2. 现状判断 diff --git a/docs/roadmap/artifacts/system-prompt-and-schema-contract.md b/docs/roadmap/artifacts/system-prompt-and-schema-contract.md index 0e9e75304..85abb9039 100644 --- a/docs/roadmap/artifacts/system-prompt-and-schema-contract.md +++ b/docs/roadmap/artifacts/system-prompt-and-schema-contract.md @@ -1,7 +1,7 @@ # Artifact Workbench 的 System Prompt 与 Schema 合同 -> 状态:进行中,核心合同已落地,P3 产品闭环已落地,rewrite typed patch 合同已落地 -> 更新时间:2026-03-25 +> 状态:进行中,核心合同已落地,P3 产品闭环已落地,rewrite typed patch 与 current incremental 合同已落地 +> 更新时间:2026-03-31 > 运行时边界:prompt 组装入口、turn metadata 主合同、runtime output schema 注入链以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只细化 Artifact 相关合同 > 关联文档: > - `docs/roadmap/artifacts/architecture-blueprint.md` @@ -16,16 +16,16 @@ 1. 后端已按 turn metadata 组装 Artifact 专属 prompt 段落。 2. Artifact 回合已在 `runtime_turn` 中绑定 turn-level output schema。 3. 结构化输出已经接入 validator / repair / fallback,并可落盘为 `ArtifactDocument v1`。 -4. `stage2` 已允许输出 `artifact_document_draft | artifact_ops`,`rewrite` 已收紧到增量 `artifact_ops`。 -5. 后端已支持最小 `artifact_ops` 应用链,可在已有 Artifact 上执行 block / source / version 级增量更新。 +4. `stage2` 已允许输出 `artifact_document_draft | current 单条 incremental op | artifact_ops`,`rewrite` 已允许输出 `artifact_rewrite_patch | current 单条 incremental op | artifact_ops`。 +5. 后端已支持最小 current-first 增量应用链,可在已有 Artifact 上执行 block / source / version 级增量更新;`artifact_ops` 只保留 compat 输入壳。 6. 当前版本已回灌 `currentVersionDiff / artifactVersionDiff`,Workbench 也已接入来源抽屉、差异面以及来源项 / 差异项到 block 的定位。 7. 当前版本已支持 `timeline item <-> artifact block` 双向跳转:timeline 可精确打开目标 block,Workbench 也可回跳对应过程项。 -8. `rewrite` 已把 `artifact_target_block_id` 下沉到 prompt hint、turn-level output schema、`artifact_ops` runtime apply 与 persist validator context;非目标 block 的改写 / 绑定 / 删除会被忽略并记录 issue。 -9. `rewrite` 已支持专用 `artifact_rewrite_patch` 输出 envelope,并在后端兼容转换为 `artifact_ops` 应用链,便于逐步把改写合同从“通用 ops”收紧到“目标 block patch”。 +8. `rewrite` 已把 `artifact_target_block_id` 下沉到 prompt hint、turn-level output schema、runtime apply 与 persist validator context;非目标 block 的改写 / 绑定 / 删除会被忽略并记录 issue。 +9. `rewrite` 已支持专用 `artifact_rewrite_patch` 输出 envelope,同时接受正式单条 incremental op;后端会直接把它们归一化到内部 action 列表,`artifact_ops` 只作为 compat 回退输入,便于逐步把改写合同从“通用 ops”收紧到“目标 block patch”。 仍未完全落地的部分: -1. rewrite 已具备 typed patch 主合同,但当前仍保留 `artifact_ops` 兼容分支;待模型稳定后可进一步收紧到单一 rewrite envelope +1. rewrite 已具备 typed patch + current 单条 op 主合同,但当前仍保留 `artifact_ops` 兼容分支;待模型稳定后可进一步收紧到单一 rewrite envelope ## 1. 核心观点 @@ -259,7 +259,8 @@ interface ArtifactStage1Result { 职责: - 输出正式 `artifact_document_draft` -- 或输出增量 `artifact ops` +- 或输出正式单条 incremental op +- `artifact_ops` 只作为兼容回退 必须: @@ -275,10 +276,11 @@ type ArtifactStage2Result = type: "artifact_document_draft"; document: ArtifactDocumentV1; } + | ArtifactOpEnvelope | { type: "artifact_ops"; artifactId: string; - ops: ArtifactOpEnvelope[]; + ops: Array>; }; ``` @@ -292,11 +294,32 @@ type ArtifactStage2Result = 建议 schema 形态: ```ts -interface ArtifactRewriteResult { - artifactId: string; - targetBlockId: string; - block: ArtifactBlockV1; -} +type ArtifactRewriteResult = + | { + type: "artifact_rewrite_patch"; + artifactId: string; + targetBlockId: string; + block: ArtifactBlockV1; + source?: ArtifactSourceV1; + sources?: ArtifactSourceV1[]; + summary?: string; + status?: ArtifactStatus; + } + | Extract< + ArtifactOpEnvelope, + { + type: + | "artifact.source.upsert" + | "artifact.block.upsert" + | "artifact.complete" + | "artifact.fail"; + } + > + | { + type: "artifact_ops"; + artifactId: string; + ops: Array>; + }; ``` ## 8. Output Schema 策略 diff --git a/package.json b/package.json index 63f084379..a7e3c0fe2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.0.0-beta", + "version": "1.0.1", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/scripts/check-harness-contracts.mjs b/scripts/check-harness-contracts.mjs index 749d05220..fc29a05ad 100644 --- a/scripts/check-harness-contracts.mjs +++ b/scripts/check-harness-contracts.mjs @@ -171,6 +171,7 @@ function main() { "selected_team_description:", "selected_team_summary:", "selected_team_roles:", + "team_memory_shadow:", "browser_requirement:", "browser_requirement_reason:", "browser_launch_url:", @@ -225,6 +226,8 @@ function main() { '("selectedTeamSummary", "selected_team_summary")', '("selected_team_roles", "selected_team_roles")', '("selectedTeamRoles", "selected_team_roles")', + '("team_memory_shadow", "team_memory_shadow")', + '("teamMemoryShadow", "team_memory_shadow")', '("browser_requirement", "browser_requirement")', '("browserRequirement", "browser_requirement")', '("browser_requirement_reason", "browser_requirement_reason")', diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f6a032d6a..71203df14 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -378,7 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.23.0" +version = "0.24.0" dependencies = [ "ahash", "anyhow", @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.23.0" +version = "0.24.0" dependencies = [ "serde", "serde_json", @@ -2417,7 +2417,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -2433,7 +2433,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.3", "smallvec", ] @@ -4374,7 +4374,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.57.0", ] [[package]] @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "async-trait", "lime-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "aster-models", "async-trait", @@ -5307,7 +5307,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "aes", "axum 0.7.9", @@ -5372,7 +5372,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5424,7 +5424,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "async-trait", "lime-core", @@ -5443,7 +5443,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "anyhow", "async-stream", @@ -5498,7 +5498,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "aster-core", "async-stream", @@ -5543,7 +5543,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "axum 0.7.9", "futures", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "anyhow", "aster-core", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5618,7 +5618,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.0.0-beta" +version = "1.0.1" dependencies = [ "axum 0.7.9", "chrono", @@ -6324,7 +6324,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.117", @@ -7077,9 +7077,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", - "proc-macro-hack", ] [[package]] @@ -7088,7 +7086,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ + "phf_macros 0.10.0", "phf_shared 0.10.0", + "proc-macro-hack", ] [[package]] @@ -7192,12 +7192,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -7609,7 +7609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -9108,7 +9108,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 4.0.0", + "dirs 6.0.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e92822a5a..365f84c2a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "1.0.0-beta" +version = "1.0.1" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -127,8 +127,8 @@ enigo = "0.3" # 如需联调本地 aster-rust,请运行: # npm run setup:local-aster -- /path/to/aster-rust # 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。 -aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.23.0" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.23.0" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.24.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.24.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -192,7 +192,7 @@ version = "2.4" [package] name = "lime" -version = "1.0.0-beta" +version = "1.0.1" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/ask_bridge.rs b/src-tauri/crates/agent/src/ask_bridge.rs index b2100ef59..c84f41b48 100644 --- a/src-tauri/crates/agent/src/ask_bridge.rs +++ b/src-tauri/crates/agent/src/ask_bridge.rs @@ -6,33 +6,36 @@ use aster::action_required_manager::ActionRequiredManager; use aster::conversation::message::ActionRequiredScope; use aster::session_context::{current_action_scope, current_session_id}; +use aster::tools::ask::{AskQuestion, AskRequest}; use aster::tools::AskCallback; use serde_json::{json, Value}; use std::time::Duration; const DEFAULT_ASK_TIMEOUT_SECS: u64 = 300; +const ASK_USER_QUESTIONS_SCHEMA_KEY: &str = "x-lime-ask-user-questions"; /// 创建 AskTool 回调 pub fn create_ask_callback() -> AskCallback { - std::sync::Arc::new(|question: String, options: Option>| { + std::sync::Arc::new(|request: AskRequest| { Box::pin(async move { - let requested_schema = build_requested_schema(&question, options.as_deref()); + let prompt = resolve_request_prompt(&request); + let requested_schema = build_requested_schema(&request); let scope = resolve_action_scope(); match ActionRequiredManager::global() .request_and_wait_scoped( scope, - question.clone(), + prompt.clone(), requested_schema, Duration::from_secs(DEFAULT_ASK_TIMEOUT_SECS), ) .await { - Ok(user_data) => extract_response(&user_data), + Ok(user_data) => extract_response(&request, &user_data), Err(err) => { tracing::warn!( - "[AsterAgent][AskBridge] 用户输入等待失败: question='{}', err={}", - question, + "[AsterAgent][AskBridge] 用户输入等待失败: prompt='{}', err={}", + prompt, err ); None @@ -53,74 +56,226 @@ fn resolve_action_scope() -> ActionRequiredScope { }) } -/// 构建 elicitation 的请求 schema -fn build_requested_schema(question: &str, options: Option<&[String]>) -> Value { - if let Some(options) = options { - let options: Vec = options.iter().map(|item| json!(item)).collect(); - json!({ - "type": "object", - "properties": { - "answer": { - "type": "string", - "description": question, - "enum": options - }, - "other": { - "type": "string", - "description": "可选:自由输入答案" - } - }, - "required": ["answer"] - }) - } else { - json!({ - "type": "object", - "properties": { - "answer": { - "type": "string", - "description": question - } - }, - "required": ["answer"] - }) - } +fn resolve_request_prompt(request: &AskRequest) -> String { + request + .questions + .first() + .map(|question| question.question.trim().to_string()) + .filter(|question| !question.is_empty()) + .unwrap_or_else(|| "请提供继续执行所需信息".to_string()) } -/// 从前端回传的 user_data 中提取可用于 AskTool 的字符串答案 -pub fn extract_response(user_data: &Value) -> Option { - match user_data { - Value::String(s) => { - let value = s.trim(); - if value.is_empty() { - None - } else { - Some(value.to_string()) +fn question_field_key(question: &AskQuestion, index: usize, total: usize) -> String { + if total == 1 { + return "answer".to_string(); + } + + if let Some(header) = question.header.as_deref() { + let normalized = header + .trim() + .to_lowercase() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .to_string(); + + if !normalized.is_empty() { + return normalized; + } + } + + format!("question_{}", index + 1) +} + +fn build_question_schema(question: &AskQuestion, index: usize, total: usize) -> (String, Value) { + let field_key = question_field_key(question, index, total); + let option_labels = question + .options + .iter() + .map(|option| option.display().to_string()) + .collect::>(); + + let mut property = json!({ + "title": question.header.clone().unwrap_or_else(|| question.question.clone()), + "description": question.question, + }); + + if let Some(object) = property.as_object_mut() { + if question.multi_select { + object.insert("type".to_string(), json!("array")); + object.insert( + "items".to_string(), + json!({ + "type": "string", + "enum": option_labels, + }), + ); + } else { + object.insert("type".to_string(), json!("string")); + if !option_labels.is_empty() { + object.insert("enum".to_string(), json!(option_labels)); } } + } + + (field_key, property) +} + +/// 构建 elicitation 的请求 schema,同时携带 rich ask 问题元数据。 +fn build_requested_schema(request: &AskRequest) -> Value { + let total = request.questions.len(); + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for (index, question) in request.questions.iter().enumerate() { + let (field_key, property) = build_question_schema(question, index, total); + properties.insert(field_key.clone(), property); + required.push(field_key); + } + + json!({ + "type": "object", + "properties": properties, + "required": required, + ASK_USER_QUESTIONS_SCHEMA_KEY: request.questions, + }) +} + +fn normalize_answer_value(question: &AskQuestion, value: &Value) -> Option { + let raw_values = match value { + Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + vec![trimmed.to_string()] + } + Value::Array(items) => items + .iter() + .filter_map(|item| match item { + Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + _ => None, + }) + .collect::>(), + Value::Number(number) => vec![number.to_string()], + Value::Bool(value) => vec![value.to_string()], + _ => return None, + }; + + if raw_values.is_empty() { + return None; + } + + let normalized = raw_values + .into_iter() + .map(|raw| { + question + .options + .iter() + .find(|option| raw == option.display() || raw == option.value) + .map(|option| option.value.clone()) + .unwrap_or(raw) + }) + .collect::>(); + + Some(normalized.join(", ")) +} + +fn collect_answers(request: &AskRequest, user_data: &Value) -> serde_json::Map { + let mut answers = serde_json::Map::new(); + let total = request.questions.len(); + + match user_data { + Value::String(_) | Value::Array(_) | Value::Number(_) | Value::Bool(_) => { + if let Some(question) = request.questions.first() { + if let Some(answer) = normalize_answer_value(question, user_data) { + answers.insert(question.question.clone(), json!(answer)); + } + } + return answers; + } Value::Object(map) => { - if let Some(Value::String(other)) = map.get("other") { - let trimmed = other.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); + if let Some(Value::Object(existing_answers)) = map.get("answers") { + for question in &request.questions { + if let Some(value) = existing_answers.get(&question.question) { + if let Some(answer) = normalize_answer_value(question, value) { + answers.insert(question.question.clone(), json!(answer)); + } + } } } - if let Some(Value::String(answer)) = map.get("answer") { - let trimmed = answer.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); + for (index, question) in request.questions.iter().enumerate() { + if answers.contains_key(&question.question) { + continue; + } + + for key in [ + question.question.clone(), + question.header.clone().unwrap_or_default(), + question_field_key(question, index, total), + ] { + if key.is_empty() { + continue; + } + + if let Some(value) = map.get(&key) { + if let Some(answer) = normalize_answer_value(question, value) { + answers.insert(question.question.clone(), json!(answer)); + break; + } + } } } - // 兼容 ask_user 场景可能返回的任意对象,降级为 JSON 字符串 - serde_json::to_string(user_data) - .ok() - .filter(|s| !s.is_empty()) + if answers.is_empty() && total == 1 { + let candidate = map.get("other").or_else(|| map.get("answer")); + if let (Some(question), Some(value)) = (request.questions.first(), candidate) { + if let Some(answer) = normalize_answer_value(question, value) { + answers.insert(question.question.clone(), json!(answer)); + } + } + } } - _ => serde_json::to_string(user_data) - .ok() - .filter(|s| !s.is_empty()), + _ => {} } + + answers +} + +/// 从前端回传的 user_data 中提取 AskTool 可消费的结构化答案。 +pub fn extract_response(request: &AskRequest, user_data: &Value) -> Option { + let answers = collect_answers(request, user_data); + if answers.is_empty() { + return None; + } + + if request.questions.len() == 1 { + let question_text = request.questions[0].question.clone(); + let answer = answers.get(&question_text)?.clone(); + return Some(json!({ + "answer": answer, + "answers": { + question_text: answer, + } + })); + } + + Some(json!({ "answers": answers })) } #[cfg(test)] @@ -157,4 +312,112 @@ mod tests { } ); } + + #[test] + fn build_requested_schema_embeds_questions_extension() { + let request = AskRequest { + questions: vec![AskQuestion { + question: "你希望主色调是什么?".to_string(), + header: Some("主色调".to_string()), + options: vec![ + aster::tools::AskOption { + value: "blue-purple".to_string(), + label: Some("蓝紫".to_string()), + description: Some("冷色科技感".to_string()), + preview: None, + }, + aster::tools::AskOption { + value: "cyber-green".to_string(), + label: Some("赛博绿".to_string()), + description: Some("高亮未来感".to_string()), + preview: None, + }, + ], + multi_select: false, + }], + }; + + let schema = build_requested_schema(&request); + assert_eq!( + schema + .get(ASK_USER_QUESTIONS_SCHEMA_KEY) + .and_then(|value| value.as_array()) + .map(|value| value.len()), + Some(1) + ); + assert_eq!( + schema["properties"]["answer"]["enum"], + serde_json::json!(["蓝紫", "赛博绿"]) + ); + } + + #[test] + fn extract_response_normalizes_question_answers() { + let request = AskRequest { + questions: vec![ + AskQuestion::new("第一问"), + AskQuestion { + question: "第二问".to_string(), + header: Some("mode".to_string()), + options: vec![ + aster::tools::AskOption::with_label("auto", "自动执行"), + aster::tools::AskOption::with_label("confirm", "确认后执行"), + ], + multi_select: false, + }, + ], + }; + + let response = extract_response( + &request, + &serde_json::json!({ + "question_1": "先看结构", + "mode": "确认后执行" + }), + ) + .expect("expected normalized response"); + + assert_eq!( + response, + serde_json::json!({ + "answers": { + "第一问": "先看结构", + "第二问": "confirm" + } + }) + ); + } + + #[test] + fn extract_response_normalizes_multi_select_answers() { + let request = AskRequest { + questions: vec![AskQuestion { + question: "请选择能力".to_string(), + header: Some("skills".to_string()), + options: vec![ + aster::tools::AskOption::with_label("analysis", "分析"), + aster::tools::AskOption::with_label("coding", "编码"), + ], + multi_select: true, + }], + }; + + let response = extract_response( + &request, + &serde_json::json!({ + "answer": ["分析", "编码"] + }), + ) + .expect("expected normalized response"); + + assert_eq!( + response, + serde_json::json!({ + "answer": "analysis, coding", + "answers": { + "请选择能力": "analysis, coding" + } + }) + ); + } } diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 2dac3ec2c..269ce3b2c 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -9,7 +9,8 @@ use aster::conversation::message::{ }; use aster::session::{ItemRuntime, ItemRuntimePayload, ItemStatus, TurnRuntime, TurnStatus}; use lime_core::database::dao::agent_timeline::{ - AgentThreadItem, AgentThreadItemPayload, AgentThreadTurn, + AgentRequestOption, AgentRequestQuestion, AgentThreadItem, AgentThreadItemPayload, + AgentThreadTurn, }; use regex::Regex; @@ -35,6 +36,7 @@ const TOOL_RESULT_TRUNCATED_NOTICE: &str = "\n\n[event_converter] 工具输出 const TOOL_RESULT_DIAG_WARN_JSON_BYTES: usize = 64 * 1024; const TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS: usize = 8_000; const TOOL_RESULT_DIAG_WARN_IMAGE_COUNT: usize = 4; +const ASK_USER_QUESTIONS_SCHEMA_KEY: &str = "x-lime-ask-user-questions"; fn enhance_execution_error_text(raw: &str) -> String { if !raw.contains("Execution error: No such file or directory (os error 2)") { @@ -702,6 +704,98 @@ fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String]) normalize_legacy_turn_summary_text(&lines.join("\n")) } +fn extract_request_options(value: &serde_json::Value) -> Option> { + let options = value.as_array()?; + let normalized = options + .iter() + .filter_map(|item| match item { + serde_json::Value::String(label) => { + let trimmed = label.trim(); + if trimmed.is_empty() { + None + } else { + Some(AgentRequestOption { + label: trimmed.to_string(), + description: None, + }) + } + } + serde_json::Value::Object(map) => { + let label = map + .get("label") + .and_then(serde_json::Value::as_str) + .or_else(|| map.get("value").and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let description = map + .get("description") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + Some(AgentRequestOption { + label: label.to_string(), + description, + }) + } + _ => None, + }) + .collect::>(); + + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +fn extract_request_questions_from_schema( + requested_schema: Option<&serde_json::Value>, +) -> Option> { + let schema = requested_schema?.as_object()?; + let raw_questions = schema.get(ASK_USER_QUESTIONS_SCHEMA_KEY)?.as_array()?; + let normalized = raw_questions + .iter() + .filter_map(|item| { + let record = item.as_object()?; + let question = record + .get("question") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let header = record + .get("header") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let options = record.get("options").and_then(extract_request_options); + let multi_select = match record + .get("multiSelect") + .or_else(|| record.get("multi_select")) + { + Some(serde_json::Value::Bool(value)) => Some(*value), + _ => None, + }; + + Some(AgentRequestQuestion { + question, + header, + options, + multi_select, + }) + }) + .collect::>(); + + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload { match payload { ItemRuntimePayload::UserMessage { content } => { @@ -773,13 +867,13 @@ fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload { request_id, action_type, prompt, - requested_schema: _, + requested_schema, response, } => AgentThreadItemPayload::RequestUserInput { request_id, action_type, prompt, - questions: None, + questions: extract_request_questions_from_schema(requested_schema.as_ref()), response, }, } @@ -1583,6 +1677,23 @@ mod tests { action_type: "elicitation".to_string(), prompt: Some("请补充发布渠道".to_string()), requested_schema: Some(serde_json::json!({ + ASK_USER_QUESTIONS_SCHEMA_KEY: [ + { + "question": "请补充发布渠道", + "header": "channel", + "options": [ + { + "label": "小红书", + "description": "适合图文种草" + }, + { + "value": "wechat-video", + "label": "视频号" + } + ], + "multiSelect": false + } + ], "type": "object", "properties": { "channel": { "type": "string" } @@ -1612,7 +1723,24 @@ mod tests { assert_eq!(request_id, "request-1"); assert_eq!(action_type, "elicitation"); assert_eq!(prompt.as_deref(), Some("请补充发布渠道")); - assert_eq!(questions, &None); + assert_eq!( + questions, + &Some(vec![AgentRequestQuestion { + question: "请补充发布渠道".to_string(), + header: Some("channel".to_string()), + options: Some(vec![ + AgentRequestOption { + label: "小红书".to_string(), + description: Some("适合图文种草".to_string()), + }, + AgentRequestOption { + label: "视频号".to_string(), + description: None, + }, + ]), + multi_select: Some(false), + }]) + ); assert_eq!(response, &None); } other => panic!("Unexpected payload: {other:?}"), diff --git a/src-tauri/crates/agent/src/prompt/templates.rs b/src-tauri/crates/agent/src/prompt/templates.rs index 2811a98b0..b3bf57849 100644 --- a/src-tauri/crates/agent/src/prompt/templates.rs +++ b/src-tauri/crates/agent/src/prompt/templates.rs @@ -31,13 +31,15 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略 ### 搜索工具 - **glob**: 使用 glob 模式搜索文件路径 - **grep**: 使用正则表达式搜索文件内容 +- **ToolSearch**: 搜索当前会话可用工具,尤其是 extension / MCP / 延迟加载工具 +- **ListMcpResourcesTool / ReadMcpResourceTool**: 浏览和读取 MCP 资源 ### 系统工具 -- **bash**: 执行 shell 命令 -- **Task** / **TaskOutput** / **KillShell**: 管理长时终端任务 +- **bash**: 执行 shell 命令;需要后台运行时使用 `background=true` +- **TaskOutput** / **TaskStop**: 读取或终止后台任务 ### 任务管理工具 -- **TodoWrite**: 创建和管理任务列表 +- **TaskCreate / TaskList / TaskGet / TaskUpdate**: 创建和管理任务板 - **EnterPlanMode** / **ExitPlanMode**: 显式进入或结束规划阶段 ### 委派工具 @@ -61,7 +63,7 @@ pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 ## 基本原则 1. **先理解再修改**:在修改代码之前,先阅读相关文件理解现有模式和架构 -2. **使用 TodoWrite 规划**:对于复杂任务,先用 TodoWrite 工具规划步骤 +2. **使用 Task* 规划**:对于复杂任务,先用 `TaskCreate / TaskList / TaskGet / TaskUpdate` 维护任务板 3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 `spawn_agent` 创建真实子代理;对强依赖既有上下文的延续任务,优先 `send_input` 4. **安全第一**:避免引入安全漏洞(命令注入、XSS、SQL 注入等) 5. **避免过度工程**:只做必要的修改,保持解决方案简单 @@ -82,7 +84,7 @@ pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 /// 任务管理指南 pub const TASK_MANAGEMENT: &str = r#"# 任务管理 -你可以使用 TodoWrite 工具来管理和规划任务。频繁使用这个工具来: +你可以使用 `TaskCreate / TaskList / TaskGet / TaskUpdate` 来管理和规划任务。频繁使用这些工具来: - 跟踪你的任务进度 - 让用户了解你的工作状态 - 将复杂任务分解为小步骤 @@ -90,10 +92,13 @@ pub const TASK_MANAGEMENT: &str = r#"# 任务管理 ## 使用示例 当用户请求一个复杂任务时: -1. 先用 TodoWrite 创建任务列表 -2. 开始执行第一个任务 -3. 完成后立即标记为已完成 -4. 继续下一个任务 +1. 先用 TaskCreate 创建任务 +2. 需要查看全量计划时用 TaskList +3. 需要查看单个任务时用 TaskGet +4. 推进执行时用 TaskUpdate 更新状态与依赖 +5. 开始执行第一个任务 +6. 完成后立即标记为已完成 +7. 继续下一个任务 不要批量完成多个任务后再标记,应该完成一个标记一个。 diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index 2647030fe..346dfef88 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -54,6 +54,7 @@ const ASTER_AUTO_COMPACTION_START_PREFIX: &str = "Exceeded auto-compact threshol const ASTER_AUTO_COMPACTION_COMPLETE_TEXT: &str = "Compaction complete"; const ASTER_AUTO_COMPACTION_THINKING_TEXT: &str = "aster is compacting the conversation..."; const ASTER_AUTO_COMPACTION_ERROR_PREFIX: &str = "Ran into this error trying to compact:"; +const ASTER_AUTO_COMPACTION_DISABLED_TEXT: &str = "Automatic compaction is disabled for this turn. The conversation reached the context limit. Compact the session manually or start a new session before retrying."; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -385,6 +386,15 @@ impl AutoCompactionProjectionState { { Some(vec![]) } + SystemNotificationType::InlineMessage + if notification_text == ASTER_AUTO_COMPACTION_DISABLED_TEXT => + { + Some(vec![RuntimeAgentEvent::Error { + message: + "当前会话已达到上下文上限,但当前工作区已关闭自动压缩。请先手动压缩上下文或新建会话后重试。" + .to_string(), + }]) + } _ => None, } } @@ -1651,6 +1661,59 @@ fn derive_preflight_query(message_text: &str) -> String { #[cfg(test)] mod tests { use super::*; + use aster::providers::base::{Provider, ProviderMetadata, ProviderUsage}; + use aster::providers::errors::ProviderError; + use aster::session::{SessionManager, SessionType, TurnContextOverride}; + use async_trait::async_trait; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + struct ContextLengthExceededProvider; + + #[async_trait] + impl Provider for ContextLengthExceededProvider { + fn metadata() -> ProviderMetadata + where + Self: Sized, + { + ProviderMetadata::empty() + } + + fn get_name(&self) -> &str { + "context-length-exceeded-provider" + } + + async fn complete_with_model( + &self, + _model_config: &aster::model::ModelConfig, + _system: &str, + _messages: &[Message], + _tools: &[rmcp::model::Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + Err(ProviderError::ContextLengthExceeded( + "mock context overflow".to_string(), + )) + } + + fn get_model_config(&self) -> aster::model::ModelConfig { + aster::model::ModelConfig::new("gpt-5.3-codex").expect("test model config") + } + } + + fn build_auto_compaction_disabled_turn_context() -> TurnContextOverride { + let mut metadata = HashMap::new(); + metadata.insert( + "lime_runtime".to_string(), + serde_json::json!({ + "auto_compact": false, + }), + ); + TurnContextOverride { + metadata, + ..TurnContextOverride::default() + } + } #[test] fn resolves_effective_web_search_with_request_override() { @@ -1882,4 +1945,98 @@ mod tests { other => panic!("Expected compaction error event, got {other:?}"), } } + + #[test] + fn auto_compaction_projection_surfaces_disabled_auto_compaction_limit_as_error() { + let mut state = AutoCompactionProjectionState::default(); + + let events = state + .project_event(&AsterAgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + ASTER_AUTO_COMPACTION_DISABLED_TEXT, + ), + )) + .expect("应识别自动压缩禁用后的上下文上限提示"); + + assert_eq!(events.len(), 1); + match &events[0] { + RuntimeAgentEvent::Error { message } => { + assert_eq!( + message, + "当前会话已达到上下文上限,但当前工作区已关闭自动压缩。请先手动压缩上下文或新建会话后重试。" + ); + } + other => panic!("Expected compaction disabled error event, got {other:?}"), + } + } + + #[tokio::test] + async fn stream_message_reply_with_policy_should_surface_disabled_auto_compaction_limit_from_aster( + ) { + let session = SessionManager::create_session( + PathBuf::default(), + "lime-auto-compact-disabled".to_string(), + SessionType::Hidden, + ) + .await + .expect("应创建测试 session"); + let agent = Agent::new(); + agent + .update_provider(Arc::new(ContextLengthExceededProvider), &session.id) + .await + .expect("应配置测试 provider"); + + let session_config = aster::agents::SessionConfig { + id: session.id.clone(), + thread_id: None, + turn_id: Some("turn-auto-compact-disabled".to_string()), + schedule_id: None, + max_turns: None, + retry_config: None, + system_prompt: None, + include_context_trace: None, + turn_context: Some(build_auto_compaction_disabled_turn_context()), + }; + let policy = resolve_request_tool_policy(Some(false), false); + let mut runtime_events = Vec::new(); + + let error = stream_message_reply_with_policy( + &agent, + Message::user().with_text("继续处理"), + None, + session_config, + None, + &policy, + |event| runtime_events.push(event.clone()), + ) + .await + .expect_err("禁用自动压缩时应透出上下文上限错误"); + + assert_eq!( + error.message, + "当前会话已达到上下文上限,但当前工作区已关闭自动压缩。请先手动压缩上下文或新建会话后重试。" + ); + assert!( + runtime_events.iter().any(|event| matches!( + event, + RuntimeAgentEvent::Error { message } + if message + == "当前会话已达到上下文上限,但当前工作区已关闭自动压缩。请先手动压缩上下文或新建会话后重试。" + )), + "应向前端投影显式错误" + ); + assert!( + !runtime_events + .iter() + .any(|event| matches!(event, RuntimeAgentEvent::ContextCompactionStarted { .. })), + "禁用自动压缩后,不应再投影 compaction started" + ); + assert!( + !runtime_events + .iter() + .any(|event| matches!(event, RuntimeAgentEvent::ContextCompactionCompleted { .. })), + "禁用自动压缩后,不应再投影 compaction completed" + ); + } } diff --git a/src-tauri/crates/core/src/config/mod.rs b/src-tauri/crates/core/src/config/mod.rs index 53eb61e61..43a720035 100644 --- a/src-tauri/crates/core/src/config/mod.rs +++ b/src-tauri/crates/core/src/config/mod.rs @@ -22,29 +22,29 @@ pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, AsrCredentialEntry, AsrProviderType, AutomationExecutionMode, AutomationSettings, BaiduConfig, ChannelsConfig, - ChatAppearanceConfig, CloudflareTunnelConfig, Config, ContentCreatorConfig, - ConversationSettings, CrashReportingConfig, CredentialEntry, CredentialPoolConfig, - CustomProviderConfig, DeliveryConfig, DiscordAccountConfig, DiscordActionsConfig, - DiscordAgentComponentsConfig, DiscordAutoPresenceConfig, DiscordBotConfig, - DiscordChannelConfig, DiscordExecApprovalsConfig, DiscordGuildConfig, DiscordIntentsConfig, - DiscordThreadBindingsConfig, DiscordUiComponentsConfig, DiscordUiConfig, - DiscordVoiceAutoJoinConfig, DiscordVoiceConfig, EndpointProvidersConfig, EnvironmentConfig, - EnvironmentVariableOverride, ExperimentalFeatures, FeishuAccountConfig, FeishuBotConfig, - FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig, GeminiApiKeyEntry, - HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig, InjectionRuleConfig, - InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig, MemoryProfileConfig, - MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig, MultiSearchConfig, - MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, - PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, - RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig, - ScreenshotChatConfig, SearchEngine, ServerConfig, ShellEnvironmentImportConfig, TaskSchedule, - TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig, - ToolCallingConfig, ToolExecutionOverrideConfig, ToolExecutionPolicyConfig, - ToolExecutionRestrictionProfileConfig, ToolExecutionSandboxProfileConfig, - ToolExecutionWarningPolicyConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry, - VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, - VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WechatAccountConfig, - WechatBotConfig, WechatGroupConfig, WhisperLocalConfig, WhisperModelSize, - WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY, + ChatAppearanceConfig, CloudflareTunnelConfig, Config, ConversationSettings, + CrashReportingConfig, CredentialEntry, CredentialPoolConfig, CustomProviderConfig, + DeliveryConfig, DiscordAccountConfig, DiscordActionsConfig, DiscordAgentComponentsConfig, + DiscordAutoPresenceConfig, DiscordBotConfig, DiscordChannelConfig, DiscordExecApprovalsConfig, + DiscordGuildConfig, DiscordIntentsConfig, DiscordThreadBindingsConfig, + DiscordUiComponentsConfig, DiscordUiConfig, DiscordVoiceAutoJoinConfig, DiscordVoiceConfig, + EndpointProvidersConfig, EnvironmentConfig, EnvironmentVariableOverride, ExperimentalFeatures, + FeishuAccountConfig, FeishuBotConfig, FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig, + GeminiApiKeyEntry, HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig, + InjectionRuleConfig, InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig, + MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig, + MultiSearchConfig, MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig, + OpenAIAsrConfig, PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig, + QuotaExceededConfig, RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, + RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine, ServerConfig, + ShellEnvironmentImportConfig, TaskSchedule, TelegramAccountConfig, TelegramBotConfig, + TelegramGroupConfig, TelegramTopicConfig, TlsConfig, ToolCallingConfig, + ToolExecutionOverrideConfig, ToolExecutionPolicyConfig, ToolExecutionRestrictionProfileConfig, + ToolExecutionSandboxProfileConfig, ToolExecutionWarningPolicyConfig, UpdateCheckConfig, + UserProfile, VertexApiKeyEntry, VertexModelAlias, VoiceConfig, VoiceInputConfig, + VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, + WebSearchProvider, WechatAccountConfig, WechatBotConfig, WechatGroupConfig, WhisperLocalConfig, + WhisperModelSize, WorkspacePreferencesConfig, WorkspaceSandboxConfig, XunfeiConfig, + DEFAULT_API_KEY, }; pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService}; diff --git a/src-tauri/crates/core/src/config/tests.rs b/src-tauri/crates/core/src/config/tests.rs index 467dc42b7..b31a85ad8 100644 --- a/src-tauri/crates/core/src/config/tests.rs +++ b/src-tauri/crates/core/src/config/tests.rs @@ -2,7 +2,7 @@ //! //! 使用 proptest 进行属性测试 -use crate::config::types::{ContentCreatorConfig, NavigationConfig}; +use crate::config::types::{NavigationConfig, WorkspacePreferencesConfig}; use crate::config::{ collapse_tilde, contains_tilde, expand_tilde, Config, ConfigManager, CustomProviderConfig, HotReloadManager, InjectionSettings, LoggingConfig, ProviderConfig, ProvidersConfig, @@ -192,7 +192,7 @@ fn arb_config() -> impl Strategy { language: "zh".to_string(), experimental: crate::config::ExperimentalFeatures::default(), tool_calling: crate::config::ToolCallingConfig::default(), - content_creator: ContentCreatorConfig::default(), + workspace_preferences: WorkspacePreferencesConfig::default(), navigation: NavigationConfig::default(), }) } @@ -434,7 +434,7 @@ fn arb_valid_config() -> impl Strategy { language: "zh".to_string(), experimental: crate::config::ExperimentalFeatures::default(), tool_calling: crate::config::ToolCallingConfig::default(), - content_creator: ContentCreatorConfig::default(), + workspace_preferences: WorkspacePreferencesConfig::default(), navigation: NavigationConfig::default(), }) } @@ -486,7 +486,7 @@ fn arb_invalid_config() -> impl Strategy { language: "zh".to_string(), experimental: crate::config::ExperimentalFeatures::default(), tool_calling: crate::config::ToolCallingConfig::default(), - content_creator: ContentCreatorConfig::default(), + workspace_preferences: WorkspacePreferencesConfig::default(), navigation: NavigationConfig::default(), }; // 根据类型使配置无效 diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 480060cbf..4f4005232 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -392,9 +392,9 @@ pub struct Config { /// Tool Calling 2.0 配置 #[serde(default)] pub tool_calling: ToolCallingConfig, - /// 内容创作配置 - #[serde(default)] - pub content_creator: ContentCreatorConfig, + /// 工作区偏好配置 + #[serde(default, rename = "workspace_preferences")] + pub workspace_preferences: WorkspacePreferencesConfig, /// 导航栏配置 #[serde(default)] pub navigation: NavigationConfig, @@ -632,11 +632,11 @@ impl Default for NativeAgentConfig { } } -// ============ 内容创作配置类型 ============ +// ============ 工作区偏好配置类型 ============ -/// 内容创作配置 +/// 工作区偏好配置 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ContentCreatorConfig { +pub struct WorkspacePreferencesConfig { /// 工作区偏好配置版本 #[serde(default)] pub schema_version: u8, @@ -649,7 +649,7 @@ fn current_workspace_preferences_schema_version() -> u8 { 1 } -impl Default for ContentCreatorConfig { +impl Default for WorkspacePreferencesConfig { fn default() -> Self { Self { schema_version: current_workspace_preferences_schema_version(), @@ -2138,7 +2138,7 @@ impl Default for Config { agent: NativeAgentConfig::default(), experimental: ExperimentalFeatures::default(), tool_calling: ToolCallingConfig::default(), - content_creator: ContentCreatorConfig::default(), + workspace_preferences: WorkspacePreferencesConfig::default(), navigation: NavigationConfig::default(), chat_appearance: ChatAppearanceConfig::default(), environment: EnvironmentConfig::default(), @@ -2189,8 +2189,8 @@ impl Config { changed = true; } - if self.content_creator.schema_version < current_version { - self.content_creator.schema_version = current_version; + if self.workspace_preferences.schema_version < current_version { + self.workspace_preferences.schema_version = current_version; changed = true; } @@ -2734,7 +2734,7 @@ mod unit_tests { assert_eq!(config.crash_reporting.environment, "production"); assert_eq!(config.crash_reporting.sample_rate, 1.0); assert!(!config.crash_reporting.send_pii); - assert_eq!(config.content_creator.schema_version, 1); + assert_eq!(config.workspace_preferences.schema_version, 1); assert_eq!(config.navigation.schema_version, 1); assert_eq!( config.navigation.enabled_items, @@ -2818,7 +2818,7 @@ mod unit_tests { #[test] fn test_normalize_workspace_preferences_upgrades_legacy_defaults() { let mut config = Config::default(); - config.content_creator.schema_version = 0; + config.workspace_preferences.schema_version = 0; config.navigation.schema_version = 0; config.navigation.enabled_items = vec![ "home-general".to_string(), @@ -2830,7 +2830,7 @@ mod unit_tests { let changed = config.normalize_workspace_preferences(); assert!(changed); - assert_eq!(config.content_creator.schema_version, 1); + assert_eq!(config.workspace_preferences.schema_version, 1); assert_eq!(config.navigation.schema_version, 1); assert_eq!( config.navigation.enabled_items, @@ -2850,7 +2850,7 @@ mod unit_tests { #[test] fn test_normalize_workspace_preferences_preserves_current_custom_values() { let mut config = Config::default(); - config.content_creator.schema_version = 0; + config.workspace_preferences.schema_version = 0; config.navigation.schema_version = 0; config.navigation.enabled_items = vec![ "home-general".to_string(), @@ -2862,7 +2862,7 @@ mod unit_tests { let changed = config.normalize_workspace_preferences(); assert!(changed); - assert_eq!(config.content_creator.schema_version, 1); + assert_eq!(config.workspace_preferences.schema_version, 1); assert_eq!(config.navigation.schema_version, 1); assert_eq!( config.navigation.enabled_items, diff --git a/src-tauri/crates/core/src/config/yaml.rs b/src-tauri/crates/core/src/config/yaml.rs index afc8735e6..f25c9fc11 100644 --- a/src-tauri/crates/core/src/config/yaml.rs +++ b/src-tauri/crates/core/src/config/yaml.rs @@ -88,7 +88,7 @@ impl ConfigManager { /// 从 YAML 字符串解析配置 pub fn parse_yaml(yaml: &str) -> Result { - serde_yaml::from_str(yaml).map_err(|e| ConfigError::ParseError(e.to_string())) + parse_yaml_config_with_legacy_tracking(yaml).map(|(config, _)| config) } /// 将配置序列化为 YAML 字符串 @@ -243,6 +243,65 @@ impl ConfigManager { use super::types::{LoggingConfig, RetrySettings, ServerConfig}; +fn normalize_legacy_workspace_preferences_yaml_value(value: &mut serde_yaml::Value) -> bool { + let Some(mapping) = value.as_mapping_mut() else { + return false; + }; + + let workspace_key = serde_yaml::Value::String("workspace_preferences".to_string()); + let legacy_key = serde_yaml::Value::String("content_creator".to_string()); + + if mapping.contains_key(&workspace_key) { + return mapping.remove(&legacy_key).is_some(); + } + + if let Some(legacy_value) = mapping.remove(&legacy_key) { + mapping.insert(workspace_key, legacy_value); + return true; + } + + false +} + +fn normalize_legacy_workspace_preferences_json_value(value: &mut serde_json::Value) -> bool { + let Some(object) = value.as_object_mut() else { + return false; + }; + + if object.contains_key("workspace_preferences") { + return object.remove("content_creator").is_some(); + } + + if let Some(legacy_value) = object.remove("content_creator") { + object.insert("workspace_preferences".to_string(), legacy_value); + return true; + } + + false +} + +fn parse_yaml_config_with_legacy_tracking(yaml: &str) -> Result<(Config, bool), ConfigError> { + let mut value: serde_yaml::Value = + serde_yaml::from_str(yaml).map_err(|e| ConfigError::ParseError(e.to_string()))?; + let migrated_legacy_key = normalize_legacy_workspace_preferences_yaml_value(&mut value); + let config = + serde_yaml::from_value(value).map_err(|e| ConfigError::ParseError(e.to_string()))?; + Ok((config, migrated_legacy_key)) +} + +fn parse_json_config_with_legacy_tracking(json: &str) -> Result<(Config, bool), ConfigError> { + let mut value: serde_json::Value = + serde_json::from_str(json).map_err(|e| ConfigError::ParseError(e.to_string()))?; + let migrated_legacy_key = normalize_legacy_workspace_preferences_json_value(&mut value); + let config = + serde_json::from_value(value).map_err(|e| ConfigError::ParseError(e.to_string()))?; + Ok((config, migrated_legacy_key)) +} + +fn parse_json_config(json: &str) -> Result { + parse_json_config_with_legacy_tracking(json).map(|(config, _)| config) +} + fn normalized_config_for_persistence(config: &Config) -> Config { let mut normalized = config.clone(); normalized.normalize_local_server_surface(); @@ -674,8 +733,8 @@ pub fn load_config() -> Result> { // 优先尝试 YAML 配置 if yaml_path.exists() { let content = std::fs::read_to_string(&yaml_path)?; - let mut config: Config = serde_yaml::from_str(&content)?; - let mut should_save = config.normalize_workspace_preferences(); + let (mut config, migrated_legacy_key) = parse_yaml_config_with_legacy_tracking(&content)?; + let mut should_save = migrated_legacy_key || config.normalize_workspace_preferences(); if config.normalize_local_server_surface() { should_save = true; } @@ -697,8 +756,8 @@ pub fn load_config() -> Result> { // 回退到 JSON 配置 if json_path.exists() { let content = std::fs::read_to_string(&json_path)?; - let mut config: Config = serde_json::from_str(&content)?; - let mut should_save = config.normalize_workspace_preferences(); + let (mut config, migrated_legacy_key) = parse_json_config_with_legacy_tracking(&content)?; + let mut should_save = migrated_legacy_key || config.normalize_workspace_preferences(); if config.normalize_local_server_surface() { should_save = true; } @@ -827,6 +886,55 @@ logging: ); } + #[test] + fn test_parse_yaml_migrates_legacy_content_creator_key() { + let yaml = r#" +content_creator: + schema_version: 0 + media_defaults: + image: + preferred_provider_id: "fal" +"#; + + let config = ConfigManager::parse_yaml(yaml).unwrap(); + assert_eq!(config.workspace_preferences.schema_version, 0); + assert_eq!( + config + .workspace_preferences + .media_defaults + .image + .preferred_provider_id + .as_deref(), + Some("fal") + ); + } + + #[test] + fn test_parse_json_config_migrates_legacy_content_creator_key() { + let json = r#"{ + "content_creator": { + "schema_version": 0, + "media_defaults": { + "image": { + "preferred_provider_id": "fal" + } + } + } + }"#; + + let config = parse_json_config(json).unwrap(); + assert_eq!(config.workspace_preferences.schema_version, 0); + assert_eq!( + config + .workspace_preferences + .media_defaults + .image + .preferred_provider_id + .as_deref(), + Some("fal") + ); + } + #[test] fn test_to_yaml_roundtrip() { let config = Config::default(); diff --git a/src-tauri/crates/core/src/content/manager.rs b/src-tauri/crates/core/src/content/manager.rs index bae2732e8..f92c48cdb 100644 --- a/src-tauri/crates/core/src/content/manager.rs +++ b/src-tauri/crates/core/src/content/manager.rs @@ -115,13 +115,10 @@ impl ContentManager { match workspace_type { WorkspaceType::Video => ContentType::Episode, - WorkspaceType::Novel => ContentType::Chapter, WorkspaceType::SocialMedia => ContentType::Post, WorkspaceType::General => ContentType::Content, WorkspaceType::Document => ContentType::Document, - WorkspaceType::Poster - | WorkspaceType::Music - | WorkspaceType::Knowledge + WorkspaceType::Knowledge | WorkspaceType::Planning | WorkspaceType::Persistent | WorkspaceType::Temporary => ContentType::Document, diff --git a/src-tauri/crates/core/src/database/dao/chat.rs b/src-tauri/crates/core/src/database/dao/chat.rs index 3abcb9802..cf5eb57cf 100644 --- a/src-tauri/crates/core/src/database/dao/chat.rs +++ b/src-tauri/crates/core/src/database/dao/chat.rs @@ -3,7 +3,7 @@ //! 提供统一的会话和消息存储功能,支持多种对话模式: //! - Agent: AI Agent 模式,支持工具调用 //! - General: 通用对话模式,纯文本 -//! - Creator: 内容创作模式,支持画布输出 +//! - Workbench: 工作台模式,支持画布输出 //! //! ## 设计原则 //! - 单一数据源:所有对话数据统一存储 @@ -26,8 +26,9 @@ pub enum ChatMode { /// 通用对话模式,纯文本 #[default] General, - /// 内容创作模式,支持画布输出 - Creator, + /// 工作台模式,支持画布输出 + #[serde(rename = "workbench")] + Workbench, } impl std::fmt::Display for ChatMode { @@ -35,7 +36,7 @@ impl std::fmt::Display for ChatMode { match self { ChatMode::Agent => write!(f, "agent"), ChatMode::General => write!(f, "general"), - ChatMode::Creator => write!(f, "creator"), + ChatMode::Workbench => write!(f, "workbench"), } } } @@ -47,7 +48,7 @@ impl std::str::FromStr for ChatMode { match s.to_lowercase().as_str() { "agent" => Ok(ChatMode::Agent), "general" => Ok(ChatMode::General), - "creator" => Ok(ChatMode::Creator), + "workbench" => Ok(ChatMode::Workbench), _ => Err(format!("未知的对话模式: {s}")), } } diff --git a/src-tauri/crates/core/src/database/dao/poster_material_dao.rs b/src-tauri/crates/core/src/database/dao/gallery_material_dao.rs similarity index 83% rename from src-tauri/crates/core/src/database/dao/poster_material_dao.rs rename to src-tauri/crates/core/src/database/dao/gallery_material_dao.rs index e5f9d6bcb..5d6799363 100644 --- a/src-tauri/crates/core/src/database/dao/poster_material_dao.rs +++ b/src-tauri/crates/core/src/database/dao/gallery_material_dao.rs @@ -1,7 +1,7 @@ -//! 海报素材元数据数据访问层 +//! 图库素材元数据数据访问层 //! -//! 提供海报素材元数据(PosterMaterialMetadata)的 CRUD 操作,包括: -//! - 创建、获取、更新、删除海报素材元数据 +//! 提供图库素材元数据(GalleryMaterialMetadata)的 CRUD 操作,包括: +//! - 创建、获取、更新、删除图库素材元数据 //! - 按分类筛选素材 use rusqlite::{params, Connection}; @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::errors::project_error::MaterialError; use crate::models::project_model::{ - CreatePosterMetadataRequest, PosterMaterial, PosterMaterialMetadata, + CreateGalleryMaterialMetadataRequest, GalleryMaterial, GalleryMaterialMetadata, }; use super::material_dao::MaterialDao; @@ -18,17 +18,17 @@ use super::material_dao::MaterialDao; // 数据访问对象 // ============================================================================ -/// 海报素材元数据 DAO +/// 图库素材元数据 DAO /// -/// 提供海报素材元数据的数据库操作方法。 -pub struct PosterMaterialDao; +/// 提供图库素材元数据的数据库操作方法。 +pub struct GalleryMaterialDao; -impl PosterMaterialDao { +impl GalleryMaterialDao { // ------------------------------------------------------------------------ // 创建元数据 // ------------------------------------------------------------------------ - /// 创建海报素材元数据 + /// 创建图库素材元数据 /// /// # 参数 /// - `conn`: 数据库连接 @@ -39,8 +39,8 @@ impl PosterMaterialDao { /// - 失败返回 MaterialError pub fn create( conn: &Connection, - req: &CreatePosterMetadataRequest, - ) -> Result { + req: &CreateGalleryMaterialMetadataRequest, + ) -> Result { // 验证素材存在 MaterialDao::get(conn, &req.material_id)? .ok_or_else(|| MaterialError::NotFound(req.material_id.clone()))?; @@ -53,7 +53,7 @@ impl PosterMaterialDao { .unwrap_or_else(|_| "[]".to_string()); conn.execute( - "INSERT INTO poster_material_metadata ( + "INSERT INTO gallery_material_metadata ( id, material_id, image_category, width, height, thumbnail, colors_json, icon_style, icon_category, color_scheme_json, mood, layout_category, element_count, preview, fabric_json, @@ -80,7 +80,7 @@ impl PosterMaterialDao { ], )?; - Ok(PosterMaterialMetadata { + Ok(GalleryMaterialMetadata { material_id: req.material_id.clone(), image_category: req.image_category.clone(), width: req.width, @@ -104,25 +104,25 @@ impl PosterMaterialDao { // 获取元数据 // ------------------------------------------------------------------------ - /// 获取海报素材元数据 + /// 获取图库素材元数据 /// /// # 参数 /// - `conn`: 数据库连接 /// - `material_id`: 素材 ID /// /// # 返回 - /// - 成功返回 Option + /// - 成功返回 Option /// - 失败返回 MaterialError pub fn get( conn: &Connection, material_id: &str, - ) -> Result, MaterialError> { + ) -> Result, MaterialError> { let mut stmt = conn.prepare( "SELECT material_id, image_category, width, height, thumbnail, colors_json, icon_style, icon_category, color_scheme_json, mood, layout_category, element_count, preview, fabric_json, created_at, updated_at - FROM poster_material_metadata WHERE material_id = ?", + FROM gallery_material_metadata WHERE material_id = ?", )?; let mut rows = stmt.query([material_id])?; @@ -134,19 +134,19 @@ impl PosterMaterialDao { } } - /// 获取完整的海报素材(基础素材 + 元数据) + /// 获取完整的图库素材(基础素材 + 元数据) /// /// # 参数 /// - `conn`: 数据库连接 /// - `material_id`: 素材 ID /// /// # 返回 - /// - 成功返回 Option + /// - 成功返回 Option /// - 失败返回 MaterialError - pub fn get_poster_material( + pub fn get_gallery_material( conn: &Connection, material_id: &str, - ) -> Result, MaterialError> { + ) -> Result, MaterialError> { // 获取基础素材 let base = match MaterialDao::get(conn, material_id)? { Some(m) => m, @@ -156,7 +156,7 @@ impl PosterMaterialDao { // 获取元数据 let metadata = Self::get(conn, material_id)?; - Ok(Some(PosterMaterial { base, metadata })) + Ok(Some(GalleryMaterial { base, metadata })) } // ------------------------------------------------------------------------ @@ -164,11 +164,11 @@ impl PosterMaterialDao { // ------------------------------------------------------------------------ /// 按图片分类获取素材列表 - pub fn list_by_image_category( + pub fn list_gallery_materials_by_image_category( conn: &Connection, project_id: &str, category: Option<&str>, - ) -> Result, MaterialError> { + ) -> Result, MaterialError> { let sql = if category.is_some() { "SELECT m.id, m.project_id, m.name, m.material_type, m.file_path, m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at, @@ -177,7 +177,7 @@ impl PosterMaterialDao { pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, pm.created_at as pm_created_at, pm.updated_at as pm_updated_at FROM materials m - LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id WHERE m.project_id = ?1 AND m.material_type = 'image' AND pm.image_category = ?2 ORDER BY m.created_at DESC" } else { @@ -188,14 +188,14 @@ impl PosterMaterialDao { pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, pm.created_at as pm_created_at, pm.updated_at as pm_updated_at FROM materials m - LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id WHERE m.project_id = ?1 AND m.material_type = 'image' ORDER BY m.created_at DESC" }; let mut stmt = conn.prepare(sql)?; - let results: Vec = if let Some(cat) = category { + let results: Vec = if let Some(cat) = category { stmt.query_map(params![project_id, cat], Self::map_joined_row)? .filter_map(|r| r.ok()) .collect() @@ -209,11 +209,11 @@ impl PosterMaterialDao { } /// 按布局分类获取素材列表 - pub fn list_by_layout_category( + pub fn list_gallery_materials_by_layout_category( conn: &Connection, project_id: &str, category: Option<&str>, - ) -> Result, MaterialError> { + ) -> Result, MaterialError> { let sql = if category.is_some() { "SELECT m.id, m.project_id, m.name, m.material_type, m.file_path, m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at, @@ -222,7 +222,7 @@ impl PosterMaterialDao { pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, pm.created_at as pm_created_at, pm.updated_at as pm_updated_at FROM materials m - LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id WHERE m.project_id = ?1 AND m.material_type = 'layout' AND pm.layout_category = ?2 ORDER BY m.created_at DESC" } else { @@ -233,14 +233,14 @@ impl PosterMaterialDao { pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, pm.created_at as pm_created_at, pm.updated_at as pm_updated_at FROM materials m - LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id WHERE m.project_id = ?1 AND m.material_type = 'layout' ORDER BY m.created_at DESC" }; let mut stmt = conn.prepare(sql)?; - let results: Vec = if let Some(cat) = category { + let results: Vec = if let Some(cat) = category { stmt.query_map(params![project_id, cat], Self::map_joined_row)? .filter_map(|r| r.ok()) .collect() @@ -254,11 +254,11 @@ impl PosterMaterialDao { } /// 按配色氛围获取素材列表 - pub fn list_by_mood( + pub fn list_gallery_materials_by_mood( conn: &Connection, project_id: &str, mood: Option<&str>, - ) -> Result, MaterialError> { + ) -> Result, MaterialError> { let sql = if mood.is_some() { "SELECT m.id, m.project_id, m.name, m.material_type, m.file_path, m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at, @@ -267,7 +267,7 @@ impl PosterMaterialDao { pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, pm.created_at as pm_created_at, pm.updated_at as pm_updated_at FROM materials m - LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id WHERE m.project_id = ?1 AND m.material_type = 'color' AND pm.mood = ?2 ORDER BY m.created_at DESC" } else { @@ -278,14 +278,14 @@ impl PosterMaterialDao { pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json, pm.created_at as pm_created_at, pm.updated_at as pm_updated_at FROM materials m - LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id + LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id WHERE m.project_id = ?1 AND m.material_type = 'color' ORDER BY m.created_at DESC" }; let mut stmt = conn.prepare(sql)?; - let results: Vec = if let Some(m) = mood { + let results: Vec = if let Some(m) = mood { stmt.query_map(params![project_id, m], Self::map_joined_row)? .filter_map(|r| r.ok()) .collect() @@ -302,14 +302,14 @@ impl PosterMaterialDao { // 更新元数据 // ------------------------------------------------------------------------ - /// 更新海报素材元数据 + /// 更新图库素材元数据 /// /// 如果元数据不存在,则创建新的元数据。 pub fn update( conn: &Connection, material_id: &str, - req: &CreatePosterMetadataRequest, - ) -> Result { + req: &CreateGalleryMaterialMetadataRequest, + ) -> Result { // 检查元数据是否存在 let existing = Self::get(conn, material_id)?; @@ -339,7 +339,7 @@ impl PosterMaterialDao { let colors_json = serde_json::to_string(&colors).unwrap_or_else(|_| "[]".to_string()); conn.execute( - "UPDATE poster_material_metadata SET + "UPDATE gallery_material_metadata SET image_category = ?1, width = ?2, height = ?3, thumbnail = ?4, colors_json = ?5, icon_style = ?6, icon_category = ?7, color_scheme_json = ?8, mood = ?9, layout_category = ?10, @@ -364,7 +364,7 @@ impl PosterMaterialDao { ], )?; - Ok(PosterMaterialMetadata { + Ok(GalleryMaterialMetadata { material_id: material_id.to_string(), image_category, width, @@ -388,10 +388,10 @@ impl PosterMaterialDao { // 删除元数据 // ------------------------------------------------------------------------ - /// 删除海报素材元数据 + /// 删除图库素材元数据 pub fn delete(conn: &Connection, material_id: &str) -> Result<(), MaterialError> { conn.execute( - "DELETE FROM poster_material_metadata WHERE material_id = ?", + "DELETE FROM gallery_material_metadata WHERE material_id = ?", [material_id], )?; Ok(()) @@ -401,12 +401,12 @@ impl PosterMaterialDao { // 辅助方法 // ------------------------------------------------------------------------ - /// 映射数据库行到 PosterMaterialMetadata 结构体 - fn map_row(row: &rusqlite::Row) -> Result { + /// 映射数据库行到 GalleryMaterialMetadata 结构体 + fn map_row(row: &rusqlite::Row) -> Result { let colors_json: String = row.get(5)?; let colors: Vec = serde_json::from_str(&colors_json).unwrap_or_default(); - Ok(PosterMaterialMetadata { + Ok(GalleryMaterialMetadata { material_id: row.get(0)?, image_category: row.get(1)?, width: row.get(2)?, @@ -426,8 +426,8 @@ impl PosterMaterialDao { }) } - /// 映射联合查询的数据库行到 PosterMaterial 结构体 - fn map_joined_row(row: &rusqlite::Row) -> Result { + /// 映射联合查询的数据库行到 GalleryMaterial 结构体 + fn map_joined_row(row: &rusqlite::Row) -> Result { use crate::models::project_model::Material; // 解析基础素材 @@ -454,7 +454,7 @@ impl PosterMaterialDao { let colors_json: String = row.get(16)?; let colors: Vec = serde_json::from_str(&colors_json).unwrap_or_default(); - Some(PosterMaterialMetadata { + Some(GalleryMaterialMetadata { material_id, image_category: row.get(12)?, width: row.get(13)?, @@ -476,7 +476,7 @@ impl PosterMaterialDao { None }; - Ok(PosterMaterial { base, metadata }) + Ok(GalleryMaterial { base, metadata }) } } @@ -534,12 +534,12 @@ mod tests { } #[test] - fn test_create_poster_metadata() { + fn test_create_gallery_material_metadata() { let conn = setup_test_db(); create_test_project(&conn, "project-1"); let material = create_test_material(&conn, "project-1", "image"); - let req = CreatePosterMetadataRequest { + let req = CreateGalleryMaterialMetadataRequest { material_id: material.id.clone(), image_category: Some("background".to_string()), width: Some(1920), @@ -556,7 +556,7 @@ mod tests { fabric_json: None, }; - let metadata = PosterMaterialDao::create(&conn, &req).unwrap(); + let metadata = GalleryMaterialDao::create(&conn, &req).unwrap(); assert_eq!(metadata.material_id, material.id); assert_eq!(metadata.image_category, Some("background".to_string())); @@ -566,12 +566,12 @@ mod tests { } #[test] - fn test_get_poster_metadata() { + fn test_get_gallery_material_metadata() { let conn = setup_test_db(); create_test_project(&conn, "project-1"); let material = create_test_material(&conn, "project-1", "image"); - let req = CreatePosterMetadataRequest { + let req = CreateGalleryMaterialMetadataRequest { material_id: material.id.clone(), image_category: Some("product".to_string()), width: Some(800), @@ -588,21 +588,21 @@ mod tests { fabric_json: None, }; - PosterMaterialDao::create(&conn, &req).unwrap(); + GalleryMaterialDao::create(&conn, &req).unwrap(); - let metadata = PosterMaterialDao::get(&conn, &material.id).unwrap(); + let metadata = GalleryMaterialDao::get(&conn, &material.id).unwrap(); assert!(metadata.is_some()); let metadata = metadata.unwrap(); assert_eq!(metadata.image_category, Some("product".to_string())); } #[test] - fn test_get_poster_material() { + fn test_get_gallery_material() { let conn = setup_test_db(); create_test_project(&conn, "project-1"); let material = create_test_material(&conn, "project-1", "image"); - let req = CreatePosterMetadataRequest { + let req = CreateGalleryMaterialMetadataRequest { material_id: material.id.clone(), image_category: Some("decoration".to_string()), width: Some(500), @@ -619,28 +619,29 @@ mod tests { fabric_json: None, }; - PosterMaterialDao::create(&conn, &req).unwrap(); + GalleryMaterialDao::create(&conn, &req).unwrap(); - let poster_material = PosterMaterialDao::get_poster_material(&conn, &material.id).unwrap(); - assert!(poster_material.is_some()); - let poster_material = poster_material.unwrap(); + let gallery_material = + GalleryMaterialDao::get_gallery_material(&conn, &material.id).unwrap(); + assert!(gallery_material.is_some()); + let gallery_material = gallery_material.unwrap(); - assert_eq!(poster_material.base.id, material.id); - assert!(poster_material.metadata.is_some()); + assert_eq!(gallery_material.base.id, material.id); + assert!(gallery_material.metadata.is_some()); assert_eq!( - poster_material.metadata.unwrap().image_category, + gallery_material.metadata.unwrap().image_category, Some("decoration".to_string()) ); } #[test] - fn test_update_poster_metadata() { + fn test_update_gallery_material_metadata() { let conn = setup_test_db(); create_test_project(&conn, "project-1"); let material = create_test_material(&conn, "project-1", "image"); // 创建初始元数据 - let req = CreatePosterMetadataRequest { + let req = CreateGalleryMaterialMetadataRequest { material_id: material.id.clone(), image_category: Some("background".to_string()), width: Some(1920), @@ -656,10 +657,10 @@ mod tests { preview: None, fabric_json: None, }; - PosterMaterialDao::create(&conn, &req).unwrap(); + GalleryMaterialDao::create(&conn, &req).unwrap(); // 更新元数据 - let update_req = CreatePosterMetadataRequest { + let update_req = CreateGalleryMaterialMetadataRequest { material_id: material.id.clone(), image_category: Some("product".to_string()), width: None, @@ -676,7 +677,7 @@ mod tests { fabric_json: None, }; - let updated = PosterMaterialDao::update(&conn, &material.id, &update_req).unwrap(); + let updated = GalleryMaterialDao::update(&conn, &material.id, &update_req).unwrap(); assert_eq!(updated.image_category, Some("product".to_string())); assert_eq!(updated.width, Some(1920)); // 保留原值 @@ -685,12 +686,12 @@ mod tests { } #[test] - fn test_delete_poster_metadata() { + fn test_delete_gallery_material_metadata() { let conn = setup_test_db(); create_test_project(&conn, "project-1"); let material = create_test_material(&conn, "project-1", "image"); - let req = CreatePosterMetadataRequest { + let req = CreateGalleryMaterialMetadataRequest { material_id: material.id.clone(), image_category: Some("texture".to_string()), width: None, @@ -706,18 +707,18 @@ mod tests { preview: None, fabric_json: None, }; - PosterMaterialDao::create(&conn, &req).unwrap(); + GalleryMaterialDao::create(&conn, &req).unwrap(); // 验证存在 - assert!(PosterMaterialDao::get(&conn, &material.id) + assert!(GalleryMaterialDao::get(&conn, &material.id) .unwrap() .is_some()); // 删除 - PosterMaterialDao::delete(&conn, &material.id).unwrap(); + GalleryMaterialDao::delete(&conn, &material.id).unwrap(); // 验证已删除 - assert!(PosterMaterialDao::get(&conn, &material.id) + assert!(GalleryMaterialDao::get(&conn, &material.id) .unwrap() .is_none()); } diff --git a/src-tauri/crates/core/src/database/dao/mod.rs b/src-tauri/crates/core/src/database/dao/mod.rs index aadc435f9..9fbdf3981 100644 --- a/src-tauri/crates/core/src/database/dao/mod.rs +++ b/src-tauri/crates/core/src/database/dao/mod.rs @@ -9,12 +9,12 @@ pub mod automation_job; pub mod browser_environment_preset; pub mod browser_profile; pub mod chat; +pub mod gallery_material_dao; pub mod installed_plugins; pub mod material_dao; pub mod mcp; pub mod orchestrator; pub mod persona_dao; -pub mod poster_material_dao; pub mod prompts; pub mod provider_pool; pub mod providers; diff --git a/src-tauri/crates/core/src/database/migration_v5.rs b/src-tauri/crates/core/src/database/migration_v5.rs new file mode 100644 index 000000000..d52e610a4 --- /dev/null +++ b/src-tauri/crates/core/src/database/migration_v5.rs @@ -0,0 +1,155 @@ +//! 历史 workbench 会话模式命名迁移 +//! +//! 将旧 `creator:*` 会话模式前缀统一迁移为 `workbench:*`, +//! 避免运行时主链继续保留旧命名兼容。 + +use rusqlite::{params, Connection}; + +use super::migration_support::{ + is_migration_completed, mark_migration_completed, run_in_transaction, +}; + +const MIGRATION_KEY_WORKBENCH_CHAT_MODE_ALIAS: &str = + "migrated_workbench_chat_mode_creator_alias_v1"; + +pub struct MigrationResult { + pub executed: bool, + pub migrated_sessions: usize, +} + +pub fn migrate_workbench_chat_mode_alias(conn: &Connection) -> Result { + if is_migration_completed(conn, MIGRATION_KEY_WORKBENCH_CHAT_MODE_ALIAS) { + tracing::debug!("[迁移] workbench 会话模式旧别名已迁移,跳过"); + return Ok(MigrationResult { + executed: false, + migrated_sessions: 0, + }); + } + + match run_in_transaction(conn, |tx| { + let migrated_sessions = execute_migration(tx)?; + mark_migration_completed(tx, MIGRATION_KEY_WORKBENCH_CHAT_MODE_ALIAS)?; + Ok(migrated_sessions) + }) { + Ok(migrated_sessions) => Ok(MigrationResult { + executed: migrated_sessions > 0, + migrated_sessions, + }), + Err(error) => { + tracing::error!("[迁移] workbench 会话模式旧别名迁移失败,已回滚: {}", error); + Err(error) + } + } +} + +fn execute_migration(conn: &Connection) -> Result { + let renamed_default = conn + .execute( + "UPDATE agent_sessions + SET model = 'workbench:default' + WHERE model = 'creator'", + [], + ) + .map_err(|e| format!("迁移 creator 默认会话模式失败: {e}"))?; + + let renamed_prefixed = conn + .execute( + "UPDATE agent_sessions + SET model = 'workbench:' || substr(model, 9) + WHERE model LIKE 'creator:%'", + params![], + ) + .map_err(|e| format!("迁移 creator 前缀会话模式失败: {e}"))?; + + Ok(renamed_default + renamed_prefixed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema; + + fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + schema::create_tables(&conn).expect("初始化表结构失败"); + conn + } + + #[test] + fn migrate_workbench_chat_mode_alias_rewrites_legacy_creator_sessions() { + let conn = setup_test_db(); + + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params!["session-creator-default", "creator", "1", "1"], + ) + .expect("插入 creator 默认会话失败"); + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params!["session-creator-prefixed", "creator:gpt-4.1", "1", "1"], + ) + .expect("插入 creator 前缀会话失败"); + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params!["session-workbench", "workbench:default", "1", "1"], + ) + .expect("插入 workbench 会话失败"); + + let result = + migrate_workbench_chat_mode_alias(&conn).expect("执行 workbench 会话模式迁移失败"); + + assert!(result.executed); + assert_eq!(result.migrated_sessions, 2); + + let creator_default: String = conn + .query_row( + "SELECT model FROM agent_sessions WHERE id = ?1", + ["session-creator-default"], + |row| row.get(0), + ) + .expect("查询 creator 默认会话失败"); + let creator_prefixed: String = conn + .query_row( + "SELECT model FROM agent_sessions WHERE id = ?1", + ["session-creator-prefixed"], + |row| row.get(0), + ) + .expect("查询 creator 前缀会话失败"); + let workbench: String = conn + .query_row( + "SELECT model FROM agent_sessions WHERE id = ?1", + ["session-workbench"], + |row| row.get(0), + ) + .expect("查询 workbench 会话失败"); + + assert_eq!(creator_default, "workbench:default"); + assert_eq!(creator_prefixed, "workbench:gpt-4.1"); + assert_eq!(workbench, "workbench:default"); + } + + #[test] + fn migrate_workbench_chat_mode_alias_runs_only_once() { + let conn = setup_test_db(); + + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params!["session-creator-prefixed", "creator:gpt-4.1", "1", "1"], + ) + .expect("插入 creator 前缀会话失败"); + + let first = + migrate_workbench_chat_mode_alias(&conn).expect("首次执行 workbench 会话模式迁移失败"); + let second = + migrate_workbench_chat_mode_alias(&conn).expect("重复执行 workbench 会话模式迁移失败"); + + assert!(first.executed); + assert_eq!(first.migrated_sessions, 1); + assert!(!second.executed); + assert_eq!(second.migrated_sessions, 0); + } +} diff --git a/src-tauri/crates/core/src/database/mod.rs b/src-tauri/crates/core/src/database/mod.rs index b36c65ca3..3bcd0deb2 100644 --- a/src-tauri/crates/core/src/database/mod.rs +++ b/src-tauri/crates/core/src/database/mod.rs @@ -6,6 +6,7 @@ mod migration_support; pub mod migration_v2; pub mod migration_v3; pub mod migration_v4; +pub mod migration_v5; pub mod schema; mod startup_migrations; pub mod system_providers; diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index 4822f6dcb..c34a38044 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -1,5 +1,50 @@ use rusqlite::Connection; +fn table_exists(conn: &Connection, table_name: &str) -> Result { + let mut stmt = conn.prepare( + "SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = ?1 + LIMIT 1", + )?; + let mut rows = stmt.query([table_name])?; + Ok(rows.next()?.is_some()) +} + +fn migrate_gallery_material_metadata_table(conn: &Connection) -> Result<(), rusqlite::Error> { + let legacy_exists = table_exists(conn, "poster_material_metadata")?; + let current_exists = table_exists(conn, "gallery_material_metadata")?; + + if legacy_exists && !current_exists { + conn.execute( + "ALTER TABLE poster_material_metadata RENAME TO gallery_material_metadata", + [], + )?; + } + + if table_exists(conn, "gallery_material_metadata")? { + conn.execute( + "DROP INDEX IF EXISTS idx_poster_material_metadata_material_id", + [], + )?; + conn.execute( + "DROP INDEX IF EXISTS idx_poster_material_metadata_image_category", + [], + )?; + conn.execute( + "DROP INDEX IF EXISTS idx_poster_material_metadata_icon_category", + [], + )?; + conn.execute( + "DROP INDEX IF EXISTS idx_poster_material_metadata_layout_category", + [], + )?; + conn.execute("DROP INDEX IF EXISTS idx_poster_material_metadata_mood", [])?; + } + + Ok(()) +} + pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { // API Key Provider 配置表 // _Requirements: 9.1_ @@ -1097,174 +1142,6 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; - // ============================================================================ - // 小说编排系统相关表 - // ============================================================================ - - // 小说项目表 - conn.execute( - "CREATE TABLE IF NOT EXISTS novel_projects ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - theme TEXT, - target_words INTEGER NOT NULL DEFAULT 100000, - status TEXT NOT NULL DEFAULT 'draft', - current_word_count INTEGER NOT NULL DEFAULT 0, - metadata_json TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_projects_status ON novel_projects(status)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_projects_updated_at ON novel_projects(updated_at DESC)", - [], - )?; - - // 小说设定版本表 - conn.execute( - "CREATE TABLE IF NOT EXISTS novel_settings ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - settings_json TEXT NOT NULL DEFAULT '{}', - version INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE, - UNIQUE(project_id, version) - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_settings_project_version ON novel_settings(project_id, version DESC)", - [], - )?; - - // 小说大纲版本表 - conn.execute( - "CREATE TABLE IF NOT EXISTS novel_outlines ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - outline_markdown TEXT NOT NULL DEFAULT '', - outline_json TEXT, - version INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE, - UNIQUE(project_id, version) - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_outlines_project_version ON novel_outlines(project_id, version DESC)", - [], - )?; - - // 小说角色快照表 - conn.execute( - "CREATE TABLE IF NOT EXISTS novel_characters ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - name TEXT NOT NULL, - role_type TEXT NOT NULL DEFAULT 'support', - card_json TEXT NOT NULL DEFAULT '{}', - version INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_characters_project ON novel_characters(project_id)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_characters_role ON novel_characters(role_type)", - [], - )?; - - // 小说章节表 - conn.execute( - "CREATE TABLE IF NOT EXISTS novel_chapters ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - chapter_no INTEGER NOT NULL, - title TEXT NOT NULL, - content TEXT NOT NULL DEFAULT '', - word_count INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'draft', - quality_score REAL, - metadata_json TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE, - UNIQUE(project_id, chapter_no) - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_chapters_project_no ON novel_chapters(project_id, chapter_no)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_chapters_status ON novel_chapters(status)", - [], - )?; - - // 小说生成运行记录 - conn.execute( - "CREATE TABLE IF NOT EXISTS novel_generation_runs ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - mode TEXT NOT NULL, - input_snapshot_json TEXT, - output_snapshot_json TEXT, - provider TEXT, - model TEXT, - latency_ms INTEGER, - token_usage_json TEXT, - result_status TEXT NOT NULL DEFAULT 'success', - error_message TEXT, - created_at INTEGER NOT NULL, - FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_runs_project_time ON novel_generation_runs(project_id, created_at DESC)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_runs_mode ON novel_generation_runs(mode)", - [], - )?; - - // 小说一致性检查结果表 - conn.execute( - "CREATE TABLE IF NOT EXISTS novel_consistency_checks ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - chapter_id TEXT NOT NULL, - issues_json TEXT NOT NULL DEFAULT '[]', - score REAL NOT NULL DEFAULT 100, - created_at INTEGER NOT NULL, - FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE, - FOREIGN KEY (chapter_id) REFERENCES novel_chapters(id) ON DELETE CASCADE - )", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_consistency_project_chapter ON novel_consistency_checks(project_id, chapter_id)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_novel_consistency_created ON novel_consistency_checks(created_at DESC)", - [], - )?; - // ============================================================================ // A2UI 表单数据表 // 存储 AI 生成的交互式表单及用户填写的数据 @@ -1297,11 +1174,12 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { )?; // ============================================================================ - // 海报素材元数据表 (PosterMaterialMetadata) - // 存储海报素材的扩展信息,与 materials 表关联 + // 图库素材元数据表 (GalleryMaterialMetadata) + // 存储图库素材的扩展信息,与 materials 表关联 // ============================================================================ + migrate_gallery_material_metadata_table(conn)?; conn.execute( - "CREATE TABLE IF NOT EXISTS poster_material_metadata ( + "CREATE TABLE IF NOT EXISTS gallery_material_metadata ( id TEXT PRIMARY KEY, material_id TEXT NOT NULL UNIQUE, image_category TEXT, @@ -1324,25 +1202,25 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; - // 创建 poster_material_metadata 索引 + // 创建 gallery_material_metadata 索引 conn.execute( - "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_material_id ON poster_material_metadata(material_id)", + "CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_material_id ON gallery_material_metadata(material_id)", [], )?; conn.execute( - "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_image_category ON poster_material_metadata(image_category)", + "CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_image_category ON gallery_material_metadata(image_category)", [], )?; conn.execute( - "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_icon_category ON poster_material_metadata(icon_category)", + "CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_icon_category ON gallery_material_metadata(icon_category)", [], )?; conn.execute( - "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_layout_category ON poster_material_metadata(layout_category)", + "CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_layout_category ON gallery_material_metadata(layout_category)", [], )?; conn.execute( - "CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_mood ON poster_material_metadata(mood)", + "CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_mood ON gallery_material_metadata(mood)", [], )?; diff --git a/src-tauri/crates/core/src/database/startup_migrations.rs b/src-tauri/crates/core/src/database/startup_migrations.rs index d77a7e252..6d80ad2be 100644 --- a/src-tauri/crates/core/src/database/startup_migrations.rs +++ b/src-tauri/crates/core/src/database/startup_migrations.rs @@ -1,6 +1,6 @@ use rusqlite::Connection; -use super::{migration, migration_v2, migration_v3, migration_v4}; +use super::{migration, migration_v2, migration_v3, migration_v4, migration_v5}; pub(super) fn run_startup_migrations(conn: &Connection) { run_provider_pool_startup_migrations(conn); @@ -178,11 +178,26 @@ fn run_versioned_startup_migrations(conn: &Connection) { }) }, ); + + run_nonfatal_logged_startup_migration( + conn, + "workbench 会话模式迁移失败", + migration_v5::migrate_workbench_chat_mode_alias, + |_, result| { + result.executed.then(|| { + format!( + "[数据库] workbench 会话模式旧别名迁移完成: sessions={}", + result.migrated_sessions + ) + }) + }, + ); } #[cfg(test)] mod tests { use super::*; + use crate::database::schema; use rusqlite::params; use std::cell::Cell; @@ -314,4 +329,27 @@ mod tests { assert!(called.get()); } + + #[test] + fn versioned_startup_migrations_rewrite_legacy_creator_chat_mode() { + let conn = Connection::open_in_memory().unwrap(); + schema::create_tables(&conn).unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params!["session-creator-prefixed", "creator:gpt-4.1", "1", "1"], + ) + .unwrap(); + + run_versioned_startup_migrations(&conn); + + let model: String = conn + .query_row( + "SELECT model FROM agent_sessions WHERE id = ?1", + ["session-creator-prefixed"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(model, "workbench:gpt-4.1"); + } } diff --git a/src-tauri/crates/core/src/models/project_model.rs b/src-tauri/crates/core/src/models/project_model.rs index 0ab4d1873..828a6d98e 100644 --- a/src-tauri/crates/core/src/models/project_model.rs +++ b/src-tauri/crates/core/src/models/project_model.rs @@ -216,8 +216,8 @@ impl std::str::FromStr for MaterialType { #[allow(dead_code)] impl MaterialType { - /// 判断是否为海报素材类型 - pub fn is_poster_material(&self) -> bool { + /// 判断是否为图库素材类型 + pub fn is_gallery_material(&self) -> bool { matches!( self, MaterialType::Image | MaterialType::Icon | MaterialType::Color | MaterialType::Layout @@ -344,12 +344,12 @@ impl std::str::FromStr for LayoutCategory { } } -/// 海报素材元数据 +/// 图库素材元数据 /// -/// 存储海报素材的扩展信息,与 materials 表关联。 +/// 存储图库素材的扩展信息,与 materials 表关联。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct PosterMaterialMetadata { +pub struct GalleryMaterialMetadata { /// 关联的素材 ID pub material_id: String, /// 图片分类(仅 image 类型) @@ -397,10 +397,10 @@ pub struct PosterMaterialMetadata { pub updated_at: i64, } -/// 创建海报素材元数据请求 +/// 创建图库素材元数据请求 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct CreatePosterMetadataRequest { +pub struct CreateGalleryMaterialMetadataRequest { /// 关联的素材 ID pub material_id: String, /// 图片分类 @@ -444,18 +444,18 @@ pub struct CreatePosterMetadataRequest { pub fabric_json: Option, } -/// 海报素材(完整视图) +/// 图库素材(完整视图) /// -/// 包含基础素材和海报扩展元数据的完整数据。 +/// 包含基础素材和图库扩展元数据的完整数据。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct PosterMaterial { +pub struct GalleryMaterial { /// 基础素材 #[serde(flatten)] pub base: Material, /// 海报元数据 #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, + pub metadata: Option, } /// 素材 @@ -717,17 +717,17 @@ mod tests { } #[test] - fn test_material_type_is_poster_material() { - assert!(MaterialType::Image.is_poster_material()); - assert!(MaterialType::Icon.is_poster_material()); - assert!(MaterialType::Color.is_poster_material()); - assert!(MaterialType::Layout.is_poster_material()); - assert!(!MaterialType::Document.is_poster_material()); - assert!(!MaterialType::Audio.is_poster_material()); - assert!(!MaterialType::Video.is_poster_material()); - assert!(!MaterialType::Text.is_poster_material()); - assert!(!MaterialType::Data.is_poster_material()); - assert!(!MaterialType::Link.is_poster_material()); + fn test_material_type_is_gallery_material() { + assert!(MaterialType::Image.is_gallery_material()); + assert!(MaterialType::Icon.is_gallery_material()); + assert!(MaterialType::Color.is_gallery_material()); + assert!(MaterialType::Layout.is_gallery_material()); + assert!(!MaterialType::Document.is_gallery_material()); + assert!(!MaterialType::Audio.is_gallery_material()); + assert!(!MaterialType::Video.is_gallery_material()); + assert!(!MaterialType::Text.is_gallery_material()); + assert!(!MaterialType::Data.is_gallery_material()); + assert!(!MaterialType::Link.is_gallery_material()); } #[test] diff --git a/src-tauri/crates/core/src/session_files/types.rs b/src-tauri/crates/core/src/session_files/types.rs index fe8264958..bcf4fe221 100644 --- a/src-tauri/crates/core/src/session_files/types.rs +++ b/src-tauri/crates/core/src/session_files/types.rs @@ -10,7 +10,7 @@ pub struct SessionMeta { pub session_id: String, /// 会话标题(第一条用户消息摘要) pub title: Option, - /// 主题类型(document, music, poster 等) + /// 主题类型(document、video、social-media 等) pub theme: Option, /// 创建模式(guided, fast) pub creation_mode: Option, diff --git a/src-tauri/crates/core/src/workspace/manager.rs b/src-tauri/crates/core/src/workspace/manager.rs index ba20829bb..bc446e783 100644 --- a/src-tauri/crates/core/src/workspace/manager.rs +++ b/src-tauri/crates/core/src/workspace/manager.rs @@ -10,6 +10,61 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use uuid::Uuid; +fn default_project_icon(workspace_type: &WorkspaceType) -> Option { + if !workspace_type.is_project_type() { + return None; + } + + Some( + match workspace_type { + WorkspaceType::General => "💬", + WorkspaceType::SocialMedia => "📱", + WorkspaceType::Knowledge => "🔍", + WorkspaceType::Planning => "📅", + WorkspaceType::Document => "📄", + WorkspaceType::Video => "🎬", + WorkspaceType::Persistent | WorkspaceType::Temporary => "📁", + } + .to_string(), + ) +} + +fn workspace_type_query_values(workspace_type: &WorkspaceType) -> &'static [&'static str] { + match workspace_type { + WorkspaceType::SocialMedia => &["social-media", "social"], + WorkspaceType::Document => &["document", "poster", "music", "novel"], + WorkspaceType::Video => &["video", "drama"], + WorkspaceType::Persistent => &["persistent"], + WorkspaceType::Temporary => &["temporary"], + WorkspaceType::General => &["general"], + WorkspaceType::Knowledge => &["knowledge"], + WorkspaceType::Planning => &["planning"], + } +} + +fn normalize_workspace_icon( + workspace_type_str: &str, + workspace_type: &WorkspaceType, + icon: Option, +) -> Option { + let legacy_default_icon = match workspace_type_str { + "poster" => Some("🖼️"), + "music" => Some("🎵"), + "novel" => Some("📖"), + "drama" => Some("🎬"), + "social" => Some("📱"), + _ => None, + }; + + match (legacy_default_icon, icon) { + (_, None) => default_project_icon(workspace_type), + (Some(legacy_icon), Some(icon)) if icon == legacy_icon => { + default_project_icon(workspace_type) + } + (_, Some(icon)) => Some(icon), + } +} + /// Workspace 管理器 #[derive(Clone)] pub struct WorkspaceManager { @@ -39,22 +94,7 @@ impl WorkspaceManager { let root_path_str = root_path.to_str().ok_or("无效的路径")?.to_string(); // 根据项目类型设置默认图标 - let icon = if workspace_type.is_project_type() { - Some(match &workspace_type { - WorkspaceType::General => "💬".to_string(), - WorkspaceType::SocialMedia => "📱".to_string(), - WorkspaceType::Poster => "🖼️".to_string(), - WorkspaceType::Music => "🎵".to_string(), - WorkspaceType::Knowledge => "🔍".to_string(), - WorkspaceType::Planning => "📅".to_string(), - WorkspaceType::Document => "📄".to_string(), - WorkspaceType::Video => "🎬".to_string(), - WorkspaceType::Novel => "📖".to_string(), - _ => "📁".to_string(), - }) - } else { - None - }; + let icon = default_project_icon(&workspace_type); let workspace = Workspace { id: id.clone(), @@ -235,7 +275,7 @@ impl WorkspaceManager { .prepare( "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json FROM workspaces - WHERE workspace_type IN ('drama', 'novel', 'social', 'document', 'general') + WHERE workspace_type IN ('general', 'social', 'social-media', 'poster', 'music', 'novel', 'document', 'drama', 'video', 'knowledge', 'planning') ORDER BY updated_at DESC", ) .map_err(|e| format!("准备查询失败: {e}"))?; @@ -252,20 +292,26 @@ impl WorkspaceManager { /// 列出指定类型的项目 pub fn list_by_type(&self, workspace_type: &WorkspaceType) -> Result, String> { let conn = self.db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let query_values = workspace_type_query_values(workspace_type); + let placeholders = std::iter::repeat_n("?", query_values.len()) + .collect::>() + .join(", "); + let sql = format!( + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json + FROM workspaces + WHERE workspace_type IN ({placeholders}) + ORDER BY updated_at DESC" + ); let mut stmt = conn - .prepare( - "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json - FROM workspaces - WHERE workspace_type = ? - ORDER BY updated_at DESC", - ) + .prepare(&sql) .map_err(|e| format!("准备查询失败: {e}"))?; let workspaces = stmt - .query_map(params![workspace_type.as_str()], |row| { - Self::row_to_workspace(row) - }) + .query_map( + rusqlite::params_from_iter(query_values.iter().copied()), + Self::row_to_workspace, + ) .map_err(|e| format!("查询失败: {e}"))? .collect::, _>>() .map_err(|e| format!("解析结果失败: {e}"))?; @@ -475,11 +521,13 @@ impl WorkspaceManager { let tags: Vec = tags_json .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); + let workspace_type = WorkspaceType::parse(&workspace_type_str); + let icon = normalize_workspace_icon(&workspace_type_str, &workspace_type, icon); Ok(Workspace { id, name, - workspace_type: WorkspaceType::parse(&workspace_type_str), + workspace_type, root_path: PathBuf::from(root_path_str), is_default, created_at: chrono::DateTime::from_timestamp_millis(created_at_ms) @@ -496,3 +544,48 @@ impl WorkspaceManager { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_workspace_type_query_values_should_include_compat_aliases() { + assert_eq!( + workspace_type_query_values(&WorkspaceType::Document), + &["document", "poster", "music", "novel"] + ); + assert_eq!( + workspace_type_query_values(&WorkspaceType::Video), + &["video", "drama"] + ); + assert_eq!( + workspace_type_query_values(&WorkspaceType::SocialMedia), + &["social-media", "social"] + ); + } + + #[test] + fn normalize_workspace_icon_should_map_legacy_defaults_to_current_surface() { + assert_eq!( + normalize_workspace_icon("poster", &WorkspaceType::Document, Some("🖼️".to_string())), + Some("📄".to_string()) + ); + assert_eq!( + normalize_workspace_icon("music", &WorkspaceType::Document, None), + Some("📄".to_string()) + ); + assert_eq!( + normalize_workspace_icon("novel", &WorkspaceType::Document, Some("📖".to_string())), + Some("📄".to_string()) + ); + } + + #[test] + fn normalize_workspace_icon_should_preserve_custom_icon() { + assert_eq!( + normalize_workspace_icon("poster", &WorkspaceType::Document, Some("⭐".to_string())), + Some("⭐".to_string()) + ); + } +} diff --git a/src-tauri/crates/core/src/workspace/types.rs b/src-tauri/crates/core/src/workspace/types.rs index bb4c4fed9..44af208e3 100644 --- a/src-tauri/crates/core/src/workspace/types.rs +++ b/src-tauri/crates/core/src/workspace/types.rs @@ -21,21 +21,18 @@ pub enum WorkspaceType { /// 通用对话 General, /// 社媒内容 + #[serde(alias = "social")] SocialMedia, - /// 图文海报 - Poster, - /// 歌词曲谱 - Music, /// 知识探索 Knowledge, /// 计划规划 Planning, - /// 办公文档 + /// 办公文档(兼容旧 poster/music/novel 类型) + #[serde(alias = "poster", alias = "music", alias = "novel")] Document, - /// 短视频 + /// 短视频(兼容旧 drama 类型) + #[serde(alias = "drama")] Video, - /// 小说创作 - Novel, } impl WorkspaceType { @@ -45,13 +42,10 @@ impl WorkspaceType { WorkspaceType::Temporary => "temporary", WorkspaceType::General => "general", WorkspaceType::SocialMedia => "social-media", - WorkspaceType::Poster => "poster", - WorkspaceType::Music => "music", WorkspaceType::Knowledge => "knowledge", WorkspaceType::Planning => "planning", WorkspaceType::Document => "document", WorkspaceType::Video => "video", - WorkspaceType::Novel => "novel", } } @@ -59,17 +53,12 @@ impl WorkspaceType { match s { "temporary" => WorkspaceType::Temporary, "general" => WorkspaceType::General, - "social-media" => WorkspaceType::SocialMedia, - "poster" => WorkspaceType::Poster, - "music" => WorkspaceType::Music, + "social-media" | "social" => WorkspaceType::SocialMedia, "knowledge" => WorkspaceType::Knowledge, "planning" => WorkspaceType::Planning, + "poster" | "music" | "novel" => WorkspaceType::Document, "document" => WorkspaceType::Document, - "video" => WorkspaceType::Video, - "novel" => WorkspaceType::Novel, - // 旧类型兼容映射 - "drama" => WorkspaceType::Video, - "social" => WorkspaceType::SocialMedia, + "video" | "drama" => WorkspaceType::Video, _ => WorkspaceType::Persistent, } } @@ -80,13 +69,10 @@ impl WorkspaceType { self, WorkspaceType::General | WorkspaceType::SocialMedia - | WorkspaceType::Poster - | WorkspaceType::Music | WorkspaceType::Knowledge | WorkspaceType::Planning | WorkspaceType::Document | WorkspaceType::Video - | WorkspaceType::Novel ) } } @@ -375,13 +361,10 @@ mod tests { assert_eq!(WorkspaceType::Temporary.as_str(), "temporary"); assert_eq!(WorkspaceType::General.as_str(), "general"); assert_eq!(WorkspaceType::SocialMedia.as_str(), "social-media"); - assert_eq!(WorkspaceType::Poster.as_str(), "poster"); - assert_eq!(WorkspaceType::Music.as_str(), "music"); assert_eq!(WorkspaceType::Knowledge.as_str(), "knowledge"); assert_eq!(WorkspaceType::Planning.as_str(), "planning"); assert_eq!(WorkspaceType::Document.as_str(), "document"); assert_eq!(WorkspaceType::Video.as_str(), "video"); - assert_eq!(WorkspaceType::Novel.as_str(), "novel"); } #[test] @@ -396,18 +379,18 @@ mod tests { WorkspaceType::parse("social-media"), WorkspaceType::SocialMedia ); - assert_eq!(WorkspaceType::parse("poster"), WorkspaceType::Poster); - assert_eq!(WorkspaceType::parse("music"), WorkspaceType::Music); assert_eq!(WorkspaceType::parse("knowledge"), WorkspaceType::Knowledge); assert_eq!(WorkspaceType::parse("planning"), WorkspaceType::Planning); assert_eq!(WorkspaceType::parse("document"), WorkspaceType::Document); assert_eq!(WorkspaceType::parse("video"), WorkspaceType::Video); - assert_eq!(WorkspaceType::parse("novel"), WorkspaceType::Novel); } #[test] fn test_legacy_type_migration() { // 旧类型应该正确映射到新类型 + assert_eq!(WorkspaceType::parse("poster"), WorkspaceType::Document); + assert_eq!(WorkspaceType::parse("music"), WorkspaceType::Document); + assert_eq!(WorkspaceType::parse("novel"), WorkspaceType::Document); assert_eq!(WorkspaceType::parse("drama"), WorkspaceType::Video); assert_eq!(WorkspaceType::parse("social"), WorkspaceType::SocialMedia); } @@ -424,13 +407,10 @@ mod tests { // 用户级类型应该返回 true assert!(WorkspaceType::General.is_project_type()); assert!(WorkspaceType::SocialMedia.is_project_type()); - assert!(WorkspaceType::Poster.is_project_type()); - assert!(WorkspaceType::Music.is_project_type()); assert!(WorkspaceType::Knowledge.is_project_type()); assert!(WorkspaceType::Planning.is_project_type()); assert!(WorkspaceType::Document.is_project_type()); assert!(WorkspaceType::Video.is_project_type()); - assert!(WorkspaceType::Novel.is_project_type()); // 系统级类型应该返回 false assert!(!WorkspaceType::Persistent.is_project_type()); @@ -455,9 +435,22 @@ mod tests { // 测试从 kebab-case 反序列化 let wt: WorkspaceType = serde_json::from_str("\"social-media\"").unwrap(); assert_eq!(wt, WorkspaceType::SocialMedia); + let wt: WorkspaceType = serde_json::from_str("\"social\"").unwrap(); + assert_eq!(wt, WorkspaceType::SocialMedia); let wt: WorkspaceType = serde_json::from_str("\"video\"").unwrap(); assert_eq!(wt, WorkspaceType::Video); + let wt: WorkspaceType = serde_json::from_str("\"drama\"").unwrap(); + assert_eq!(wt, WorkspaceType::Video); + + let wt: WorkspaceType = serde_json::from_str("\"document\"").unwrap(); + assert_eq!(wt, WorkspaceType::Document); + let wt: WorkspaceType = serde_json::from_str("\"poster\"").unwrap(); + assert_eq!(wt, WorkspaceType::Document); + let wt: WorkspaceType = serde_json::from_str("\"music\"").unwrap(); + assert_eq!(wt, WorkspaceType::Document); + let wt: WorkspaceType = serde_json::from_str("\"novel\"").unwrap(); + assert_eq!(wt, WorkspaceType::Document); let wt: WorkspaceType = serde_json::from_str("\"persistent\"").unwrap(); assert_eq!(wt, WorkspaceType::Persistent); @@ -470,13 +463,10 @@ mod tests { WorkspaceType::Temporary, WorkspaceType::General, WorkspaceType::SocialMedia, - WorkspaceType::Poster, - WorkspaceType::Music, WorkspaceType::Knowledge, WorkspaceType::Planning, WorkspaceType::Document, WorkspaceType::Video, - WorkspaceType::Novel, ]; for wt in types { diff --git a/src-tauri/resources/default-skills/broadcast_generate/SKILL.md b/src-tauri/resources/default-skills/broadcast_generate/SKILL.md index 2d063b9a9..66f48f390 100644 --- a/src-tauri/resources/default-skills/broadcast_generate/SKILL.md +++ b/src-tauri/resources/default-skills/broadcast_generate/SKILL.md @@ -7,7 +7,7 @@ metadata: lime_when_to_use: 用户希望把现有文稿转成播客内容,但不要求你直接写主持稿。 lime_version: 1.1.0 lime_execution_mode: prompt - lime_surface: creator + lime_surface: workbench lime_category: media --- diff --git a/src-tauri/resources/default-skills/cover_generate/SKILL.md b/src-tauri/resources/default-skills/cover_generate/SKILL.md index cc79cd28d..20c832a50 100644 --- a/src-tauri/resources/default-skills/cover_generate/SKILL.md +++ b/src-tauri/resources/default-skills/cover_generate/SKILL.md @@ -7,7 +7,7 @@ metadata: lime_when_to_use: 用户明确要求“封面图”时使用,不要被普通配图任务替代。 lime_version: 1.1.0 lime_execution_mode: prompt - lime_surface: creator + lime_surface: workbench lime_category: media --- diff --git a/src-tauri/resources/default-skills/image_generate/SKILL.md b/src-tauri/resources/default-skills/image_generate/SKILL.md index 4c8783db5..ba52e34dc 100644 --- a/src-tauri/resources/default-skills/image_generate/SKILL.md +++ b/src-tauri/resources/default-skills/image_generate/SKILL.md @@ -7,7 +7,7 @@ metadata: lime_when_to_use: 用户需要普通配图、插图或概念图时使用;封面需求优先交给 cover_generate。 lime_version: 1.1.0 lime_execution_mode: prompt - lime_surface: creator + lime_surface: workbench lime_category: media --- diff --git a/src-tauri/resources/default-skills/modal_resource_search/SKILL.md b/src-tauri/resources/default-skills/modal_resource_search/SKILL.md index 5c61684da..275651c8c 100644 --- a/src-tauri/resources/default-skills/modal_resource_search/SKILL.md +++ b/src-tauri/resources/default-skills/modal_resource_search/SKILL.md @@ -7,7 +7,7 @@ metadata: lime_when_to_use: 用户需要为当前内容补充外部素材资源时使用。 lime_version: 1.1.0 lime_execution_mode: prompt - lime_surface: creator + lime_surface: workbench lime_category: media --- diff --git a/src-tauri/resources/default-skills/social_post_with_cover/SKILL.md b/src-tauri/resources/default-skills/social_post_with_cover/SKILL.md index 51da3f036..5673a2967 100644 --- a/src-tauri/resources/default-skills/social_post_with_cover/SKILL.md +++ b/src-tauri/resources/default-skills/social_post_with_cover/SKILL.md @@ -8,7 +8,7 @@ metadata: lime_version: 1.4.0 lime_execution_mode: workflow lime_workflow_ref: references/workflow.json - lime_surface: creator + lime_surface: workbench lime_category: social --- diff --git a/src-tauri/resources/default-skills/typesetting/SKILL.md b/src-tauri/resources/default-skills/typesetting/SKILL.md index 9eabb3e2f..df1a435f3 100644 --- a/src-tauri/resources/default-skills/typesetting/SKILL.md +++ b/src-tauri/resources/default-skills/typesetting/SKILL.md @@ -7,7 +7,7 @@ metadata: lime_when_to_use: 用户希望提升文本可读性、结构清晰度、发布观感时使用。 lime_version: 1.1.0 lime_execution_mode: prompt - lime_surface: creator + lime_surface: workbench lime_category: writing --- diff --git a/src-tauri/resources/default-skills/video_generate/SKILL.md b/src-tauri/resources/default-skills/video_generate/SKILL.md index 1c9df27c2..79e525002 100644 --- a/src-tauri/resources/default-skills/video_generate/SKILL.md +++ b/src-tauri/resources/default-skills/video_generate/SKILL.md @@ -7,7 +7,7 @@ metadata: lime_when_to_use: 用户要求生成视频,或将现有文稿改编为短视频。 lime_version: 1.1.0 lime_execution_mode: prompt - lime_surface: creator + lime_surface: workbench lime_category: media --- diff --git a/src-tauri/resources/music/chord-progressions.json b/src-tauri/resources/music/chord-progressions.json deleted file mode 100644 index a6d98cbc6..000000000 --- a/src-tauri/resources/music/chord-progressions.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "meta": { - "version": "1.0", - "description": "和弦进行数据库 - 覆盖多种音乐风格的和弦进行模式", - "created_for": "Musicify Music Theory Skill" - }, - - "basic_progressions": { - "pop_progressions": [ - { - "name": "流行四和弦", - "pattern": "vi-IV-I-V", - "chords": ["Am", "F", "C", "G"], - "roman_numeral": ["vi", "IV", "I", "V"], - "emotion": "感人、朗朗上口", - "difficulty": 1, - "usage": "最常用的流行歌曲进行,适合主歌和副歌", - "examples": ["《Someone Like You》", "《Let It Be》"], - "variations": [ - {"pattern": "vi-V-IV-I", "description": "更加流畅的解决"}, - {"pattern": "vi-IV-I-V-vi", "description": "添加循环"} - ] - }, - { - "name": "卡农进行", - "pattern": "I-V-vi-IV", - "chords": ["C", "G", "Am", "F"], - "roman_numeral": ["I", "V", "vi", "IV"], - "emotion": "优美、经典、温暖", - "difficulty": 1, - "usage": "经典抒情歌曲,主歌部分特别适用", - "examples": ["《Canon in D》", "《Five Hundred Miles》"], - "variations": [ - {"pattern": "I-V-vi-iii-IV", "description": "加入三级和弦增加色彩"}, - {"pattern": "I-V-vi-IV-V", "description": "强调属功能"} - ] - }, - { - "name": "循环进行", - "pattern": "I-V-vi-iii-IV-I-IV-V", - "chords": ["C", "G", "Am", "Em", "F", "C", "F", "G"], - "roman_numeral": ["I", "V", "vi", "iii", "IV", "I", "IV", "V"], - "emotion": "流动、持续、丰富", - "difficulty": 2, - "usage": "适合较长的歌曲段落,创造持续的动力", - "examples": ["《Autumn Leaves》", "《Fly Me to the Moon》"] - } - ], - - "rock_progressions": [ - { - "name": "力量三和弦", - "pattern": "I-♭VII-IV", - "chords": ["C", "Bb", "F"], - "roman_numeral": ["I", "♭VII", "IV"], - "emotion": "有力、激昂、叛逆", - "difficulty": 2, - "usage": "摇滚歌曲副歌,营造强烈的推动力", - "examples": ["《Sweet Caroline》", "《Free Fallin'》"], - "guitar_tips": "使用强力和弦(power chords),强调根音和五度音" - }, - { - "name": "蓝调进行", - "pattern": "I-I-I-I-IV-IV-I-I-V-IV-I-I", - "chords": ["C7", "C7", "C7", "C7", "F7", "F7", "C7", "C7", "G7", "F7", "C7", "C7"], - "roman_numeral": ["I7", "I7", "I7", "I7", "IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"], - "emotion": "忧郁、深沉、表达性强", - "difficulty": 2, - "usage": "12小节蓝调,摇滚和布鲁斯的基础", - "examples": ["《Johnny B. Goode》", "《Stormy Monday》"] - } - ], - - "jazz_progressions": [ - { - "name": "ii-V-I 进行", - "pattern": "ii7-V7-Imaj7", - "chords": ["Dm7", "G7", "Cmaj7"], - "roman_numeral": ["ii7", "V7", "Imaj7"], - "emotion": "成熟、精致、和谐", - "difficulty": 3, - "usage": "爵士乐最基本的进行,用于歌曲的解决", - "examples": ["《All The Things You Are》", "《Autumn Leaves》"], - "extensions": [ - {"pattern": "ii7(b5)-V7alt-i7", "description": "小调ii-V-i"}, - {"pattern": "IImaj7-V7-Imaj7", "description": "大二级替代"} - ] - }, - { - "name": "Circle of Fifths", - "pattern": "I-vi-ii-V", - "chords": ["Cmaj7", "Am7", "Dm7", "G7"], - "roman_numeral": ["Imaj7", "vi7", "ii7", "V7"], - "emotion": "流动、自然、渐进", - "difficulty": 3, - "usage": "爵士标准曲常用,创造平滑的和声运动", - "examples": ["《I Got Rhythm》", "《All of Me》"] - } - ], - - "chinese_style": [ - { - "name": "五声音阶进行", - "pattern": "I-III-vi-IV", - "chords": ["C", "E", "Am", "F"], - "roman_numeral": ["I", "III", "vi", "IV"], - "emotion": "古典、东方韵味、祥和", - "difficulty": 2, - "usage": "中国风歌曲,古风音乐", - "examples": ["《青花瓷》", "《菊花台》"], - "scales": ["C-D-E-G-A (宫商角徵羽)"], - "instruments": ["古筝", "二胡", "笛子", "琵琶"] - }, - { - "name": "宫调式进行", - "pattern": "I-V-vi-IV-ii-V-I", - "chords": ["C", "G", "Am", "F", "Dm", "G", "C"], - "roman_numeral": ["I", "V", "vi", "IV", "ii", "V", "I"], - "emotion": "庄重、典雅、传统", - "difficulty": 2, - "usage": "古风歌曲的主歌部分,营造古典氛围", - "traditional_harmony": "以宫音为主,强调五度圈运动" - } - ] - }, - - "advanced_techniques": { - "chord_substitutions": [ - { - "name": "三全音替代", - "original": "V7", - "substitute": "♭II7", - "example": {"original": "G7", "substitute": "D♭7"}, - "effect": "增加色彩和张力", - "usage": "爵士乐中常用,特别在ii-V-I进行中" - }, - { - "name": "相对和弦替代", - "original": "I", - "substitute": "vi", - "example": {"original": "C", "substitute": "Am"}, - "effect": "从大调转向小调色彩", - "usage": "营造忧郁或内省的情绪" - }, - { - "name": "二级和弦", - "technique": "目标和弦前加入其二级和弦", - "example": "C-Dm-G7-C (在G7前加入Dm)", - "effect": "增强和声运动感", - "usage": "增加进行的丰富性" - } - ], - - "modal_progressions": [ - { - "mode": "Dorian", - "characteristic": "自然六度,♭七度", - "progression": "i-IV-♭VII-i", - "chords": ["Dm", "G", "C", "Dm"], - "emotion": "神秘、中性、民族色彩", - "examples": ["《Scarborough Fair》", "《Eleanor Rigby》"] - }, - { - "mode": "Mixolydian", - "characteristic": "♭七度", - "progression": "I-♭VII-IV-I", - "chords": ["G", "F", "C", "G"], - "emotion": "开朗中带有忧郁", - "examples": ["《Sweet Caroline》", "《Norwegian Wood》"] - }, - { - "mode": "Aeolian (Natural Minor)", - "characteristic": "♭三度,♭六度,♭七度", - "progression": "i-♭VII-♭VI-♭VII", - "chords": ["Am", "G", "F", "G"], - "emotion": "忧郁、深沉、戏剧性", - "examples": ["《House of the Rising Sun》", "《Stairway to Heaven》"] - } - ] - }, - - "rhythm_patterns": { - "basic_strumming": [ - { - "name": "基础4/4拍型", - "pattern": "D-D-U-U-D-U", - "notation": "下-下-上-上-下-上", - "tempo": "适中速度 (120 BPM)", - "usage": "最基础的吉他扫弦模式", - "songs": ["《Hotel California》", "《Wonderwall》"] - }, - { - "name": "民谣分解", - "pattern": "1-3-2-3-1-3-2-3", - "fingers": "拇指-食指-中指-食指", - "usage": "指弹民谣,抒情歌曲", - "songs": ["《Dust in the Wind》", "《Blackbird》"] - } - ], - - "advanced_patterns": [ - { - "name": "放克节奏", - "pattern": "重音在16分音符的切分位置", - "characteristic": "强调反拍,使用切音技巧", - "instruments": ["电吉他", "贝斯", "鼓"], - "examples": ["《Superstition》", "《I Want Your Love》"] - }, - { - "name": "雷鬼节奏", - "pattern": "强调2、4拍的后半拍", - "characteristic": "轻松摇摆,强调上拍", - "tempo": "中慢速度 (70-90 BPM)", - "examples": ["《No Woman No Cry》", "《Three Little Birds》"] - } - ] - }, - - "song_structure_templates": { - "pop_structure": { - "sections": ["Intro", "Verse 1", "Pre-Chorus", "Chorus", "Verse 2", "Pre-Chorus", "Chorus", "Bridge", "Chorus", "Outro"], - "chord_suggestions": { - "Verse": "较为平静的进行,如 vi-IV-I-V", - "Pre-Chorus": "建立张力,如 ii-V 或 IV-V", - "Chorus": "强有力的进行,如 I-V-vi-IV", - "Bridge": "对比性进行,可尝试不同调性" - } - }, - - "ballad_structure": { - "sections": ["Intro", "Verse 1", "Chorus", "Verse 2", "Chorus", "Bridge", "Chorus", "Outro"], - "dynamic": "从安静开始,逐渐建立到高潮", - "chord_suggestions": { - "Verse": "简单温柔的进行", - "Chorus": "情感饱满的和弦" - } - } - }, - - "key_relationships": { - "circle_of_fifths": { - "major_keys": ["C", "G", "D", "A", "E", "B", "F#", "Db", "Ab", "Eb", "Bb", "F"], - "relative_minors": ["Am", "Em", "Bm", "F#m", "C#m", "G#m", "D#m", "Bbm", "Fm", "Cm", "Gm", "Dm"], - "modulation_techniques": [ - "共同和弦转调", - "属和弦转调", - "半音阶下行转调" - ] - } - } -} \ No newline at end of file diff --git a/src-tauri/resources/music/guofeng-patterns.json b/src-tauri/resources/music/guofeng-patterns.json deleted file mode 100644 index 6cce684b8..000000000 --- a/src-tauri/resources/music/guofeng-patterns.json +++ /dev/null @@ -1,266 +0,0 @@ -{ - "meta": { - "version": "1.0", - "description": "国风旋律模式库 - 定义情绪映射、结构差异和常用旋律模式", - "created_for": "Musicify 国风旋律生成 Skill" - }, - - "emotions": { - "sorrowful": { - "name": "忧伤", - "preferredMode": "yu", - "contour": "descending", - "intervalRange": 3, - "tempo": "慢板 (60-72 BPM)", - "characteristics": [ - "多用下行旋律线", - "羽调式为主,偶用商调式", - "音程以级进为主,偶有小跳", - "多用滑音和颤音装饰", - "句尾常落在羽音(6)或商音(2)" - ], - "typicalPatterns": ["weeping", "sighing"] - }, - "joyful": { - "name": "欢快", - "preferredMode": "gong", - "contour": "ascending", - "intervalRange": 5, - "tempo": "快板 (120-140 BPM)", - "characteristics": [ - "多用上行旋律线", - "宫调式为主,偶用徵调式", - "音程跳进较多,节奏活泼", - "装饰音轻快,多用波音", - "句尾常落在宫音(1)或徵音(5)" - ], - "typicalPatterns": ["celebration", "dance"] - }, - "peaceful": { - "name": "平静", - "preferredMode": "jue", - "contour": "stable", - "intervalRange": 2, - "tempo": "中板 (80-96 BPM)", - "characteristics": [ - "旋律走向平稳", - "角调式为主,音色空灵", - "以级进为主,避免大跳", - "装饰音少而精", - "音域范围较窄" - ], - "typicalPatterns": ["flowing", "meditation"] - }, - "passionate": { - "name": "激昂", - "preferredMode": "zhi", - "contour": "wave", - "intervalRange": 6, - "tempo": "快板 (116-132 BPM)", - "characteristics": [ - "旋律起伏大,波浪式进行", - "徵调式为主,热情奔放", - "音程跳进多,张力强", - "高音区使用频繁", - "句尾常有力度强调" - ], - "typicalPatterns": ["heroic", "climax"] - }, - "nostalgic": { - "name": "思念", - "preferredMode": "shang", - "contour": "wave", - "intervalRange": 4, - "tempo": "慢板 (66-80 BPM)", - "characteristics": [ - "旋律婉转起伏", - "商调式为主,深沉内敛", - "上行后下行,欲言又止", - "多用滑音表达情感", - "句尾常有延长音" - ], - "typicalPatterns": ["longing", "sighing"] - }, - "ethereal": { - "name": "空灵", - "preferredMode": "jue", - "contour": "ascending", - "intervalRange": 5, - "tempo": "自由节拍", - "characteristics": [ - "旋律飘逸,多用高音区", - "角调式为主,清新脱俗", - "音符稀疏,留白较多", - "颤音装饰增加空灵感", - "节奏自由,不拘一格" - ], - "typicalPatterns": ["floating", "meditation"] - } - }, - - "structures": { - "verse": { - "name": "主歌", - "range": 6, - "contour": "stable", - "characteristics": [ - "音域较窄,一般在六度以内", - "旋律平稳,以叙述为主", - "节奏规整,便于歌词表达", - "情感内敛,为副歌做铺垫" - ], - "typicalStartNotes": [5, 3, 1], - "typicalEndNotes": [1, 5, 6] - }, - "chorus": { - "name": "副歌", - "range": 10, - "contour": "wave", - "characteristics": [ - "音域扩展,可达十度", - "旋律起伏大,情感爆发", - "常有高潮点设计", - "节奏可更自由或更强烈" - ], - "typicalStartNotes": [1, 5, 6], - "typicalEndNotes": [1, 5] - }, - "bridge": { - "name": "桥段", - "range": 8, - "contour": "ascending", - "characteristics": [ - "音域介于主歌和副歌之间", - "旋律上行为主,推向高潮", - "可转调或变化调式", - "为副歌再现做准备" - ], - "typicalStartNotes": [6, 3, 2], - "typicalEndNotes": [5, 1] - } - }, - - "patterns": { - "weeping": { - "name": "哭腔模式", - "sequence": [6, 5, 3, 2, 1, 6], - "contour": "descending", - "usage": "表达悲伤、哀怨情绪", - "examples": ["《青花瓷》副歌", "《烟花易冷》"], - "ornaments": ["滑音", "颤音"] - }, - "sighing": { - "name": "叹息模式", - "sequence": [5, 6, 5, 3, 2], - "contour": "wave", - "usage": "表达思念、无奈情绪", - "examples": ["《千里之外》", "《菊花台》"], - "ornaments": ["滑音"] - }, - "celebration": { - "name": "欢庆模式", - "sequence": [1, 3, 5, 6, 5, 3, 1], - "contour": "ascending", - "usage": "表达喜悦、庆祝情绪", - "examples": ["《好日子》", "《恭喜发财》"], - "ornaments": ["波音"] - }, - "dance": { - "name": "舞曲模式", - "sequence": [5, 1, 3, 5, 6, 5], - "contour": "wave", - "usage": "活泼的舞蹈节奏", - "examples": ["《最炫民族风》"], - "ornaments": ["波音", "倚音"] - }, - "flowing": { - "name": "流水模式", - "sequence": [3, 2, 1, 2, 3, 5], - "contour": "stable", - "usage": "平静、流畅的叙述", - "examples": ["《高山流水》", "《渔舟唱晚》"], - "ornaments": ["滑音"] - }, - "meditation": { - "name": "禅意模式", - "sequence": [5, 3, 5, 6, 5], - "contour": "stable", - "usage": "空灵、冥想的意境", - "examples": ["《大悲咒》", "《心经》"], - "ornaments": ["颤音"] - }, - "heroic": { - "name": "英雄模式", - "sequence": [5, 1, 5, 6, 1, 5], - "contour": "ascending", - "usage": "激昂、豪迈的情绪", - "examples": ["《精忠报国》", "《男儿当自强》"], - "ornaments": ["倚音"] - }, - "climax": { - "name": "高潮模式", - "sequence": [1, 3, 5, 6, 1, 6, 5], - "contour": "wave", - "usage": "歌曲高潮部分", - "examples": ["副歌高潮段落"], - "ornaments": ["颤音", "滑音"] - }, - "longing": { - "name": "思念模式", - "sequence": [2, 3, 5, 3, 2, 1, 2], - "contour": "wave", - "usage": "表达思念、期盼情绪", - "examples": ["《但愿人长久》", "《明月几时有》"], - "ornaments": ["滑音", "颤音"] - }, - "floating": { - "name": "飘逸模式", - "sequence": [3, 5, 6, 5, 3], - "contour": "ascending", - "usage": "空灵、超脱的意境", - "examples": ["《沧海一声笑》"], - "ornaments": ["颤音"] - } - }, - - "references": { - "classicSongs": [ - { - "name": "青花瓷", - "mode": "yu", - "emotion": "nostalgic", - "features": "羽调式为主,旋律婉转,多用下行和滑音" - }, - { - "name": "菊花台", - "mode": "yu", - "emotion": "sorrowful", - "features": "羽调式,忧伤婉转,句尾多用延长音" - }, - { - "name": "千里之外", - "mode": "shang", - "emotion": "nostalgic", - "features": "商调式,深沉内敛,旋律起伏适中" - }, - { - "name": "沧海一声笑", - "mode": "zhi", - "emotion": "passionate", - "features": "徵调式,豪迈奔放,音域宽广" - }, - { - "name": "高山流水", - "mode": "gong", - "emotion": "peaceful", - "features": "宫调式,典雅庄重,旋律流畅" - }, - { - "name": "茉莉花", - "mode": "gong", - "emotion": "peaceful", - "features": "宫调式,清新优美,级进为主" - } - ] - } -} diff --git a/src-tauri/resources/music/midi-parser-rules.json b/src-tauri/resources/music/midi-parser-rules.json deleted file mode 100644 index d7d9a5fa2..000000000 --- a/src-tauri/resources/music/midi-parser-rules.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "meta": { - "version": "1.0", - "description": "MIDI 解析规则库 - 定义音符映射、时值解析、节奏型识别和调式推断规则", - "created_for": "Musicify 旋律风格学习 Skill" - }, - - "noteMapping": { - "midiToName": { - "36": "C2", "37": "C#2", "38": "D2", "39": "D#2", "40": "E2", "41": "F2", - "42": "F#2", "43": "G2", "44": "G#2", "45": "A2", "46": "A#2", "47": "B2", - "48": "C3", "49": "C#3", "50": "D3", "51": "D#3", "52": "E3", "53": "F3", - "54": "F#3", "55": "G3", "56": "G#3", "57": "A3", "58": "A#3", "59": "B3", - "60": "C4", "61": "C#4", "62": "D4", "63": "D#4", "64": "E4", "65": "F4", - "66": "F#4", "67": "G4", "68": "G#4", "69": "A4", "70": "A#4", "71": "B4", - "72": "C5", "73": "C#5", "74": "D5", "75": "D#5", "76": "E5", "77": "F5", - "78": "F#5", "79": "G5", "80": "G#5", "81": "A5", "82": "A#5", "83": "B5", - "84": "C6", "85": "C#6", "86": "D6", "87": "D#6", "88": "E6", "89": "F6", - "90": "F#6", "91": "G6", "92": "G#6", "93": "A6", "94": "A#6", "95": "B6" - }, - "midiToJianpu": { - "description": "基于 C 大调的简谱映射,实际使用时需根据调号偏移", - "baseKey": "C", - "mapping": { - "0": "1", "2": "2", "4": "3", "5": "4", "7": "5", "9": "6", "11": "7" - }, - "octaveMarkers": { - "-2": ",,", "-1": ",", "0": "", "1": "'", "2": "''" - } - } - }, - - "durationMapping": { - "ticksPerBeat": 480, - "durationNames": { - "1920": { "name": "全音符", "symbol": "○", "beats": 4 }, - "1440": { "name": "附点二分音符", "symbol": "●.", "beats": 3 }, - "960": { "name": "二分音符", "symbol": "●", "beats": 2 }, - "720": { "name": "附点四分音符", "symbol": "♩.", "beats": 1.5 }, - "480": { "name": "四分音符", "symbol": "♩", "beats": 1 }, - "360": { "name": "附点八分音符", "symbol": "♪.", "beats": 0.75 }, - "240": { "name": "八分音符", "symbol": "♪", "beats": 0.5 }, - "180": { "name": "附点十六分音符", "symbol": "♬.", "beats": 0.375 }, - "120": { "name": "十六分音符", "symbol": "♬", "beats": 0.25 }, - "160": { "name": "三连音(四分)", "symbol": "♩³", "beats": 0.333 }, - "80": { "name": "三连音(八分)", "symbol": "♪³", "beats": 0.167 } - }, - "tolerancePercent": 10 - }, - - "rhythmPatterns": { - "quarter": { - "name": "四分音符型", - "durations": [480], - "description": "稳定的四分音符节奏,常用于叙述性段落", - "category": "basic" - }, - "eighth": { - "name": "八分音符型", - "durations": [240, 240], - "description": "连续八分音符,增加流动感", - "category": "basic" - }, - "dotted_quarter_eighth": { - "name": "附点四分+八分", - "durations": [720, 240], - "description": "附点节奏,增加推动力", - "category": "dotted" - }, - "eighth_dotted_quarter": { - "name": "八分+附点四分", - "durations": [240, 720], - "description": "切分感的附点节奏", - "category": "dotted" - }, - "syncopation_basic": { - "name": "基本切分", - "durations": [240, 480, 240], - "description": "基本切分节奏,强拍弱化", - "category": "syncopation" - }, - "syncopation_offbeat": { - "name": "后半拍切分", - "durations": [240, 240, 480], - "description": "后半拍强调的切分", - "category": "syncopation" - }, - "triplet_quarter": { - "name": "四分三连音", - "durations": [160, 160, 160], - "description": "三连音节奏,增加流畅感", - "category": "triplet" - }, - "long_short": { - "name": "长短型", - "durations": [960, 480], - "description": "二分+四分,舒缓的节奏", - "category": "basic" - }, - "sixteenth_group": { - "name": "十六分音符组", - "durations": [120, 120, 120, 120], - "description": "快速的十六分音符,增加紧张感", - "category": "fast" - } - }, - - "modeDetection": { - "description": "基于五声音阶特征音检测调式", - "pentatonic": { - "gong": { - "name": "宫调式", - "characteristicDegrees": [0, 2, 4, 7, 9], - "rootDegree": 0, - "endingNotes": [0, 7], - "weight": { "root": 3, "fifth": 2, "others": 1 } - }, - "shang": { - "name": "商调式", - "characteristicDegrees": [0, 2, 4, 7, 9], - "rootDegree": 2, - "endingNotes": [2, 0], - "weight": { "root": 3, "fifth": 2, "others": 1 } - }, - "jue": { - "name": "角调式", - "characteristicDegrees": [0, 2, 4, 7, 9], - "rootDegree": 4, - "endingNotes": [4, 2], - "weight": { "root": 3, "fifth": 2, "others": 1 } - }, - "zhi": { - "name": "徵调式", - "characteristicDegrees": [0, 2, 4, 7, 9], - "rootDegree": 7, - "endingNotes": [7, 9], - "weight": { "root": 3, "fifth": 2, "others": 1 } - }, - "yu": { - "name": "羽调式", - "characteristicDegrees": [0, 2, 4, 7, 9], - "rootDegree": 9, - "endingNotes": [9, 7], - "weight": { "root": 3, "fifth": 2, "others": 1 } - } - }, - "keySignatures": { - "C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3, - "E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8, - "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11 - } - }, - - "trackMatching": { - "vocalRangeMin": 48, - "vocalRangeMax": 84, - "vocalRangeDescription": "人声音域范围 C3(48) 到 C6(84)", - "vocalRangeZones": { - "belowVocal": { "min": 0, "max": 47, "description": "低于人声范围,可能是贝斯" }, - "vocalLow": { "min": 48, "max": 59, "description": "C3-B3,男声常用区" }, - "vocalMid": { "min": 60, "max": 71, "description": "C4-B4,男女声共用区" }, - "vocalHigh": { "min": 72, "max": 84, "description": "C5-C6,女声常用区" }, - "aboveVocal": { "min": 85, "max": 127, "description": "高于人声范围,可能是装饰音" } - }, - "tolerancePercent": 15, - "minVocalRangeOverlap": 0.5, - "priorityKeywords": ["vocal", "melody", "voice", "lead", "主旋律", "人声"], - "matchingRules": [ - { - "rule": "keyword_match", - "description": "音轨名称包含人声关键词时优先选择", - "priority": 1, - "scoreBonus": 30 - }, - { - "rule": "note_count_match", - "description": "音符数量与歌词字数最接近的音轨", - "priority": 2, - "maxScore": 40, - "toleranceLevels": { - "exact": { "tolerance": 0, "score": 40 }, - "close": { "tolerance": 0.05, "score": 35 }, - "acceptable": { "tolerance": 0.15, "score": 28 } - } - }, - { - "rule": "pitch_range_filter", - "description": "过滤音域超出人声范围的音轨", - "priority": 3, - "maxScore": 30, - "overlapScoring": { - "full": { "minOverlap": 1.0, "score": 30 }, - "high": { "minOverlap": 0.75, "score": 22 }, - "medium": { "minOverlap": 0.5, "score": 15 }, - "low": { "minOverlap": 0, "score": 0 } - } - } - ], - "confidenceThresholds": { - "high": 90, - "medium": 70, - "low": 50 - }, - "confidenceDescriptions": { - "high": "自动选择,无需确认", - "medium": "建议选择,请求确认", - "low": "需要用户手动确认", - "noMatch": "不推荐,列出供参考" - }, - "conflictResolution": { - "scoreDifferenceThreshold": 5, - "priorityOrder": ["keyword_match", "note_count_match", "pitch_range_filter"] - } - }, - - "intervalClassification": { - "stepwise": { - "name": "级进", - "semitones": [1, 2], - "description": "相邻音级的进行,旋律流畅" - }, - "smallLeap": { - "name": "小跳", - "semitones": [3, 4], - "description": "三度或四度跳进,增加起伏" - }, - "largeLeap": { - "name": "大跳", - "semitones": [5, 6, 7, 8, 9, 10, 11, 12], - "description": "五度及以上跳进,戏剧性强" - } - }, - - "contourAnalysis": { - "ascending": { - "name": "上行", - "condition": "后一音高于前一音", - "emotion": "积极、上升、期待" - }, - "descending": { - "name": "下行", - "condition": "后一音低于前一音", - "emotion": "忧伤、下沉、释放" - }, - "stable": { - "name": "平稳", - "condition": "音高变化在二度以内", - "emotion": "平静、叙述、稳定" - } - } -} diff --git a/src-tauri/resources/music/pentatonic-rules.json b/src-tauri/resources/music/pentatonic-rules.json deleted file mode 100644 index 26dc9c61f..000000000 --- a/src-tauri/resources/music/pentatonic-rules.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "meta": { - "version": "1.0", - "description": "五声音阶规则库 - 定义中国传统五声音阶的调式结构、装饰音和音程规则", - "created_for": "Musicify 国风旋律生成 Skill" - }, - - "scales": { - "gong": { - "name": "宫调式", - "notes": [1, 2, 3, 5, 6], - "root": 1, - "characteristic": "以宫音(1)为主音,音阶明亮开阔,具有庄重典雅的特点", - "emotion": "庄重、明亮、欢快、积极向上", - "typicalCadence": [5, 1], - "avoidNotes": [4, 7] - }, - "shang": { - "name": "商调式", - "notes": [1, 2, 3, 5, 6], - "root": 2, - "characteristic": "以商音(2)为主音,音阶略带忧郁,具有深沉内敛的特点", - "emotion": "深沉、内敛、略带忧郁、思念", - "typicalCadence": [1, 2], - "avoidNotes": [4, 7] - }, - "jue": { - "name": "角调式", - "notes": [1, 2, 3, 5, 6], - "root": 3, - "characteristic": "以角音(3)为主音,音阶清新脱俗,具有空灵飘逸的特点", - "emotion": "清新、空灵、飘逸、超脱", - "typicalCadence": [2, 3], - "avoidNotes": [4, 7] - }, - "zhi": { - "name": "徵调式", - "notes": [1, 2, 3, 5, 6], - "root": 5, - "characteristic": "以徵音(5)为主音,音阶热情奔放,具有激昂豪迈的特点", - "emotion": "热情、奔放、激昂、豪迈", - "typicalCadence": [6, 5], - "avoidNotes": [4, 7] - }, - "yu": { - "name": "羽调式", - "notes": [1, 2, 3, 5, 6], - "root": 6, - "characteristic": "以羽音(6)为主音,音阶柔和婉转,具有忧伤哀怨的特点", - "emotion": "忧伤、婉转、哀怨、柔美", - "typicalCadence": [5, 6], - "avoidNotes": [4, 7] - } - }, - - "ornaments": { - "huayin": { - "name": "滑音", - "notation": "↗ 或 ↘", - "description": "从一个音滑向另一个音,常用于表达情感的流动", - "usage": "句尾延长音、情感转折处、模仿人声哭腔", - "examples": ["5↗6", "3↘2"] - }, - "chanyin": { - "name": "颤音", - "notation": "~", - "description": "在主音上快速交替相邻音,增加音色的丰富性", - "usage": "长音装饰、情感强调、模仿弦乐器效果", - "examples": ["5~", "6~"] - }, - "yiyin": { - "name": "倚音", - "notation": "小音符标记", - "description": "在主音前快速演奏的装饰音,增加旋律的流畅性", - "usage": "乐句开头、强拍装饰、增加韵味", - "examples": ["(3)5", "(6)1"] - }, - "boyin": { - "name": "波音", - "notation": "∿", - "description": "主音与上方或下方相邻音快速交替一次", - "usage": "轻快段落、活泼情绪、增加灵动感", - "examples": ["5∿", "3∿"] - } - }, - - "intervals": { - "allowed": [1, 2, 3, 4, 5], - "preferred": [1, 2], - "descriptions": { - "1": "同度/八度 - 稳定、强调", - "2": "二度 - 级进,最常用,流畅自然", - "3": "三度 - 小跳进,增加起伏", - "4": "四度 - 中跳进,增加张力", - "5": "五度 - 大跳进,戏剧性强,慎用" - }, - "rules": [ - "优先使用级进(二度)保持旋律流畅", - "跳进后宜用级进反向进行", - "避免连续大跳进", - "句尾常用下行级进解决到主音" - ] - } -} diff --git a/src-tauri/resources/music/rhyme-patterns.json b/src-tauri/resources/music/rhyme-patterns.json deleted file mode 100644 index ccaefe07a..000000000 --- a/src-tauri/resources/music/rhyme-patterns.json +++ /dev/null @@ -1,183 +0,0 @@ -{ - "meta": { - "version": "1.0", - "description": "中文歌词押韵数据库 - 基于拼音和声调的押韵分析", - "created_for": "Musicify Skill System" - }, - - "rhyme_patterns": { - "AABB": { - "description": "两行一韵,连续押韵", - "difficulty": 1, - "usage": "适合流行歌曲,容易上口" - }, - "ABAB": { - "description": "交错押韵", - "difficulty": 2, - "usage": "增加节奏变化,适合抒情歌曲" - }, - "ABCB": { - "description": "隔行押韵", - "difficulty": 2, - "usage": "常用于民谣和说唱" - }, - "AAAA": { - "description": "通韵到底", - "difficulty": 3, - "usage": "适合短小精悍的段落" - } - }, - - "common_rhymes": { - "爱情主题": [ - {"group": "ai", "words": ["爱", "在", "来", "开", "怀", "猜", "陪", "等待"]}, - {"group": "ing", "words": ["情", "心", "真", "深", "亲", "信", "认", "永恒"]}, - {"group": "ou", "words": ["走", "久", "守", "有", "后", "手", "温柔", "拥有"]}, - {"group": "an", "words": ["伴", "暖", "看", "汗", "伞", "岸", "陪伴", "温暖"]} - ], - - "励志主题": [ - {"group": "eng", "words": ["梦", "能", "成", "风", "空", "勇", "冲", "成功"]}, - {"group": "iang", "words": ["想", "强", "光", "方", "向", "长", "希望", "力量"]}, - {"group": "u", "words": ["路", "步", "住", "哭", "努", "苦", "付出", "坚持"]}, - {"group": "i", "words": ["力", "立", "起", "地", "意", "义", "坚毅", "奇迹"]} - ], - - "青春回忆": [ - {"group": "ian", "words": ["年", "天", "前", "甜", "变", "见", "青春", "遇见"]}, - {"group": "ao", "words": ["好", "老", "少", "跑", "闹", "笑", "美好", "年少"]}, - {"group": "ei", "words": ["美", "回", "累", "醉", "泪", "岁", "珍贵", "无悔"]}, - {"group": "ong", "words": ["梦", "中", "空", "痛", "重", "懂", "朦胧", "感动"]} - ], - - "离别思念": [ - {"group": "ie", "words": ["别", "夜", "雪", "月", "切", "说", "离别", "永别"]}, - {"group": "iao", "words": ["远", "想", "飘", "桥", "料", "瞧", "思念", "遥远"]}, - {"group": "iu", "words": ["留", "久", "流", "愁", "求", "收", "停留", "不朽"]}, - {"group": "eng", "words": ["等", "朋", "冷", "疼", "能", "层", "等候", "心疼"]} - ], - - "家乡故土": [ - {"group": "ang", "words": ["乡", "长", "方", "香", "窗", "望", "故乡", "远方"]}, - {"group": "ou", "words": ["家", "花", "话", "画", "挂", "牵挂", "变化"]}, - {"group": "i", "words": ["地", "里", "起", "记", "意", "立", "土地", "回忆"]}, - {"group": "an", "words": ["山", "田", "甘", "看", "暖", "伴", "青山", "温暖"]} - ] - }, - - "emotion_vocabulary": { - "欢快": { - "adjectives": ["明亮", "轻快", "绚烂", "灿烂", "活泼", "欢乐", "愉悦", "畅快"], - "verbs": ["跳跃", "飞扬", "奔跑", "舞蹈", "歌唱", "欢笑", "庆祝", "绽放"], - "nouns": ["阳光", "彩虹", "花朵", "蝴蝶", "鸟儿", "春风", "笑声", "节拍"] - }, - - "忧伤": { - "adjectives": ["黯然", "凄凉", "孤独", "冷清", "沉重", "苦涩", "惆怅", "迷茫"], - "verbs": ["凋零", "飘零", "消散", "哭泣", "叹息", "怀念", "失去", "离开"], - "nouns": ["雨滴", "落叶", "寒风", "夜晚", "眼泪", "回忆", "阴霾", "孤影"] - }, - - "温暖": { - "adjectives": ["温柔", "暖和", "亲切", "慈爱", "安详", "舒适", "贴心", "甜蜜"], - "verbs": ["拥抱", "守护", "陪伴", "关怀", "温暖", "照亮", "安慰", "包容"], - "nouns": ["怀抱", "家", "母亲", "暖阳", "火炉", "热茶", "羽毛", "港湾"] - }, - - "励志": { - "adjectives": ["坚强", "勇敢", "坚定", "不屈", "执着", "顽强", "无畏", "坚毅"], - "verbs": ["奋斗", "追求", "坚持", "突破", "攀登", "拼搏", "冲刺", "征服"], - "nouns": ["梦想", "目标", "理想", "信念", "勇气", "力量", "意志", "希望"] - }, - - "浪漫": { - "adjectives": ["浪漫", "梦幻", "迷人", "优雅", "柔美", "诗意", "唯美", "动人"], - "verbs": ["邂逅", "心动", "倾心", "眷恋", "凝视", "等候", "思念", "相拥"], - "nouns": ["月光", "星空", "玫瑰", "诗歌", "约定", "信物", "回音", "倩影"] - } - }, - - "rhyme_quality_metrics": { - "perfect_match": { - "score": 95, - "description": "完全押韵,音调和韵母都匹配" - }, - "near_rhyme": { - "score": 80, - "description": "近似押韵,韵母相同音调略不同" - }, - "assonance": { - "score": 65, - "description": "元音押韵,主要元音相同" - }, - "consonance": { - "score": 50, - "description": "辅音押韵,结尾辅音相同" - }, - "weak_rhyme": { - "score": 30, - "description": "弱押韵,仅部分音素相似" - }, - "no_rhyme": { - "score": 0, - "description": "无押韵关系" - } - }, - - "songwriting_tips": { - "rhyme_techniques": [ - { - "name": "内部押韵", - "description": "在同一行或相邻行的内部创造押韵效果", - "example": "心中的梦想如星光闪亮" - }, - { - "name": "重复押韵", - "description": "使用相同的韵脚增强记忆点", - "example": "爱你的心永不改变,爱你到永远" - }, - { - "name": "多重押韵", - "description": "在一行中使用多个押韵点", - "example": "阳光灿烂照人间,温暖如春风拂面" - } - ], - - "rhythm_patterns": [ - { - "name": "七字句", - "pattern": "2-2-3", - "example": "青春/如梦/多美好", - "usage": "经典中文歌词节奏" - }, - { - "name": "五字句", - "pattern": "2-3", - "example": "思君/不见君", - "usage": "古风歌曲常用" - }, - { - "name": "九字句", - "pattern": "3-3-3", - "example": "走过了/春夏秋冬/多少年", - "usage": "适合叙事性歌曲" - } - ] - }, - - "advanced_features": { - "tone_analysis": { - "first_tone": {"description": "阴平,高平调", "compatibility": ["first_tone", "second_tone"]}, - "second_tone": {"description": "阳平,中升调", "compatibility": ["first_tone", "second_tone"]}, - "third_tone": {"description": "上声,低降升调", "compatibility": ["third_tone", "fourth_tone"]}, - "fourth_tone": {"description": "去声,高降调", "compatibility": ["third_tone", "fourth_tone"]} - }, - - "syllable_structure": { - "monosyllabic": {"description": "单音节词", "usage": "适合快节奏部分"}, - "disyllabic": {"description": "双音节词", "usage": "最常用的词汇结构"}, - "trisyllabic": {"description": "三音节词", "usage": "适合慢节奏抒情"}, - "polysyllabic": {"description": "多音节词", "usage": "用于特殊效果"} - } - } -} \ No newline at end of file diff --git a/src-tauri/resources/scripts/audio_to_midi.py b/src-tauri/resources/scripts/audio_to_midi.py deleted file mode 100644 index 28bbf3e73..000000000 --- a/src-tauri/resources/scripts/audio_to_midi.py +++ /dev/null @@ -1,367 +0,0 @@ -#!/usr/bin/env python3 -""" -MP3 转 MIDI 工具 -使用 Demucs 分离人声 + Basic Pitch 转换 MIDI - -用法: - python audio_to_midi.py [output_dir] - python audio_to_midi.py --check # 检查依赖和硬件 - -输出: - JSON 格式的处理结果 -""" - -import sys -import os -import json -import subprocess -import shutil -from pathlib import Path -from datetime import datetime - - -def output_json(data): - """输出 JSON 格式结果""" - print(json.dumps(data, ensure_ascii=False, indent=2)) - - -def detect_hardware(): - """检测可用硬件加速""" - try: - import torch - if torch.cuda.is_available(): - device_name = torch.cuda.get_device_name(0) - return { - "device": "cuda", - "name": device_name, - "description": f"NVIDIA GPU 加速 ({device_name})", - "estimated_time": "1-2 分钟" - } - elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available(): - return { - "device": "mps", - "name": "Apple Silicon", - "description": "Apple Silicon 加速 (MPS)", - "estimated_time": "2-3 分钟" - } - except ImportError: - pass - - return { - "device": "cpu", - "name": "CPU", - "description": "CPU 模式 (较慢)", - "estimated_time": "8-15 分钟" - } - - -def check_dependencies(): - """检查依赖是否安装""" - dependencies = { - "demucs": {"installed": False, "version": None}, - "basic_pitch": {"installed": False, "version": None}, - "torch": {"installed": False, "version": None}, - } - - try: - import demucs - dependencies["demucs"]["installed"] = True - dependencies["demucs"]["version"] = getattr(demucs, '__version__', 'unknown') - except ImportError: - pass - - try: - import basic_pitch - dependencies["basic_pitch"]["installed"] = True - dependencies["basic_pitch"]["version"] = getattr(basic_pitch, '__version__', 'unknown') - except ImportError: - pass - - try: - import torch - dependencies["torch"]["installed"] = True - dependencies["torch"]["version"] = torch.__version__ - except ImportError: - pass - - return dependencies - - -def check_command_available(cmd): - """检查命令行工具是否可用""" - return shutil.which(cmd) is not None - - -def separate_vocals(input_mp3, output_dir, device="cpu"): - """ - 使用 Demucs 分离人声 - - Args: - input_mp3: 输入 MP3 文件路径 - output_dir: 输出目录 - device: 使用的设备 (cuda/mps/cpu) - - Returns: - vocals_path: 人声文件路径 - """ - input_path = Path(input_mp3) - output_path = Path(output_dir) - - # 构建 demucs 命令 - cmd = [ - sys.executable, "-m", "demucs", - "--two-stems=vocals", # 只分离人声和伴奏 - "-o", str(output_path), - "--device", device if device != "mps" else "mps", - ] - - # 添加输入文件 - cmd.append(str(input_path)) - - # 执行命令 - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=1800 # 30 分钟超时 - ) - - if result.returncode != 0: - return None, f"Demucs 执行失败: {result.stderr}" - - # 查找输出的人声文件 - # Demucs 输出格式: output_dir/htdemucs/song_name/vocals.wav - song_name = input_path.stem - vocals_path = output_path / "htdemucs" / song_name / "vocals.wav" - - if not vocals_path.exists(): - # 尝试其他可能的路径 - for model_dir in output_path.iterdir(): - if model_dir.is_dir(): - possible_path = model_dir / song_name / "vocals.wav" - if possible_path.exists(): - vocals_path = possible_path - break - - if vocals_path.exists(): - return str(vocals_path), None - else: - return None, f"未找到人声文件,请检查 {output_path} 目录" - - except subprocess.TimeoutExpired: - return None, "Demucs 处理超时 (超过 30 分钟)" - except Exception as e: - return None, f"Demucs 执行异常: {str(e)}" - - -def convert_to_midi(vocals_wav, output_dir): - """ - 使用 Basic Pitch 将人声转换为 MIDI - - Args: - vocals_wav: 人声 WAV 文件路径 - output_dir: 输出目录 - - Returns: - midi_path: MIDI 文件路径 - """ - vocals_path = Path(vocals_wav) - output_path = Path(output_dir) - - # 构建 basic-pitch 命令 - cmd = [ - sys.executable, "-m", "basic_pitch", - str(output_path), - str(vocals_path) - ] - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=300 # 5 分钟超时 - ) - - if result.returncode != 0: - return None, f"Basic Pitch 执行失败: {result.stderr}" - - # 查找输出的 MIDI 文件 - # Basic Pitch 输出格式: output_dir/vocals_basic_pitch.mid - midi_name = vocals_path.stem + "_basic_pitch.mid" - midi_path = output_path / midi_name - - if midi_path.exists(): - return str(midi_path), None - else: - # 尝试查找任何 .mid 文件 - for f in output_path.glob("*.mid"): - return str(f), None - return None, f"未找到 MIDI 文件,请检查 {output_path} 目录" - - except subprocess.TimeoutExpired: - return None, "Basic Pitch 处理超时 (超过 5 分钟)" - except Exception as e: - return None, f"Basic Pitch 执行异常: {str(e)}" - - -def process_audio(input_mp3, output_dir=None): - """ - 完整的音频处理流程 - - Args: - input_mp3: 输入 MP3 文件路径 - output_dir: 输出目录 (默认为输入文件所在目录) - - Returns: - 处理结果字典 - """ - input_path = Path(input_mp3) - - if not input_path.exists(): - return { - "status": "error", - "error": f"输入文件不存在: {input_mp3}" - } - - if output_dir is None: - output_dir = input_path.parent - - output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) - - # 检测硬件 - hardware = detect_hardware() - - # 检查依赖 - deps = check_dependencies() - missing_deps = [name for name, info in deps.items() - if not info["installed"] and name != "torch"] - - if missing_deps: - return { - "status": "error", - "error": "缺少必要依赖", - "missing_dependencies": missing_deps, - "install_command": f"pip install {' '.join(missing_deps).replace('_', '-')}", - "alternative": "或使用在线工具: https://basicpitch.spotify.com" - } - - result = { - "status": "processing", - "input_file": str(input_path), - "output_dir": str(output_path), - "hardware": hardware, - "steps": [] - } - - # Step 1: 分离人声 - result["steps"].append({ - "step": 1, - "name": "分离人声", - "status": "in_progress", - "tool": "Demucs" - }) - - vocals_path, error = separate_vocals( - input_mp3, - output_path, - hardware["device"] - ) - - if error: - result["status"] = "error" - result["steps"][-1]["status"] = "failed" - result["steps"][-1]["error"] = error - return result - - result["steps"][-1]["status"] = "completed" - result["steps"][-1]["output"] = vocals_path - result["vocals_file"] = vocals_path - - # Step 2: 转换为 MIDI - result["steps"].append({ - "step": 2, - "name": "转换 MIDI", - "status": "in_progress", - "tool": "Basic Pitch" - }) - - midi_path, error = convert_to_midi(vocals_path, output_path) - - if error: - result["status"] = "error" - result["steps"][-1]["status"] = "failed" - result["steps"][-1]["error"] = error - return result - - result["steps"][-1]["status"] = "completed" - result["steps"][-1]["output"] = midi_path - result["midi_file"] = midi_path - - # 重命名 MIDI 文件为更友好的名称 - final_midi_name = input_path.stem + ".mid" - final_midi_path = output_path / final_midi_name - - if str(midi_path) != str(final_midi_path): - try: - shutil.move(midi_path, final_midi_path) - result["midi_file"] = str(final_midi_path) - except Exception: - pass # 保持原文件名 - - result["status"] = "success" - result["message"] = "MP3 转 MIDI 完成" - result["completed_at"] = datetime.now().isoformat() - - return result - - -def main(): - """主函数""" - if len(sys.argv) < 2: - output_json({ - "status": "error", - "error": "缺少参数", - "usage": "python audio_to_midi.py [output_dir]", - "examples": [ - "python audio_to_midi.py song.mp3", - "python audio_to_midi.py song.mp3 ./output", - "python audio_to_midi.py --check" - ] - }) - sys.exit(1) - - # 检查模式 - if sys.argv[1] == "--check": - deps = check_dependencies() - hardware = detect_hardware() - - all_installed = all( - info["installed"] - for name, info in deps.items() - if name != "torch" - ) - - output_json({ - "status": "ready" if all_installed else "missing_dependencies", - "dependencies": deps, - "hardware": hardware, - "install_command": "pip install demucs basic-pitch" if not all_installed else None, - "online_alternative": "https://basicpitch.spotify.com" - }) - sys.exit(0 if all_installed else 1) - - # 处理模式 - input_mp3 = sys.argv[1] - output_dir = sys.argv[2] if len(sys.argv) > 2 else None - - result = process_audio(input_mp3, output_dir) - output_json(result) - - sys.exit(0 if result["status"] == "success" else 1) - - -if __name__ == "__main__": - main() diff --git a/src-tauri/resources/scripts/midi_analyzer.py b/src-tauri/resources/scripts/midi_analyzer.py deleted file mode 100644 index f5ac878b3..000000000 --- a/src-tauri/resources/scripts/midi_analyzer.py +++ /dev/null @@ -1,677 +0,0 @@ -#!/usr/bin/env python3 -""" -MIDI 音乐分析器 - 专业级旋律风格分析 -从"太简单"的文件检查升级为专业 MIDI 分析和特征提取 - -支持功能: -- 智能人声音轨识别 -- 深度旋律特征分析(节奏型、音程、调式) -- 音乐理论分析(五声音阶、调式推断) -- AI 风格学习准备 -""" - -import sys -import json -import argparse -from pathlib import Path -from typing import Dict, List, Any, Optional, Tuple -from dataclasses import dataclass, asdict -import traceback - -# 检查并导入依赖 -try: - import mido - import music21 - import numpy as np -except ImportError as e: - print(json.dumps({ - "status": "error", - "error_type": "missing_dependency", - "message": f"缺少必需的 Python 库: {str(e)}", - "solution": "请安装依赖: pip install mido music21 numpy", - "dependencies": { - "mido": "MIDI 文件解析", - "music21": "音乐理论分析", - "numpy": "数值计算" - } - }, ensure_ascii=False, indent=2)) - sys.exit(1) - -@dataclass -class VocalTrackCandidate: - """人声音轨候选""" - track_index: int - track_name: str - note_count: int - note_range: Tuple[int, int] # (min_pitch, max_pitch) - confidence_score: float - reasons: List[str] - -@dataclass -class MelodyFeatures: - """旋律特征分析结果""" - # 基本信息 - total_notes: int - note_range: Tuple[int, int] - duration_beats: float - - # 节奏特征 - rhythm_complexity: float - rhythm_patterns: Dict[str, float] # 节奏型分布 - syncopation_level: float - - # 音程特征 - interval_distribution: Dict[str, float] - stepwise_ratio: float - leap_ratio: float - - # 调式特征 - key_signature: str - mode_analysis: Dict[str, float] - scale_notes: List[str] - - # 旋律轮廓 - contour_vector: List[int] - phrase_structure: List[Tuple[int, int]] - -class ProfessionalMidiAnalyzer: - """专业级 MIDI 分析器""" - - def __init__(self): - # 人声音域范围 (MIDI note numbers) - self.vocal_range = (48, 84) # C3 to C6 - - # 五声音阶映射 - self.pentatonic_scales = { - 'C': [0, 2, 4, 7, 9], # C D E G A - 'G': [7, 9, 11, 2, 4], # G A B D E - 'D': [2, 4, 6, 9, 11], # D E F# A B - 'A': [9, 11, 1, 4, 6], # A B C# E F# - 'E': [4, 6, 8, 11, 1], # E F# G# B C# - 'B': [11, 1, 3, 6, 8], # B C# D# F# G# - 'F#': [6, 8, 10, 1, 3], # F# G# A# C# D# - 'Db': [1, 3, 5, 8, 10], # Db Eb F Ab Bb - 'Ab': [8, 10, 0, 3, 5], # Ab Bb C Eb F - 'Eb': [3, 5, 7, 10, 0], # Eb F G Bb C - 'Bb': [10, 0, 2, 5, 7], # Bb C D F G - 'F': [5, 7, 9, 0, 2] # F G A C D - } - - # 节奏模式识别 - self.rhythm_patterns = { - 'quarter': 480, # 四分音符 - 'eighth': 240, # 八分音符 - 'dotted_quarter': 720, # 附点四分音符 - 'sixteenth': 120, # 十六分音符 - 'triplet': 160 # 三连音 - } - - def analyze_midi_file(self, midi_path: str, lyrics_path: Optional[str] = None) -> Dict[str, Any]: - """分析 MIDI 文件的主入口""" - try: - # 基本文件检查 - if not Path(midi_path).exists(): - raise FileNotFoundError(f"MIDI 文件不存在: {midi_path}") - - # 加载 MIDI 文件 - midi_file = mido.MidiFile(midi_path) - - # 分析歌词信息 - lyrics_info = self._analyze_lyrics(lyrics_path) if lyrics_path else None - - # 识别人声音轨 - vocal_candidates = self._identify_vocal_tracks(midi_file, lyrics_info) - - if not vocal_candidates: - return self._create_error_result("no_vocal_track", "未找到合适的人声音轨") - - # 选择最佳人声音轨 - best_vocal = max(vocal_candidates, key=lambda x: x.confidence_score) - - # 提取音轨的音符数据 - notes = self._extract_notes_from_track(midi_file, best_vocal.track_index) - - if not notes: - return self._create_error_result("no_notes", "人声音轨中未找到音符数据") - - # 深度旋律特征分析 - melody_features = self._extract_melody_features(notes, midi_file) - - # 生成创作模式推荐 - mode_recommendation = self.recommend_creation_mode(melody_features, lyrics_info) - - # 构建分析结果 - result = { - "status": "success", - "analysis_type": "professional", - "file_info": { - "midi_path": midi_path, - "lyrics_path": lyrics_path, - "file_size": Path(midi_path).stat().st_size, - "track_count": len(midi_file.tracks) - }, - "vocal_track_analysis": { - "selected_track": asdict(best_vocal), - "all_candidates": [asdict(c) for c in vocal_candidates], - "selection_confidence": best_vocal.confidence_score - }, - "melody_features": asdict(melody_features), - "lyrics_analysis": lyrics_info, - "mode_recommendation": mode_recommendation, # NEW: 模式推荐信息 - "technical_info": { - "ticks_per_beat": midi_file.ticks_per_beat, - "total_time": sum(msg.time for track in midi_file.tracks for msg in track), - "format_type": midi_file.type - } - } - - return result - - except Exception as e: - return self._create_error_result( - "analysis_error", - f"分析过程中发生错误: {str(e)}", - {"traceback": traceback.format_exc()} - ) - - def _analyze_lyrics(self, lyrics_path: str) -> Optional[Dict[str, Any]]: - """分析歌词文件""" - try: - with open(lyrics_path, 'r', encoding='utf-8') as f: - content = f.read() - - # 统计字数(排除标点符号) - clean_text = ''.join(char for char in content if char.isalpha()) - - # 检测段落结构 - sections = [] - current_section = None - - for line in content.split('\n'): - line = line.strip() - if line.startswith('[') and line.endswith(']'): - if current_section: - sections.append(current_section) - current_section = { - "name": line[1:-1], - "lines": [], - "char_count": 0 - } - elif line and current_section: - current_section["lines"].append(line) - current_section["char_count"] += len([c for c in line if c.isalpha()]) - - if current_section: - sections.append(current_section) - - return { - "total_chars": len(clean_text), - "total_lines": len([line for line in content.split('\n') if line.strip() and not line.strip().startswith('[')]), - "sections": sections, - "has_structure_markers": any(line.startswith('[') for line in content.split('\n')) - } - - except Exception as e: - return {"error": f"歌词分析失败: {str(e)}"} - - def _identify_vocal_tracks(self, midi_file: mido.MidiFile, lyrics_info: Optional[Dict]) -> List[VocalTrackCandidate]: - """智能识别人声音轨""" - candidates = [] - - for track_idx, track in enumerate(midi_file.tracks): - notes = self._extract_notes_from_track(midi_file, track_idx) - - if not notes: - continue - - # 计算基本信息 - pitches = [note['pitch'] for note in notes] - min_pitch, max_pitch = min(pitches), max(pitches) - note_count = len(notes) - - # 评分系统 - score = 0.0 - reasons = [] - - # 1. 音轨名称匹配(30分) - track_name = getattr(track, 'name', f'Track {track_idx}') - vocal_keywords = ['vocal', 'voice', 'melody', 'lead', '主旋律', '人声'] - if any(keyword.lower() in track_name.lower() for keyword in vocal_keywords): - score += 30 - reasons.append(f"音轨名包含人声关键词: {track_name}") - - # 2. 音域匹配(25分) - vocal_range_overlap = self._calculate_range_overlap( - (min_pitch, max_pitch), self.vocal_range - ) - if vocal_range_overlap > 0.7: - score += 25 - reasons.append(f"音域高度匹配人声范围: {vocal_range_overlap:.1%}") - elif vocal_range_overlap > 0.5: - score += 15 - reasons.append(f"音域部分匹配人声范围: {vocal_range_overlap:.1%}") - - # 3. 歌词字数匹配(20分) - if lyrics_info and 'total_chars' in lyrics_info: - lyrics_chars = lyrics_info['total_chars'] - if lyrics_chars > 0: - ratio = abs(1 - note_count / lyrics_chars) - if ratio < 0.1: # 10%内匹配 - score += 20 - reasons.append(f"音符数与歌词字数高度匹配: {note_count}≈{lyrics_chars}") - elif ratio < 0.3: # 30%内匹配 - score += 10 - reasons.append(f"音符数与歌词字数基本匹配: {note_count}vs{lyrics_chars}") - - # 4. 音符密度合理性(15分) - if 20 <= note_count <= 200: # 合理的旋律长度 - score += 15 - reasons.append(f"音符数量合理: {note_count}") - elif note_count > 10: - score += 5 - reasons.append(f"音符数量可接受: {note_count}") - - # 5. 旋律特征(10分) - interval_variety = self._calculate_interval_variety(notes) - if interval_variety > 0.3: # 有合理的音程变化 - score += 10 - reasons.append(f"音程变化丰富: {interval_variety:.2f}") - - candidates.append(VocalTrackCandidate( - track_index=track_idx, - track_name=track_name, - note_count=note_count, - note_range=(min_pitch, max_pitch), - confidence_score=score, - reasons=reasons - )) - - # 按置信度排序 - return sorted(candidates, key=lambda x: x.confidence_score, reverse=True) - - def _extract_notes_from_track(self, midi_file: mido.MidiFile, track_idx: int) -> List[Dict]: - """从指定音轨提取音符信息""" - track = midi_file.tracks[track_idx] - notes = [] - current_time = 0 - active_notes = {} # pitch -> start_time - - for msg in track: - current_time += msg.time - - if msg.type == 'note_on' and msg.velocity > 0: - active_notes[msg.note] = current_time - elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0): - if msg.note in active_notes: - start_time = active_notes.pop(msg.note) - duration = current_time - start_time - - notes.append({ - 'pitch': msg.note, - 'start_time': start_time, - 'duration': duration, - 'velocity': getattr(msg, 'velocity', 64) - }) - - # 按开始时间排序 - return sorted(notes, key=lambda x: x['start_time']) - - def _extract_melody_features(self, notes: List[Dict], midi_file: mido.MidiFile) -> MelodyFeatures: - """深度旋律特征提取""" - ticks_per_beat = midi_file.ticks_per_beat - - # 基本信息 - pitches = [note['pitch'] for note in notes] - durations = [note['duration'] for note in notes] - - # 节奏分析 - rhythm_analysis = self._analyze_rhythm_patterns(durations, ticks_per_beat) - - # 音程分析 - interval_analysis = self._analyze_intervals(pitches) - - # 调式分析 - key_analysis = self._analyze_key_and_mode(pitches) - - # 旋律轮廓 - contour = self._extract_melody_contour(pitches) - - # 乐句结构 - phrases = self._identify_phrases(notes, ticks_per_beat) - - return MelodyFeatures( - total_notes=len(notes), - note_range=(min(pitches), max(pitches)), - duration_beats=sum(durations) / ticks_per_beat, - rhythm_complexity=rhythm_analysis['complexity'], - rhythm_patterns=rhythm_analysis['patterns'], - syncopation_level=rhythm_analysis['syncopation'], - interval_distribution=interval_analysis['distribution'], - stepwise_ratio=interval_analysis['stepwise_ratio'], - leap_ratio=interval_analysis['leap_ratio'], - key_signature=key_analysis['key'], - mode_analysis=key_analysis['modes'], - scale_notes=key_analysis['scale_notes'], - contour_vector=contour, - phrase_structure=phrases - ) - - def _analyze_rhythm_patterns(self, durations: List[int], ticks_per_beat: int) -> Dict[str, Any]: - """分析节奏型模式""" - if not durations: - return {'complexity': 0, 'patterns': {}, 'syncopation': 0} - - # 标准化时值到节拍单位 - beat_durations = [d / ticks_per_beat for d in durations] - - # 计算节奏模式分布 - patterns = { - 'whole': 0, # 全音符 - 'half': 0, # 二分音符 - 'quarter': 0, # 四分音符 - 'eighth': 0, # 八分音符 - 'sixteenth': 0, # 十六分音符 - 'dotted': 0, # 附点节奏 - 'triplet': 0 # 三连音 - } - - for duration in beat_durations: - if abs(duration - 4.0) < 0.1: - patterns['whole'] += 1 - elif abs(duration - 2.0) < 0.1: - patterns['half'] += 1 - elif abs(duration - 1.0) < 0.1: - patterns['quarter'] += 1 - elif abs(duration - 0.5) < 0.1: - patterns['eighth'] += 1 - elif abs(duration - 0.25) < 0.1: - patterns['sixteenth'] += 1 - elif abs(duration - 1.5) < 0.1: - patterns['dotted'] += 1 - elif abs(duration - 0.33) < 0.1: - patterns['triplet'] += 1 - - total = len(durations) - pattern_ratios = {k: v/total for k, v in patterns.items()} if total > 0 else patterns - - # 计算节奏复杂度 - complexity = len([v for v in pattern_ratios.values() if v > 0.05]) # 超过5%的模式 - - # 简单的切分检测 - syncopation = sum(1 for d in beat_durations if 0.3 < d < 0.7 or 1.3 < d < 1.7) / total if total > 0 else 0 - - return { - 'complexity': complexity, - 'patterns': pattern_ratios, - 'syncopation': syncopation - } - - def _analyze_intervals(self, pitches: List[int]) -> Dict[str, Any]: - """分析音程分布""" - if len(pitches) < 2: - return {'distribution': {}, 'stepwise_ratio': 0, 'leap_ratio': 0} - - intervals = [pitches[i+1] - pitches[i] for i in range(len(pitches)-1)] - - # 音程分类 - interval_types = { - 'unison': 0, # 同度 (0) - 'step': 0, # 级进 (1-2) - 'small_leap': 0, # 小跳 (3-4) - 'large_leap': 0, # 大跳 (5+) - 'octave': 0 # 八度 (12) - } - - for interval in intervals: - abs_interval = abs(interval) - if abs_interval == 0: - interval_types['unison'] += 1 - elif abs_interval <= 2: - interval_types['step'] += 1 - elif abs_interval <= 4: - interval_types['small_leap'] += 1 - elif abs_interval == 12: - interval_types['octave'] += 1 - else: - interval_types['large_leap'] += 1 - - total = len(intervals) - distribution = {k: v/total for k, v in interval_types.items()} if total > 0 else interval_types - - return { - 'distribution': distribution, - 'stepwise_ratio': distribution['step'], - 'leap_ratio': distribution['small_leap'] + distribution['large_leap'] - } - - def _analyze_key_and_mode(self, pitches: List[int]) -> Dict[str, Any]: - """分析调性和调式""" - if not pitches: - return {'key': 'Unknown', 'modes': {}, 'scale_notes': []} - - # 统计音高类别 - pitch_classes = [p % 12 for p in pitches] - pc_counts = {} - for pc in pitch_classes: - pc_counts[pc] = pc_counts.get(pc, 0) + 1 - - # 尝试匹配五声音阶 - best_key = 'C' - best_score = 0 - - for key, scale in self.pentatonic_scales.items(): - score = sum(pc_counts.get(pc, 0) for pc in scale) - if score > best_score: - best_score = score - best_key = key - - # 生成调式信息 - note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] - scale_notes = [note_names[pc] for pc in self.pentatonic_scales[best_key]] - - # 简化的调式检测 - modes = { - 'pentatonic': best_score / len(pitches) if pitches else 0, - 'major': 0.5, # 占位符 - 'minor': 0.3 # 占位符 - } - - return { - 'key': best_key, - 'modes': modes, - 'scale_notes': scale_notes - } - - def _extract_melody_contour(self, pitches: List[int]) -> List[int]: - """提取旋律轮廓""" - if len(pitches) < 2: - return [] - - contour = [] - for i in range(1, len(pitches)): - diff = pitches[i] - pitches[i-1] - if diff > 0: - contour.append(1) # 上行 - elif diff < 0: - contour.append(-1) # 下行 - else: - contour.append(0) # 平行 - - return contour - - def _identify_phrases(self, notes: List[Dict], ticks_per_beat: int) -> List[Tuple[int, int]]: - """识别乐句结构""" - if not notes: - return [] - - # 简单的乐句分割:基于较长的休止或时间间隔 - phrases = [] - phrase_start = 0 - - for i in range(1, len(notes)): - # 检测乐句间隔(如果两个音符间隔超过一拍) - gap = notes[i]['start_time'] - (notes[i-1]['start_time'] + notes[i-1]['duration']) - if gap > ticks_per_beat: # 超过一拍的间隔 - phrases.append((phrase_start, i-1)) - phrase_start = i - - # 添加最后一个乐句 - phrases.append((phrase_start, len(notes)-1)) - - return phrases - - def _calculate_range_overlap(self, range1: Tuple[int, int], range2: Tuple[int, int]) -> float: - """计算两个音域的重叠度""" - overlap_start = max(range1[0], range2[0]) - overlap_end = min(range1[1], range2[1]) - - if overlap_start >= overlap_end: - return 0.0 - - overlap_size = overlap_end - overlap_start - range1_size = range1[1] - range1[0] - - return overlap_size / range1_size if range1_size > 0 else 0.0 - - def _calculate_interval_variety(self, notes: List[Dict]) -> float: - """计算音程变化丰富度""" - if len(notes) < 2: - return 0.0 - - pitches = [note['pitch'] for note in notes] - intervals = [abs(pitches[i+1] - pitches[i]) for i in range(len(pitches)-1)] - unique_intervals = len(set(intervals)) - - return unique_intervals / len(intervals) if intervals else 0.0 - - def calculate_complexity(self, melody_features: MelodyFeatures, lyrics_info: Dict = None) -> float: - """计算旋律复杂度(0-100分)""" - try: - # 节奏复杂度 (0-40分) - rhythm_score = 0 - if hasattr(melody_features, 'rhythm_patterns'): - syncopation = melody_features.rhythm_patterns.get('syncopation', 0) - sixteenth = melody_features.rhythm_patterns.get('sixteenth', 0) - rhythm_score = min(40, (syncopation + sixteenth) * 0.4) - - # 音程复杂度 (0-30分) - interval_score = 0 - if hasattr(melody_features, 'interval_distribution'): - large_leap = melody_features.interval_distribution.get('large_leap', 0) - interval_score = min(30, large_leap * 0.3) - - # 调式不确定性 (0-30分) - modal_score = 0 - if hasattr(melody_features, 'mode_analysis'): - # 如果调式分析有置信度信息 - max_confidence = max(melody_features.mode_analysis.values()) if melody_features.mode_analysis else 0 - modal_uncertainty = 1 - max_confidence - modal_score = modal_uncertainty * 30 - - total_complexity = rhythm_score + interval_score + modal_score - return min(100, total_complexity) - - except Exception: - return 30.0 # 默认中等复杂度 - - def recommend_creation_mode(self, melody_features: MelodyFeatures, lyrics_info: Dict = None) -> Dict[str, Any]: - """基于旋律特征推荐创作模式""" - try: - # 计算整体复杂度 - complexity = self.calculate_complexity(melody_features, lyrics_info) - - # 段落数量(从歌词信息获取) - section_count = 0 - if lyrics_info and 'structure' in lyrics_info: - sections = lyrics_info['structure'].get('sections', []) - section_count = len(sections) - - # 推荐逻辑 - if complexity >= 60: - recommended = "expert" - reason = f"旋律复杂度高 ({complexity:.1f}/100),建议使用专家模式进行精细控制" - elif complexity <= 25: - recommended = "express" - reason = f"旋律相对简单 ({complexity:.1f}/100),适合快速模式自动生成" - elif section_count >= 4: - recommended = "coach" - reason = f"歌曲结构复杂 ({section_count}个段落),建议教练模式逐步创作" - else: - recommended = "professional" - reason = f"旋律复杂度适中 ({complexity:.1f}/100),推荐专业模式平衡效率与质量" - - # 备选方案 - alternatives = [] - if recommended != "express": - alternatives.append({"mode": "express", "reason": "需要快速原型或demo时使用"}) - if recommended != "professional": - alternatives.append({"mode": "professional", "reason": "平衡创作质量与效率的通用选择"}) - if recommended != "coach": - alternatives.append({"mode": "coach", "reason": "学习创作技巧或深度个性化表达时使用"}) - if recommended != "expert": - alternatives.append({"mode": "expert", "reason": "需要完全控制创作过程的专业制作"}) - - return { - "recommended": recommended, - "complexity_score": complexity, - "reasoning": reason, - "section_count": section_count, - "alternatives": alternatives - } - - except Exception as e: - # 错误时返回默认推荐 - return { - "recommended": "professional", - "complexity_score": 50.0, - "reasoning": "分析过程中出现问题,推荐使用通用的专业模式", - "section_count": 0, - "alternatives": [], - "error": f"推荐逻辑错误: {str(e)}" - } - - def _create_error_result(self, error_type: str, message: str, details: Dict = None) -> Dict[str, Any]: - """创建错误结果""" - result = { - "status": "error", - "error_type": error_type, - "message": message, - "timestamp": __import__('datetime').datetime.now().isoformat() - } - - if details: - result["details"] = details - - return result - -def main(): - """命令行入口""" - parser = argparse.ArgumentParser(description="专业级 MIDI 音乐分析器") - parser.add_argument("midi_file", help="MIDI 文件路径") - parser.add_argument("--lyrics", help="歌词文件路径(可选)") - parser.add_argument("--output", help="输出 JSON 文件路径(可选)") - parser.add_argument("--pretty", action="store_true", help="格式化 JSON 输出") - - args = parser.parse_args() - - # 创建分析器 - analyzer = ProfessionalMidiAnalyzer() - - # 执行分析 - result = analyzer.analyze_midi_file(args.midi_file, args.lyrics) - - # 输出结果 - if args.pretty: - output = json.dumps(result, ensure_ascii=False, indent=2) - else: - output = json.dumps(result, ensure_ascii=False) - - if args.output: - with open(args.output, 'w', encoding='utf-8') as f: - f.write(output) - print(f"分析结果已保存到: {args.output}") - else: - print(output) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs index d1c210888..af9609368 100644 --- a/src-tauri/src/agent_tools/catalog.rs +++ b/src-tauri/src/agent_tools/catalog.rs @@ -1,7 +1,9 @@ use crate::mcp::McpToolDefinition; use serde::{Deserialize, Serialize}; -pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +pub const TOOL_SEARCH_TOOL_NAME: &str = "ToolSearch"; +pub const LIST_MCP_RESOURCES_TOOL_NAME: &str = "ListMcpResourcesTool"; +pub const READ_MCP_RESOURCE_TOOL_NAME: &str = "ReadMcpResourceTool"; pub const SOCIAL_IMAGE_TOOL_NAME: &str = "social_generate_cover_image"; pub const LIME_CREATE_VIDEO_TASK_TOOL_NAME: &str = "lime_create_video_generation_task"; pub const LIME_CREATE_BROADCAST_TASK_TOOL_NAME: &str = "lime_create_broadcast_generation_task"; @@ -22,7 +24,8 @@ pub const BROWSER_RUNTIME_TOOL_PREFIX: &str = "mcp__lime-browser__"; #[serde(rename_all = "snake_case")] pub enum ToolSurfaceProfile { Core, - Creator, + #[serde(rename = "workbench")] + Workbench, BrowserAssist, } @@ -78,35 +81,35 @@ pub struct ToolCatalogEntry { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct WorkspaceToolSurface { - pub creator: bool, + pub workbench: bool, pub browser_assist: bool, } impl WorkspaceToolSurface { pub const fn core() -> Self { Self { - creator: false, + workbench: false, browser_assist: false, } } - pub const fn creator() -> Self { + pub const fn workbench() -> Self { Self { - creator: true, + workbench: true, browser_assist: false, } } pub const fn browser_assist() -> Self { Self { - creator: false, + workbench: false, browser_assist: true, } } - pub const fn creator_with_browser_assist() -> Self { + pub const fn workbench_with_browser_assist() -> Self { Self { - creator: true, + workbench: true, browser_assist: true, } } @@ -114,14 +117,14 @@ impl WorkspaceToolSurface { pub const fn includes_profile(self, profile: ToolSurfaceProfile) -> bool { match profile { ToolSurfaceProfile::Core => true, - ToolSurfaceProfile::Creator => self.creator, + ToolSurfaceProfile::Workbench => self.workbench, ToolSurfaceProfile::BrowserAssist => self.browser_assist, } } } const CORE_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Core]; -const CREATOR_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Creator]; +const WORKBENCH_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Workbench]; const BROWSER_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::BrowserAssist]; const PLAN_CAP: &[ToolCapability] = &[ToolCapability::Planning]; @@ -210,26 +213,44 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: true, }, ToolCatalogEntry { - name: "Task", + name: "TaskCreate", profiles: CORE_PROFILES, - capabilities: EXECUTION_CAP, + capabilities: PLAN_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::AsterBuiltin, - permission_plane: ToolPermissionPlane::ParameterRestricted, - workspace_default_allow: false, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "TaskList", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "TaskGet", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "TaskUpdate", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, }, ToolCatalogEntry { name: "TaskOutput", profiles: CORE_PROFILES, - capabilities: PLAN_CAP, - lifecycle: ToolLifecycle::Current, - source: ToolSourceKind::AsterBuiltin, - permission_plane: ToolPermissionPlane::SessionAllowlist, - workspace_default_allow: true, - }, - ToolCatalogEntry { - name: "KillShell", - profiles: CORE_PROFILES, capabilities: EXECUTION_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::AsterBuiltin, @@ -237,9 +258,9 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: true, }, ToolCatalogEntry { - name: "TodoWrite", + name: "TaskStop", profiles: CORE_PROFILES, - capabilities: PLAN_CAP, + capabilities: EXECUTION_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::AsterBuiltin, permission_plane: ToolPermissionPlane::SessionAllowlist, @@ -317,6 +338,24 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, + ToolCatalogEntry { + name: LIST_MCP_RESOURCES_TOOL_NAME, + profiles: CORE_PROFILES, + capabilities: SEARCH_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: READ_MCP_RESOURCE_TOOL_NAME, + profiles: CORE_PROFILES, + capabilities: SEARCH_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, ToolCatalogEntry { name: "spawn_agent", profiles: CORE_PROFILES, @@ -373,7 +412,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ }, ToolCatalogEntry { name: SOCIAL_IMAGE_TOOL_NAME, - profiles: CREATOR_PROFILES, + profiles: WORKBENCH_PROFILES, capabilities: CONTENT_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::LimeInjected, @@ -382,7 +421,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ }, ToolCatalogEntry { name: LIME_CREATE_VIDEO_TASK_TOOL_NAME, - profiles: CREATOR_PROFILES, + profiles: WORKBENCH_PROFILES, capabilities: CONTENT_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::LimeInjected, @@ -391,7 +430,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ }, ToolCatalogEntry { name: LIME_CREATE_BROADCAST_TASK_TOOL_NAME, - profiles: CREATOR_PROFILES, + profiles: WORKBENCH_PROFILES, capabilities: CONTENT_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::LimeInjected, @@ -400,7 +439,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ }, ToolCatalogEntry { name: LIME_CREATE_COVER_TASK_TOOL_NAME, - profiles: CREATOR_PROFILES, + profiles: WORKBENCH_PROFILES, capabilities: CONTENT_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::LimeInjected, @@ -409,7 +448,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ }, ToolCatalogEntry { name: LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME, - profiles: CREATOR_PROFILES, + profiles: WORKBENCH_PROFILES, capabilities: CONTENT_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::LimeInjected, @@ -418,7 +457,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ }, ToolCatalogEntry { name: LIME_CREATE_IMAGE_TASK_TOOL_NAME, - profiles: CREATOR_PROFILES, + profiles: WORKBENCH_PROFILES, capabilities: CONTENT_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::LimeInjected, @@ -427,7 +466,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ }, ToolCatalogEntry { name: LIME_CREATE_URL_PARSE_TASK_TOOL_NAME, - profiles: CREATOR_PROFILES, + profiles: WORKBENCH_PROFILES, capabilities: CONTENT_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::LimeInjected, @@ -436,7 +475,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ }, ToolCatalogEntry { name: LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, - profiles: CREATOR_PROFILES, + profiles: WORKBENCH_PROFILES, capabilities: CONTENT_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::LimeInjected, @@ -548,10 +587,10 @@ pub fn workspace_allowed_tool_names(surface: WorkspaceToolSurface) -> Vec<&'stat workspace_default_allowed_tool_names(surface) } -pub fn creator_tool_names() -> Vec<&'static str> { - tool_catalog_entries_for_surface(WorkspaceToolSurface::creator()) +pub fn workbench_tool_names() -> Vec<&'static str> { + tool_catalog_entries_for_surface(WorkspaceToolSurface::workbench()) .into_iter() - .filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::Creator)) + .filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::Workbench)) .filter(|entry| entry.name != BROWSER_RUNTIME_TOOL_PREFIX) .map(|entry| entry.name) .collect() @@ -685,8 +724,8 @@ mod tests { } #[test] - fn test_workspace_default_allowed_tool_names_includes_creator_surface() { - let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::creator()); + fn test_workspace_default_allowed_tool_names_includes_workbench_surface() { + let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::workbench()); assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME)); assert!(names.contains(&LIME_CREATE_VIDEO_TASK_TOOL_NAME)); } @@ -694,12 +733,20 @@ mod tests { #[test] fn test_tool_catalog_entries_for_surface_counts_and_lifecycle_boundaries() { let core = tool_catalog_entries_for_surface(WorkspaceToolSurface::core()); - assert_eq!(core.len(), 26); + let workbench_increment = native_tool_catalog() + .iter() + .filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::Workbench)) + .count(); + let browser_increment = native_tool_catalog() + .iter() + .filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist)) + .count(); + assert_eq!(core.len(), 30); assert_eq!( core.iter() .filter(|entry| entry.lifecycle == ToolLifecycle::Current) .count(), - 25 + 29 ); assert_eq!( core.iter() @@ -709,50 +756,55 @@ mod tests { ); assert!(core .iter() - .all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::Creator))); + .all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::Workbench))); assert!(core .iter() .all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist))); - let creator = tool_catalog_entries_for_surface(WorkspaceToolSurface::creator()); - assert_eq!(creator.len(), 34); - assert!(creator + let workbench = tool_catalog_entries_for_surface(WorkspaceToolSurface::workbench()); + assert_eq!(workbench.len(), core.len() + workbench_increment); + assert!(workbench .iter() .any(|entry| entry.name == SOCIAL_IMAGE_TOOL_NAME)); - assert!(!creator + assert!(!workbench .iter() .any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX)); let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist()); - assert_eq!(browser.len(), 32); + assert_eq!(browser.len(), core.len() + browser_increment); assert!(browser .iter() .any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX)); let combined = - tool_catalog_entries_for_surface(WorkspaceToolSurface::creator_with_browser_assist()); - assert_eq!(combined.len(), 40); + tool_catalog_entries_for_surface(WorkspaceToolSurface::workbench_with_browser_assist()); + assert_eq!( + combined.len(), + core.len() + workbench_increment + browser_increment + ); } #[test] - fn test_creator_tool_names_only_returns_creator_increment() { - let names = creator_tool_names().into_iter().collect::>(); + fn test_workbench_tool_names_only_returns_workbench_increment() { + let names = workbench_tool_names().into_iter().collect::>(); assert_eq!(names.len(), 8); assert!(names.contains(SOCIAL_IMAGE_TOOL_NAME)); assert!(names.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME)); - assert!(!names.contains("tool_search")); + assert!(!names.contains(TOOL_SEARCH_TOOL_NAME)); assert!(!names.contains(BROWSER_RUNTIME_TOOL_PREFIX)); } #[test] - fn test_workspace_default_allowed_tool_names_creator_with_browser_assist_excludes_prefix_tool() - { + fn test_workspace_default_allowed_tool_names_workbench_with_browser_assist_excludes_prefix_tool( + ) { let names = workspace_default_allowed_tool_names( - WorkspaceToolSurface::creator_with_browser_assist(), + WorkspaceToolSurface::workbench_with_browser_assist(), ); - assert_eq!(names.len(), 27); + assert_eq!(names.len(), 32); assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME)); - assert!(names.contains(&"tool_search")); + assert!(names.contains(&TOOL_SEARCH_TOOL_NAME)); + assert!(names.contains(&LIST_MCP_RESOURCES_TOOL_NAME)); + assert!(names.contains(&READ_MCP_RESOURCE_TOOL_NAME)); assert!(names.contains(&LIME_SITE_RECOMMEND_TOOL_NAME)); assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME)); assert!(!names diff --git a/src-tauri/src/agent_tools/execution.rs b/src-tauri/src/agent_tools/execution.rs index cb2486af6..983576bc4 100644 --- a/src-tauri/src/agent_tools/execution.rs +++ b/src-tauri/src/agent_tools/execution.rs @@ -126,11 +126,6 @@ pub fn tool_execution_policy(tool_name: &str) -> ToolExecutionPolicy { restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand, sandbox_profile: ToolExecutionSandboxProfile::WorkspaceCommand, }, - "Task" => ToolExecutionPolicy { - warning_policy: ToolExecutionWarningPolicy::ShellCommandRisk, - restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand, - sandbox_profile: ToolExecutionSandboxProfile::None, - }, "NotebookEdit" => ToolExecutionPolicy { restriction_profile: ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired, ..ToolExecutionPolicy::default() @@ -779,7 +774,6 @@ mod tests { let input = ToolExecutionResolverInput::default(); assert!(should_auto_approve_tool_warnings("bash", true, input)); - assert!(should_auto_approve_tool_warnings("Task", true, input)); assert!(!should_auto_approve_tool_warnings("read", true, input)); assert!(!should_auto_approve_tool_warnings("bash", false, input)); } diff --git a/src-tauri/src/agent_tools/inventory.rs b/src-tauri/src/agent_tools/inventory.rs index 145b8e54b..2f9f90c42 100644 --- a/src-tauri/src/agent_tools/inventory.rs +++ b/src-tauri/src/agent_tools/inventory.rs @@ -76,14 +76,14 @@ pub enum RuntimeExtensionSourceKind { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ToolInventorySurfaceSnapshot { - pub creator: bool, + pub workbench: bool, pub browser_assist: bool, } impl From for ToolInventorySurfaceSnapshot { fn from(value: WorkspaceToolSurface) -> Self { Self { - creator: value.creator, + workbench: value.workbench, browser_assist: value.browser_assist, } } @@ -793,7 +793,7 @@ mod tests { vec!["assistant"], )], registry_definitions: vec![ - definition("tool_search", "search tools", json!({ "type": "object" })), + definition("ToolSearch", "search tools", json!({ "type": "object" })), definition( "read", "read file", @@ -837,7 +837,9 @@ mod tests { ], }); - assert_eq!(inventory.counts.catalog_total, 26); + assert_eq!(inventory.counts.catalog_total, 30); + assert_eq!(inventory.counts.catalog_current_total, 29); + assert_eq!(inventory.counts.catalog_compat_total, 1); assert_eq!(inventory.counts.registry_total, 3); assert_eq!(inventory.counts.registry_visible_total, 2); assert_eq!(inventory.counts.registry_catalog_unmapped_total, 1); @@ -849,7 +851,7 @@ mod tests { assert_eq!(inventory.counts.mcp_tool_visible_total, 0); assert!(inventory .default_allowed_tools - .contains(&"tool_search".to_string())); + .contains(&"ToolSearch".to_string())); let bash_catalog = inventory .catalog_tools .iter() @@ -951,9 +953,9 @@ mod tests { } #[test] - fn test_build_tool_inventory_creator_with_browser_surface_keeps_small_default_allowlist() { + fn test_build_tool_inventory_workbench_with_browser_surface_keeps_small_default_allowlist() { let inventory = build_tool_inventory(AgentToolInventoryBuildInput { - surface: WorkspaceToolSurface::creator_with_browser_assist(), + surface: WorkspaceToolSurface::workbench_with_browser_assist(), caller: "assistant".to_string(), agent_initialized: true, warnings: Vec::new(), @@ -967,14 +969,14 @@ mod tests { searchable_extension_tools: Vec::new(), }); let expected_default_allowed = workspace_default_allowed_tool_names( - WorkspaceToolSurface::creator_with_browser_assist(), + WorkspaceToolSurface::workbench_with_browser_assist(), ) .into_iter() .map(ToString::to_string) .collect::>(); - assert_eq!(inventory.counts.catalog_total, 40); - assert_eq!(inventory.counts.catalog_current_total, 39); + assert_eq!(inventory.counts.catalog_total, 44); + assert_eq!(inventory.counts.catalog_current_total, 43); assert_eq!(inventory.counts.catalog_compat_total, 1); assert_eq!(inventory.default_allowed_tools, expected_default_allowed); assert_eq!( @@ -983,7 +985,13 @@ mod tests { ); assert!(inventory .default_allowed_tools - .contains(&"tool_search".to_string())); + .contains(&"ToolSearch".to_string())); + assert!(inventory + .default_allowed_tools + .contains(&"ListMcpResourcesTool".to_string())); + assert!(inventory + .default_allowed_tools + .contains(&"ReadMcpResourceTool".to_string())); assert!(inventory .default_allowed_tools .contains(&"social_generate_cover_image".to_string())); diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 6f8be91b1..2445e26c1 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1645,12 +1645,6 @@ pub fn run() { commands::update_cmd::update_last_check_timestamp, commands::update_cmd::close_update_window, commands::update_cmd::test_update_window, - // Music commands - commands::music_cmd::check_python_env, - commands::music_cmd::analyze_midi, - commands::music_cmd::convert_mp3_to_midi, - commands::music_cmd::load_music_resource, - commands::music_cmd::install_python_dependencies, // Session Files commands commands::session_files_cmd::session_files_create, commands::session_files_cmd::session_files_exists, @@ -1717,15 +1711,15 @@ pub fn run() { commands::video_generation_cmd::get_video_generation_task, commands::video_generation_cmd::list_video_generation_tasks, commands::video_generation_cmd::cancel_video_generation_task, - // Poster Material commands - commands::poster_material_cmd::create_poster_metadata, - commands::poster_material_cmd::get_poster_metadata, - commands::poster_material_cmd::get_poster_material, - commands::poster_material_cmd::list_by_image_category, - commands::poster_material_cmd::list_by_layout_category, - commands::poster_material_cmd::list_by_mood, - commands::poster_material_cmd::update_poster_metadata, - commands::poster_material_cmd::delete_poster_metadata, + // Gallery material commands + commands::gallery_material_cmd::create_gallery_material_metadata, + commands::gallery_material_cmd::get_gallery_material_metadata, + commands::gallery_material_cmd::get_gallery_material, + commands::gallery_material_cmd::list_gallery_materials_by_image_category, + commands::gallery_material_cmd::list_gallery_materials_by_layout_category, + commands::gallery_material_cmd::list_gallery_materials_by_mood, + commands::gallery_material_cmd::update_gallery_material_metadata, + commands::gallery_material_cmd::delete_gallery_material_metadata, // A2UI Form commands commands::a2ui_form_cmd::create_a2ui_form, commands::a2ui_form_cmd::get_a2ui_form, @@ -1743,19 +1737,6 @@ pub fn run() { commands::content_cmd::content_delete, commands::content_cmd::content_reorder, commands::content_cmd::content_stats, - // Novel Orchestrator commands - commands::novel_cmd::novel_create_project, - commands::novel_cmd::novel_update_settings, - commands::novel_cmd::novel_generate_outline, - commands::novel_cmd::novel_generate_characters, - commands::novel_cmd::novel_generate_chapter, - commands::novel_cmd::novel_continue_chapter, - commands::novel_cmd::novel_rewrite_chapter, - commands::novel_cmd::novel_polish_chapter, - commands::novel_cmd::novel_check_consistency, - commands::novel_cmd::novel_get_project_snapshot, - commands::novel_cmd::novel_list_runs, - commands::novel_cmd::novel_delete_character, // Memory commands (Character, WorldBuilding, Outline) commands::memory_cmd::character_create, commands::memory_cmd::character_get, diff --git a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs index 3295f473e..c74ff7339 100644 --- a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs @@ -1,5 +1,6 @@ use super::*; use lime_agent::AgentEvent as RuntimeAgentEvent; +use lime_core::workspace::WorkspaceSettings; /// 统一运行时:删除会话。 #[tauri::command] @@ -111,6 +112,76 @@ fn emit_action_resume_runtime_status(app: &AppHandle, event_name: &str) { } } +async fn load_runtime_workspace_settings_or_default( + db: &DbConnection, + session_id: &str, +) -> WorkspaceSettings { + let detail = match AsterAgentWrapper::get_runtime_session_detail(db, session_id).await { + Ok(detail) => detail, + Err(error) => { + tracing::warn!( + "[AsterAgent] 读取 elicitation 所属 workspace 失败,已降级使用默认设置: session_id={}, error={}", + session_id, + error + ); + return WorkspaceSettings::default(); + } + }; + + let Some(workspace_id) = detail + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return WorkspaceSettings::default(); + }; + + let manager = WorkspaceManager::new(db.clone()); + let workspace_id = workspace_id.to_string(); + match manager.get(&workspace_id) { + Ok(Some(workspace)) => workspace.settings, + Ok(None) => { + tracing::warn!( + "[AsterAgent] elicitation 所属 workspace 不存在,已降级使用默认设置: session_id={}, workspace_id={}", + session_id, + workspace_id + ); + WorkspaceSettings::default() + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 读取 elicitation 所属 workspace 设置失败,已降级使用默认设置: session_id={}, workspace_id={}, error={}", + session_id, + workspace_id, + error + ); + WorkspaceSettings::default() + } + } +} + +pub(crate) fn build_runtime_action_session_config( + session_id: &str, + request_metadata: Option<&serde_json::Value>, + workspace_settings: &WorkspaceSettings, +) -> aster::agents::SessionConfig { + let mut session_config_builder = + SessionConfigBuilder::new(session_id).include_context_trace(true); + if let Some(prompt) = merge_system_prompt_with_elicitation_context(None, request_metadata) { + session_config_builder = session_config_builder.system_prompt(prompt); + } + if let Some(turn_context) = + super::runtime_turn::merge_turn_context_with_workspace_auto_compaction( + None, + workspace_settings, + ) + { + session_config_builder = session_config_builder.turn_context(turn_context); + } + session_config_builder.build() +} + pub(crate) fn build_runtime_action_user_data( request: &AgentRuntimeRespondActionRequest, ) -> serde_json::Value { @@ -160,6 +231,7 @@ pub(crate) fn build_runtime_action_scope( pub async fn agent_runtime_respond_action( app: AppHandle, state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, request: AgentRuntimeRespondActionRequest, ) -> Result<(), String> { match request.action_type { @@ -180,6 +252,7 @@ pub async fn agent_runtime_respond_action( let resume_event_name = normalize_optional_text(request.event_name.clone()); submit_runtime_elicitation_response_internal( state.inner(), + db.inner(), request.session_id.clone(), SubmitElicitationResponseRequest { request_id: request.request_id.clone(), @@ -200,6 +273,7 @@ pub async fn agent_runtime_respond_action( async fn submit_runtime_elicitation_response_internal( state: &AsterAgentState, + db: &DbConnection, session_id: String, request: SubmitElicitationResponseRequest, ) -> Result<(), String> { @@ -220,14 +294,12 @@ async fn submit_runtime_elicitation_response_internal( scope: request.action_scope, })); - let mut session_config_builder = - SessionConfigBuilder::new(&session_id).include_context_trace(true); - if let Some(prompt) = - merge_system_prompt_with_elicitation_context(None, request.metadata.as_ref()) - { - session_config_builder = session_config_builder.system_prompt(prompt); - } - let session_config = session_config_builder.build(); + let workspace_settings = load_runtime_workspace_settings_or_default(db, &session_id).await; + let session_config = build_runtime_action_session_config( + &session_id, + request.metadata.as_ref(), + &workspace_settings, + ); let agent_arc = state.get_agent_arc(); let guard = agent_arc.read().await; diff --git a/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs b/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs index 0df0ec310..8316b873b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs +++ b/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs @@ -32,7 +32,7 @@ pub(crate) enum BrowserTaskRequirement { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RuntimeChatMode { Agent, - Creator, + Workbench, General, } @@ -216,7 +216,7 @@ pub(crate) fn resolve_runtime_chat_mode( if let Some(chat_mode) = extract_harness_string(request_metadata, &["chat_mode", "chatMode"]) { match chat_mode.as_str() { "general" => return RuntimeChatMode::General, - "creator" => return RuntimeChatMode::Creator, + "workbench" => return RuntimeChatMode::Workbench, _ => {} } } @@ -230,7 +230,7 @@ pub(crate) fn resolve_runtime_chat_mode( pub(crate) fn runtime_chat_mode_label(mode: RuntimeChatMode) -> &'static str { match mode { RuntimeChatMode::Agent => "agent", - RuntimeChatMode::Creator => "creator", + RuntimeChatMode::Workbench => "workbench", RuntimeChatMode::General => "general", } } diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api.rs index 19aaf9a4c..1c3e16352 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api.rs @@ -9,7 +9,7 @@ use crate::commands::aster_agent_cmd::subagent_runtime::{ agent_runtime_send_subagent_input_internal, agent_runtime_spawn_subagent_internal, agent_runtime_wait_subagents_internal, SubagentControlRuntime, }; -use crate::commands::aster_agent_cmd::tool_runtime::ensure_tool_search_tool_registered; +use crate::commands::aster_agent_cmd::tool_runtime::ensure_runtime_support_tools_registered; #[path = "command_api/provider_api.rs"] pub(crate) mod provider_api; diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs index 28ea3ae3a..616466782 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs @@ -17,11 +17,12 @@ fn build_agent_status( pub async fn aster_agent_init( state: State<'_, AsterAgentState>, db: State<'_, DbConnection>, + mcp_manager: State<'_, McpManagerState>, ) -> Result { tracing::info!("[AsterAgent] 初始化 Agent"); state.init_agent_with_db(&db).await?; - ensure_tool_search_tool_registered(state.inner()).await?; + ensure_runtime_support_tools_registered(state.inner(), mcp_manager.inner()).await?; let provider_config = state.get_provider_config().await; diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs index e9ba8b93b..57315b91c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -565,12 +565,16 @@ pub async fn agent_runtime_get_tool_inventory( mcp_manager: State<'_, McpManagerState>, request: Option, ) -> Result { + if state.is_initialized().await { + ensure_runtime_support_tools_registered(state.inner(), mcp_manager.inner()).await?; + } + let request = request.unwrap_or_default(); let caller = lime_core::tool_calling::normalize_tool_caller(request.caller.as_deref()) .unwrap_or_else(|| "assistant".to_string()); - let surface = match (request.creator, request.browser_assist) { - (true, true) => WorkspaceToolSurface::creator_with_browser_assist(), - (true, false) => WorkspaceToolSurface::creator(), + let surface = match (request.workbench, request.browser_assist) { + (true, true) => WorkspaceToolSurface::workbench_with_browser_assist(), + (true, false) => WorkspaceToolSurface::workbench(), (false, true) => WorkspaceToolSurface::browser_assist(), (false, false) => WorkspaceToolSurface::core(), }; diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index c5342e3e7..c5e217655 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -39,7 +39,7 @@ pub struct ConfigureFromPoolRequest { #[serde(rename_all = "camelCase")] pub struct AgentRuntimeToolInventoryRequest { #[serde(default)] - pub creator: bool, + pub workbench: bool, #[serde(default)] pub browser_assist: bool, #[serde(default)] diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 4779420e4..c403e65b4 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -19,13 +19,14 @@ use crate::agent::{ SessionInfo, SubAgentRole, }; use crate::agent_tools::catalog::{ - browser_runtime_tool_prefix, build_mcp_extension_surface, creator_tool_names, + browser_runtime_tool_prefix, build_mcp_extension_surface, workbench_tool_names, WorkspaceToolSurface, LIME_CREATE_BROADCAST_TASK_TOOL_NAME, LIME_CREATE_COVER_TASK_TOOL_NAME, LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME, LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME, LIME_CREATE_VIDEO_TASK_TOOL_NAME, LIME_SITE_INFO_TOOL_NAME, LIME_SITE_LIST_TOOL_NAME, LIME_SITE_RECOMMEND_TOOL_NAME, LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, - SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, READ_MCP_RESOURCE_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME, + TOOL_SEARCH_TOOL_NAME, }; #[cfg(test)] use crate::agent_tools::execution::build_workspace_shell_allow_pattern; @@ -76,9 +77,8 @@ use aster::session::extension_data::{ExtensionData, ExtensionState}; use aster::session::{SessionType, SubagentSessionMetadata}; use aster::tools::task_output_tool::TaskOutputInput; use aster::tools::{ - BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager, - TaskOutputTool, TaskTool, Tool, ToolContext, ToolError, ToolOptions, ToolResult, - MAX_OUTPUT_LENGTH, + BashTool, PermissionBehavior, PermissionCheckResult, TaskManager, TaskOutputTool, TaskStopTool, + Tool, ToolContext, ToolError, ToolOptions, ToolResult, MAX_OUTPUT_LENGTH, }; use async_trait::async_trait; use futures::{FutureExt, StreamExt}; @@ -396,7 +396,7 @@ pub(crate) use tool_runtime::{ }; pub(crate) use tool_runtime::{ ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered, - ensure_social_image_tool_registered, ensure_tool_search_tool_registered, + ensure_runtime_support_tools_registered, ensure_social_image_tool_registered, }; pub async fn resume_persisted_runtime_queues_on_startup( @@ -469,11 +469,12 @@ impl AsterExecutionStrategy { fn should_force_react_for_message(message: &str) -> bool { let lowered = message.to_lowercase(); let default_hints = [ + "toolsearch", + "调用 toolsearch", + "调用toolsearch", + "use toolsearch", + "call toolsearch", "tool_search", - "调用 tool_search", - "调用tool_search", - "use tool_search", - "call tool_search", "websearch", "web search", "web_search", diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index 5242c143b..f6bae2f12 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -554,6 +554,62 @@ fn render_team_roles(role_items: &[serde_json::Value]) -> Vec { .collect() } +fn render_team_memory_shadow_content(content: &str) -> Option { + let normalized_lines = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .take(6) + .collect::>(); + if normalized_lines.is_empty() { + return None; + } + + let joined = normalized_lines.join(" | "); + let rendered = if joined.chars().count() > 320 { + let truncated = joined.chars().take(320).collect::(); + format!("{truncated}...") + } else { + joined + }; + + Some(rendered) +} + +fn render_team_memory_shadow_entries( + request_metadata: Option<&serde_json::Value>, +) -> Option<(Option, Vec)> { + let shadow = extract_harness_nested_object( + request_metadata, + &["team_memory_shadow", "teamMemoryShadow"], + )?; + let repo_scope = extract_object_string(shadow, &["repo_scope", "repoScope"]); + let entries = shadow.get("entries")?.as_array()?; + + let rendered_entries = entries + .iter() + .filter_map(|value| { + let object = value.as_object()?; + let key = extract_object_string(object, &["key"])?; + let content = extract_object_string(object, &["content"])?; + let updated_at_suffix = object + .get("updated_at") + .or_else(|| object.get("updatedAt")) + .and_then(serde_json::Value::as_i64) + .map(|value| format!(" / updatedAt: {value}")) + .unwrap_or_default(); + let rendered_content = render_team_memory_shadow_content(&content)?; + Some(format!(" - {key}{updated_at_suffix}: {rendered_content}")) + }) + .collect::>(); + + if rendered_entries.is_empty() { + return None; + } + + Some((repo_scope, rendered_entries)) +} + pub(crate) fn build_team_preference_system_prompt( request_metadata: Option<&serde_json::Value>, session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>, @@ -698,6 +754,20 @@ pub(crate) fn build_team_preference_system_prompt( } } + if let Some((repo_scope, rendered_shadow_entries)) = + render_team_memory_shadow_entries(request_metadata) + { + lines.push("- 当前项目的 repo-scoped Team 协作记忆(低优先级参考):".to_string()); + if let Some(repo_scope) = repo_scope.as_deref() { + lines.push(format!(" - repoScope: {repo_scope}")); + } + lines.extend(rendered_shadow_entries); + lines.push( + "- 上述 shadow 只代表当前仓库最近一次 Team 协作痕迹;如与本次显式 selected Team、Team Preset 或当前任务意图冲突,以本次请求为准。" + .to_string(), + ); + } + lines.push( "- spawn_agent 支持这些结构化字段:blueprintRoleId、blueprintRoleLabel、teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。" .to_string(), diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs index 6d411eae5..648a5a880 100644 --- a/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs @@ -133,6 +133,8 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields( ("selectedTeamSummary", "selected_team_summary"), ("selected_team_roles", "selected_team_roles"), ("selectedTeamRoles", "selected_team_roles"), + ("team_memory_shadow", "team_memory_shadow"), + ("teamMemoryShadow", "team_memory_shadow"), ("browser_requirement", "browser_requirement"), ("browserRequirement", "browser_requirement"), ("browser_requirement_reason", "browser_requirement_reason"), 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 f63839bd5..2d37644e8 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -9,6 +9,8 @@ const ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE: &str = "artifact_document_p const AUTO_CONTEXT_COMPACTION_EVENT_PREFIX: &str = "agent_context_compaction_auto_internal"; const AUTO_CONTEXT_COMPACTION_FAILED_WARNING_CODE: &str = "context_compaction_auto_failed"; const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not_needed"; +const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; +const LIME_RUNTIME_AUTO_COMPACT_KEY: &str = "auto_compact"; fn emit_runtime_side_event( app: &AppHandle, @@ -72,6 +74,32 @@ fn merge_turn_context_with_artifact_output_schema( ) } +pub(crate) fn merge_turn_context_with_workspace_auto_compaction( + turn_context: Option, + workspace_settings: &WorkspaceSettings, +) -> Option { + if workspace_settings.auto_compact { + return turn_context; + } + + let mut turn_context = turn_context.unwrap_or_default(); + let runtime_metadata = turn_context + .metadata + .entry(LIME_RUNTIME_METADATA_KEY.to_string()) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + if !runtime_metadata.is_object() { + *runtime_metadata = serde_json::Value::Object(serde_json::Map::new()); + } + if let serde_json::Value::Object(runtime_metadata_map) = runtime_metadata { + runtime_metadata_map.insert( + LIME_RUNTIME_AUTO_COMPACT_KEY.to_string(), + serde_json::Value::Bool(false), + ); + } + + Some(turn_context) +} + fn normalize_runtime_turn_request_metadata( request: &mut AsterChatRequest, session_recent_theme: Option<&str>, @@ -330,7 +358,7 @@ async fn execute_aster_chat_request( tracing::warn!("[AsterAgent] session_store 存在: {}", has_store); } } - ensure_tool_search_tool_registered(state).await?; + ensure_runtime_support_tools_registered(state, mcp_manager).await?; let request_session_id = request.session_id.clone(); let mcp_runtime_prepare_future = async { let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await; @@ -1017,14 +1045,19 @@ async fn execute_aster_chat_request( turn_state.turn_id.clone(), request.message.clone(), )?)); + let workspace_settings = workspace.settings.clone(); let runtime_status_session_config = { let mut session_config_builder = SessionConfigBuilder::new(session_id) .thread_id(turn_state.thread_id.clone()) .turn_id(turn_state.turn_id.clone()); - if let Some(turn_context) = merge_turn_context_with_artifact_output_schema( - turn_input_envelope.turn_context_override(), - request_metadata.as_ref(), - ) { + let turn_context = merge_turn_context_with_workspace_auto_compaction( + merge_turn_context_with_artifact_output_schema( + turn_input_envelope.turn_context_override(), + request_metadata.as_ref(), + ), + &workspace_settings, + ); + if let Some(turn_context) = turn_context { session_config_builder = session_config_builder.turn_context(turn_context); } session_config_builder.build() @@ -1086,10 +1119,14 @@ async fn execute_aster_chat_request( if let Some(prompt) = turn_input_envelope_for_session.system_prompt() { session_config_builder = session_config_builder.system_prompt(prompt.to_string()); } - if let Some(turn_context) = merge_turn_context_with_artifact_output_schema( - turn_input_envelope_for_session.turn_context_override(), - request_metadata_for_session.as_ref(), - ) { + let turn_context = merge_turn_context_with_workspace_auto_compaction( + merge_turn_context_with_artifact_output_schema( + turn_input_envelope_for_session.turn_context_override(), + request_metadata_for_session.as_ref(), + ), + &workspace_settings, + ); + if let Some(turn_context) = turn_context { session_config_builder = session_config_builder.turn_context(turn_context); } session_config_builder = session_config_builder @@ -2920,6 +2957,36 @@ mod tests { .expect("检查自动压缩阈值失败")); } + #[test] + fn should_inject_turn_context_metadata_when_workspace_auto_compaction_disabled() { + let merged = merge_turn_context_with_workspace_auto_compaction( + Some(TurnContextOverride::default()), + &WorkspaceSettings { + auto_compact: false, + ..WorkspaceSettings::default() + }, + ) + .expect("应返回 turn context"); + + assert_eq!( + merged + .metadata + .get(LIME_RUNTIME_METADATA_KEY) + .and_then(|value| value.get(LIME_RUNTIME_AUTO_COMPACT_KEY)) + .and_then(Value::as_bool), + Some(false) + ); + } + + #[test] + fn should_keep_turn_context_unchanged_when_workspace_auto_compaction_enabled() { + assert!(merge_turn_context_with_workspace_auto_compaction( + None, + &WorkspaceSettings::default() + ) + .is_none()); + } + #[test] fn should_skip_artifact_document_autopersist_when_output_is_empty() { let observation = Arc::new(Mutex::new(ChatRunObservation::default())); diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 8c9fb280d..0f551df77 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -1,7 +1,9 @@ #[cfg(test)] mod tests { use super::*; - use crate::commands::aster_agent_cmd::action_runtime::build_runtime_action_scope; + use crate::commands::aster_agent_cmd::action_runtime::{ + build_runtime_action_scope, build_runtime_action_session_config, + }; use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope; use crate::services::site_capability_service::{ RunSiteAdapterRequest, SiteAdapterDefinition, SiteAdapterRunResult, @@ -317,7 +319,7 @@ mod tests { RuntimeChatMode::Agent )); assert!(!default_web_search_enabled_for_chat_mode( - RuntimeChatMode::Creator + RuntimeChatMode::Workbench )); assert!(!default_web_search_enabled_for_chat_mode( RuntimeChatMode::General @@ -969,6 +971,39 @@ mod tests { ); } + #[test] + fn test_build_runtime_action_session_config_injects_auto_compact_override() { + let session_config = build_runtime_action_session_config( + "session-1", + None, + &lime_core::workspace::WorkspaceSettings { + auto_compact: false, + ..lime_core::workspace::WorkspaceSettings::default() + }, + ); + + assert_eq!( + session_config + .turn_context + .as_ref() + .and_then(|context| context.metadata.get("lime_runtime")) + .and_then(|value| value.get("auto_compact")) + .and_then(serde_json::Value::as_bool), + Some(false) + ); + } + + #[test] + fn test_build_runtime_action_session_config_keeps_auto_compact_enabled_default() { + let session_config = build_runtime_action_session_config( + "session-1", + None, + &lime_core::workspace::WorkspaceSettings::default(), + ); + + assert!(session_config.turn_context.is_none()); + } + #[test] fn test_build_runtime_action_scope_ignores_blank_values() { let request = AgentRuntimeRespondActionRequest { @@ -1456,6 +1491,74 @@ mod tests { ); } + #[test] + fn test_build_chat_run_metadata_base_flattens_team_memory_shadow() { + let metadata = build_chat_run_metadata_base( + &AsterChatRequest { + message: "hello".to_string(), + session_id: "session-team-shadow".to_string(), + event_name: "event-team-shadow".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: Some("project-1".to_string()), + workspace_id: "workspace-1".to_string(), + web_search: Some(false), + search_mode: None, + execution_strategy: Some(AsterExecutionStrategy::React), + auto_continue: None, + system_prompt: None, + metadata: Some(serde_json::json!({ + "harness": { + "team_memory_shadow": { + "repo_scope": "/tmp/repo", + "entries": [ + { + "key": "team.selection", + "content": "Team:前端联调团队", + "updated_at": 1 + } + ] + } + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }, + "workspace-1", + AsterExecutionStrategy::React, + &RequestToolPolicy { + search_mode: RequestToolPolicyMode::Disabled, + effective_web_search: false, + required_tools: vec![], + allowed_tools: vec![], + disallowed_tools: vec![], + }, + false, + None, + None, + ); + + assert_eq!( + metadata.get("team_memory_shadow"), + Some(&serde_json::json!({ + "repo_scope": "/tmp/repo", + "entries": [ + { + "key": "team.selection", + "content": "Team:前端联调团队", + "updated_at": 1 + } + ] + })) + ); + } + #[test] fn test_build_chat_run_metadata_base_derives_access_mode_from_formal_turn_context() { let metadata = build_chat_run_metadata_base( @@ -1751,7 +1854,7 @@ mod tests { #[test] fn test_aster_execution_strategy_auto_prefers_react_when_tool_search_explicit() { let strategy = - AsterExecutionStrategy::Auto.effective_for_message("请先调用 tool_search 再继续"); + AsterExecutionStrategy::Auto.effective_for_message("请先调用 ToolSearch 再继续"); assert_eq!(strategy, AsterExecutionStrategy::React); } @@ -2369,6 +2472,45 @@ mod tests { assert!(prompt.contains("主动汇总关键进展、风险和下一步")); } + #[test] + fn test_build_team_preference_system_prompt_renders_repo_scoped_team_memory_shadow() { + let prompt = build_team_preference_system_prompt( + Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": true, + "team_memory_shadow": { + "repo_scope": "/tmp/repo", + "entries": [ + { + "key": "team.selection", + "content": "主题:general\nTeam:前端联调团队\n角色:\n- 分析:负责定位问题。", + "updated_at": 1 + }, + { + "key": "team.subagents", + "content": "会话:session-1\n子代理:\n- 分析 [running] 负责定位问题", + "updated_at": 2 + } + ] + } + } + })), + None, + true, + ) + .expect("team prompt should exist"); + + assert!(prompt.contains("repo-scoped Team 协作记忆")); + assert!(prompt.contains("repoScope: /tmp/repo")); + assert!(prompt.contains("team.selection / updatedAt: 1")); + assert!( + prompt.contains("主题:general | Team:前端联调团队 | 角色: | - 分析:负责定位问题。") + ); + assert!(prompt.contains("team.subagents / updatedAt: 2")); + assert!(prompt.contains("会话:session-1 | 子代理: | - 分析 [running] 负责定位问题")); + assert!(prompt.contains("如与本次显式 selected Team")); + } + #[test] fn test_build_team_preference_system_prompt_accepts_session_fallback_flag() { let prompt = build_team_preference_system_prompt( @@ -3124,10 +3266,10 @@ mod tests { &context, ) .await - .expect("tool_search should succeed"); - let hidden_output = hidden_result.output.expect("tool_search output"); + .expect("ToolSearch should succeed"); + let hidden_output = hidden_result.output.expect("ToolSearch output"); let hidden_json: serde_json::Value = - serde_json::from_str(&hidden_output).expect("parse tool_search output"); + serde_json::from_str(&hidden_output).expect("parse ToolSearch output"); assert_eq!(hidden_json["count"], serde_json::json!(0)); let visible_result = tool @@ -3141,10 +3283,10 @@ mod tests { &context, ) .await - .expect("tool_search should succeed"); - let visible_output = visible_result.output.expect("tool_search output"); + .expect("ToolSearch should succeed"); + let visible_output = visible_result.output.expect("ToolSearch output"); let visible_json: serde_json::Value = - serde_json::from_str(&visible_output).expect("parse tool_search output"); + serde_json::from_str(&visible_output).expect("parse ToolSearch output"); let tools = visible_json["tools"] .as_array() .expect("tools should be array"); 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 9282dba39..a47109f6d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -4,6 +4,8 @@ use super::*; mod browser_tools; #[path = "tool_runtime/creation_tools.rs"] mod creation_tools; +#[path = "tool_runtime/mcp_resource_tools.rs"] +mod mcp_resource_tools; #[path = "tool_runtime/search_bridge.rs"] mod search_bridge; #[path = "tool_runtime/site_tools.rs"] @@ -19,6 +21,9 @@ pub(crate) use browser_tools::ensure_browser_mcp_tools_registered; #[allow(unused_imports)] pub(crate) use browser_tools::LimeBrowserMcpTool; pub(crate) use creation_tools::ensure_creation_task_tools_registered; +pub(crate) use mcp_resource_tools::ensure_mcp_resource_tools_registered; +#[allow(unused_imports)] +pub(crate) use mcp_resource_tools::{ListMcpResourcesBridgeTool, ReadMcpResourceBridgeTool}; pub(crate) use search_bridge::ensure_tool_search_tool_registered; #[allow(unused_imports)] pub(crate) use search_bridge::ToolSearchBridgeTool; @@ -81,7 +86,7 @@ fn sync_workspace_mode_native_tool_surface( site_tools::unregister_site_tools_from_registry(registry); } - if surface.creator { + if surface.workbench { social_tools::register_social_image_tool_to_registry(registry, config_manager); creation_tools::register_creation_task_tools_to_registry( registry, @@ -90,8 +95,8 @@ fn sync_workspace_mode_native_tool_surface( app_handle, ); } else { - let creator_tools = creator_tool_names(); - unregister_named_tools(registry, &creator_tools); + let workbench_tools = workbench_tool_names(); + unregister_named_tools(registry, &workbench_tools); } } @@ -126,7 +131,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions( let lock_service_skill_launch_to_site_tools = should_lock_service_skill_launch_to_site_tools(request_metadata); let tool_surface = WorkspaceToolSurface { - creator: runtime_chat_mode == RuntimeChatMode::Creator, + workbench: runtime_chat_mode == RuntimeChatMode::Workbench, browser_assist: is_browser_assist_enabled(request_metadata), }; let mut sandboxed_bash_tool: Option = None; @@ -195,7 +200,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions( workspace_tools::register_workspace_runtime_tools( &mut registry, task_manager, - should_auto_approve_tool_warnings("Task", auto_mode, execution_policy_input), + should_auto_approve_tool_warnings("bash", auto_mode, execution_policy_input), sandboxed_bash_tool, ); @@ -224,6 +229,15 @@ pub(crate) async fn apply_workspace_sandbox_permissions( Ok(apply_outcome) } +pub(crate) async fn ensure_runtime_support_tools_registered( + state: &AsterAgentState, + mcp_manager: &McpManagerState, +) -> Result<(), String> { + ensure_tool_search_tool_registered(state).await?; + ensure_mcp_resource_tools_registered(state, mcp_manager).await?; + Ok(()) +} + /// 图片输入 #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/mcp_resource_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/mcp_resource_tools.rs new file mode 100644 index 000000000..41913d257 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/mcp_resource_tools.rs @@ -0,0 +1,302 @@ +use super::*; +use base64::Engine; +use std::fs; + +pub(crate) struct ListMcpResourcesBridgeTool { + mcp_manager: McpManagerState, +} + +impl ListMcpResourcesBridgeTool { + pub(crate) fn new(mcp_manager: McpManagerState) -> Self { + Self { mcp_manager } + } +} + +#[async_trait] +impl Tool for ListMcpResourcesBridgeTool { + fn name(&self) -> &str { + LIST_MCP_RESOURCES_TOOL_NAME + } + + fn description(&self) -> &str { + "列出运行中 MCP server 暴露的资源,返回 uri、name、mimeType、description、server。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "可选,按 MCP server 名称过滤资源" + } + }, + "required": [] + }) + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(1) + .with_base_timeout(Duration::from_secs(15)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let target_server = params + .get("server") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let manager = self.mcp_manager.lock().await; + let running_servers = manager.get_running_servers().await; + if let Some(server_name) = target_server.as_ref() { + if !running_servers.iter().any(|item| item == server_name) { + return Err(ToolError::execution_failed(format!( + "MCP server 不存在或未运行: {server_name}. 当前可用: {}", + running_servers.join(", ") + ))); + } + } + + let resources = manager + .list_resources() + .await + .map_err(|error| ToolError::execution_failed(format!("列出 MCP 资源失败: {error}")))?; + + let output = resources + .into_iter() + .filter(|resource| { + target_server + .as_ref() + .map(|server_name| resource.server_name == *server_name) + .unwrap_or(true) + }) + .map(|resource| { + serde_json::json!({ + "uri": resource.uri, + "name": resource.name, + "mimeType": resource.mime_type, + "description": resource.description, + "server": resource.server_name + }) + }) + .collect::>(); + + let text = serde_json::to_string_pretty(&output).map_err(|error| { + ToolError::execution_failed(format!( + "{LIST_MCP_RESOURCES_TOOL_NAME} 序列化失败: {error}" + )) + })?; + + Ok(ToolResult::success(text)) + } +} + +pub(crate) struct ReadMcpResourceBridgeTool { + mcp_manager: McpManagerState, +} + +impl ReadMcpResourceBridgeTool { + pub(crate) fn new(mcp_manager: McpManagerState) -> Self { + Self { mcp_manager } + } + + fn sanitize_path_segment(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '_' + } + }) + .collect::(); + let trimmed = sanitized.trim_matches('_'); + if trimmed.is_empty() { + "resource".to_string() + } else { + trimmed.to_string() + } + } + + fn persist_blob( + server_name: &str, + uri: &str, + mime_type: Option<&str>, + blob: &str, + ) -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(blob) + .map_err(|error| { + ToolError::execution_failed(format!("MCP 资源 blob 解码失败: {error}")) + })?; + let directory = std::env::temp_dir() + .join("lime-mcp-resource-tool") + .join(Self::sanitize_path_segment(server_name)); + fs::create_dir_all(&directory).map_err(|error| { + ToolError::execution_failed(format!("创建 MCP 资源暂存目录失败: {error}")) + })?; + + let extension = match mime_type { + Some("application/json") => "json", + Some("text/plain") => "txt", + Some("text/markdown") => "md", + Some("image/png") => "png", + Some("image/jpeg") => "jpg", + Some("image/webp") => "webp", + Some("application/pdf") => "pdf", + _ => "bin", + }; + let file_name = format!( + "{}-{}.{}", + Self::sanitize_path_segment(uri), + Uuid::new_v4(), + extension + ); + let file_path = directory.join(file_name); + fs::write(&file_path, bytes).map_err(|error| { + ToolError::execution_failed(format!("写入 MCP 资源临时文件失败: {error}")) + })?; + Ok(file_path.display().to_string()) + } +} + +#[async_trait] +impl Tool for ReadMcpResourceBridgeTool { + fn name(&self) -> &str { + READ_MCP_RESOURCE_TOOL_NAME + } + + fn description(&self) -> &str { + "按 MCP server 名称与资源 URI 读取单个资源内容。文本资源直接返回,二进制资源会保存到本地临时文件。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "MCP server 名称" + }, + "uri": { + "type": "string", + "description": "资源 URI" + } + }, + "required": ["server", "uri"] + }) + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(1) + .with_base_timeout(Duration::from_secs(20)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let server_name = params + .get("server") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ToolError::invalid_params("server 必填"))?; + let uri = params + .get("uri") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ToolError::invalid_params("uri 必填"))?; + + let manager = self.mcp_manager.lock().await; + let running_servers = manager.get_running_servers().await; + if !running_servers.iter().any(|item| item == server_name) { + return Err(ToolError::execution_failed(format!( + "MCP server 不存在或未运行: {server_name}. 当前可用: {}", + running_servers.join(", ") + ))); + } + + let resources = manager.list_resources().await.map_err(|error| { + ToolError::execution_failed(format!("读取 MCP 资源索引失败: {error}")) + })?; + let resource_exists = resources + .iter() + .any(|resource| resource.server_name == server_name && resource.uri == uri); + if !resource_exists { + return Err(ToolError::execution_failed(format!( + "MCP server {server_name} 未暴露资源: {uri}" + ))); + } + + let resource = manager + .read_resource(uri) + .await + .map_err(|error| ToolError::execution_failed(format!("读取 MCP 资源失败: {error}")))?; + + let blob_saved_to = resource + .blob + .as_deref() + .map(|blob| Self::persist_blob(server_name, uri, resource.mime_type.as_deref(), blob)) + .transpose()?; + let text = if let Some(text) = resource.text { + Some(text) + } else { + blob_saved_to + .as_ref() + .map(|path| format!("Binary resource saved to {path}")) + }; + + let payload = serde_json::json!({ + "contents": [{ + "uri": resource.uri, + "mimeType": resource.mime_type, + "text": text, + "blobSavedTo": blob_saved_to + }] + }); + let output = serde_json::to_string_pretty(&payload).map_err(|error| { + ToolError::execution_failed(format!( + "{READ_MCP_RESOURCE_TOOL_NAME} 序列化失败: {error}" + )) + })?; + Ok(ToolResult::success(output)) + } +} + +pub(super) fn register_mcp_resource_tools_to_registry( + registry: &mut aster::tools::ToolRegistry, + mcp_manager: McpManagerState, +) { + if !registry.contains(LIST_MCP_RESOURCES_TOOL_NAME) { + registry.register(Box::new(ListMcpResourcesBridgeTool::new( + mcp_manager.clone(), + ))); + } + if !registry.contains(READ_MCP_RESOURCE_TOOL_NAME) { + registry.register(Box::new(ReadMcpResourceBridgeTool::new(mcp_manager))); + } +} + +pub(crate) async fn ensure_mcp_resource_tools_registered( + state: &AsterAgentState, + mcp_manager: &McpManagerState, +) -> Result<(), String> { + let (registry_arc, _) = resolve_agent_registry(state).await?; + let mut registry = registry_arc.write().await; + register_mcp_resource_tools_to_registry(&mut registry, mcp_manager.clone()); + Ok(()) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs index 08ffe20c1..00e629567 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs @@ -303,7 +303,9 @@ impl Tool for ToolSearchBridgeTool { "count": result.len(), "tools": result })) - .map_err(|e| ToolError::execution_failed(format!("tool_search 序列化失败: {e}")))?; + .map_err(|e| { + ToolError::execution_failed(format!("{TOOL_SEARCH_TOOL_NAME} 序列化失败: {e}")) + })?; Ok(ToolResult::success(text)) } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs index 5b891dde6..8da766675 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs @@ -513,473 +513,143 @@ impl Tool for SubAgentTaskTool { } } -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SpawnAgentToolInput { - message: String, - agent_type: Option, - model: Option, - reasoning_effort: Option, - fork_context: Option, - blueprint_role_id: Option, - blueprint_role_label: Option, - profile_id: Option, - profile_name: Option, - role_key: Option, - #[serde(default)] - skill_ids: Vec, - #[serde(default)] - skill_directories: Vec, - team_preset_id: Option, - theme: Option, - system_overlay: Option, - output_contract: Option, -} - -#[derive(Debug, Clone)] -struct SpawnAgentTool { +fn build_agent_control_tool_config( runtime: SubagentControlRuntime, -} +) -> aster::tools::AgentControlToolConfig { + let spawn_runtime = runtime.clone(); + let send_runtime = runtime.clone(); + let wait_runtime = runtime.clone(); + let resume_runtime = runtime.clone(); + let close_runtime = runtime; -impl SpawnAgentTool { - fn new(runtime: SubagentControlRuntime) -> Self { - Self { runtime } - } -} + aster::tools::AgentControlToolConfig::new() + .with_spawn_agent_callback(Arc::new(move |request| { + let runtime = spawn_runtime.clone(); + Box::pin(async move { + let response = agent_runtime_spawn_subagent_internal( + &runtime, + AgentRuntimeSpawnSubagentRequest { + parent_session_id: request.parent_session_id, + message: request.message, + agent_type: request.agent_type, + model: request.model, + reasoning_effort: request.reasoning_effort, + fork_context: request.fork_context, + blueprint_role_id: request.blueprint_role_id, + blueprint_role_label: request.blueprint_role_label, + profile_id: request.profile_id, + profile_name: request.profile_name, + role_key: request.role_key, + skill_ids: request.skill_ids, + skill_directories: request.skill_directories, + team_preset_id: request.team_preset_id, + theme: request.theme, + system_overlay: request.system_overlay, + output_contract: request.output_contract, + }, + ) + .await?; -#[async_trait] -impl Tool for SpawnAgentTool { - fn name(&self) -> &str { - "spawn_agent" - } + Ok(aster::tools::SpawnAgentResponse { + agent_id: response.agent_id, + nickname: response.nickname, + extra: std::collections::BTreeMap::new(), + }) + }) + })) + .with_send_input_callback(Arc::new(move |request| { + let runtime = send_runtime.clone(); + Box::pin(async move { + let response = agent_runtime_send_subagent_input_internal( + &runtime, + AgentRuntimeSendSubagentInputRequest { + id: request.id, + message: request.message, + interrupt: request.interrupt, + }, + ) + .await?; - fn description(&self) -> &str { - "仅在任务需要拆成多个独立子范围、并行评审/验证,或用户明确要求多代理时使用。先判断当前关键路径:如果下一步立即依赖结果,不要把阻塞工作委派出去;优先把可并行推进的 sidecar 子任务交给子代理,同时主线程继续做不重叠的工作。创建真实子代理会话,并异步开始执行首条任务。不要对简单任务创建子代理;多个子代理必须分工明确,避免修改同一片文件;当前 team runtime 默认不允许子代理继续创建新的子代理。" - } + Ok(aster::tools::SendInputResponse { + submission_id: response.submission_id, + extra: std::collections::BTreeMap::new(), + }) + }) + })) + .with_wait_agent_callback(Arc::new(move |request| { + let runtime = wait_runtime.clone(); + Box::pin(async move { + let response = agent_runtime_wait_subagents_internal( + &runtime, + AgentRuntimeWaitSubagentsRequest { + ids: request.ids, + timeout_ms: request.timeout_ms, + }, + ) + .await?; + let status = response + .status + .into_iter() + .map(|(id, status)| { + serde_json::to_value(status) + .map(|value| (id, value)) + .map_err(|error| format!("wait_agent 状态序列化失败: {error}")) + }) + .collect::, _>>()?; - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "发送给子代理的首条任务消息。应是边界清晰、可独立完成、不会与其他并发子代理写入范围重叠的子任务。" - }, - "agentType": { - "type": "string", - "description": "子代理角色提示,例如 explorer/planner/executor,也可以是 Image #1 这类展示标签" - }, - "model": { - "type": "string", - "description": "可选模型覆盖" - }, - "reasoningEffort": { - "type": "string", - "description": "保留字段,当前仅记录到 metadata" - }, - "forkContext": { - "type": "boolean", - "description": "保留字段,当前仅记录到 metadata" - }, - "blueprintRoleId": { - "type": "string", - "description": "可选当前 Team 蓝图角色 id;当 GUI 已提前准备协作分工时,优先传入对应角色 id,便于真实成员接管画布泳道" - }, - "blueprintRoleLabel": { - "type": "string", - "description": "可选当前 Team 蓝图角色标签,例如 分析 / 执行 / 验证" - }, - "profileId": { - "type": "string", - "description": "可选内置 profile id,例如 code-explorer / code-executor / code-verifier" - }, - "profileName": { - "type": "string", - "description": "可选 profile 展示名称,用于 Team Workspace 与子代理 prompt" - }, - "roleKey": { - "type": "string", - "description": "可选角色键,例如 explorer / executor / verifier / researcher" - }, - "skillIds": { - "type": "array", - "items": { "type": "string" }, - "description": "可选 builtin skill id 列表,用于附加子代理技能提示" - }, - "skillDirectories": { - "type": "array", - "items": { "type": "string" }, - "description": "可选本地已安装 skill 目录名;会读取对应 SKILL.md 注入子代理 prompt" - }, - "teamPresetId": { - "type": "string", - "description": "可选 team preset id,例如 code-triage-team / research-team / content-creation-team" - }, - "theme": { - "type": "string", - "description": "可选子代理主题标签,用于 GUI 展示与 prompt 约束" - }, - "systemOverlay": { - "type": "string", - "description": "附加给该子代理的额外系统约束" - }, - "outputContract": { - "type": "string", - "description": "要求子代理遵循的输出契约" - } - }, - "required": ["message"], - "additionalProperties": false - }) - } + Ok(aster::tools::WaitAgentResponse { + status, + timed_out: response.timed_out, + extra: std::collections::BTreeMap::new(), + }) + }) + })) + .with_resume_agent_callback(Arc::new(move |request| { + let runtime = resume_runtime.clone(); + Box::pin(async move { + let response = agent_runtime_resume_subagent_internal( + &runtime, + AgentRuntimeResumeSubagentRequest { id: request.id }, + ) + .await?; + let mut extra = std::collections::BTreeMap::new(); + extra.insert( + "cascade_session_ids".to_string(), + serde_json::to_value(response.cascade_session_ids) + .map_err(|error| format!("resume_agent 级联会话序列化失败: {error}"))?, + ); - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input: SpawnAgentToolInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("spawn_agent 参数无效: {error}")))?; - let response = agent_runtime_spawn_subagent_internal( - &self.runtime, - AgentRuntimeSpawnSubagentRequest { - parent_session_id: context.session_id.clone(), - message: input.message, - agent_type: input.agent_type, - model: input.model, - reasoning_effort: input.reasoning_effort, - fork_context: input.fork_context.unwrap_or(false), - blueprint_role_id: input.blueprint_role_id, - blueprint_role_label: input.blueprint_role_label, - profile_id: input.profile_id, - profile_name: input.profile_name, - role_key: input.role_key, - skill_ids: input.skill_ids, - skill_directories: input.skill_directories, - team_preset_id: input.team_preset_id, - theme: input.theme, - system_overlay: input.system_overlay, - output_contract: input.output_contract, - }, - ) - .await - .map_err(ToolError::execution_failed)?; + Ok(aster::tools::ResumeAgentResponse { + status: serde_json::to_value(response.status) + .map_err(|error| format!("resume_agent 状态序列化失败: {error}"))?, + changed_session_ids: response.changed_session_ids, + extra, + }) + }) + })) + .with_close_agent_callback(Arc::new(move |request| { + let runtime = close_runtime.clone(); + Box::pin(async move { + let response = agent_runtime_close_subagent_internal( + &runtime, + AgentRuntimeCloseSubagentRequest { id: request.id }, + ) + .await?; + let mut extra = std::collections::BTreeMap::new(); + extra.insert( + "cascade_session_ids".to_string(), + serde_json::to_value(response.cascade_session_ids) + .map_err(|error| format!("close_agent 级联会话序列化失败: {error}"))?, + ); - Ok( - ToolResult::success(format!("子代理已创建: {}", response.agent_id)).with_metadata( - "spawn_agent", - serde_json::to_value(&response).unwrap_or_default(), - ), - ) - } -} - -#[derive(Debug, Clone, Deserialize)] -struct SendInputToolInput { - id: String, - message: String, - #[serde(default)] - interrupt: bool, -} - -#[derive(Debug, Clone)] -struct SendInputTool { - runtime: SubagentControlRuntime, -} - -impl SendInputTool { - fn new(runtime: SubagentControlRuntime) -> Self { - Self { runtime } - } -} - -#[async_trait] -impl Tool for SendInputTool { - fn name(&self) -> &str { - "send_input" - } - - fn description(&self) -> &str { - "向已存在的子代理追加输入。对强依赖既有上下文的后续任务,优先复用已有子代理而不是重复 spawn;interrupt=true 时会先中断当前执行并清空旧队列。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "子代理 session id" - }, - "message": { - "type": "string", - "description": "要发送给子代理的输入" - }, - "interrupt": { - "type": "boolean", - "description": "是否先中断当前执行" - } - }, - "required": ["id", "message"], - "additionalProperties": false - }) - } - - async fn execute( - &self, - params: serde_json::Value, - _context: &ToolContext, - ) -> Result { - let input: SendInputToolInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("send_input 参数无效: {error}")))?; - let response = agent_runtime_send_subagent_input_internal( - &self.runtime, - AgentRuntimeSendSubagentInputRequest { - id: input.id, - message: input.message, - interrupt: input.interrupt, - }, - ) - .await - .map_err(ToolError::execution_failed)?; - - Ok( - ToolResult::success(format!("子代理输入已提交: {}", response.submission_id)) - .with_metadata( - "send_input", - serde_json::to_value(&response).unwrap_or_default(), - ), - ) - } -} - -#[derive(Debug, Clone, Deserialize)] -struct WaitAgentToolInput { - ids: Vec, - #[serde(default, alias = "timeoutMs")] - timeout_ms: Option, -} - -#[derive(Debug, Clone)] -struct WaitAgentTool { - runtime: SubagentControlRuntime, -} - -impl WaitAgentTool { - fn new(runtime: SubagentControlRuntime) -> Self { - Self { runtime } - } -} - -#[async_trait] -impl Tool for WaitAgentTool { - fn name(&self) -> &str { - "wait_agent" - } - - fn description(&self) -> &str { - "等待一个或多个子代理进入最终状态。只有在主线程确实被结果阻塞、下一步必须依赖这些结果时才调用;可以同时等待多个 id,任一子代理先完成就会返回。不要反复机械 wait,优先在等待前继续做不重叠的本地工作;timeout_ms 应与任务规模匹配,避免过短轮询。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "ids": { - "type": "array", - "items": { "type": "string" }, - "description": "要等待的子代理 session id 列表" - }, - "timeoutMs": { - "type": "integer", - "minimum": 1, - "description": "最长等待时间(毫秒)" - } - }, - "required": ["ids"], - "additionalProperties": false - }) - } - - fn options(&self) -> ToolOptions { - ToolOptions::new() - .with_max_retries(0) - .with_base_timeout(Duration::from_secs(310)) - .with_dynamic_timeout(false) - } - - async fn execute( - &self, - params: serde_json::Value, - _context: &ToolContext, - ) -> Result { - let input: WaitAgentToolInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("wait_agent 参数无效: {error}")))?; - let response = agent_runtime_wait_subagents_internal( - &self.runtime, - AgentRuntimeWaitSubagentsRequest { - ids: input.ids, - timeout_ms: input.timeout_ms, - }, - ) - .await - .map_err(ToolError::execution_failed)?; - let summary = if response.timed_out { - "wait_agent 超时,未观测到最终状态".to_string() - } else { - format!("已观测到 {} 个子代理进入最终状态", response.status.len()) - }; - - Ok(ToolResult::success(summary).with_metadata( - "wait_agent", - serde_json::to_value(&response).unwrap_or_default(), - )) - } -} - -#[derive(Debug, Clone, Deserialize)] -struct ResumeAgentToolInput { - id: String, -} - -#[derive(Debug, Clone)] -struct ResumeAgentTool { - runtime: SubagentControlRuntime, -} - -impl ResumeAgentTool { - fn new(runtime: SubagentControlRuntime) -> Self { - Self { runtime } - } -} - -#[async_trait] -impl Tool for ResumeAgentTool { - fn name(&self) -> &str { - "resume_agent" - } - - fn description(&self) -> &str { - "恢复之前关闭的子代理;若子代理未关闭则返回当前状态" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "子代理 session id" - } - }, - "required": ["id"], - "additionalProperties": false - }) - } - - async fn execute( - &self, - params: serde_json::Value, - _context: &ToolContext, - ) -> Result { - let input: ResumeAgentToolInput = serde_json::from_value(params).map_err(|error| { - ToolError::invalid_params(format!("resume_agent 参数无效: {error}")) - })?; - let response = agent_runtime_resume_subagent_internal( - &self.runtime, - AgentRuntimeResumeSubagentRequest { id: input.id }, - ) - .await - .map_err(ToolError::execution_failed)?; - - let changed_count = response.changed_session_ids.len(); - let success_message = if changed_count > 1 { - format!("子代理已恢复,并级联恢复 {changed_count} 个会话") - } else if changed_count == 1 { - "子代理已恢复".to_string() - } else { - format!("子代理当前状态: {:?}", response.status.kind) - }; - - Ok(ToolResult::success(success_message).with_metadata( - "resume_agent", - serde_json::to_value(&response).unwrap_or_default(), - )) - } -} - -#[derive(Debug, Clone, Deserialize)] -struct CloseAgentToolInput { - id: String, -} - -#[derive(Debug, Clone)] -struct CloseAgentTool { - runtime: SubagentControlRuntime, -} - -impl CloseAgentTool { - fn new(runtime: SubagentControlRuntime) -> Self { - Self { runtime } - } -} - -#[async_trait] -impl Tool for CloseAgentTool { - fn name(&self) -> &str { - "close_agent" - } - - fn description(&self) -> &str { - "关闭子代理并级联关闭其子树;历史保留,可后续恢复" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "子代理 session id" - } - }, - "required": ["id"], - "additionalProperties": false - }) - } - - async fn execute( - &self, - params: serde_json::Value, - _context: &ToolContext, - ) -> Result { - let input: CloseAgentToolInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("close_agent 参数无效: {error}")))?; - let response = agent_runtime_close_subagent_internal( - &self.runtime, - AgentRuntimeCloseSubagentRequest { id: input.id }, - ) - .await - .map_err(ToolError::execution_failed)?; - - let changed_count = response.changed_session_ids.len(); - let success_message = if changed_count > 1 { - format!( - "子代理已关闭,并级联关闭 {changed_count} 个会话;关闭前状态: {:?}", - response.previous_status.kind - ) - } else { - format!( - "子代理已关闭,关闭前状态: {:?}", - response.previous_status.kind - ) - }; - - Ok(ToolResult::success(success_message).with_metadata( - "close_agent", - serde_json::to_value(&response).unwrap_or_default(), - )) - } + Ok(aster::tools::CloseAgentResponse { + previous_status: serde_json::to_value(response.previous_status) + .map_err(|error| format!("close_agent 状态序列化失败: {error}"))?, + changed_session_ids: response.changed_session_ids, + extra, + }) + }) + })) } pub(super) fn register_subagent_runtime_tools( @@ -987,9 +657,5 @@ pub(super) fn register_subagent_runtime_tools( runtime: SubagentControlRuntime, ) { registry.register(Box::new(SubAgentTaskTool::new(runtime.clone()))); - registry.register(Box::new(SpawnAgentTool::new(runtime.clone()))); - registry.register(Box::new(SendInputTool::new(runtime.clone()))); - registry.register(Box::new(WaitAgentTool::new(runtime.clone()))); - registry.register(Box::new(ResumeAgentTool::new(runtime.clone()))); - registry.register(Box::new(CloseAgentTool::new(runtime))); + aster::tools::register_agent_control_tools(registry, &build_agent_control_tool_config(runtime)); } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs index 25efe89cc..29dda887a 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs @@ -663,23 +663,23 @@ impl Tool for WorkspaceSandboxedBashTool { } } -/// 统一处理 Task 工具的 Ask 权限,避免缺少回调导致流程中断 -struct WorkspaceTaskTool { - delegate: TaskTool, +/// 统一处理 bash 工具的风险提示与共享任务管理器 +struct WorkspaceBashTool { + delegate: BashTool, auto_approve_warnings: bool, } -impl WorkspaceTaskTool { +impl WorkspaceBashTool { fn new(auto_approve_warnings: bool, task_manager: Arc) -> Self { Self { - delegate: TaskTool::with_manager(task_manager), + delegate: BashTool::with_task_manager(task_manager), auto_approve_warnings, } } } #[async_trait] -impl Tool for WorkspaceTaskTool { +impl Tool for WorkspaceBashTool { fn name(&self) -> &str { self.delegate.name() } @@ -801,12 +801,12 @@ pub(super) fn register_workspace_runtime_tools( auto_approve_warnings: bool, sandboxed_bash_tool: Option, ) { - registry.register(Box::new(WorkspaceTaskTool::new( + registry.register(Box::new(WorkspaceBashTool::new( auto_approve_warnings, task_manager.clone(), ))); registry.register(Box::new(WorkspaceTaskOutputTool::new(task_manager.clone()))); - registry.register(Box::new(KillShellTool::with_task_manager(task_manager))); + registry.register(Box::new(TaskStopTool::with_task_manager(task_manager))); if let Some(workspace_bash_tool) = sandboxed_bash_tool { registry.register(Box::new(workspace_bash_tool)); diff --git a/src-tauri/src/commands/poster_material_cmd.rs b/src-tauri/src/commands/gallery_material_cmd.rs similarity index 56% rename from src-tauri/src/commands/poster_material_cmd.rs rename to src-tauri/src/commands/gallery_material_cmd.rs index e4a418e15..bcfc115d9 100644 --- a/src-tauri/src/commands/poster_material_cmd.rs +++ b/src-tauri/src/commands/gallery_material_cmd.rs @@ -1,24 +1,24 @@ -//! 海报素材相关的 Tauri 命令 +//! 图库素材相关的 Tauri 命令 //! -//! 提供海报素材元数据(PosterMaterialMetadata)管理的前端 API,包括: -//! - 创建、获取、更新、删除海报素材元数据 +//! 提供图库素材元数据(GalleryMaterialMetadata)管理的前端 API,包括: +//! - 创建、获取、更新、删除图库素材元数据 //! - 按分类筛选素材 use tauri::State; -use crate::database::dao::poster_material_dao::PosterMaterialDao; +use crate::database::dao::gallery_material_dao::GalleryMaterialDao; use crate::database::DbConnection; use crate::models::project_model::{ - CreatePosterMetadataRequest, PosterMaterial, PosterMaterialMetadata, + CreateGalleryMaterialMetadataRequest, GalleryMaterial, GalleryMaterialMetadata, }; // ============================================================================ // Tauri 命令 // ============================================================================ -/// 创建海报素材元数据 +/// 创建图库素材元数据 /// -/// 为已存在的素材创建海报专用元数据。 +/// 为已存在的素材创建图库专用元数据。 /// /// # 参数 /// - `db`: 数据库连接状态 @@ -28,52 +28,52 @@ use crate::models::project_model::{ /// - 成功返回创建的元数据 /// - 失败返回错误信息 #[tauri::command] -pub async fn create_poster_metadata( +pub async fn create_gallery_material_metadata( db: State<'_, DbConnection>, - req: CreatePosterMetadataRequest, -) -> Result { + req: CreateGalleryMaterialMetadataRequest, +) -> Result { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - PosterMaterialDao::create(&conn, &req).map_err(|e| e.to_string()) + GalleryMaterialDao::create(&conn, &req).map_err(|e| e.to_string()) } -/// 获取海报素材元数据 +/// 获取图库素材元数据 /// -/// 根据素材 ID 获取海报元数据。 +/// 根据素材 ID 获取图库元数据。 /// /// # 参数 /// - `db`: 数据库连接状态 /// - `material_id`: 素材 ID /// /// # 返回 -/// - 成功返回 Option +/// - 成功返回 Option /// - 失败返回错误信息 #[tauri::command] -pub async fn get_poster_metadata( +pub async fn get_gallery_material_metadata( db: State<'_, DbConnection>, material_id: String, -) -> Result, String> { +) -> Result, String> { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - PosterMaterialDao::get(&conn, &material_id).map_err(|e| e.to_string()) + GalleryMaterialDao::get(&conn, &material_id).map_err(|e| e.to_string()) } -/// 获取完整的海报素材 +/// 获取完整的图库素材 /// -/// 获取包含基础素材和元数据的完整海报素材。 +/// 获取包含基础素材和元数据的完整图库素材。 /// /// # 参数 /// - `db`: 数据库连接状态 /// - `material_id`: 素材 ID /// /// # 返回 -/// - 成功返回 Option +/// - 成功返回 Option /// - 失败返回错误信息 #[tauri::command] -pub async fn get_poster_material( +pub async fn get_gallery_material( db: State<'_, DbConnection>, material_id: String, -) -> Result, String> { +) -> Result, String> { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - PosterMaterialDao::get_poster_material(&conn, &material_id).map_err(|e| e.to_string()) + GalleryMaterialDao::get_gallery_material(&conn, &material_id).map_err(|e| e.to_string()) } /// 按图片分类获取素材列表 @@ -86,17 +86,21 @@ pub async fn get_poster_material( /// - `category`: 可选的图片分类 /// /// # 返回 -/// - 成功返回海报素材列表 +/// - 成功返回图库素材列表 /// - 失败返回错误信息 #[tauri::command] -pub async fn list_by_image_category( +pub async fn list_gallery_materials_by_image_category( db: State<'_, DbConnection>, project_id: String, category: Option, -) -> Result, String> { +) -> Result, String> { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - PosterMaterialDao::list_by_image_category(&conn, &project_id, category.as_deref()) - .map_err(|e| e.to_string()) + GalleryMaterialDao::list_gallery_materials_by_image_category( + &conn, + &project_id, + category.as_deref(), + ) + .map_err(|e| e.to_string()) } /// 按布局分类获取素材列表 @@ -109,17 +113,21 @@ pub async fn list_by_image_category( /// - `category`: 可选的布局分类 /// /// # 返回 -/// - 成功返回海报素材列表 +/// - 成功返回图库素材列表 /// - 失败返回错误信息 #[tauri::command] -pub async fn list_by_layout_category( +pub async fn list_gallery_materials_by_layout_category( db: State<'_, DbConnection>, project_id: String, category: Option, -) -> Result, String> { +) -> Result, String> { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - PosterMaterialDao::list_by_layout_category(&conn, &project_id, category.as_deref()) - .map_err(|e| e.to_string()) + GalleryMaterialDao::list_gallery_materials_by_layout_category( + &conn, + &project_id, + category.as_deref(), + ) + .map_err(|e| e.to_string()) } /// 按配色氛围获取素材列表 @@ -132,21 +140,22 @@ pub async fn list_by_layout_category( /// - `mood`: 可选的配色氛围 /// /// # 返回 -/// - 成功返回海报素材列表 +/// - 成功返回图库素材列表 /// - 失败返回错误信息 #[tauri::command] -pub async fn list_by_mood( +pub async fn list_gallery_materials_by_mood( db: State<'_, DbConnection>, project_id: String, mood: Option, -) -> Result, String> { +) -> Result, String> { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - PosterMaterialDao::list_by_mood(&conn, &project_id, mood.as_deref()).map_err(|e| e.to_string()) + GalleryMaterialDao::list_gallery_materials_by_mood(&conn, &project_id, mood.as_deref()) + .map_err(|e| e.to_string()) } -/// 更新海报素材元数据 +/// 更新图库素材元数据 /// -/// 更新指定素材的海报元数据。如果元数据不存在,则创建新的。 +/// 更新指定素材的图库元数据。如果元数据不存在,则创建新的。 /// /// # 参数 /// - `db`: 数据库连接状态 @@ -157,18 +166,18 @@ pub async fn list_by_mood( /// - 成功返回更新后的元数据 /// - 失败返回错误信息 #[tauri::command] -pub async fn update_poster_metadata( +pub async fn update_gallery_material_metadata( db: State<'_, DbConnection>, material_id: String, - req: CreatePosterMetadataRequest, -) -> Result { + req: CreateGalleryMaterialMetadataRequest, +) -> Result { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - PosterMaterialDao::update(&conn, &material_id, &req).map_err(|e| e.to_string()) + GalleryMaterialDao::update(&conn, &material_id, &req).map_err(|e| e.to_string()) } -/// 删除海报素材元数据 +/// 删除图库素材元数据 /// -/// 删除指定素材的海报元数据。 +/// 删除指定素材的图库元数据。 /// 注意:这只删除元数据,不删除基础素材。 /// /// # 参数 @@ -179,10 +188,10 @@ pub async fn update_poster_metadata( /// - 成功返回 () /// - 失败返回错误信息 #[tauri::command] -pub async fn delete_poster_metadata( +pub async fn delete_gallery_material_metadata( db: State<'_, DbConnection>, material_id: String, ) -> Result<(), String> { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - PosterMaterialDao::delete(&conn, &material_id).map_err(|e| e.to_string()) + GalleryMaterialDao::delete(&conn, &material_id).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/mcp_cmd.rs b/src-tauri/src/commands/mcp_cmd.rs index 1132304ae..d7c0d25f8 100644 --- a/src-tauri/src/commands/mcp_cmd.rs +++ b/src-tauri/src/commands/mcp_cmd.rs @@ -44,9 +44,62 @@ use crate::mcp::{ }; use crate::models::mcp_model::McpServer; use lime_services::mcp_service::McpService; -use tauri::State; +use serde::Serialize; +use tauri::{AppHandle, Emitter, State}; use tracing::{debug, error, info, Instrument}; +#[derive(Debug, Serialize)] +struct McpServerStartedPayload { + server_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + server_info: Option, +} + +#[derive(Debug, Serialize)] +struct McpServerStoppedPayload { + server_name: String, +} + +#[derive(Debug, Serialize)] +struct McpServerErrorPayload { + server_name: String, + error: String, +} + +#[derive(Debug, Serialize)] +struct McpServerCapabilitiesPayload { + name: String, + version: String, + supports_tools: bool, + supports_prompts: bool, + supports_resources: bool, +} + +#[derive(Debug, Serialize)] +struct McpToolsUpdatedPayload { + tools: Vec, +} + +fn emit_mcp_event(app: &AppHandle, event_name: &str, payload: &T) +where + T: Serialize, +{ + if let Err(error) = app.emit(event_name, payload) { + error!(event_name, error = %error, "发送 MCP 事件失败"); + } +} + +fn emit_mcp_server_error(app: &AppHandle, server_name: &str, message: &str) { + emit_mcp_event( + app, + "mcp:server_error", + &McpServerErrorPayload { + server_name: server_name.to_string(), + error: message.to_string(), + }, + ); +} + #[tauri::command] pub fn get_mcp_servers(db: State<'_, DbConnection>) -> Result, String> { McpService::get_all(&db) @@ -179,6 +232,7 @@ pub async fn mcp_list_servers_with_status( )] #[tauri::command] pub async fn mcp_start_server( + app: AppHandle, db: State<'_, DbConnection>, mcp_manager: State<'_, McpManagerState>, name: String, @@ -200,11 +254,35 @@ pub async fn mcp_start_server( .instrument(tracing::debug_span!("mcp_start_server.acquire_manager")) .await; manager.start_server(&name, &config).await.map_err(|e| { - error!(server_name = %name, error = %e, "启动 MCP 服务器失败"); - e.to_string() + let message = e.to_string(); + error!(server_name = %name, error = %message, "启动 MCP 服务器失败"); + emit_mcp_server_error(&app, &name, &message); + message })?; + let server_info = + manager + .get_client_capabilities(&name) + .await + .map(|info| McpServerCapabilitiesPayload { + name: info.name, + version: info.version, + supports_tools: info.supports_tools, + supports_prompts: info.supports_prompts, + supports_resources: info.supports_resources, + }); + let tools = manager.list_tools().await.unwrap_or_default(); + info!(server_name = %name, "MCP 服务器启动成功"); + emit_mcp_event( + &app, + "mcp:server_started", + &McpServerStartedPayload { + server_name: name.clone(), + server_info, + }, + ); + emit_mcp_event(&app, "mcp:tools_updated", &McpToolsUpdatedPayload { tools }); Ok(()) } @@ -227,6 +305,7 @@ pub async fn mcp_start_server( /// - **9.3**: THE mcp_stop_server command SHALL stop a specified MCP server #[tauri::command] pub async fn mcp_stop_server( + app: AppHandle, mcp_manager: State<'_, McpManagerState>, name: String, ) -> Result<(), String> { @@ -235,11 +314,20 @@ pub async fn mcp_stop_server( // 获取管理器锁并停止服务器 let manager = mcp_manager.lock().await; manager.stop_server(&name).await.map_err(|e| { - error!(server_name = %name, error = %e, "停止 MCP 服务器失败"); - e.to_string() + let message = e.to_string(); + error!(server_name = %name, error = %message, "停止 MCP 服务器失败"); + emit_mcp_server_error(&app, &name, &message); + message })?; + let tools = manager.list_tools().await.unwrap_or_default(); info!(server_name = %name, "MCP 服务器已停止"); + emit_mcp_event( + &app, + "mcp:server_stopped", + &McpServerStoppedPayload { server_name: name }, + ); + emit_mcp_event(&app, "mcp:tools_updated", &McpToolsUpdatedPayload { tools }); Ok(()) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6873e360c..391d632bf 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod ecommerce_review_reply_cmd; pub mod execution_run_cmd; pub mod external_tools_cmd; pub mod file_upload_cmd; +pub mod gallery_material_cmd; pub mod gateway_channel_cmd; pub mod gateway_tunnel_cmd; pub mod image_search_cmd; @@ -37,8 +38,6 @@ pub mod memory_search_cmd; pub mod model_cmd; pub mod model_registry_cmd; pub mod models_cmd; -pub mod music_cmd; -pub mod novel_cmd; pub mod oauth_cmd; pub mod openclaw_cmd; pub mod orchestrator_cmd; @@ -46,7 +45,6 @@ pub mod persona_cmd; pub mod plugin_cmd; pub mod plugin_install_cmd; pub mod plugin_rpc_cmd; -pub mod poster_material_cmd; pub mod prompt_cmd; pub mod provider_pool_cmd; pub mod resilience_cmd; diff --git a/src-tauri/src/commands/music_cmd.rs b/src-tauri/src/commands/music_cmd.rs deleted file mode 100644 index 2ebea1b81..000000000 --- a/src-tauri/src/commands/music_cmd.rs +++ /dev/null @@ -1,270 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::process::Command; - -/// MIDI 分析结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MidiAnalysisResult { - /// 调式信息 - pub mode: String, - /// BPM (每分钟节拍数) - pub bpm: f64, - /// 拍号 - pub time_signature: String, - /// 音轨信息 - pub tracks: Vec, - /// 旋律特征 - pub melody_features: MelodyFeatures, -} - -/// 音轨信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrackInfo { - /// 音轨索引 - pub index: usize, - /// 音轨名称 - pub name: String, - /// 乐器名称 - pub instrument: String, - /// 音符数量 - pub note_count: usize, - /// 是否为人声音轨 - pub is_vocal: bool, -} - -/// 旋律特征 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MelodyFeatures { - /// 音域范围 (半音数) - pub range: i32, - /// 平均音高 - pub avg_pitch: f64, - /// 音程跳跃频率 - pub interval_jumps: f64, - /// 节奏复杂度 - pub rhythm_complexity: f64, -} - -/// Python 环境检测结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PythonEnvInfo { - /// 是否已安装 Python - pub python_installed: bool, - /// Python 版本 - pub python_version: Option, - /// 缺失的依赖包 - pub missing_packages: Vec, -} - -fn python_candidates() -> &'static [&'static str] { - #[cfg(target_os = "windows")] - { - &["python", "py", "python3"] - } - - #[cfg(not(target_os = "windows"))] - { - &["python3", "python"] - } -} - -fn detect_python_command() -> Option { - for candidate in python_candidates() { - if let Ok(output) = Command::new(candidate).arg("--version").output() { - if output.status.success() { - return Some((*candidate).to_string()); - } - } - } - None -} - -fn extract_python_version(output: &std::process::Output) -> String { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !stdout.is_empty() { - return stdout; - } - String::from_utf8_lossy(&output.stderr).trim().to_string() -} - -/// 检查 Python 环境 -#[tauri::command] -pub async fn check_python_env() -> Result { - let python_command = detect_python_command(); - let (python_installed, python_version, python_cmd) = match python_command { - Some(cmd) => { - let output = Command::new(&cmd).arg("--version").output(); - match output { - Ok(output) => (true, Some(extract_python_version(&output)), cmd), - Err(_) => (true, None, cmd), - } - } - None => { - return Ok(PythonEnvInfo { - python_installed: false, - python_version: None, - missing_packages: vec![], - }); - } - }; - - // 检查必需的 Python 包 - let required_packages = vec!["mido", "music21", "numpy", "demucs", "basic-pitch"]; - let mut missing_packages = Vec::new(); - - for package in required_packages { - let check = Command::new(&python_cmd) - .arg("-c") - .arg(format!("import {}", package.replace("-", "_"))) - .output(); - - if check.is_err() || !check.unwrap().status.success() { - missing_packages.push(package.to_string()); - } - } - - Ok(PythonEnvInfo { - python_installed, - python_version, - missing_packages, - }) -} - -/// 分析 MIDI 文件 -#[tauri::command] -pub async fn analyze_midi(midi_path: String) -> Result { - // 获取 Python 脚本路径 - let script_path = get_resource_path("scripts/midi_analyzer.py")?; - let python_cmd = detect_python_command() - .ok_or_else(|| "Python is not installed or not found in PATH".to_string())?; - - // 调用 Python 脚本 - let output = Command::new(&python_cmd) - .arg(&script_path) - .arg(&midi_path) - .output() - .map_err(|e| format!("Failed to execute Python script: {e}"))?; - - if !output.status.success() { - let error = String::from_utf8_lossy(&output.stderr); - return Err(format!("MIDI analysis failed: {error}")); - } - - // 解析 JSON 输出 - let result_json = String::from_utf8_lossy(&output.stdout); - serde_json::from_str(&result_json).map_err(|e| format!("Failed to parse analysis result: {e}")) -} - -/// 将 MP3 转换为 MIDI -#[tauri::command] -pub async fn convert_mp3_to_midi(mp3_path: String, output_path: String) -> Result { - // 获取 Python 脚本路径 - let script_path = get_resource_path("scripts/audio_to_midi.py")?; - let python_cmd = detect_python_command() - .ok_or_else(|| "Python is not installed or not found in PATH".to_string())?; - - // 调用 Python 脚本 - let output = Command::new(&python_cmd) - .arg(&script_path) - .arg(&mp3_path) - .arg(&output_path) - .output() - .map_err(|e| format!("Failed to execute Python script: {e}"))?; - - if !output.status.success() { - let error = String::from_utf8_lossy(&output.stderr); - return Err(format!("MP3 to MIDI conversion failed: {error}")); - } - - Ok(output_path) -} - -/// 加载资源文件 -#[tauri::command] -pub async fn load_music_resource(resource_name: String) -> Result { - let resource_path = get_resource_path(&format!("music/{resource_name}"))?; - - std::fs::read_to_string(&resource_path) - .map_err(|e| format!("Failed to read resource file: {e}")) -} - -/// 获取资源文件路径 -fn get_resource_path(relative_path: &str) -> Result { - // 在开发环境中,资源文件在 src-tauri/resources/ - // 在生产环境中,资源文件会被打包到应用程序包中 - let mut path = - std::env::current_exe().map_err(|e| format!("Failed to get executable path: {e}"))?; - - path.pop(); // 移除可执行文件名 - - #[cfg(target_os = "macos")] - { - // macOS: 资源在 .app/Contents/Resources/ - path.pop(); // 移除 MacOS - path.push("Resources"); - } - - #[cfg(not(target_os = "macos"))] - { - // Windows/Linux: 资源在可执行文件同级目录 - path.push("resources"); - } - - path.push(relative_path); - - if !path.exists() { - // 尝试开发环境路径 - let dev_path = PathBuf::from("src-tauri/resources").join(relative_path); - if dev_path.exists() { - return Ok(dev_path); - } - return Err(format!("Resource not found: {relative_path}")); - } - - Ok(path) -} - -/// 安装 Python 依赖 -#[tauri::command] -pub async fn install_python_dependencies() -> Result { - let packages = vec!["mido", "music21", "numpy", "demucs", "basic-pitch"]; - let python_cmd = detect_python_command() - .ok_or_else(|| "Python is not installed or not found in PATH".to_string())?; - - let output = Command::new(&python_cmd) - .arg("-m") - .arg("pip") - .arg("install") - .args(&packages) - .output() - .map_err(|e| format!("Failed to install packages: {e}"))?; - - if !output.status.success() { - let error = String::from_utf8_lossy(&output.stderr); - return Err(format!("Installation failed: {error}")); - } - - Ok("Dependencies installed successfully".to_string()) -} - -#[cfg(test)] -mod tests { - use super::python_candidates; - - #[test] - fn python_candidates_should_not_be_empty() { - assert!(!python_candidates().is_empty()); - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_python_candidates_should_prioritize_python() { - assert_eq!(python_candidates().first().copied(), Some("python")); - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn unix_python_candidates_should_prioritize_python3() { - assert_eq!(python_candidates().first().copied(), Some("python3")); - } -} diff --git a/src-tauri/src/commands/novel_cmd.rs b/src-tauri/src/commands/novel_cmd.rs deleted file mode 100644 index 97534f178..000000000 --- a/src-tauri/src/commands/novel_cmd.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! 小说创作命令 -//! -//! 兼容层:对外保持 tauri command 名称不变,内部转发到主题模块实现。 - -use crate::database::DbConnection; -use crate::services::novel_service::{ - NovelCheckConsistencyRequest, NovelCreateProjectRequest, NovelDeleteCharacterRequest, - NovelGenerateChapterRequest, NovelGenerateRequest, NovelGenerateResult, NovelGenerationRun, - NovelListRunsRequest, NovelPolishChapterRequest, NovelProject, NovelProjectSnapshot, - NovelRewriteChapterRequest, NovelSettingsRecord, NovelUpdateSettingsRequest, -}; -use tauri::State; - -/// 创建小说项目 -#[tauri::command] -pub async fn novel_create_project( - db: State<'_, DbConnection>, - request: NovelCreateProjectRequest, -) -> Result { - crate::theme::novel::command::novel_create_project(db, request).await -} - -/// 更新小说设定(自动版本递增) -#[tauri::command] -pub async fn novel_update_settings( - db: State<'_, DbConnection>, - request: NovelUpdateSettingsRequest, -) -> Result { - crate::theme::novel::command::novel_update_settings(db, request).await -} - -/// 生成小说大纲 -#[tauri::command] -pub async fn novel_generate_outline( - db: State<'_, DbConnection>, - request: NovelGenerateRequest, -) -> Result { - crate::theme::novel::command::novel_generate_outline(db, request).await -} - -/// 生成角色卡 -#[tauri::command] -pub async fn novel_generate_characters( - db: State<'_, DbConnection>, - request: NovelGenerateRequest, -) -> Result { - crate::theme::novel::command::novel_generate_characters(db, request).await -} - -/// 生成章节 -#[tauri::command] -pub async fn novel_generate_chapter( - db: State<'_, DbConnection>, - request: NovelGenerateChapterRequest, -) -> Result { - crate::theme::novel::command::novel_generate_chapter(db, request).await -} - -/// 续写下一章 -#[tauri::command] -pub async fn novel_continue_chapter( - db: State<'_, DbConnection>, - request: NovelGenerateRequest, -) -> Result { - crate::theme::novel::command::novel_continue_chapter(db, request).await -} - -/// 重写章节 -#[tauri::command] -pub async fn novel_rewrite_chapter( - db: State<'_, DbConnection>, - request: NovelRewriteChapterRequest, -) -> Result { - crate::theme::novel::command::novel_rewrite_chapter(db, request).await -} - -/// 润色章节 -#[tauri::command] -pub async fn novel_polish_chapter( - db: State<'_, DbConnection>, - request: NovelPolishChapterRequest, -) -> Result { - crate::theme::novel::command::novel_polish_chapter(db, request).await -} - -/// 章节一致性检查 -#[tauri::command] -pub async fn novel_check_consistency( - db: State<'_, DbConnection>, - request: NovelCheckConsistencyRequest, -) -> Result { - crate::theme::novel::command::novel_check_consistency(db, request).await -} - -/// 获取项目完整快照 -#[tauri::command] -pub async fn novel_get_project_snapshot( - db: State<'_, DbConnection>, - project_id: String, -) -> Result { - crate::theme::novel::command::novel_get_project_snapshot(db, project_id).await -} - -/// 获取生成运行记录 -#[tauri::command] -pub async fn novel_list_runs( - db: State<'_, DbConnection>, - request: NovelListRunsRequest, -) -> Result, String> { - crate::theme::novel::command::novel_list_runs(db, request).await -} - -/// 删除单个角色 -#[tauri::command] -pub async fn novel_delete_character( - db: State<'_, DbConnection>, - request: NovelDeleteCharacterRequest, -) -> Result { - crate::theme::novel::command::novel_delete_character(db, request).await -} diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index e38fb92a8..4a368f8b8 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -40,7 +40,7 @@ use serde_json::{json, Value}; use std::collections::{HashMap, HashSet, VecDeque}; use std::ffi::OsString; use std::path::{Path, PathBuf}; -use std::process::{Child, Command}; +use std::process::{Child, Command, Stdio}; use std::sync::Arc; use sysinfo::{Pid, Signal, System}; use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; @@ -3167,36 +3167,78 @@ fn spawn_chrome_with_profile( extension_dir: Option<&Path>, launch_options: &ChromeProfileLaunchOptions, ) -> Result { - let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy()); let mut cmd = Command::new(browser_path); - cmd.arg(profile_arg) - .arg(format!("--remote-debugging-port={remote_debugging_port}")) - .arg("--remote-allow-origins=*") - .arg("--no-first-run") - .arg("--no-default-browser-check"); + cmd.args(build_chrome_launch_args( + profile_dir, + remote_debugging_port, + url, + new_window, + extension_dir, + launch_options, + )); + + if should_silence_chrome_child_logs(launch_options) { + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + + cmd.spawn().map_err(|e| format!("启动 Chrome 失败: {e}")) +} + +fn should_silence_chrome_child_logs(launch_options: &ChromeProfileLaunchOptions) -> bool { + launch_options.headless +} + +fn build_chrome_launch_args( + profile_dir: &Path, + remote_debugging_port: u16, + url: &str, + new_window: bool, + extension_dir: Option<&Path>, + launch_options: &ChromeProfileLaunchOptions, +) -> Vec { + let mut args = vec![ + OsString::from(format!("--user-data-dir={}", profile_dir.to_string_lossy())), + OsString::from(format!("--remote-debugging-port={remote_debugging_port}")), + OsString::from("--remote-allow-origins=*"), + OsString::from("--no-first-run"), + OsString::from("--no-default-browser-check"), + // 托管浏览器不需要参与 Chrome 自身的后台更新 / 崩溃上报 / 同步链路。 + OsString::from("--disable-background-networking"), + OsString::from("--disable-component-update"), + OsString::from("--disable-breakpad"), + OsString::from("--disable-sync"), + OsString::from("--disable-default-apps"), + OsString::from("--metrics-recording-only"), + OsString::from("--no-service-autorun"), + ]; if let Some(proxy_server) = launch_options.proxy_server.as_deref() { - cmd.arg(format!("--proxy-server={proxy_server}")); + args.push(OsString::from(format!("--proxy-server={proxy_server}"))); } if let Some(language) = launch_options.language.as_deref() { - cmd.arg(format!("--lang={language}")); + args.push(OsString::from(format!("--lang={language}"))); } if launch_options.headless { - cmd.arg("--headless=new").arg("--disable-gpu"); + args.push(OsString::from("--headless=new")); + args.push(OsString::from("--disable-gpu")); } if !launch_options.headless { if let Some(ext_dir) = extension_dir { - cmd.arg(format!("--load-extension={}", ext_dir.to_string_lossy())); + args.push(OsString::from(format!( + "--load-extension={}", + ext_dir.to_string_lossy() + ))); } } if new_window && !launch_options.headless { - cmd.arg("--new-window"); + args.push(OsString::from("--new-window")); } - cmd.arg(url); - cmd.spawn().map_err(|e| format!("启动 Chrome 失败: {e}")) + + args.push(OsString::from(url)); + args } fn chrome_process_uses_profile_dir(args: &[OsString], profile_dir: &Path) -> bool { @@ -3832,6 +3874,69 @@ mod tests { assert!(cdp_backend_capabilities().contains(&"find".to_string())); } + #[test] + fn build_chrome_launch_args_should_include_background_noise_reduction_flags() { + let args = build_chrome_launch_args( + Path::new("/tmp/lime-profile"), + 9222, + "about:blank", + false, + None, + &ChromeProfileLaunchOptions::default(), + ); + let args = args + .iter() + .map(|value| value.to_string_lossy().to_string()) + .collect::>(); + + assert!(args.contains(&"--disable-background-networking".to_string())); + assert!(args.contains(&"--disable-component-update".to_string())); + assert!(args.contains(&"--disable-breakpad".to_string())); + assert!(args.contains(&"--disable-sync".to_string())); + assert!(args.contains(&"--disable-default-apps".to_string())); + assert!(args.contains(&"--metrics-recording-only".to_string())); + assert!(args.contains(&"--no-service-autorun".to_string())); + } + + #[test] + fn build_chrome_launch_args_should_skip_extension_and_force_headless_flags() { + let args = build_chrome_launch_args( + Path::new("/tmp/lime-profile"), + 9222, + "https://example.com", + true, + Some(Path::new("/tmp/lime-extension")), + &ChromeProfileLaunchOptions { + headless: true, + ..ChromeProfileLaunchOptions::default() + }, + ); + let args = args + .iter() + .map(|value| value.to_string_lossy().to_string()) + .collect::>(); + + assert!(args.contains(&"--headless=new".to_string())); + assert!(args.contains(&"--disable-gpu".to_string())); + assert!(!args + .iter() + .any(|value| value.starts_with("--load-extension="))); + assert!(!args.contains(&"--new-window".to_string())); + } + + #[test] + fn should_silence_chrome_child_logs_should_only_enable_for_headless() { + assert!(should_silence_chrome_child_logs( + &ChromeProfileLaunchOptions { + headless: true, + ..ChromeProfileLaunchOptions::default() + } + )); + assert!(!should_silence_chrome_child_logs( + &ChromeProfileLaunchOptions::default() + )); + } + #[test] fn bridge_result_to_value_should_include_data_payload() { let value = bridge_result_to_value(ChromeBridgeCommandResult { diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs index 7bfadf7b5..f2b43bad5 100644 --- a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs +++ b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs @@ -301,10 +301,12 @@ pub(super) async fn try_handle( crate::commands::aster_agent_cmd::AgentRuntimeRespondActionRequest, >(args)?; let aster_state = app_handle.state::(); + let db = app_handle.state::(); crate::commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action( app_handle.clone(), aster_state, + db, request, ) .await?; diff --git a/src-tauri/src/services/README.md b/src-tauri/src/services/README.md index e514c3ebc..2c45cdfa2 100644 --- a/src-tauri/src/services/README.md +++ b/src-tauri/src/services/README.md @@ -10,7 +10,6 @@ ## 文件索引 - `mod.rs` - 模块入口 -- `novel_service.rs` - 小说编排服务(项目/设定/章节生成/一致性检查) - `site_adapter_import_service.rs` - 外部适配器来源导入与 Lime 标准编译层 - `provider_pool_service.rs` - Provider 凭证池服务(多凭证轮询) - `token_cache_service.rs` - Token 缓存服务 diff --git a/src-tauri/src/services/artifact_document_service.rs b/src-tauri/src/services/artifact_document_service.rs index 445176beb..18abf075c 100644 --- a/src-tauri/src/services/artifact_document_service.rs +++ b/src-tauri/src/services/artifact_document_service.rs @@ -160,14 +160,15 @@ pub fn persist_artifact_document_from_text( .and_then(|relative_path| { read_existing_artifact_document(¶ms.workspace_root, relative_path) }); - let mut outcome = if let Some(ops_value) = - crate::services::artifact_ops_service::extract_artifact_ops_candidate(raw_text) + let mut outcome = if let Some(operation_value) = + crate::services::artifact_ops_service::extract_artifact_operation_candidate(raw_text) { - let applied = crate::services::artifact_ops_service::apply_artifact_ops_to_document( - existing_document.as_ref(), - &ops_value, - &validation_context, - ); + let applied = + crate::services::artifact_ops_service::apply_artifact_operation_candidate_to_document( + existing_document.as_ref(), + &operation_value, + &validation_context, + ); operation_issues = applied.issues; validate_or_repair_artifact_document_value(&applied.document, raw_text, &validation_context) } else { @@ -483,30 +484,75 @@ fn normalize_text(value: Option<&str>) -> Option { .map(ToString::to_string) } -fn extract_block_text(block: &Map) -> Option { - normalize_text(block.get("markdown").and_then(Value::as_str)) - .or_else(|| normalize_text(block.get("text").and_then(Value::as_str))) - .or_else(|| normalize_text(block.get("content").and_then(Value::as_str))) - .or_else(|| normalize_text(block.get("summary").and_then(Value::as_str))) - .or_else(|| { - block.get("items").and_then(Value::as_array).map(|items| { - items - .iter() - .filter_map(|item| { - if let Some(text) = item.as_str() { - return normalize_text(Some(text)); - } +fn extract_portable_text(value: &Value) -> Option { + match value { + Value::String(text) => normalize_text(Some(text)), + Value::Array(items) => { + let parts = items + .iter() + .filter_map(extract_portable_text) + .collect::>(); + if parts.is_empty() { + None + } else { + normalize_text(Some(parts.join("\n").as_str())) + } + } + Value::Object(record) => { + if let Some(text) = record.get("text").and_then(Value::as_str) { + return normalize_text(Some(text)); + } + if let Some(content) = record.get("content").and_then(extract_portable_text) { + return Some(content); + } + None + } + _ => None, + } +} - let item = item.as_object()?; - normalize_text(item.get("label").and_then(Value::as_str)) - .or_else(|| normalize_text(item.get("text").and_then(Value::as_str))) - .or_else(|| normalize_text(item.get("title").and_then(Value::as_str))) - }) - .collect::>() - .join("\n") - }) +fn extract_block_text(block: &Map) -> Option { + (if block + .get("contentFormat") + .and_then(Value::as_str) + .map(str::trim) + == Some("markdown") + { + block + .get("content") + .and_then(Value::as_str) + .and_then(|text| normalize_text(Some(text))) + } else { + None + }) + .or_else(|| normalize_text(block.get("markdown").and_then(Value::as_str))) + .or_else(|| normalize_text(block.get("body").and_then(Value::as_str))) + .or_else(|| normalize_text(block.get("text").and_then(Value::as_str))) + .or_else(|| normalize_text(block.get("content").and_then(Value::as_str))) + .or_else(|| block.get("content").and_then(extract_portable_text)) + .or_else(|| normalize_text(block.get("summary").and_then(Value::as_str))) + .or_else(|| normalize_text(block.get("attribution").and_then(Value::as_str))) + .or_else(|| { + block.get("items").and_then(Value::as_array).map(|items| { + items + .iter() + .filter_map(|item| { + if let Some(text) = item.as_str() { + return normalize_text(Some(text)); + } + + let item = item.as_object()?; + normalize_text(item.get("label").and_then(Value::as_str)) + .or_else(|| normalize_text(item.get("text").and_then(Value::as_str))) + .or_else(|| normalize_text(item.get("title").and_then(Value::as_str))) + .or_else(|| normalize_text(item.get("value").and_then(Value::as_str))) + .or_else(|| normalize_text(item.get("note").and_then(Value::as_str))) + }) + .collect::>() + .join("\n") }) - .and_then(|value| normalize_text(Some(value.as_str()))) + }) + .and_then(|value| normalize_text(Some(value.as_str()))) } fn build_content_body_from_document(document: &Value) -> String { @@ -854,6 +900,15 @@ fn enrich_document_with_history( } fn infer_source_type(source: &Map) -> String { + if let Some(source_type) = source + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return source_type.to_string(); + } + if let Some(kind) = source .get("kind") .and_then(Value::as_str) @@ -863,6 +918,45 @@ fn infer_source_type(source: &Map) -> String { return kind.to_string(); } + if let Some(locator) = source.get("locator").and_then(Value::as_object) { + if locator + .get("toolCallId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + return "tool".to_string(); + } + if locator + .get("messageId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + return "message".to_string(); + } + if locator + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + return "file".to_string(); + } + if locator + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + return "web".to_string(); + } + } + if let Some(url) = source .get("url") .and_then(Value::as_str) @@ -880,6 +974,58 @@ fn infer_source_type(source: &Map) -> String { "unknown".to_string() } +fn resolve_source_ref(source_id: &str, source: &Map) -> String { + if let Some(locator) = source.get("locator").and_then(Value::as_object) { + for key in ["url", "path", "toolCallId", "messageId"] { + if let Some(value) = locator + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return value.to_string(); + } + } + } + + source + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(source_id) + .to_string() +} + +fn resolve_source_link_locator(source: &Map) -> Option { + if let Some(locator) = source.get("locator").and_then(Value::as_object) { + if let Some(url) = locator + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(Value::String(url.to_string())); + } + if let Some(path) = locator + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(Value::String(path.to_string())); + } + return Some(Value::Object(locator.clone())); + } + + source + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| Value::String(value.to_string())) +} + fn derive_source_links_from_document(document: &Value) -> Vec> { let Some(record) = document.as_object() else { return Vec::new(); @@ -952,33 +1098,25 @@ fn derive_source_links_from_document(document: &Value) -> Vec ); link.insert( "sourceRef".to_string(), - Value::String( + Value::String(resolve_source_ref(source_id, source)), + ); + if let Some(label) = source + .get("label") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { source - .get("url") + .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .unwrap_or(source_id) - .to_string(), - ), - ); - if let Some(label) = source - .get("title") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) + }) { link.insert("label".to_string(), Value::String(label.to_string())); } - if let Some(locator) = source.get("locator") { - link.insert("locator".to_string(), locator.clone()); - } else if let Some(url) = source - .get("url") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - link.insert("locator".to_string(), Value::String(url.to_string())); + if let Some(locator) = resolve_source_link_locator(source) { + link.insert("locator".to_string(), locator); } links.push(link); } @@ -1499,6 +1637,164 @@ mod tests { ); } + #[test] + fn persist_artifact_document_from_text_should_accept_current_incremental_block_upsert() { + let params = build_params(); + let first = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "summary": "第一版摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" }, + { "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" } + ], + "sources": [], + "metadata": {} + }) + .to_string(); + let current_op = serde_json::json!({ + "type": "artifact.block.upsert", + "artifactId": "artifact-document:artifact:analysis:demo", + "block": { + "id": "body-1", + "type": "rich_text", + "contentFormat": "markdown", + "content": "通过正式单条 op 更新后的正文" + } + }) + .to_string(); + + let persisted_first = + persist_artifact_document_from_text(&first, ¶ms).expect("first persist"); + let persisted_second = + persist_artifact_document_from_text(¤t_op, ¶ms).expect("op persist"); + + assert_eq!(persisted_first.current_version_no, 1); + assert_eq!(persisted_second.current_version_no, 2); + assert!(persisted_second + .serialized_document + .contains("通过正式单条 op 更新后的正文")); + } + + #[test] + fn persist_artifact_document_from_text_should_accept_current_incremental_block_upsert_in_rewrite_mode( + ) { + let mut params = build_params(); + params.request_metadata = Some(serde_json::json!({ + "artifact_mode": "rewrite", + "artifact_stage": "rewrite", + "artifact_kind": "analysis", + "source_policy": "required", + "artifact_request_id": "artifact:analysis:demo", + "artifact_target_block_id": "body-1", + "theme": "knowledge" + })); + + let first = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "summary": "第一版摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" }, + { "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 1", "sourceIds": ["source-1"] }, + { "id": "body-2", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 2" } + ], + "sources": [ + { "id": "source-1", "type": "web", "label": "OpenAI", "locator": { "url": "https://openai.com" } } + ], + "metadata": {} + }) + .to_string(); + let current_op = serde_json::json!({ + "type": "artifact.block.upsert", + "artifactId": "artifact-document:artifact:analysis:demo", + "block": { + "id": "body-1", + "type": "rich_text", + "contentFormat": "markdown", + "content": "rewrite current op 改写后的正文", + "sourceIds": ["source-1"] + } + }) + .to_string(); + + let persisted_first = + persist_artifact_document_from_text(&first, ¶ms).expect("first persist"); + let persisted_second = + persist_artifact_document_from_text(¤t_op, ¶ms).expect("op persist"); + + assert_eq!(persisted_first.current_version_no, 1); + assert_eq!(persisted_second.current_version_no, 2); + assert!(persisted_second + .serialized_document + .contains("rewrite current op 改写后的正文")); + assert!(persisted_second.serialized_document.contains("source-1")); + assert!(!persisted_second + .issues + .iter() + .any(|issue| issue.contains("非目标 block"))); + } + + #[test] + fn persist_artifact_document_from_text_should_restrict_current_incremental_rewrite_to_target_block( + ) { + let mut params = build_params(); + params.request_metadata = Some(serde_json::json!({ + "artifact_mode": "rewrite", + "artifact_stage": "rewrite", + "artifact_kind": "analysis", + "source_policy": "required", + "artifact_request_id": "artifact:analysis:demo", + "artifact_target_block_id": "body-1", + "theme": "knowledge" + })); + + let first = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "summary": "第一版摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" }, + { "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 1" }, + { "id": "body-2", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 2" } + ], + "sources": [], + "metadata": {} + }) + .to_string(); + let current_op = serde_json::json!({ + "type": "artifact.block.upsert", + "artifactId": "artifact-document:artifact:analysis:demo", + "block": { + "id": "body-2", + "type": "rich_text", + "contentFormat": "markdown", + "content": "不应被应用" + } + }) + .to_string(); + + let persisted_first = + persist_artifact_document_from_text(&first, ¶ms).expect("first persist"); + let persisted_second = + persist_artifact_document_from_text(¤t_op, ¶ms).expect("op persist"); + + assert_eq!(persisted_first.current_version_no, 1); + assert_eq!(persisted_second.current_version_no, 2); + assert!(persisted_second.serialized_document.contains("旧正文 2")); + assert!(!persisted_second.serialized_document.contains("不应被应用")); + assert!(persisted_second + .issues + .iter() + .any(|issue| issue.contains("非目标 block `body-2`"))); + } + #[test] fn persist_artifact_document_from_text_should_restrict_rewrite_to_target_block() { let mut params = build_params(); @@ -1642,4 +1938,77 @@ mod tests { .and_then(Value::as_str) .is_some_and(|summary| summary.contains("typed patch"))); } + + #[test] + fn persist_artifact_document_from_text_should_accept_current_shaped_typed_rewrite_patch() { + let mut params = build_params(); + params.request_metadata = Some(serde_json::json!({ + "artifact_mode": "rewrite", + "artifact_stage": "rewrite", + "artifact_kind": "analysis", + "source_policy": "required", + "artifact_request_id": "artifact:analysis:demo", + "artifact_target_block_id": "body-1", + "theme": "knowledge" + })); + + let first = serde_json::json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "kind": "analysis", + "title": "结构化结论", + "status": "ready", + "summary": "第一版摘要", + "blocks": [ + { "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" }, + { "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 1", "sourceIds": ["source-1"] } + ], + "sources": [ + { "id": "source-1", "type": "web", "label": "OpenAI", "locator": { "url": "https://openai.com" } } + ], + "metadata": {} + }) + .to_string(); + let rewrite_patch = serde_json::json!({ + "type": "artifact_rewrite_patch", + "artifactId": "artifact-document:artifact:analysis:demo", + "targetBlockId": "body-1", + "block": { + "id": "body-1", + "type": "rich_text", + "contentFormat": "markdown", + "content": "current shape typed patch 改写后的正文", + "sourceIds": ["source-2"] + }, + "source": { + "id": "source-2", + "type": "web", + "label": "Anthropic", + "locator": { + "url": "https://anthropic.com" + } + }, + "summary": "通过 current shape typed patch 改写正文" + }) + .to_string(); + + let persisted_first = + persist_artifact_document_from_text(&first, ¶ms).expect("first persist"); + let persisted_second = + persist_artifact_document_from_text(&rewrite_patch, ¶ms).expect("rewrite persist"); + + assert_eq!(persisted_first.current_version_no, 1); + assert_eq!(persisted_second.current_version_no, 2); + assert!(persisted_second + .serialized_document + .contains("current shape typed patch 改写后的正文")); + assert!(persisted_second.serialized_document.contains("source-2")); + assert!(persisted_second.serialized_document.contains("Anthropic")); + assert!(persisted_second + .snapshot_metadata + .get("artifactVersion") + .and_then(Value::as_object) + .and_then(|record| record.get("summary")) + .and_then(Value::as_str) + .is_some_and(|summary| summary.contains("current shape typed patch"))); + } } diff --git a/src-tauri/src/services/artifact_document_validator.rs b/src-tauri/src/services/artifact_document_validator.rs index 971a23287..4d4f6df77 100644 --- a/src-tauri/src/services/artifact_document_validator.rs +++ b/src-tauri/src/services/artifact_document_validator.rs @@ -33,7 +33,11 @@ const ARTIFACT_BLOCK_TYPE_VALUES: &[&str] = &[ "code_block", "divider", ]; +const ARTIFACT_SOURCE_TYPE_VALUES: &[&str] = &["web", "file", "tool", "message", "search_result"]; +const ARTIFACT_SOURCE_RELIABILITY_VALUES: &[&str] = &["primary", "secondary", "derived"]; const MAX_BLOCK_COUNT: usize = 40; +const MAX_METRIC_COUNT: usize = 8; +const MAX_SOURCE_SNIPPET_CHARS: usize = 280; const MARKDOWN_RECOVERY_REASON: &str = "模型未返回合法的 ArtifactDocument JSON,已按 Markdown 正文自动恢复为可渲染文档。"; const TRUNCATED_JSON_RECOVERY_REASON: &str = @@ -266,6 +270,7 @@ fn build_failed_fallback_document( merge_string_field(&mut metadata, "theme", context.theme.as_deref()); merge_string_field(&mut metadata, "generatedBy", Some("agent")); merge_source_run_binding(&mut metadata, context); + ensure_renderer_density(&mut metadata, "comfortable"); if let Some(request_id) = context.request_id.as_deref() { metadata.insert( "artifactRequestId".to_string(), @@ -304,11 +309,11 @@ fn build_failed_fallback_document( ), ( "blocks".to_string(), - Value::Array(vec![Value::Object(Map::from_iter([ - ("id".to_string(), Value::String("fallback-1".to_string())), - ("type".to_string(), Value::String("rich_text".to_string())), - ("markdown".to_string(), Value::String(fallback_markdown)), - ]))]), + Value::Array(vec![Value::Object(build_fallback_rich_text_block( + "fallback-1", + fallback_markdown.as_str(), + Some("failed_fallback"), + ))]), ), ("sources".to_string(), Value::Array(Vec::new())), ("metadata".to_string(), Value::Object(metadata)), @@ -575,27 +580,43 @@ fn normalize_sources( continue; } - let title = find_string(record, &["title", "label"]); - let url = find_string(record, &["url", "href", "link"]); - let note = find_string(record, &["note", "summary", "description"]); - let kind = find_string(record, &["kind", "type"]); - let quote = find_string(record, &["quote"]); - let published_at = find_string(record, &["publishedAt", "published_at"]); - - if title.is_none() && url.is_none() && note.is_none() && quote.is_none() { + let locator = normalize_source_locator(record); + let raw_label = find_string(record, &["label", "title"]); + let snippet = find_string( + record, + &["snippet", "note", "summary", "description", "quote"], + ) + .map(|text| truncate_text(text.as_str(), MAX_SOURCE_SNIPPET_CHARS)); + if raw_label.is_none() && locator.is_none() && snippet.is_none() { *repaired = true; issues.push(format!("sources[{}] 缺少可展示字段,已忽略。", index)); continue; } - let mut normalized = record.clone(); + let label = raw_label + .or_else(|| resolve_source_locator_hint(locator.as_ref())) + .unwrap_or_else(|| id.clone()); + let source_type = normalize_source_type( + find_string(record, &["type", "kind"]).as_deref(), + locator.as_ref(), + id.as_str(), + ); + let reliability = + normalize_source_reliability(find_string(record, &["reliability"]).as_deref()); + + let mut normalized = Map::new(); normalized.insert("id".to_string(), Value::String(id)); - upsert_optional_string(&mut normalized, "title", title.as_deref()); - upsert_optional_string(&mut normalized, "url", url.as_deref()); - upsert_optional_string(&mut normalized, "note", note.as_deref()); - upsert_optional_string(&mut normalized, "kind", kind.as_deref()); - upsert_optional_string(&mut normalized, "quote", quote.as_deref()); - upsert_optional_string(&mut normalized, "publishedAt", published_at.as_deref()); + normalized.insert("type".to_string(), Value::String(source_type)); + normalized.insert("label".to_string(), Value::String(label)); + if let Some(locator) = locator { + normalized.insert("locator".to_string(), Value::Object(locator)); + } + if let Some(snippet) = snippet { + normalized.insert("snippet".to_string(), Value::String(snippet)); + } + if let Some(reliability) = reliability { + normalized.insert("reliability".to_string(), Value::String(reliability)); + } sources.push(normalized); } @@ -689,7 +710,7 @@ fn normalize_block( )); } - let mut normalized = record.clone(); + let mut normalized = Map::new(); normalized.insert( "id".to_string(), Value::String( @@ -701,8 +722,10 @@ fn normalize_block( if let Some(section_id) = find_string(record, &["sectionId", "section_id"]) { normalized.insert("sectionId".to_string(), Value::String(section_id)); - } else { - normalized.remove("sectionId"); + } + + if record.get("hidden").and_then(Value::as_bool) == Some(true) { + normalized.insert("hidden".to_string(), Value::Bool(true)); } if let Some(source_ids) = normalize_string_array(record, &["sourceIds", "source_ids"]) { @@ -720,7 +743,7 @@ fn normalize_block( match block_type.as_str() { "section_header" => { - if find_string(record, &["title"]).is_none() { + let Some(title) = find_string(record, &["title"]) else { return Some(( build_fallback_rich_text_block( &format!("block-{}", index + 1), @@ -729,17 +752,49 @@ fn normalize_block( ), true, )); - } + }; + normalized.insert("title".to_string(), Value::String(title)); + upsert_optional_string( + &mut normalized, + "description", + find_string(record, &["description"]).as_deref(), + ); } "hero_summary" => { - if find_string(record, &["summary"]).is_none() { - let fallback = extract_portable_text(value)?; - normalized.insert("summary".to_string(), Value::String(fallback)); - repaired = true; + let summary = + find_string(record, &["summary", "text"]).or_else(|| extract_portable_text(value)); + let Some(summary) = summary else { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("hero_summary"), + ), + true, + )); + }; + normalized.insert("summary".to_string(), Value::String(summary)); + upsert_optional_string( + &mut normalized, + "eyebrow", + find_string(record, &["eyebrow"]).as_deref(), + ); + upsert_optional_string( + &mut normalized, + "title", + find_string(record, &["title"]).as_deref(), + ); + let highlights = normalize_string_array_value(record.get("highlights")); + if !highlights.is_empty() { + normalized.insert( + "highlights".to_string(), + Value::Array(highlights.into_iter().map(Value::String).collect()), + ); } } "key_points" => { - if !has_non_empty_string_array(record.get("items")) { + let items = normalize_string_array_value(record.get("items")); + if items.is_empty() { return Some(( build_fallback_rich_text_block( &format!("block-{}", index + 1), @@ -749,9 +804,20 @@ fn normalize_block( true, )); } + normalized.insert( + "items".to_string(), + Value::Array(items.into_iter().map(Value::String).collect()), + ); + upsert_optional_string( + &mut normalized, + "title", + find_string(record, &["title"]).as_deref(), + ); } "rich_text" => { - if extract_rich_text_body(record).is_none() { + let Some((content_format, content, markdown_compat)) = + normalize_rich_text_content(record) + else { return Some(( build_fallback_rich_text_block( &format!("block-{}", index + 1), @@ -760,12 +826,23 @@ fn normalize_block( ), true, )); + }; + normalized.insert("contentFormat".to_string(), Value::String(content_format)); + normalized.insert("content".to_string(), content); + if let Some(markdown) = markdown_compat { + normalized.insert("markdown".to_string(), Value::String(markdown)); } + upsert_optional_string( + &mut normalized, + "title", + find_string(record, &["title"]).as_deref(), + ); } "callout" => { - let content = - find_string(record, &["content", "text"]).or_else(|| extract_portable_text(value)); - if find_string(record, &["title"]).is_none() && content.is_none() { + let body = find_string(record, &["body", "content", "text"]) + .or_else(|| record.get("content").and_then(extract_portable_text)) + .or_else(|| extract_portable_text(value)); + let Some(body) = body else { return Some(( build_fallback_rich_text_block( &format!("block-{}", index + 1), @@ -774,26 +851,26 @@ fn normalize_block( ), true, )); - } - if find_string(record, &["content", "text"]).is_none() { - if let Some(text) = content { - normalized.insert("content".to_string(), Value::String(text)); - repaired = true; - } - } + }; + normalized.insert( + "tone".to_string(), + Value::String(normalize_callout_tone( + find_string(record, &["tone", "variant"]).as_deref(), + )), + ); + normalized.insert("body".to_string(), Value::String(body.clone())); + normalized.insert("content".to_string(), Value::String(body.clone())); + normalized.insert("text".to_string(), Value::String(body)); + upsert_optional_string( + &mut normalized, + "title", + find_string(record, &["title"]).as_deref(), + ); } "table" => { - let has_columns = record - .get("columns") - .and_then(Value::as_array) - .map(|items| !items.is_empty()) - .unwrap_or(false); - let has_rows = record - .get("rows") - .and_then(Value::as_array) - .map(|items| !items.is_empty()) - .unwrap_or(false); - if !has_columns && !has_rows { + let columns = normalize_table_columns(record); + let rows = normalize_table_rows(record, &columns); + if columns.is_empty() && rows.is_empty() { return Some(( build_fallback_rich_text_block( &format!("block-{}", index + 1), @@ -803,60 +880,100 @@ fn normalize_block( true, )); } + normalized.insert( + "columns".to_string(), + Value::Array(columns.into_iter().map(Value::String).collect()), + ); + normalized.insert("rows".to_string(), Value::Array(rows)); + upsert_optional_string( + &mut normalized, + "title", + find_string(record, &["title"]).as_deref(), + ); } - "checklist" | "metric_grid" => { - let has_items = record - .get("items") - .and_then(Value::as_array) - .map(|items| !items.is_empty()) - .unwrap_or(false); - let has_metrics = record - .get("metrics") - .and_then(Value::as_array) - .map(|items| !items.is_empty()) - .unwrap_or(false); - if !has_items && !has_metrics { + "checklist" => { + let items = normalize_checklist_items(record); + if items.is_empty() { return Some(( build_fallback_rich_text_block( &format!("block-{}", index + 1), extract_portable_text(value).as_deref().unwrap_or(""), - Some(block_type.as_str()), + Some("checklist"), ), true, )); } + normalized.insert("items".to_string(), Value::Array(items)); + upsert_optional_string( + &mut normalized, + "title", + find_string(record, &["title"]).as_deref(), + ); + } + "metric_grid" => { + let mut metrics = normalize_metric_items(record); + if metrics.is_empty() { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("metric_grid"), + ), + true, + )); + } + if metrics.len() > MAX_METRIC_COUNT { + metrics.truncate(MAX_METRIC_COUNT); + repaired = true; + } + normalized.insert("metrics".to_string(), Value::Array(metrics)); + upsert_optional_string( + &mut normalized, + "title", + find_string(record, &["title"]).as_deref(), + ); } "quote" => { - if find_string(record, &["quote", "text"]).is_none() { - let fallback = extract_portable_text(value)?; - normalized.insert("quote".to_string(), Value::String(fallback)); - repaired = true; - } + let text = + find_string(record, &["text", "quote"]).or_else(|| extract_portable_text(value)); + let Some(text) = text else { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("quote"), + ), + true, + )); + }; + normalized.insert("text".to_string(), Value::String(text)); + upsert_optional_string( + &mut normalized, + "attribution", + find_string(record, &["attribution", "author", "source"]).as_deref(), + ); } "citation_list" => { - let has_items = record - .get("items") - .and_then(Value::as_array) - .map(|items| !items.is_empty()) - .unwrap_or(false); - if !has_items { - let items = normalize_citation_items_for_block(&normalized, sources); - if items.is_empty() { - return Some(( - build_fallback_rich_text_block( - &format!("block-{}", index + 1), - extract_portable_text(value).as_deref().unwrap_or(""), - Some("citation_list"), - ), - true, - )); - } - normalized.insert("items".to_string(), Value::Array(items)); - repaired = true; + let items = normalize_citation_items_for_block(record, source_id_set, sources); + if items.is_empty() { + return Some(( + build_fallback_rich_text_block( + &format!("block-{}", index + 1), + extract_portable_text(value).as_deref().unwrap_or(""), + Some("citation_list"), + ), + true, + )); } + normalized.insert("items".to_string(), Value::Array(items)); + upsert_optional_string( + &mut normalized, + "title", + find_string(record, &["title"]).as_deref(), + ); } "image" => { - if find_string(record, &["url", "src", "imageUrl"]).is_none() { + let Some(url) = find_string(record, &["url", "src", "imageUrl"]) else { return Some(( build_fallback_rich_text_block( &format!("block-{}", index + 1), @@ -865,12 +982,24 @@ fn normalize_block( ), true, )); - } + }; + normalized.insert("url".to_string(), Value::String(url)); + upsert_optional_string( + &mut normalized, + "alt", + find_string(record, &["alt"]).as_deref(), + ); + upsert_optional_string( + &mut normalized, + "caption", + find_string(record, &["caption"]).as_deref(), + ); } "code_block" => { - if find_string(record, &["code", "content"]).is_none() - && extract_portable_text(value).is_none() - { + let code = find_string(record, &["code", "content"]) + .or_else(|| record.get("content").and_then(extract_portable_text)) + .or_else(|| extract_portable_text(value)); + let Some(code) = code else { return Some(( build_fallback_rich_text_block( &format!("block-{}", index + 1), @@ -882,7 +1011,18 @@ fn normalize_block( ), true, )); - } + }; + normalized.insert("code".to_string(), Value::String(code)); + upsert_optional_string( + &mut normalized, + "language", + find_string(record, &["language"]).as_deref(), + ); + upsert_optional_string( + &mut normalized, + "title", + find_string(record, &["title"]).as_deref(), + ); } "divider" => {} _ => {} @@ -893,11 +1033,46 @@ fn normalize_block( fn normalize_citation_items_for_block( block: &Map, + source_id_set: &HashSet, sources: &[Map], ) -> Vec { - let source_ids = - normalize_string_array(block, &["sourceIds", "source_ids"]).unwrap_or_default(); - let preferred_ids = source_ids.into_iter().collect::>(); + let mut has_explicit_items = false; + let explicit_items = block + .get("items") + .and_then(Value::as_array) + .map(|items| { + has_explicit_items = true; + items + .iter() + .filter_map(|item| { + let record = item.as_object()?; + let source_id = resolve_citation_source_id(record, sources)?; + if !source_id_set.contains(source_id.as_str()) { + return None; + } + + let mut normalized = Map::new(); + normalized.insert("sourceId".to_string(), Value::String(source_id)); + upsert_optional_string( + &mut normalized, + "note", + find_string(record, &["note", "summary", "description"]).as_deref(), + ); + Some(Value::Object(normalized)) + }) + .collect::>() + }) + .unwrap_or_default(); + + if has_explicit_items { + return explicit_items; + } + + let preferred_ids = normalize_string_array(block, &["sourceIds", "source_ids"]) + .unwrap_or_default() + .into_iter() + .filter(|source_id| source_id_set.contains(source_id)) + .collect::>(); let selected_sources = if preferred_ids.is_empty() { sources.iter().collect::>() } else { @@ -915,14 +1090,16 @@ fn normalize_citation_items_for_block( selected_sources .into_iter() - .map(|source| { - let mut item = Map::new(); - for key in ["title", "url", "note", "quote", "kind", "publishedAt"] { - if let Some(value) = source.get(key).cloned() { - item.insert(key.to_string(), value); - } - } - Value::Object(item) + .filter_map(|source| { + let source_id = source.get("id").and_then(Value::as_str)?; + let mut normalized = Map::new(); + normalized.insert("sourceId".to_string(), Value::String(source_id.to_string())); + upsert_optional_string( + &mut normalized, + "note", + source.get("snippet").and_then(Value::as_str), + ); + Some(Value::Object(normalized)) }) .collect() } @@ -940,6 +1117,11 @@ fn build_fallback_rich_text_block( let mut block = Map::from_iter([ ("id".to_string(), Value::String(id.to_string())), ("type".to_string(), Value::String("rich_text".to_string())), + ( + "contentFormat".to_string(), + Value::String("markdown".to_string()), + ), + ("content".to_string(), Value::String(markdown.clone())), ("markdown".to_string(), Value::String(markdown)), ]); if let Some(value) = original_type @@ -952,12 +1134,451 @@ fn build_fallback_rich_text_block( } fn extract_rich_text_body(record: &Map) -> Option { - find_string(record, &["markdown", "text", "content"]) + if find_string(record, &["contentFormat"]).as_deref() == Some("markdown") { + if let Some(content) = record + .get("content") + .and_then(stringify_value) + .or_else(|| record.get("content").and_then(extract_portable_text)) + { + return Some(content); + } + } + + find_string(record, &["markdown", "text"]) + .or_else(|| record.get("content").and_then(stringify_value)) .or_else(|| record.get("content").and_then(extract_portable_text)) .or_else(|| record.get("tiptap").and_then(extract_portable_text)) .or_else(|| record.get("proseMirror").and_then(extract_portable_text)) } +fn normalize_source_locator(record: &Map) -> Option> { + let existing = record + .get("locator") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let url = + find_string(&existing, &["url"]).or_else(|| find_string(record, &["url", "href", "link"])); + let path = find_string(&existing, &["path"]).or_else(|| find_string(record, &["path"])); + let line_start = existing + .get("lineStart") + .and_then(normalize_number_value) + .or_else(|| record.get("lineStart").and_then(normalize_number_value)) + .or_else(|| record.get("line_start").and_then(normalize_number_value)); + let line_end = existing + .get("lineEnd") + .and_then(normalize_number_value) + .or_else(|| record.get("lineEnd").and_then(normalize_number_value)) + .or_else(|| record.get("line_end").and_then(normalize_number_value)); + let tool_call_id = find_string(&existing, &["toolCallId"]) + .or_else(|| find_string(record, &["toolCallId", "tool_call_id"])); + let message_id = find_string(&existing, &["messageId"]) + .or_else(|| find_string(record, &["messageId", "message_id"])); + + if url.is_none() + && path.is_none() + && line_start.is_none() + && line_end.is_none() + && tool_call_id.is_none() + && message_id.is_none() + { + return None; + } + + let mut locator = existing; + upsert_optional_string(&mut locator, "url", url.as_deref()); + upsert_optional_string(&mut locator, "path", path.as_deref()); + upsert_optional_number(&mut locator, "lineStart", line_start); + upsert_optional_number(&mut locator, "lineEnd", line_end); + upsert_optional_string(&mut locator, "toolCallId", tool_call_id.as_deref()); + upsert_optional_string(&mut locator, "messageId", message_id.as_deref()); + Some(locator) +} + +fn resolve_source_locator_hint(locator: Option<&Map>) -> Option { + locator.and_then(|value| find_string(value, &["url"]).or_else(|| find_string(value, &["path"]))) +} + +fn normalize_source_type( + value: Option<&str>, + locator: Option<&Map>, + id: &str, +) -> String { + let normalized = + normalize_text(value).map(|text| text.to_ascii_lowercase().replace([' ', '-'], "_")); + + match normalized.as_deref() { + Some("browser") => return "web".to_string(), + Some("search") | Some("searchresult") => return "search_result".to_string(), + Some(value) if ARTIFACT_SOURCE_TYPE_VALUES.contains(&value) => return value.to_string(), + _ => {} + } + + let normalized_id = id.to_ascii_lowercase(); + if normalized_id.starts_with("file:") { + return "file".to_string(); + } + if normalized_id.starts_with("tool:") { + return "tool".to_string(); + } + if normalized_id.starts_with("message:") { + return "message".to_string(); + } + if normalized_id.starts_with("search:") { + return "search_result".to_string(); + } + if locator + .and_then(|value| find_string(value, &["toolCallId"])) + .is_some() + { + return "tool".to_string(); + } + if locator + .and_then(|value| find_string(value, &["messageId"])) + .is_some() + { + return "message".to_string(); + } + if locator + .and_then(|value| find_string(value, &["path"])) + .is_some() + { + return "file".to_string(); + } + if locator + .and_then(|value| find_string(value, &["url"])) + .is_some() + { + return "web".to_string(); + } + "message".to_string() +} + +fn normalize_source_reliability(value: Option<&str>) -> Option { + let normalized = normalize_text(value).map(|text| text.to_ascii_lowercase())?; + if ARTIFACT_SOURCE_RELIABILITY_VALUES.contains(&normalized.as_str()) { + Some(normalized) + } else { + None + } +} + +fn normalize_string_array_value(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .filter_map(|item| normalize_text(Some(item))) + .collect::>() + }) + .unwrap_or_default() +} + +fn normalize_rich_text_content( + record: &Map, +) -> Option<(String, Value, Option)> { + let declared_format = + find_string(record, &["contentFormat"]).map(|text| text.to_ascii_lowercase()); + if declared_format.as_deref() == Some("prosemirror_json") { + if let Some(content) = record.get("content").cloned() { + return Some(("prosemirror_json".to_string(), content, None)); + } + } + + if let Some(content) = record + .get("proseMirror") + .cloned() + .or_else(|| record.get("tiptap").cloned()) + { + return Some(("prosemirror_json".to_string(), content, None)); + } + + let markdown = find_string(record, &["markdown", "text"]) + .or_else(|| record.get("content").and_then(stringify_value)) + .or_else(|| record.get("content").and_then(extract_portable_text)); + markdown.map(|text| { + ( + "markdown".to_string(), + Value::String(text.clone()), + Some(text), + ) + }) +} + +fn normalize_callout_tone(value: Option<&str>) -> String { + match normalize_text(value) + .map(|text| text.to_ascii_lowercase()) + .as_deref() + { + Some("success") => "success".to_string(), + Some("warning") => "warning".to_string(), + Some("danger") | Some("error") | Some("critical") => "danger".to_string(), + Some("neutral") => "neutral".to_string(), + _ => "info".to_string(), + } +} + +fn normalize_table_columns(record: &Map) -> Vec { + let columns = record + .get("columns") + .and_then(Value::as_array) + .or_else(|| record.get("headers").and_then(Value::as_array)); + + columns + .map(|items| { + items + .iter() + .filter_map(|column| match column { + Value::String(text) => normalize_text(Some(text)), + Value::Object(entry) => find_string(entry, &["label", "title", "key"]), + _ => None, + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn normalize_table_rows(record: &Map, columns: &[String]) -> Vec { + let target_len = columns.len(); + let Some(rows) = record.get("rows").and_then(Value::as_array) else { + return Vec::new(); + }; + + rows.iter() + .filter_map(|row| { + let mut cells = match row { + Value::Array(items) => items + .iter() + .map(|cell| stringify_value(cell).unwrap_or_default()) + .collect::>(), + Value::Object(entry) => { + if let Some(items) = entry.get("cells").and_then(Value::as_array) { + items + .iter() + .map(|cell| stringify_value(cell).unwrap_or_default()) + .collect::>() + } else if let Some(items) = entry.get("values").and_then(Value::as_array) { + items + .iter() + .map(|cell| stringify_value(cell).unwrap_or_default()) + .collect::>() + } else if !columns.is_empty() { + columns + .iter() + .map(|column| { + entry + .get(column.as_str()) + .and_then(stringify_value) + .unwrap_or_default() + }) + .collect::>() + } else { + entry + .values() + .map(|cell| stringify_value(cell).unwrap_or_default()) + .collect::>() + } + } + _ => return None, + }; + + if cells.iter().all(|cell| cell.trim().is_empty()) { + return None; + } + + if target_len > 0 { + cells.truncate(target_len); + while cells.len() < target_len { + cells.push(String::new()); + } + } + + Some(Value::Array(cells.into_iter().map(Value::String).collect())) + }) + .collect() +} + +fn normalize_checklist_items(record: &Map) -> Vec { + let Some(items) = record.get("items").and_then(Value::as_array) else { + return Vec::new(); + }; + + items + .iter() + .enumerate() + .filter_map(|(index, item)| match item { + Value::String(text) => normalize_text(Some(text)).map(|text| { + Value::Object(Map::from_iter([ + ( + "id".to_string(), + Value::String(format!("check-{}", index + 1)), + ), + ("text".to_string(), Value::String(text)), + ("state".to_string(), Value::String("todo".to_string())), + ])) + }), + Value::Object(entry) => { + let text = find_string(entry, &["text", "label", "title", "content"])?; + let explicit_state = + find_string(entry, &["state"]).map(|value| value.to_ascii_lowercase()); + let state = match explicit_state.as_deref() { + Some("todo") | Some("doing") | Some("done") => { + explicit_state.unwrap_or_else(|| "todo".to_string()) + } + _ if entry.get("checked").and_then(Value::as_bool) == Some(true) + || entry.get("done").and_then(Value::as_bool) == Some(true) + || entry.get("completed").and_then(Value::as_bool) == Some(true) => + { + "done".to_string() + } + _ => "todo".to_string(), + }; + Some(Value::Object(Map::from_iter([ + ( + "id".to_string(), + Value::String( + find_string(entry, &["id"]) + .unwrap_or_else(|| format!("check-{}", index + 1)), + ), + ), + ("text".to_string(), Value::String(text)), + ("state".to_string(), Value::String(state)), + ]))) + } + _ => None, + }) + .collect() +} + +fn normalize_metric_items(record: &Map) -> Vec { + let items = record + .get("metrics") + .and_then(Value::as_array) + .or_else(|| record.get("items").and_then(Value::as_array)); + let Some(items) = items else { + return Vec::new(); + }; + + items + .iter() + .enumerate() + .filter_map(|(index, item)| { + let entry = item.as_object()?; + let label = find_string(entry, &["label", "title"]) + .unwrap_or_else(|| format!("指标 {}", index + 1)); + let value = entry + .get("value") + .and_then(stringify_value) + .or_else(|| entry.get("metric").and_then(stringify_value)) + .or_else(|| entry.get("score").and_then(stringify_value))?; + + let mut normalized = Map::new(); + normalized.insert( + "id".to_string(), + Value::String( + find_string(entry, &["id"]).unwrap_or_else(|| format!("metric-{}", index + 1)), + ), + ); + normalized.insert("label".to_string(), Value::String(label)); + normalized.insert("value".to_string(), Value::String(value)); + upsert_optional_string( + &mut normalized, + "note", + find_string(entry, &["note", "detail", "description", "trend"]).as_deref(), + ); + if let Some(tone) = normalize_metric_tone(find_string(entry, &["tone"]).as_deref()) { + normalized.insert("tone".to_string(), Value::String(tone)); + } + Some(Value::Object(normalized)) + }) + .collect() +} + +fn normalize_metric_tone(value: Option<&str>) -> Option { + match normalize_text(value) + .map(|text| text.to_ascii_lowercase()) + .as_deref() + { + Some("neutral") => Some("neutral".to_string()), + Some("success") => Some("success".to_string()), + Some("warning") => Some("warning".to_string()), + Some("danger") | Some("error") | Some("critical") => Some("danger".to_string()), + _ => None, + } +} + +fn resolve_citation_source_id( + item_record: &Map, + sources: &[Map], +) -> Option { + let direct_id = find_string(item_record, &["sourceId", "source_id"]); + if let Some(source_id) = direct_id { + if sources + .iter() + .any(|source| source.get("id").and_then(Value::as_str) == Some(source_id.as_str())) + { + return Some(source_id); + } + } + + if let Some(url) = find_string(item_record, &["url", "href", "link"]) { + if let Some(source) = sources.iter().find(|source| { + source + .get("locator") + .and_then(Value::as_object) + .and_then(|locator| locator.get("url")) + .and_then(Value::as_str) + == Some(url.as_str()) + }) { + return source + .get("id") + .and_then(Value::as_str) + .map(ToString::to_string); + } + } + + if let Some(label) = find_string(item_record, &["label", "title"]) { + if let Some(source) = sources + .iter() + .find(|source| source.get("label").and_then(Value::as_str) == Some(label.as_str())) + { + return source + .get("id") + .and_then(Value::as_str) + .map(ToString::to_string); + } + } + + None +} + +fn normalize_number_value(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_u64().or_else(|| { + number + .as_i64() + .filter(|candidate| *candidate >= 0) + .map(|candidate| candidate as u64) + }), + Value::String(text) => text.trim().parse::().ok(), + _ => None, + } +} + +fn stringify_value(value: &Value) -> Option { + match value { + Value::String(text) => normalize_text(Some(text)), + Value::Number(number) => Some(number.to_string()), + Value::Bool(flag) => Some(flag.to_string()), + Value::Null => None, + Value::Array(_) | Value::Object(_) => extract_portable_text(value) + .or_else(|| serde_json::to_string(value).ok()) + .and_then(|text| normalize_text(Some(text.as_str()))), + } +} + fn derive_summary_from_blocks(blocks: &[Map]) -> Option { for block in blocks { let block_type = block @@ -1130,20 +1751,6 @@ fn normalize_string_array(record: &Map, keys: &[&str]) -> Option< } } -fn has_non_empty_string_array(value: Option<&Value>) -> bool { - value - .and_then(Value::as_array) - .map(|items| { - items.iter().any(|item| { - item.as_str() - .map(str::trim) - .map(|text| !text.is_empty()) - .unwrap_or(false) - }) - }) - .unwrap_or(false) -} - fn normalize_text(value: Option<&str>) -> Option { value .map(str::trim) @@ -1177,6 +1784,14 @@ fn merge_string_field(target: &mut Map, key: &str, value: Option< } } +fn upsert_optional_number(target: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + target.insert(key.to_string(), Value::from(value)); + } else { + target.remove(key); + } +} + fn upsert_optional_string(target: &mut Map, key: &str, value: Option<&str>) { if let Some(value) = value.map(str::trim).filter(|text| !text.is_empty()) { target.insert(key.to_string(), Value::String(value.to_string())); @@ -1185,6 +1800,16 @@ fn upsert_optional_string(target: &mut Map, key: &str, value: Opt } } +fn ensure_renderer_density(metadata: &mut Map, density: &str) { + let mut hints = metadata + .get("rendererHints") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + hints.insert("density".to_string(), Value::String(density.to_string())); + metadata.insert("rendererHints".to_string(), Value::Object(hints)); +} + fn merge_source_run_binding( metadata: &mut Map, context: &ArtifactDocumentValidationContext, diff --git a/src-tauri/src/services/artifact_ops_service.rs b/src-tauri/src/services/artifact_ops_service.rs index c18bcb1a8..7c1fa1ef8 100644 --- a/src-tauri/src/services/artifact_ops_service.rs +++ b/src-tauri/src/services/artifact_ops_service.rs @@ -1,6 +1,6 @@ -//! Artifact ops 应用服务 +//! Artifact 增量包络应用服务 //! -//! 负责解析 `artifact_ops` 包络,并把增量操作应用到现有 +//! 负责解析 current / compat Artifact 增量包络,并把它们应用到现有 //! `ArtifactDocument v1`,为后续 rewrite / diff / 版本化打地基。 use crate::services::artifact_document_validator::{ @@ -10,6 +10,16 @@ use serde_json::{Map, Value}; const ARTIFACT_OPS_ENVELOPE_TYPE: &str = "artifact_ops"; const ARTIFACT_REWRITE_PATCH_ENVELOPE_TYPE: &str = "artifact_rewrite_patch"; +const ARTIFACT_INTERNAL_SOURCE_UPSERT_OP: &str = "artifact.source.upsert"; +const ARTIFACT_INCREMENTAL_ENVELOPE_TYPES: &[&str] = &[ + "artifact.begin", + "artifact.meta.patch", + "artifact.source.upsert", + "artifact.block.upsert", + "artifact.block.remove", + "artifact.complete", + "artifact.fail", +]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ArtifactOpsApplyOutcome { @@ -17,7 +27,7 @@ pub struct ArtifactOpsApplyOutcome { pub issues: Vec, } -pub fn extract_artifact_ops_candidate(raw_text: &str) -> Option { +pub fn extract_artifact_operation_candidate(raw_text: &str) -> Option { let trimmed = raw_text.trim(); if trimmed.is_empty() { return None; @@ -41,45 +51,74 @@ pub fn extract_artifact_ops_candidate(raw_text: &str) -> Option { if let Some(ops) = unwrap_artifact_ops_envelope(&parsed) { return Some(ops.clone()); } + if let Some(incremental) = unwrap_artifact_incremental_envelope(&parsed) { + return Some(incremental.clone()); + } if let Some(rewrite_patch) = unwrap_artifact_rewrite_patch_envelope(&parsed) { - if let Some(ops) = convert_rewrite_patch_to_artifact_ops(rewrite_patch) { - return Some(ops); - } + return Some(rewrite_patch.clone()); } } None } -pub fn apply_artifact_ops_to_document( +pub fn apply_artifact_operation_candidate_to_document( base_document: Option<&Value>, - ops_value: &Value, + operation_value: &Value, + context: &ArtifactDocumentValidationContext, +) -> ArtifactOpsApplyOutcome { + let Some(actions) = normalize_artifact_operation_candidate_to_actions(operation_value) else { + let mut issues = Vec::new(); + let document = initialize_document(base_document, context); + issues.push("未识别的 Artifact 增量包络,已回退到当前文档骨架。".to_string()); + return ArtifactOpsApplyOutcome { document, issues }; + }; + + apply_artifact_actions_to_document(base_document, &actions, context) +} + +fn apply_artifact_actions_to_document( + base_document: Option<&Value>, + actions: &[Value], context: &ArtifactDocumentValidationContext, ) -> ArtifactOpsApplyOutcome { let mut issues = Vec::new(); let mut document = initialize_document(base_document, context); - let Some(record) = ops_value.as_object() else { - issues.push("artifact_ops 顶层不是对象,已回退为空文档骨架。".to_string()); - return ArtifactOpsApplyOutcome { document, issues }; - }; - - let ops = record - .get("ops") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - if ops.is_empty() { - issues.push("artifact_ops 没有提供可执行的 ops,已沿用现有文档骨架。".to_string()); + if actions.is_empty() { + issues.push("增量包络没有提供可执行的动作,已沿用现有文档骨架。".to_string()); return ArtifactOpsApplyOutcome { document, issues }; } - for (index, op) in ops.iter().enumerate() { - apply_single_op(&mut document, op, index, context, &mut issues); + for (index, action) in actions.iter().enumerate() { + apply_single_action(&mut document, action, index, context, &mut issues); } ArtifactOpsApplyOutcome { document, issues } } +fn normalize_artifact_operation_candidate_to_actions(candidate: &Value) -> Option> { + if let Some(ops) = unwrap_artifact_ops_envelope(candidate) { + return Some(extract_ops_actions(ops)); + } + if let Some(incremental) = unwrap_artifact_incremental_envelope(candidate) { + return convert_incremental_envelope_to_artifact_action(incremental) + .map(|action| vec![action]); + } + if let Some(rewrite_patch) = unwrap_artifact_rewrite_patch_envelope(candidate) { + return convert_rewrite_patch_to_artifact_actions(rewrite_patch); + } + None +} + +fn extract_ops_actions(value: &Value) -> Vec { + value + .as_object() + .and_then(|record| record.get("ops")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + fn unwrap_artifact_ops_envelope(value: &Value) -> Option<&Value> { let record = value.as_object()?; let op_type = record @@ -98,6 +137,20 @@ fn unwrap_artifact_ops_envelope(value: &Value) -> Option<&Value> { None } +fn unwrap_artifact_incremental_envelope(value: &Value) -> Option<&Value> { + let record = value.as_object()?; + let envelope_type = record + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + if ARTIFACT_INCREMENTAL_ENVELOPE_TYPES.contains(&envelope_type) { + Some(value) + } else { + None + } +} + fn unwrap_artifact_rewrite_patch_envelope(value: &Value) -> Option<&Value> { let record = value.as_object()?; let patch_type = record @@ -121,7 +174,7 @@ fn unwrap_artifact_rewrite_patch_envelope(value: &Value) -> Option<&Value> { None } -fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option { +fn convert_rewrite_patch_to_artifact_actions(value: &Value) -> Option> { let record = value.as_object()?; let block_record = record.get("block").and_then(Value::as_object)?.clone(); let block_id = normalize_text(block_record.get("id").and_then(Value::as_str))?; @@ -136,13 +189,13 @@ fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option { return None; } - let mut ops = vec![serde_json::json!({ + let mut actions = vec![serde_json::json!({ "op": "artifact.upsert_block", "block": Value::Object(block_record) })]; if let Some(source) = record.get("source") { - ops.push(serde_json::json!({ + actions.push(serde_json::json!({ "op": "artifact.attach_source", "blockId": target_block_id, "source": source @@ -150,7 +203,7 @@ fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option { } if let Some(sources) = record.get("sources").and_then(Value::as_array) { for source in sources { - ops.push(serde_json::json!({ + actions.push(serde_json::json!({ "op": "artifact.attach_source", "blockId": target_block_id, "source": source @@ -170,24 +223,73 @@ fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option { finalize_record.insert("status".to_string(), Value::String(status)); } if finalize_record.len() > 1 { - ops.push(Value::Object(finalize_record)); + actions.push(Value::Object(finalize_record)); } - let mut envelope = Map::new(); - envelope.insert( - "type".to_string(), - Value::String(ARTIFACT_OPS_ENVELOPE_TYPE.to_string()), - ); - if let Some(artifact_id) = normalize_text( - record - .get("artifactId") - .or_else(|| record.get("artifact_id")) - .and_then(Value::as_str), - ) { - envelope.insert("artifactId".to_string(), Value::String(artifact_id)); + Some(actions) +} + +fn convert_incremental_envelope_to_artifact_action(value: &Value) -> Option { + let record = value.as_object()?; + let envelope_type = normalize_text(record.get("type").and_then(Value::as_str))?; + match envelope_type.as_str() { + "artifact.begin" => { + let kind = normalize_text(record.get("kind").and_then(Value::as_str))?; + let title = normalize_text(record.get("title").and_then(Value::as_str))?; + Some(serde_json::json!({ + "op": "artifact.create", + "kind": kind, + "title": title, + })) + } + "artifact.meta.patch" => { + let patch = record.get("patch").and_then(Value::as_object)?.clone(); + Some(serde_json::json!({ + "op": "artifact.set_meta", + "metadata": Value::Object(patch), + })) + } + "artifact.source.upsert" => { + let source = record.get("source").and_then(Value::as_object)?.clone(); + Some(serde_json::json!({ + "op": ARTIFACT_INTERNAL_SOURCE_UPSERT_OP, + "source": Value::Object(source), + })) + } + "artifact.block.upsert" => { + let block = record.get("block").and_then(Value::as_object)?.clone(); + Some(serde_json::json!({ + "op": "artifact.upsert_block", + "block": Value::Object(block), + })) + } + "artifact.block.remove" => { + let block_id = normalize_text(record.get("blockId").and_then(Value::as_str)) + .or_else(|| normalize_text(record.get("block_id").and_then(Value::as_str)))?; + Some(serde_json::json!({ + "op": "artifact.remove_block", + "blockId": block_id, + })) + } + "artifact.complete" => { + let mut op = Map::from_iter([( + "op".to_string(), + Value::String("artifact.finalize_version".to_string()), + )]); + if let Some(summary) = normalize_text(record.get("summary").and_then(Value::as_str)) { + op.insert("summary".to_string(), Value::String(summary)); + } + Some(Value::Object(op)) + } + "artifact.fail" => { + let reason = normalize_text(record.get("reason").and_then(Value::as_str))?; + Some(serde_json::json!({ + "op": "artifact.fail", + "reason": reason, + })) + } + _ => None, } - envelope.insert("ops".to_string(), Value::Array(ops)); - Some(Value::Object(envelope)) } fn strip_outer_code_fence(raw: &str) -> String { @@ -292,7 +394,7 @@ fn initialize_document( Value::Object(document) } -fn apply_single_op( +fn apply_single_action( document: &mut Value, op_value: &Value, index: usize, @@ -337,6 +439,9 @@ fn apply_single_op( "artifact.attach_source" => { apply_attach_source_op(document, op_record, index, context, issues) } + ARTIFACT_INTERNAL_SOURCE_UPSERT_OP => { + apply_source_upsert_op(document, op_record, index, issues) + } "artifact.finalize_version" => apply_finalize_version_op(document, op_record), "artifact.fail" => apply_fail_op(document, op_record), _ => issues.push(format!("ops[{index}].op `{op_name}` 暂不支持,已忽略。")), @@ -544,12 +649,103 @@ fn apply_attach_source_op( let Some(document_record) = document.as_object_mut() else { return; }; + let next_source_id = upsert_source_record( + document_record, + source_record, + op_record.get("sourceLink").and_then(Value::as_object), + ); + + let blocks = ensure_array_field(document_record, "blocks"); + let Some(block) = blocks.iter_mut().find(|block| { + block + .as_object() + .and_then(|record| record.get("id")) + .and_then(Value::as_str) + .map(str::trim) + == Some(block_id.as_str()) + }) else { + issues.push(format!( + "ops[{index}] 目标 block `{block_id}` 不存在,已保留 source 但未完成绑定。" + )); + return; + }; + let Some(block_record) = block.as_object_mut() else { + return; + }; + let source_ids = ensure_array_field(block_record, "sourceIds"); + let already_exists = source_ids + .iter() + .any(|value| value.as_str().map(str::trim) == Some(next_source_id.as_str())); + if !already_exists { + source_ids.push(Value::String(next_source_id)); + } +} + +fn apply_source_upsert_op( + document: &mut Value, + op_record: &Map, + index: usize, + issues: &mut Vec, +) { + let Some(source_record) = op_record.get("source").and_then(Value::as_object) else { + issues.push(format!("ops[{index}] 缺少 source 对象,已忽略来源写入。")); + return; + }; + let Some(document_record) = document.as_object_mut() else { + return; + }; + upsert_source_record(document_record, source_record, None); +} + +fn is_allowed_target_block(context: &ArtifactDocumentValidationContext, block_id: &str) -> bool { + context + .target_block_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none_or(|target_block_id| target_block_id == block_id) +} + +fn apply_finalize_version_op(document: &mut Value, op_record: &Map) { + let Some(document_record) = document.as_object_mut() else { + return; + }; + if let Some(status) = normalize_text(op_record.get("status").and_then(Value::as_str)) { + document_record.insert("status".to_string(), Value::String(status)); + } + if let Some(summary) = normalize_text(op_record.get("summary").and_then(Value::as_str)) { + let metadata = ensure_object_field(document_record, "metadata"); + metadata.insert("versionSummary".to_string(), Value::String(summary)); + } +} + +fn apply_fail_op(document: &mut Value, op_record: &Map) { + let Some(document_record) = document.as_object_mut() else { + return; + }; + document_record.insert("status".to_string(), Value::String("failed".to_string())); + if let Some(reason) = normalize_text( + op_record + .get("reason") + .or_else(|| op_record.get("message")) + .and_then(Value::as_str), + ) { + let metadata = ensure_object_field(document_record, "metadata"); + metadata.insert("failureReason".to_string(), Value::String(reason)); + } +} + +fn upsert_source_record( + document_record: &mut Map, + source_record: &Map, + source_link: Option<&Map>, +) -> String { let sources = ensure_array_field(document_record, "sources"); let next_source_id = normalize_text(source_record.get("id").and_then(Value::as_str)) .unwrap_or_else(|| format!("source-{}", sources.len() + 1)); let mut merged_source = source_record.clone(); merged_source.insert("id".to_string(), Value::String(next_source_id.clone())); - if let Some(link_record) = op_record.get("sourceLink").and_then(Value::as_object) { + if let Some(link_record) = source_link { if !merged_source.contains_key("locator") { if let Some(locator) = link_record.get("locator") { merged_source.insert("locator".to_string(), locator.clone()); @@ -606,68 +802,7 @@ fn apply_attach_source_op( sources.push(Value::Object(merged_source)); } - let blocks = ensure_array_field(document_record, "blocks"); - let Some(block) = blocks.iter_mut().find(|block| { - block - .as_object() - .and_then(|record| record.get("id")) - .and_then(Value::as_str) - .map(str::trim) - == Some(block_id.as_str()) - }) else { - issues.push(format!( - "ops[{index}] 目标 block `{block_id}` 不存在,已保留 source 但未完成绑定。" - )); - return; - }; - let Some(block_record) = block.as_object_mut() else { - return; - }; - let source_ids = ensure_array_field(block_record, "sourceIds"); - let already_exists = source_ids - .iter() - .any(|value| value.as_str().map(str::trim) == Some(next_source_id.as_str())); - if !already_exists { - source_ids.push(Value::String(next_source_id)); - } -} - -fn is_allowed_target_block(context: &ArtifactDocumentValidationContext, block_id: &str) -> bool { - context - .target_block_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_none_or(|target_block_id| target_block_id == block_id) -} - -fn apply_finalize_version_op(document: &mut Value, op_record: &Map) { - let Some(document_record) = document.as_object_mut() else { - return; - }; - if let Some(status) = normalize_text(op_record.get("status").and_then(Value::as_str)) { - document_record.insert("status".to_string(), Value::String(status)); - } - if let Some(summary) = normalize_text(op_record.get("summary").and_then(Value::as_str)) { - let metadata = ensure_object_field(document_record, "metadata"); - metadata.insert("versionSummary".to_string(), Value::String(summary)); - } -} - -fn apply_fail_op(document: &mut Value, op_record: &Map) { - let Some(document_record) = document.as_object_mut() else { - return; - }; - document_record.insert("status".to_string(), Value::String("failed".to_string())); - if let Some(reason) = normalize_text( - op_record - .get("reason") - .or_else(|| op_record.get("message")) - .and_then(Value::as_str), - ) { - let metadata = ensure_object_field(document_record, "metadata"); - metadata.insert("failureReason".to_string(), Value::String(reason)); - } + next_source_id } fn merge_document_patch(document_record: &mut Map, patch: &Map) { @@ -805,6 +940,11 @@ mod tests { use super::*; use serde_json::json; + fn extract_normalized_actions(raw_text: &str) -> Vec { + let candidate = extract_artifact_operation_candidate(raw_text).expect("operation envelope"); + normalize_artifact_operation_candidate_to_actions(&candidate).expect("normalized actions") + } + fn build_context() -> ArtifactDocumentValidationContext { ArtifactDocumentValidationContext { artifact_id: "artifact-document:demo".to_string(), @@ -836,15 +976,73 @@ mod tests { ``` "#; - let extracted = extract_artifact_ops_candidate(raw).expect("ops envelope"); + let extracted = extract_normalized_actions(raw); assert_eq!( - extracted.get("type").and_then(Value::as_str).map(str::trim), - Some("artifact_ops") + extracted + .first() + .and_then(|action| action.get("op")) + .and_then(Value::as_str), + Some("artifact.set_meta") ); } #[test] - fn should_convert_rewrite_patch_to_artifact_ops_candidate() { + fn should_extract_current_incremental_candidate_without_normalizing_to_compat() { + let raw = r#" +{ + "type": "artifact.block.upsert", + "artifactId": "artifact-document:demo", + "block": { + "id": "body-1", + "type": "rich_text", + "contentFormat": "markdown", + "content": "增量改写后的正文" + } +} +"#; + + let extracted = extract_artifact_operation_candidate(raw).expect("operation envelope"); + + assert_eq!( + extracted.get("type").and_then(Value::as_str).map(str::trim), + Some("artifact.block.upsert") + ); + assert!(extracted.get("ops").is_none()); + } + + #[test] + fn should_extract_rewrite_patch_candidate_without_normalizing_to_compat() { + let raw = r#" +{ + "type": "artifact_rewrite_patch", + "artifactId": "artifact-document:demo", + "targetBlockId": "body-1", + "block": { + "id": "body-1", + "type": "rich_text", + "contentFormat": "markdown", + "content": "current shape 改写后的正文" + } +} +"#; + + let extracted = extract_artifact_operation_candidate(raw).expect("operation envelope"); + + assert_eq!( + extracted.get("type").and_then(Value::as_str).map(str::trim), + Some("artifact_rewrite_patch") + ); + assert_eq!( + extracted + .get("block") + .and_then(|block| block.get("content")) + .and_then(Value::as_str), + Some("current shape 改写后的正文") + ); + } + + #[test] + fn should_normalize_rewrite_patch_to_internal_actions() { let raw = r#" { "type": "artifact_rewrite_patch", @@ -864,37 +1062,355 @@ mod tests { } "#; - let extracted = extract_artifact_ops_candidate(raw).expect("ops envelope"); - let ops = extracted - .get("ops") - .and_then(Value::as_array) - .expect("ops array"); + let actions = extract_normalized_actions(raw); + assert_eq!(actions.len(), 3); assert_eq!( - extracted.get("type").and_then(Value::as_str).map(str::trim), - Some("artifact_ops") - ); - assert_eq!(ops.len(), 3); - assert_eq!( - ops.first() + actions + .first() .and_then(|op| op.get("op")) .and_then(Value::as_str), Some("artifact.upsert_block") ); assert_eq!( - ops.get(1) + actions + .get(1) .and_then(|op| op.get("op")) .and_then(Value::as_str), Some("artifact.attach_source") ); assert_eq!( - ops.get(2) + actions + .get(2) .and_then(|op| op.get("summary")) .and_then(Value::as_str), Some("仅改写目标正文") ); } + #[test] + fn should_normalize_current_shaped_rewrite_patch_to_internal_actions() { + let raw = r#" +{ + "type": "artifact_rewrite_patch", + "artifactId": "artifact-document:demo", + "targetBlockId": "body-1", + "block": { + "id": "body-1", + "type": "rich_text", + "contentFormat": "markdown", + "content": "current shape 改写后的正文" + }, + "source": { + "id": "source-1", + "type": "web", + "label": "OpenAI", + "locator": { + "url": "https://openai.com" + } + }, + "summary": "current shape typed patch" +} +"#; + + let actions = extract_normalized_actions(raw); + + assert_eq!(actions.len(), 3); + assert_eq!( + actions + .first() + .and_then(|op| op.get("block")) + .and_then(|block| block.get("content")) + .and_then(Value::as_str), + Some("current shape 改写后的正文") + ); + assert_eq!( + actions + .get(1) + .and_then(|op| op.get("source")) + .and_then(|source| source.get("label")) + .and_then(Value::as_str), + Some("OpenAI") + ); + assert_eq!( + actions + .get(2) + .and_then(|op| op.get("summary")) + .and_then(Value::as_str), + Some("current shape typed patch") + ); + } + + #[test] + fn should_normalize_current_incremental_block_upsert_to_internal_action() { + let raw = r#" +{ + "type": "artifact.block.upsert", + "artifactId": "artifact-document:demo", + "block": { + "id": "body-1", + "type": "rich_text", + "contentFormat": "markdown", + "content": "增量改写后的正文" + } +} +"#; + + let actions = extract_normalized_actions(raw); + + assert_eq!(actions.len(), 1); + assert_eq!( + actions + .first() + .and_then(|op| op.get("op")) + .and_then(Value::as_str), + Some("artifact.upsert_block") + ); + } + + #[test] + fn should_apply_current_incremental_candidate_without_compat_envelope() { + let base_document = json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "artifactId": "artifact-document:demo", + "kind": "analysis", + "title": "季度结论", + "status": "ready", + "language": "zh-CN", + "summary": "旧摘要", + "blocks": [ + { "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" } + ], + "sources": [], + "metadata": { + "generatedBy": "agent" + } + }); + let operation = json!({ + "type": "artifact.block.upsert", + "artifactId": "artifact-document:demo", + "block": { + "id": "body-1", + "type": "rich_text", + "contentFormat": "markdown", + "content": "直接通过 current 包络改写后的正文" + } + }); + + let outcome = apply_artifact_operation_candidate_to_document( + Some(&base_document), + &operation, + &build_context(), + ); + + assert!(outcome.issues.is_empty()); + assert_eq!( + outcome + .document + .get("blocks") + .and_then(Value::as_array) + .and_then(|blocks| blocks.first()) + .and_then(|block| block.get("content")) + .and_then(Value::as_str), + Some("直接通过 current 包络改写后的正文") + ); + } + + #[test] + fn should_apply_rewrite_patch_candidate_without_compat_envelope() { + let base_document = json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "artifactId": "artifact-document:demo", + "kind": "analysis", + "title": "季度结论", + "status": "ready", + "language": "zh-CN", + "summary": "旧摘要", + "blocks": [ + { "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" } + ], + "sources": [], + "metadata": { + "generatedBy": "agent" + } + }); + let operation = json!({ + "type": "artifact_rewrite_patch", + "artifactId": "artifact-document:demo", + "targetBlockId": "body-1", + "block": { + "id": "body-1", + "type": "rich_text", + "contentFormat": "markdown", + "content": "rewrite patch 直接改写后的正文" + }, + "source": { + "id": "source-1", + "type": "web", + "label": "OpenAI", + "locator": { + "url": "https://openai.com" + } + }, + "summary": "仅改写目标正文" + }); + + let outcome = apply_artifact_operation_candidate_to_document( + Some(&base_document), + &operation, + &build_context(), + ); + + assert!(outcome.issues.is_empty()); + assert_eq!( + outcome + .document + .get("blocks") + .and_then(Value::as_array) + .and_then(|blocks| blocks.first()) + .and_then(|block| block.get("content")) + .and_then(Value::as_str), + Some("rewrite patch 直接改写后的正文") + ); + assert_eq!( + outcome + .document + .get("sources") + .and_then(Value::as_array) + .and_then(|sources| sources.first()) + .and_then(|source| source.get("label")) + .and_then(Value::as_str), + Some("OpenAI") + ); + assert_eq!( + outcome + .document + .get("metadata") + .and_then(Value::as_object) + .and_then(|metadata| metadata.get("versionSummary")) + .and_then(Value::as_str), + Some("仅改写目标正文") + ); + } + + #[test] + fn should_apply_current_incremental_source_upsert_without_block_binding() { + let base_document = json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "artifactId": "artifact-document:demo", + "kind": "analysis", + "title": "季度结论", + "status": "ready", + "language": "zh-CN", + "summary": "旧摘要", + "blocks": [ + { "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" } + ], + "sources": [], + "metadata": { + "generatedBy": "agent" + } + }); + let operation = json!({ + "type": "artifact.source.upsert", + "artifactId": "artifact-document:demo", + "source": { + "id": "source-1", + "type": "web", + "label": "OpenAI", + "locator": { + "url": "https://openai.com" + } + } + }); + + let outcome = apply_artifact_operation_candidate_to_document( + Some(&base_document), + &operation, + &build_context(), + ); + + assert!(outcome.issues.is_empty()); + assert_eq!( + outcome + .document + .get("sources") + .and_then(Value::as_array) + .map(|items| items.len()), + Some(1) + ); + assert_eq!( + outcome + .document + .get("blocks") + .and_then(Value::as_array) + .and_then(|blocks| blocks.first()) + .and_then(|block| block.get("sourceIds")), + None + ); + } + + #[test] + fn should_keep_compat_artifact_ops_as_fallback_input() { + let base_document = json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "artifactId": "artifact-document:demo", + "kind": "analysis", + "title": "季度结论", + "status": "ready", + "language": "zh-CN", + "summary": "旧摘要", + "blocks": [ + { "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" } + ], + "sources": [], + "metadata": { + "generatedBy": "agent" + } + }); + let operation = json!({ + "type": "artifact_ops", + "ops": [ + { + "op": "artifact.source.upsert", + "source": { + "id": "source-1", + "type": "web", + "label": "OpenAI", + "locator": { + "url": "https://openai.com" + } + } + } + ] + }); + + let outcome = apply_artifact_operation_candidate_to_document( + Some(&base_document), + &operation, + &build_context(), + ); + + assert!(outcome.issues.is_empty()); + assert_eq!( + outcome + .document + .get("sources") + .and_then(Value::as_array) + .map(|items| items.len()), + Some(1) + ); + assert_eq!( + outcome + .document + .get("blocks") + .and_then(Value::as_array) + .and_then(|blocks| blocks.first()) + .and_then(|block| block.get("sourceIds")), + None + ); + } + #[test] fn should_apply_upsert_block_and_attach_source_on_existing_document() { let base_document = json!({ @@ -942,7 +1458,11 @@ mod tests { ] }); - let outcome = apply_artifact_ops_to_document(Some(&base_document), &ops, &build_context()); + let outcome = apply_artifact_operation_candidate_to_document( + Some(&base_document), + &ops, + &build_context(), + ); assert!(outcome.issues.is_empty()); let body_block = outcome @@ -1036,7 +1556,8 @@ mod tests { let mut context = build_context(); context.target_block_id = Some("body-1".to_string()); - let outcome = apply_artifact_ops_to_document(Some(&base_document), &ops, &context); + let outcome = + apply_artifact_operation_candidate_to_document(Some(&base_document), &ops, &context); let blocks = outcome .document diff --git a/src-tauri/src/services/artifact_output_schema_service.rs b/src-tauri/src/services/artifact_output_schema_service.rs index e100b580c..770171882 100644 --- a/src-tauri/src/services/artifact_output_schema_service.rs +++ b/src-tauri/src/services/artifact_output_schema_service.rs @@ -55,6 +55,21 @@ const ARTIFACT_STAGE2_OP_VALUES: &[&str] = &[ "artifact.finalize_version", "artifact.fail", ]; +const ARTIFACT_STAGE2_INCREMENTAL_OP_TYPES: &[&str] = &[ + "artifact.begin", + "artifact.meta.patch", + "artifact.source.upsert", + "artifact.block.upsert", + "artifact.block.remove", + "artifact.complete", + "artifact.fail", +]; +const ARTIFACT_REWRITE_INCREMENTAL_OP_TYPES: &[&str] = &[ + "artifact.source.upsert", + "artifact.block.upsert", + "artifact.complete", + "artifact.fail", +]; const ARTIFACT_REWRITE_OP_VALUES: &[&str] = &[ "artifact.set_meta", "artifact.upsert_block", @@ -349,37 +364,38 @@ fn build_stage2_document_schema(context: &ArtifactOutputSchemaContext) -> Value "items": { "type": "object", "additionalProperties": true, - "required": ["id"], + "required": ["id", "type", "label"], "properties": { "id": { "type": "string", "minLength": 1 }, - "title": { + "type": { + "type": "string", + "enum": ["web", "file", "tool", "message", "search_result"] + }, + "label": { "type": "string" }, - "url": { + "snippet": { "type": "string" }, - "note": { + "reliability": { "type": "string" }, - "quote": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "publishedAt": { - "type": "string" + "locator": { + "type": "object", + "additionalProperties": true, + "properties": { + "url": { "type": "string" }, + "path": { "type": "string" }, + "lineStart": { "type": "number" }, + "lineEnd": { "type": "number" }, + "toolCallId": { "type": "string" }, + "messageId": { "type": "string" } + } } - }, - "anyOf": [ - { "required": ["title"] }, - { "required": ["url"] }, - { "required": ["note"] }, - { "required": ["quote"] } - ] + } } }, "metadata": { @@ -425,38 +441,39 @@ fn build_artifact_source_schema() -> Value { json!({ "type": "object", "additionalProperties": true, + "required": ["id", "type", "label"], "properties": { "id": { "type": "string", "minLength": 1 }, - "title": { + "type": { + "type": "string", + "enum": ["web", "file", "tool", "message", "search_result"] + }, + "label": { "type": "string" }, - "url": { + "snippet": { "type": "string" }, - "note": { - "type": "string" + "reliability": { + "type": "string", + "enum": ["primary", "secondary", "derived"] }, - "quote": { - "type": "string" + "locator": { + "type": "object", + "additionalProperties": true, + "properties": { + "url": { "type": "string" }, + "path": { "type": "string" }, + "lineStart": { "type": "number" }, + "lineEnd": { "type": "number" }, + "toolCallId": { "type": "string" }, + "messageId": { "type": "string" } + } }, - "kind": { - "type": "string" - }, - "publishedAt": { - "type": "string" - }, - "locator": {} - }, - "anyOf": [ - { "required": ["id"] }, - { "required": ["title"] }, - { "required": ["url"] }, - { "required": ["note"] }, - { "required": ["quote"] } - ] + } }) } @@ -691,6 +708,144 @@ fn build_artifact_ops_item_schema(op_values: &[&str], target_block_id: Option<&s }) } +fn build_artifact_incremental_output_schema( + op_types: &[&str], + target_block_id: Option<&str>, +) -> Value { + let mut item_schemas = Vec::new(); + + if op_types.contains(&"artifact.begin") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "artifactId", "kind", "title"], + "properties": { + "type": build_string_schema(op_types, Some("artifact.begin"), None), + "artifactId": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ARTIFACT_KIND_VALUES + }, + "title": { + "type": "string", + "minLength": 1 + } + } + })); + } + + if op_types.contains(&"artifact.meta.patch") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "artifactId", "patch"], + "properties": { + "type": build_string_schema(op_types, Some("artifact.meta.patch"), None), + "artifactId": { + "type": "string", + "minLength": 1 + }, + "patch": { + "type": "object" + } + } + })); + } + + if op_types.contains(&"artifact.source.upsert") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "artifactId", "source"], + "properties": { + "type": build_string_schema(op_types, Some("artifact.source.upsert"), None), + "artifactId": { + "type": "string", + "minLength": 1 + }, + "source": build_artifact_source_schema() + } + })); + } + + if op_types.contains(&"artifact.block.upsert") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "artifactId", "block"], + "properties": { + "type": build_string_schema(op_types, Some("artifact.block.upsert"), None), + "artifactId": { + "type": "string", + "minLength": 1 + }, + "block": build_artifact_block_schema_with_target_id(target_block_id) + } + })); + } + + if op_types.contains(&"artifact.block.remove") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "artifactId", "blockId"], + "properties": { + "type": build_string_schema(op_types, Some("artifact.block.remove"), None), + "artifactId": { + "type": "string", + "minLength": 1 + }, + "blockId": build_block_id_constraint_schema(target_block_id) + } + })); + } + + if op_types.contains(&"artifact.complete") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "artifactId"], + "properties": { + "type": build_string_schema(op_types, Some("artifact.complete"), None), + "artifactId": { + "type": "string", + "minLength": 1 + }, + "summary": { + "type": "string" + } + } + })); + } + + if op_types.contains(&"artifact.fail") { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "artifactId", "reason"], + "properties": { + "type": build_string_schema(op_types, Some("artifact.fail"), None), + "artifactId": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + })); + } + + json!({ + "title": "lime_artifact_incremental", + "oneOf": item_schemas + }) +} + fn build_artifact_ops_output_schema(op_values: &[&str], target_block_id: Option<&str>) -> Value { json!({ "title": "lime_artifact_ops", @@ -755,6 +910,7 @@ fn build_stage2_output_schema(context: &ArtifactOutputSchemaContext) -> Value { "title": "lime_artifact_stage2_result", "oneOf": [ build_stage2_document_envelope_schema(context), + build_artifact_incremental_output_schema(ARTIFACT_STAGE2_INCREMENTAL_OP_TYPES, None), build_artifact_ops_output_schema(ARTIFACT_STAGE2_OP_VALUES, None) ] }) @@ -766,6 +922,10 @@ fn build_rewrite_output_schema(context: &ArtifactOutputSchemaContext) -> Value { "title": "lime_artifact_rewrite_result", "oneOf": [ build_artifact_rewrite_patch_output_schema(context), + build_artifact_incremental_output_schema( + ARTIFACT_REWRITE_INCREMENTAL_OP_TYPES, + context.target_block_id.as_deref() + ), build_artifact_ops_output_schema( ARTIFACT_REWRITE_OP_VALUES, context.target_block_id.as_deref() @@ -894,10 +1054,30 @@ mod tests { .and_then(Value::as_u64), Some(1) ); + assert!(schema + .get("oneOf") + .and_then(Value::as_array) + .is_some_and(|items| items.iter().any(|item| { + item.get("oneOf") + .and_then(Value::as_array) + .is_some_and(|variants| { + variants.iter().any(|variant| { + variant + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("type")) + .and_then(|value| value.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str) + == Some("artifact.block.upsert") + }) + }) + }))); } #[test] - fn rewrite_should_build_artifact_ops_schema() { + fn rewrite_should_build_patch_incremental_and_ops_schema() { let metadata = json!({ "artifact": { "artifact_mode": "rewrite", @@ -913,7 +1093,7 @@ mod tests { .get("oneOf") .and_then(Value::as_array) .map(|items| items.len()), - Some(2) + Some(3) ); let rewrite_patch_schema = schema .get("oneOf") @@ -961,6 +1141,69 @@ mod tests { .and_then(Value::as_str), Some("body-1") ); + let incremental_schema = schema + .get("oneOf") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("title").and_then(Value::as_str) == Some("lime_artifact_incremental") + }) + }) + .expect("incremental schema"); + let incremental_upsert_schema = incremental_schema + .get("oneOf") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("type")) + .and_then(|op| op.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str) + == Some("artifact.block.upsert") + }) + }) + .expect("incremental upsert schema"); + assert_eq!( + incremental_upsert_schema + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("block")) + .and_then(|block| block.get("properties")) + .and_then(Value::as_object) + .and_then(|properties| properties.get("id")) + .and_then(|id| id.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str), + Some("body-1") + ); + let rewrite_incremental_has_type = |expected: &str| { + incremental_schema + .get("oneOf") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("type")) + .and_then(|op| op.get("enum")) + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(Value::as_str) + == Some(expected) + }) + }) + }; + assert!(rewrite_incremental_has_type("artifact.source.upsert")); + assert!(rewrite_incremental_has_type("artifact.block.upsert")); + assert!(rewrite_incremental_has_type("artifact.complete")); + assert!(rewrite_incremental_has_type("artifact.fail")); + assert!(!rewrite_incremental_has_type("artifact.begin")); + assert!(!rewrite_incremental_has_type("artifact.meta.patch")); + assert!(!rewrite_incremental_has_type("artifact.block.remove")); let ops_schema = schema .get("oneOf") .and_then(Value::as_array) diff --git a/src-tauri/src/services/artifact_prompt_service.rs b/src-tauri/src/services/artifact_prompt_service.rs index 25f3d8795..e1f8bb869 100644 --- a/src-tauri/src/services/artifact_prompt_service.rs +++ b/src-tauri/src/services/artifact_prompt_service.rs @@ -224,8 +224,7 @@ fn build_artifact_stage_prompt(context: &ArtifactPromptContext) -> Option Option Option< Some("rewrite") ); let output_contract = if is_rewrite { - "本轮优先输出 `artifact_rewrite_patch`;兼容情况下也可输出 `artifact_ops`,不要返回整篇 `artifact_document_draft`。" + "本轮优先输出 `artifact_rewrite_patch`;也允许输出 rewrite 专用正式单条 op envelope(仅 `artifact.source.upsert / artifact.block.upsert / artifact.complete / artifact.fail`);仅兼容情况下才回退到 `artifact_ops`,不要返回整篇 `artifact_document_draft`。" } else { - "本轮可以输出 `artifact_document_draft`,也可以在已有文档上输出 `artifact_ops`。" + "本轮可以输出 `artifact_document_draft`;若做增量补充,优先输出正式单条 op envelope,兼容情况下也可回退到 `artifact_ops`。" }; let shape_hint = if is_rewrite { - "`artifact_rewrite_patch` 顶层字段优先包含:type、artifactId、targetBlockId、block\n- 可选补充 `source / sources / summary / status`\n- 若需兼容旧链路,也可回退到 `artifact_ops`\n- 若存在 target block,schema 与运行时都会限制改写范围,只允许命中该 block" + "`artifact_rewrite_patch` 顶层字段优先包含:type、artifactId、targetBlockId、block\n- 可选补充 `source / sources / summary / status`\n- 若使用正式单条 op,仅允许 `artifact.source.upsert / artifact.block.upsert / artifact.complete / artifact.fail`,顶层字段包含:type、artifactId,以及 source / block / summary / reason 中对应字段\n- 只有兼容旧链路时才回退到 `artifact_ops`\n- 若存在 target block,schema 与运行时都会限制改写范围,只允许命中该 block" } else { - "顶层字段优先包含:artifactId、kind、title、status、language、summary、blocks、sources、metadata\n- 若使用 `artifact_ops`,顶层字段包含:type、artifactId、ops" + "顶层字段优先包含:artifactId、kind、title、status、language、summary、blocks、sources、metadata\n- 若使用正式单条 op,顶层字段包含:type、artifactId,以及 block / source / patch / blockId / summary / reason 中对应字段\n- 若需兼容旧链路,也可回退到 `artifact_ops`,其顶层字段包含:type、artifactId、ops" }; let example = if is_rewrite { - "{\n \ + "Patch 示例:\n{\n \ \"type\": \"artifact_rewrite_patch\",\n \ \"artifactId\": \"artifact-demo\",\n \ \"targetBlockId\": \"body-1\",\n \ -\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"改写后的正文\" },\n \ +\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"改写后的正文\" },\n \ \"summary\": \"把正文改成更适合董事会的措辞\"\n\ +}\n\n\ +单条 op 示例:\n{\n \ +\"type\": \"artifact.block.upsert\",\n \ +\"artifactId\": \"artifact-demo\",\n \ +\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"改写后的正文\" }\n\ }" } else { - "{\n \ + "草稿示例:\n{\n \ \"type\": \"artifact_document_draft\",\n \ \"document\": {\n \ \"schemaVersion\": \"artifact_document.v1\",\n \ @@ -288,11 +292,18 @@ fn build_artifact_schema_hint_prompt(context: &ArtifactPromptContext) -> Option< \"summary\": \"一句话摘要\",\n \ \"blocks\": [\n \ { \"id\": \"hero-1\", \"type\": \"hero_summary\", \"summary\": \"核心结论\" },\n \ -{ \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"正文内容\" }\n \ +{ \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"正文内容\" }\n \ +],\n \ +\"sources\": [\n \ +{ \"id\": \"source-1\", \"type\": \"web\", \"label\": \"OpenAI Blog\", \"locator\": { \"url\": \"https://openai.com\" }, \"snippet\": \"来源摘录\" }\n \ ],\n \ -\"sources\": [],\n \ \"metadata\": {}\n \ }\n\ +}\n\n\ +增量示例:\n{\n \ +\"type\": \"artifact.block.upsert\",\n \ +\"artifactId\": \"artifact-demo\",\n \ +\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"补充后的正文\" }\n\ }" }; @@ -380,6 +391,7 @@ mod tests { assert!(merged.contains(ARTIFACT_STAGE2_PROMPT_MARKER)); assert!(merged.contains(ARTIFACT_SCHEMA_HINT_PROMPT_MARKER)); assert!(merged.contains("ArtifactDocument v1")); + assert!(merged.contains("artifact.block.upsert")); assert!(merged.contains("artifact_ops")); } @@ -401,7 +413,12 @@ mod tests { assert!(merged.contains("block-3")); assert!(merged.contains("更适合董事会")); assert!(merged.contains("artifact_rewrite_patch")); + assert!(merged.contains("artifact.source.upsert")); + assert!(merged.contains("artifact.block.upsert")); assert!(merged.contains("artifact_ops")); + assert!(!merged.contains("artifact.begin")); + assert!(!merged.contains("artifact.meta.patch")); + assert!(!merged.contains("artifact.block.remove")); } #[test] diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 0c8897145..7ded91dd7 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -28,7 +28,6 @@ pub mod memory_import_parser_service; pub mod memory_profile_prompt_service; pub mod memory_rules_loader_service; pub mod memory_source_resolver_service; -pub mod novel_service; pub mod openclaw_service; pub mod runtime_agents_template_service; pub mod runtime_analysis_handoff_service; diff --git a/src-tauri/src/services/novel_service.rs b/src-tauri/src/services/novel_service.rs deleted file mode 100644 index a0e93d1ca..000000000 --- a/src-tauri/src/services/novel_service.rs +++ /dev/null @@ -1,2719 +0,0 @@ -//! 小说领域服务 -//! -//! 提供小说项目、设定、章节生成与一致性检查能力。 - -use crate::database::{lock_db, DbConnection}; -use lime_services::api_key_provider_service::ApiKeyProviderService; -use lime_services::provider_pool_service::ProviderPoolService; -use lime_skills::{LimeLlmProvider, LlmProvider}; -use rusqlite::{params, OptionalExtension}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::sync::Arc; -use std::time::Instant; -use uuid::Uuid; - -const DEFAULT_TARGET_WORDS: i64 = 100_000; -const DEFAULT_MODEL: &str = "default"; -const DEFAULT_RECENT_CHAPTERS: usize = 3; -const NOVEL_SETTINGS_SCHEMA_VERSION: i32 = 1; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MainCharacter { - pub name: String, - pub gender: String, - pub age: String, - pub personality: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SideCharacter { - pub id: String, - pub name: String, - pub nickname: String, - pub gender: String, - pub age: String, - pub relationship: String, - #[serde(rename = "relationshipCustom")] - pub relationship_custom: String, - #[serde(rename = "personalityTags")] - pub personality_tags: Vec, - pub background: String, - pub abilities: String, - pub role: String, - pub arc: String, - #[serde(rename = "arcCustom")] - pub arc_custom: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Antagonist { - pub id: String, - pub name: String, - pub nickname: String, - pub gender: String, - pub age: String, - pub relationship: String, - #[serde(rename = "relationshipCustom")] - pub relationship_custom: String, - #[serde(rename = "personalityTags")] - pub personality_tags: Vec, - pub background: String, - pub abilities: String, - pub role: String, - pub arc: String, - #[serde(rename = "arcCustom")] - pub arc_custom: String, - pub motive: String, - pub fate: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct WorldDetails { - #[serde(rename = "powerSystem")] - pub power_system: String, - pub factions: String, - #[serde(rename = "historyEvents")] - pub history_events: String, - #[serde(rename = "importantLocations")] - pub important_locations: String, - #[serde(rename = "cultureAndTaboos")] - pub culture_and_taboos: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PlotBeat { - pub id: String, - pub title: String, - pub detail: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WritingStyle { - pub narration: String, - pub tones: Vec, - #[serde(rename = "cheatLevel")] - pub cheat_level: String, - #[serde(rename = "focusAreas")] - pub focus_areas: Vec, - #[serde(rename = "wordsPerChapter")] - pub words_per_chapter: i64, - pub temperature: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TabooRule { - pub id: String, - pub content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReferenceWork { - pub id: String, - pub title: String, - pub inspiration: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelSettingsV1 { - pub genres: Vec, - #[serde(rename = "oneLinePitch")] - pub one_line_pitch: String, - #[serde(rename = "mainCharacter")] - pub main_character: MainCharacter, - #[serde(rename = "sideCharacters")] - pub side_characters: Vec, - pub antagonists: Vec, - #[serde(rename = "worldSummary")] - pub world_summary: String, - #[serde(rename = "conflictTheme")] - pub conflict_theme: String, - #[serde(rename = "worldDetails")] - pub world_details: WorldDetails, - pub opening: String, - #[serde(rename = "middleBeats")] - pub middle_beats: Vec, - #[serde(rename = "endingType")] - pub ending_type: String, - pub subplots: Vec, - #[serde(rename = "writingStyle")] - pub writing_style: WritingStyle, - #[serde(rename = "totalWords")] - pub total_words: i64, - #[serde(rename = "chapterWords")] - pub chapter_words: i64, - pub nsfw: bool, - #[serde(rename = "systemNovel")] - pub system_novel: bool, - pub harem: bool, - pub taboos: Vec, - pub references: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelSettingsEnvelope { - pub schema_version: i32, - pub data: NovelSettingsV1, -} - -impl Default for MainCharacter { - fn default() -> Self { - Self { - name: String::new(), - gender: "男".to_string(), - age: String::new(), - personality: String::new(), - } - } -} - -impl Default for SideCharacter { - fn default() -> Self { - Self { - id: Uuid::new_v4().to_string(), - name: String::new(), - nickname: String::new(), - gender: "男".to_string(), - age: String::new(), - relationship: String::new(), - relationship_custom: String::new(), - personality_tags: Vec::new(), - background: String::new(), - abilities: String::new(), - role: String::new(), - arc: String::new(), - arc_custom: String::new(), - } - } -} - -impl Default for Antagonist { - fn default() -> Self { - Self { - id: Uuid::new_v4().to_string(), - name: String::new(), - nickname: String::new(), - gender: "男".to_string(), - age: String::new(), - relationship: String::new(), - relationship_custom: String::new(), - personality_tags: Vec::new(), - background: String::new(), - abilities: String::new(), - role: String::new(), - arc: String::new(), - arc_custom: String::new(), - motive: String::new(), - fate: String::new(), - } - } -} - -impl Default for WritingStyle { - fn default() -> Self { - Self { - narration: "第三人称有限".to_string(), - tones: Vec::new(), - cheat_level: "稳步成长".to_string(), - focus_areas: Vec::new(), - words_per_chapter: 3000, - temperature: 0.7, - } - } -} - -impl Default for NovelSettingsV1 { - fn default() -> Self { - Self { - genres: Vec::new(), - one_line_pitch: String::new(), - main_character: MainCharacter::default(), - side_characters: Vec::new(), - antagonists: Vec::new(), - world_summary: String::new(), - conflict_theme: String::new(), - world_details: WorldDetails::default(), - opening: String::new(), - middle_beats: Vec::new(), - ending_type: String::new(), - subplots: Vec::new(), - writing_style: WritingStyle::default(), - total_words: 100_000, - chapter_words: 3000, - nsfw: false, - system_novel: false, - harem: false, - taboos: Vec::new(), - references: Vec::new(), - } - } -} - -impl Default for NovelSettingsEnvelope { - fn default() -> Self { - Self { - schema_version: NOVEL_SETTINGS_SCHEMA_VERSION, - data: NovelSettingsV1::default(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelProject { - pub id: String, - pub title: String, - pub theme: Option, - pub target_words: i64, - pub status: String, - pub current_word_count: i64, - pub metadata_json: Option, - pub created_at: i64, - pub updated_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelSettingsRecord { - pub id: String, - pub project_id: String, - pub settings_json: NovelSettingsEnvelope, - pub version: i32, - pub created_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelOutlineRecord { - pub id: String, - pub project_id: String, - pub outline_markdown: String, - pub outline_json: Option, - pub version: i32, - pub created_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelCharacterRecord { - pub id: String, - pub project_id: String, - pub name: String, - pub role_type: String, - pub card_json: Value, - pub version: i32, - pub created_at: i64, - pub updated_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelChapterRecord { - pub id: String, - pub project_id: String, - pub chapter_no: i32, - pub title: String, - pub content: String, - pub word_count: i64, - pub status: String, - pub quality_score: Option, - pub metadata_json: Option, - pub created_at: i64, - pub updated_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelGenerationRun { - pub id: String, - pub project_id: String, - pub mode: String, - pub input_snapshot_json: Option, - pub output_snapshot_json: Option, - pub provider: Option, - pub model: Option, - pub latency_ms: Option, - pub token_usage_json: Option, - pub result_status: String, - pub error_message: Option, - pub created_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelConsistencyIssue { - pub level: String, - pub code: String, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelConsistencyCheck { - pub id: String, - pub project_id: String, - pub chapter_id: String, - pub issues: Vec, - pub score: f64, - pub created_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelProjectSnapshot { - pub project: NovelProject, - pub latest_settings: Option, - pub latest_outline: Option, - pub characters: Vec, - pub chapters: Vec, - pub latest_consistency: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NovelGenerateResult { - pub mode: String, - pub run_id: String, - pub content: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub chapter: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct NovelCreateProjectRequest { - #[serde(default)] - pub id: Option, - pub title: String, - #[serde(default)] - pub theme: Option, - #[serde(default)] - pub target_words: Option, - #[serde(default)] - pub metadata_json: Option, - #[serde(default)] - pub settings_json: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct NovelUpdateSettingsRequest { - pub project_id: String, - pub settings_json: Value, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct NovelGenerateRequest { - pub project_id: String, - #[serde(default)] - pub provider: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub temperature: Option, - #[serde(default)] - pub max_tokens: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct NovelGenerateChapterRequest { - pub project_id: String, - #[serde(default)] - pub chapter_no: Option, - #[serde(default)] - pub force_overwrite: Option, - #[serde(default)] - pub provider: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub temperature: Option, - #[serde(default)] - pub max_tokens: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct NovelRewriteChapterRequest { - pub project_id: String, - pub chapter_id: String, - #[serde(default)] - pub instructions: Option, - #[serde(default)] - pub provider: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub temperature: Option, - #[serde(default)] - pub max_tokens: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct NovelPolishChapterRequest { - pub project_id: String, - pub chapter_id: String, - #[serde(default)] - pub focus: Option, - #[serde(default)] - pub provider: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub temperature: Option, - #[serde(default)] - pub max_tokens: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct NovelCheckConsistencyRequest { - pub project_id: String, - pub chapter_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct NovelListRunsRequest { - pub project_id: String, - #[serde(default)] - pub limit: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct NovelDeleteCharacterRequest { - pub project_id: String, - pub character_id: String, -} - -#[derive(Clone)] -pub struct NovelService { - db: DbConnection, -} - -impl NovelService { - pub fn new(db: DbConnection) -> Self { - Self { db } - } - - pub fn create_project( - &self, - request: NovelCreateProjectRequest, - ) -> Result { - let now = chrono::Utc::now().timestamp_millis(); - let project_id = request.id.unwrap_or_else(|| Uuid::new_v4().to_string()); - let settings_id = Uuid::new_v4().to_string(); - let target_words = request.target_words.unwrap_or(DEFAULT_TARGET_WORDS); - let metadata_json_str = request - .metadata_json - .as_ref() - .map(serde_json::to_string) - .transpose() - .map_err(|e| format!("序列化 metadata_json 失败: {e}"))?; - let settings_envelope = normalize_settings_envelope_from_value( - request.settings_json.unwrap_or_else(|| json!({})), - ); - let settings_json_str = serde_json::to_string(&settings_envelope) - .map_err(|e| format!("序列化 settings_json 失败: {e}"))?; - - let mut conn = lock_db(&self.db)?; - let tx = conn - .transaction() - .map_err(|e| format!("开启事务失败: {e}"))?; - - let already_exists = tx - .query_row( - "SELECT 1 FROM novel_projects WHERE id = ?1 LIMIT 1", - params![&project_id], - |_| Ok(()), - ) - .optional() - .map_err(|e| format!("检查小说项目是否存在失败: {e}"))? - .is_some(); - if already_exists { - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - drop(conn); - return self - .get_project(&project_id)? - .ok_or_else(|| "项目已存在但读取失败".to_string()); - } - - tx.execute( - "INSERT INTO novel_projects (id, title, theme, target_words, status, current_word_count, metadata_json, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, 'draft', 0, ?5, ?6, ?7)", - params![ - &project_id, - &request.title, - &request.theme, - target_words, - &metadata_json_str, - now, - now - ], - ) - .map_err(|e| format!("创建小说项目失败: {e}"))?; - - tx.execute( - "INSERT INTO novel_settings (id, project_id, settings_json, version, created_at) - VALUES (?1, ?2, ?3, 1, ?4)", - params![&settings_id, &project_id, &settings_json_str, now], - ) - .map_err(|e| format!("初始化小说设定失败: {e}"))?; - - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - drop(conn); - - self.get_project(&project_id)? - .ok_or_else(|| "项目创建成功但读取失败".to_string()) - } - - pub fn get_project(&self, project_id: &str) -> Result, String> { - let conn = lock_db(&self.db)?; - let result = conn.query_row( - "SELECT id, title, theme, target_words, status, current_word_count, metadata_json, created_at, updated_at - FROM novel_projects WHERE id = ?1", - params![project_id], - row_to_project, - ); - - match result { - Ok(project) => Ok(Some(project)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(format!("读取项目失败: {e}")), - } - } - - pub fn update_settings( - &self, - request: NovelUpdateSettingsRequest, - ) -> Result { - let now = chrono::Utc::now().timestamp_millis(); - let id = Uuid::new_v4().to_string(); - let settings_envelope = normalize_settings_envelope_from_value(request.settings_json); - let settings_json = serde_json::to_string(&settings_envelope) - .map_err(|e| format!("序列化 settings_json 失败: {e}"))?; - let mut conn = lock_db(&self.db)?; - let tx = conn - .transaction() - .map_err(|e| format!("开启事务失败: {e}"))?; - - let next_version = query_next_version( - &tx, - "SELECT COALESCE(MAX(version), 0) + 1 FROM novel_settings WHERE project_id = ?1", - &request.project_id, - )?; - - tx.execute( - "INSERT INTO novel_settings (id, project_id, settings_json, version, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![id, &request.project_id, settings_json, next_version, now], - ) - .map_err(|e| format!("写入小说设定失败: {e}"))?; - - tx.execute( - "UPDATE novel_projects SET updated_at = ?1 WHERE id = ?2", - params![now, &request.project_id], - ) - .map_err(|e| format!("更新项目时间失败: {e}"))?; - - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - drop(conn); - self.get_latest_settings(&request.project_id)? - .ok_or_else(|| "设定更新后读取失败".to_string()) - } - - pub fn get_latest_settings( - &self, - project_id: &str, - ) -> Result, String> { - let conn = lock_db(&self.db)?; - let result = conn.query_row( - "SELECT id, project_id, settings_json, version, created_at - FROM novel_settings WHERE project_id = ?1 - ORDER BY version DESC LIMIT 1", - params![project_id], - row_to_settings, - ); - - match result { - Ok(record) => Ok(Some(record)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(format!("读取最新设定失败: {e}")), - } - } - - pub async fn generate_outline( - &self, - request: NovelGenerateRequest, - ) -> Result { - self.ensure_project_exists(&request.project_id)?; - let settings = self.get_latest_settings(&request.project_id)?; - let prompt = build_outline_prompt(settings.as_ref().map(|s| &s.settings_json)); - self.generate_and_save_outline(&request, &prompt).await - } - - pub async fn generate_characters( - &self, - request: NovelGenerateRequest, - ) -> Result { - self.ensure_project_exists(&request.project_id)?; - let settings = self.get_latest_settings(&request.project_id)?; - let prompt = build_characters_prompt(settings.as_ref().map(|s| &s.settings_json)); - self.generate_and_save_characters(&request, &prompt).await - } - - pub async fn generate_chapter( - &self, - request: NovelGenerateChapterRequest, - ) -> Result { - self.ensure_project_exists(&request.project_id)?; - let settings = self.get_latest_settings(&request.project_id)?; - let outline = self.get_latest_outline(&request.project_id)?; - let characters = self.list_characters(&request.project_id)?; - let chapters = self.list_chapters(&request.project_id)?; - let chapter_no = request - .chapter_no - .unwrap_or_else(|| chapters.len() as i32 + 1); - let prompt = build_chapter_prompt( - settings.as_ref().map(|s| &s.settings_json), - outline.as_ref().map(|o| o.outline_markdown.as_str()), - &characters, - &chapters, - chapter_no, - ); - - self.generate_and_upsert_chapter( - &request.project_id, - "generate", - prompt, - chapter_no, - request.force_overwrite.unwrap_or(false), - request.provider.clone(), - request.model.clone(), - request.temperature, - request.max_tokens, - ) - .await - } - - pub async fn continue_chapter( - &self, - request: NovelGenerateRequest, - ) -> Result { - self.ensure_project_exists(&request.project_id)?; - let settings = self.get_latest_settings(&request.project_id)?; - let outline = self.get_latest_outline(&request.project_id)?; - let characters = self.list_characters(&request.project_id)?; - let chapters = self.list_chapters(&request.project_id)?; - let chapter_no = chapters.len() as i32 + 1; - - let prompt = build_continue_prompt( - settings.as_ref().map(|s| &s.settings_json), - outline.as_ref().map(|o| o.outline_markdown.as_str()), - &characters, - &chapters, - chapter_no, - ); - - self.generate_and_upsert_chapter( - &request.project_id, - "continue", - prompt, - chapter_no, - false, - request.provider, - request.model, - request.temperature, - request.max_tokens, - ) - .await - } - - pub async fn rewrite_chapter( - &self, - request: NovelRewriteChapterRequest, - ) -> Result { - self.ensure_project_exists(&request.project_id)?; - let source = self - .get_chapter(&request.chapter_id)? - .ok_or_else(|| "章节不存在".to_string())?; - let settings = self.get_latest_settings(&request.project_id)?; - let prompt = build_rewrite_prompt( - settings.as_ref().map(|s| &s.settings_json), - &source, - request.instructions.as_deref(), - ); - - self.generate_and_update_chapter( - &request.project_id, - "rewrite", - prompt, - &source, - request.provider, - request.model, - request.temperature, - request.max_tokens, - ) - .await - } - - pub async fn polish_chapter( - &self, - request: NovelPolishChapterRequest, - ) -> Result { - self.ensure_project_exists(&request.project_id)?; - let source = self - .get_chapter(&request.chapter_id)? - .ok_or_else(|| "章节不存在".to_string())?; - let settings = self.get_latest_settings(&request.project_id)?; - let prompt = build_polish_prompt( - settings.as_ref().map(|s| &s.settings_json), - &source, - request.focus.as_deref(), - ); - - self.generate_and_update_chapter( - &request.project_id, - "polish", - prompt, - &source, - request.provider, - request.model, - request.temperature, - request.max_tokens, - ) - .await - } - - pub fn check_consistency( - &self, - request: NovelCheckConsistencyRequest, - ) -> Result { - self.ensure_project_exists(&request.project_id)?; - let chapter = self - .get_chapter(&request.chapter_id)? - .ok_or_else(|| "章节不存在".to_string())?; - - let settings = self.get_latest_settings(&request.project_id)?; - let characters = self.list_characters(&request.project_id)?; - let issues = evaluate_consistency(&chapter, settings.as_ref(), &characters); - let score = calculate_score(&issues); - let now = chrono::Utc::now().timestamp_millis(); - let id = Uuid::new_v4().to_string(); - let issues_json = - serde_json::to_string(&issues).map_err(|e| format!("序列化一致性结果失败: {e}"))?; - let mut conn = lock_db(&self.db)?; - let tx = conn - .transaction() - .map_err(|e| format!("开启事务失败: {e}"))?; - - tx.execute( - "INSERT INTO novel_consistency_checks (id, project_id, chapter_id, issues_json, score, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![id, &request.project_id, &request.chapter_id, issues_json, score, now], - ) - .map_err(|e| format!("保存一致性检查失败: {e}"))?; - - tx.execute( - "UPDATE novel_chapters SET quality_score = ?1, updated_at = ?2 WHERE id = ?3", - params![score, now, &request.chapter_id], - ) - .map_err(|e| format!("更新章节质量分失败: {e}"))?; - - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - - Ok(NovelConsistencyCheck { - id, - project_id: request.project_id, - chapter_id: request.chapter_id, - issues, - score, - created_at: now, - }) - } - - pub fn get_project_snapshot(&self, project_id: &str) -> Result { - let project = self - .get_project(project_id)? - .ok_or_else(|| "项目不存在".to_string())?; - let latest_settings = self.get_latest_settings(project_id)?; - let latest_outline = self.get_latest_outline(project_id)?; - let characters = self.list_characters(project_id)?; - let chapters = self.list_chapters(project_id)?; - let latest_consistency = self.get_latest_consistency(project_id)?; - - Ok(NovelProjectSnapshot { - project, - latest_settings, - latest_outline, - characters, - chapters, - latest_consistency, - }) - } - - pub fn list_runs( - &self, - request: NovelListRunsRequest, - ) -> Result, String> { - self.ensure_project_exists(&request.project_id)?; - let limit = request.limit.unwrap_or(50).clamp(1, 500); - let conn = lock_db(&self.db)?; - let mut stmt = conn - .prepare( - "SELECT id, project_id, mode, input_snapshot_json, output_snapshot_json, provider, model, - latency_ms, token_usage_json, result_status, error_message, created_at - FROM novel_generation_runs - WHERE project_id = ?1 - ORDER BY created_at DESC - LIMIT ?2", - ) - .map_err(|e| format!("准备查询 run 失败: {e}"))?; - - let rows = stmt - .query_map(params![&request.project_id, limit], row_to_run) - .map_err(|e| format!("查询 run 失败: {e}"))? - .collect::, _>>() - .map_err(|e| format!("解析 run 失败: {e}"))?; - - Ok(rows) - } - - pub fn delete_character(&self, request: NovelDeleteCharacterRequest) -> Result { - self.ensure_project_exists(&request.project_id)?; - let now = chrono::Utc::now().timestamp_millis(); - let mut conn = lock_db(&self.db)?; - let tx = conn - .transaction() - .map_err(|e| format!("开启事务失败: {e}"))?; - - let exists = tx - .query_row( - "SELECT 1 FROM novel_characters WHERE id = ?1 AND project_id = ?2 LIMIT 1", - params![&request.character_id, &request.project_id], - |_| Ok(()), - ) - .optional() - .map_err(|e| format!("检查角色是否存在失败: {e}"))? - .is_some(); - - if !exists { - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - return Ok(false); - } - - tx.execute( - "DELETE FROM novel_characters WHERE id = ?1 AND project_id = ?2", - params![&request.character_id, &request.project_id], - ) - .map_err(|e| format!("删除角色失败: {e}"))?; - - tx.execute( - "UPDATE novel_projects SET updated_at = ?1 WHERE id = ?2", - params![now, &request.project_id], - ) - .map_err(|e| format!("更新项目时间失败: {e}"))?; - - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - Ok(true) - } - - fn list_characters(&self, project_id: &str) -> Result, String> { - let conn = lock_db(&self.db)?; - let mut stmt = conn - .prepare( - "SELECT id, project_id, name, role_type, card_json, version, created_at, updated_at - FROM novel_characters - WHERE project_id = ?1 - ORDER BY created_at ASC", - ) - .map_err(|e| format!("准备查询角色失败: {e}"))?; - let rows = stmt - .query_map(params![project_id], row_to_character) - .map_err(|e| format!("查询角色失败: {e}"))? - .collect::, _>>() - .map_err(|e| format!("解析角色失败: {e}"))?; - Ok(rows) - } - - fn list_chapters(&self, project_id: &str) -> Result, String> { - let conn = lock_db(&self.db)?; - let mut stmt = conn - .prepare( - "SELECT id, project_id, chapter_no, title, content, word_count, status, quality_score, - metadata_json, created_at, updated_at - FROM novel_chapters WHERE project_id = ?1 - ORDER BY chapter_no ASC", - ) - .map_err(|e| format!("准备查询章节失败: {e}"))?; - - let rows = stmt - .query_map(params![project_id], row_to_chapter) - .map_err(|e| format!("查询章节失败: {e}"))? - .collect::, _>>() - .map_err(|e| format!("解析章节失败: {e}"))?; - - Ok(rows) - } - - fn get_chapter(&self, chapter_id: &str) -> Result, String> { - let conn = lock_db(&self.db)?; - let result = conn.query_row( - "SELECT id, project_id, chapter_no, title, content, word_count, status, quality_score, - metadata_json, created_at, updated_at - FROM novel_chapters WHERE id = ?1", - params![chapter_id], - row_to_chapter, - ); - - match result { - Ok(chapter) => Ok(Some(chapter)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(format!("读取章节失败: {e}")), - } - } - - fn get_latest_outline(&self, project_id: &str) -> Result, String> { - let conn = lock_db(&self.db)?; - let result = conn.query_row( - "SELECT id, project_id, outline_markdown, outline_json, version, created_at - FROM novel_outlines - WHERE project_id = ?1 - ORDER BY version DESC - LIMIT 1", - params![project_id], - row_to_outline, - ); - - match result { - Ok(record) => Ok(Some(record)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(format!("读取大纲失败: {e}")), - } - } - - fn get_latest_consistency( - &self, - project_id: &str, - ) -> Result, String> { - let conn = lock_db(&self.db)?; - let result = conn.query_row( - "SELECT id, project_id, chapter_id, issues_json, score, created_at - FROM novel_consistency_checks - WHERE project_id = ?1 - ORDER BY created_at DESC - LIMIT 1", - params![project_id], - |row| { - let issues_json: String = row.get(3)?; - let issues: Vec = - serde_json::from_str(&issues_json).unwrap_or_default(); - Ok(NovelConsistencyCheck { - id: row.get(0)?, - project_id: row.get(1)?, - chapter_id: row.get(2)?, - issues, - score: row.get(4)?, - created_at: row.get(5)?, - }) - }, - ); - - match result { - Ok(check) => Ok(Some(check)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(format!("读取一致性检查失败: {e}")), - } - } - - fn ensure_project_exists(&self, project_id: &str) -> Result<(), String> { - if self.get_project(project_id)?.is_none() { - return Err("小说项目不存在".to_string()); - } - Ok(()) - } - - async fn generate_and_save_outline( - &self, - request: &NovelGenerateRequest, - prompt: &str, - ) -> Result { - let (model_used, generated, latency_ms) = self - .call_llm( - prompt, - request.provider.as_deref(), - request.model.as_deref(), - request.temperature, - request.max_tokens, - ) - .await?; - - let now = chrono::Utc::now().timestamp_millis(); - let id = Uuid::new_v4().to_string(); - let run_id = Uuid::new_v4().to_string(); - let mut conn = lock_db(&self.db)?; - let tx = conn - .transaction() - .map_err(|e| format!("开启事务失败: {e}"))?; - - let next_version = query_next_version( - &tx, - "SELECT COALESCE(MAX(version), 0) + 1 FROM novel_outlines WHERE project_id = ?1", - &request.project_id, - )?; - - tx.execute( - "INSERT INTO novel_outlines (id, project_id, outline_markdown, outline_json, version, created_at) - VALUES (?1, ?2, ?3, NULL, ?4, ?5)", - params![id, &request.project_id, &generated, next_version, now], - ) - .map_err(|e| format!("写入大纲失败: {e}"))?; - - self.insert_run_with_tx( - &tx, - InsertRunParams { - run_id: &run_id, - project_id: &request.project_id, - mode: "outline", - input_snapshot: json!({ "prompt": prompt }), - output_snapshot: json!({ "outline": generated }), - model: &model_used, - latency_ms, - status: "success", - error_message: None, - created_at: now, - }, - )?; - - tx.execute( - "UPDATE novel_projects SET updated_at = ?1 WHERE id = ?2", - params![now, &request.project_id], - ) - .map_err(|e| format!("更新项目时间失败: {e}"))?; - - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - - Ok(NovelGenerateResult { - mode: "outline".to_string(), - run_id, - content: generated, - chapter: None, - }) - } - - async fn generate_and_save_characters( - &self, - request: &NovelGenerateRequest, - prompt: &str, - ) -> Result { - let (model_used, generated, latency_ms) = self - .call_llm( - prompt, - request.provider.as_deref(), - request.model.as_deref(), - request.temperature, - request.max_tokens, - ) - .await?; - - let cards = parse_character_cards(&generated); - let now = chrono::Utc::now().timestamp_millis(); - let run_id = Uuid::new_v4().to_string(); - let mut conn = lock_db(&self.db)?; - let tx = conn - .transaction() - .map_err(|e| format!("开启事务失败: {e}"))?; - let next_version = query_next_version( - &tx, - "SELECT COALESCE(MAX(version), 0) + 1 FROM novel_characters WHERE project_id = ?1", - &request.project_id, - )?; - - tx.execute( - "DELETE FROM novel_characters WHERE project_id = ?1", - params![&request.project_id], - ) - .map_err(|e| format!("清理旧角色失败: {e}"))?; - - for (index, card) in cards.iter().enumerate() { - let id = Uuid::new_v4().to_string(); - let name = card - .get("name") - .and_then(Value::as_str) - .filter(|s| !s.trim().is_empty()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| format!("角色{}", index + 1)); - let role_type = card - .get("role_type") - .and_then(Value::as_str) - .unwrap_or("support"); - let card_json = serde_json::to_string(card).map_err(|e| e.to_string())?; - - tx.execute( - "INSERT INTO novel_characters (id, project_id, name, role_type, card_json, version, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - id, - &request.project_id, - name, - role_type, - card_json, - next_version, - now, - now - ], - ) - .map_err(|e| format!("写入角色失败: {e}"))?; - } - - self.insert_run_with_tx( - &tx, - InsertRunParams { - run_id: &run_id, - project_id: &request.project_id, - mode: "characters", - input_snapshot: json!({ "prompt": prompt }), - output_snapshot: json!({ "raw": generated, "cards": cards }), - model: &model_used, - latency_ms, - status: "success", - error_message: None, - created_at: now, - }, - )?; - - tx.execute( - "UPDATE novel_projects SET updated_at = ?1 WHERE id = ?2", - params![now, &request.project_id], - ) - .map_err(|e| format!("更新项目时间失败: {e}"))?; - - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - - Ok(NovelGenerateResult { - mode: "characters".to_string(), - run_id, - content: generated, - chapter: None, - }) - } - - async fn generate_and_upsert_chapter( - &self, - project_id: &str, - mode: &str, - prompt: String, - chapter_no: i32, - force_overwrite: bool, - provider: Option, - model: Option, - temperature: Option, - max_tokens: Option, - ) -> Result { - let (model_used, generated, latency_ms) = self - .call_llm( - &prompt, - provider.as_deref(), - model.as_deref(), - temperature, - max_tokens, - ) - .await?; - let (title, content) = split_title_and_content(&generated, chapter_no); - let chapter_word_count = count_words(&content); - let now = chrono::Utc::now().timestamp_millis(); - let run_id = Uuid::new_v4().to_string(); - - let mut conn = lock_db(&self.db)?; - let tx = conn - .transaction() - .map_err(|e| format!("开启事务失败: {e}"))?; - let existing = tx - .query_row( - "SELECT id FROM novel_chapters WHERE project_id = ?1 AND chapter_no = ?2", - params![project_id, chapter_no], - |row| row.get::<_, String>(0), - ) - .ok(); - - let chapter = if let Some(existing_id) = existing { - if !force_overwrite { - return Err(format!( - "第 {} 章已存在,若要覆盖请设置 force_overwrite=true", - chapter_no - )); - } - tx.execute( - "UPDATE novel_chapters - SET title = ?1, content = ?2, word_count = ?3, status = 'draft', quality_score = NULL, updated_at = ?4 - WHERE id = ?5", - params![&title, &content, chapter_word_count, now, &existing_id], - ) - .map_err(|e| format!("覆盖章节失败: {e}"))?; - self.fetch_chapter_with_tx(&tx, &existing_id)? - } else { - let chapter_id = Uuid::new_v4().to_string(); - tx.execute( - "INSERT INTO novel_chapters - (id, project_id, chapter_no, title, content, word_count, status, quality_score, metadata_json, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'draft', NULL, NULL, ?7, ?8)", - params![ - &chapter_id, - project_id, - chapter_no, - &title, - &content, - chapter_word_count, - now, - now - ], - ) - .map_err(|e| format!("写入章节失败: {e}"))?; - self.fetch_chapter_with_tx(&tx, &chapter_id)? - }; - - self.recalculate_project_word_count_with_tx(&tx, project_id, now)?; - self.insert_run_with_tx( - &tx, - InsertRunParams { - run_id: &run_id, - project_id, - mode, - input_snapshot: json!({ "prompt": prompt, "chapter_no": chapter_no }), - output_snapshot: json!({ "title": title, "content": content }), - model: &model_used, - latency_ms, - status: "success", - error_message: None, - created_at: now, - }, - )?; - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - - Ok(NovelGenerateResult { - mode: mode.to_string(), - run_id, - content: generated, - chapter: Some(chapter), - }) - } - - async fn generate_and_update_chapter( - &self, - project_id: &str, - mode: &str, - prompt: String, - source: &NovelChapterRecord, - provider: Option, - model: Option, - temperature: Option, - max_tokens: Option, - ) -> Result { - let (model_used, generated, latency_ms) = self - .call_llm( - &prompt, - provider.as_deref(), - model.as_deref(), - temperature, - max_tokens, - ) - .await?; - let (title, content) = split_title_and_content(&generated, source.chapter_no); - let new_word_count = count_words(&content); - let now = chrono::Utc::now().timestamp_millis(); - let run_id = Uuid::new_v4().to_string(); - - let mut conn = lock_db(&self.db)?; - let tx = conn - .transaction() - .map_err(|e| format!("开启事务失败: {e}"))?; - - tx.execute( - "UPDATE novel_chapters - SET title = ?1, content = ?2, word_count = ?3, status = 'draft', quality_score = NULL, updated_at = ?4 - WHERE id = ?5 AND project_id = ?6", - params![&title, &content, new_word_count, now, &source.id, project_id], - ) - .map_err(|e| format!("更新章节失败: {e}"))?; - - let chapter = self.fetch_chapter_with_tx(&tx, &source.id)?; - self.recalculate_project_word_count_with_tx(&tx, project_id, now)?; - self.insert_run_with_tx( - &tx, - InsertRunParams { - run_id: &run_id, - project_id, - mode, - input_snapshot: json!({ - "prompt": prompt, - "chapter_id": source.id, - "chapter_no": source.chapter_no - }), - output_snapshot: json!({ "title": title, "content": content }), - model: &model_used, - latency_ms, - status: "success", - error_message: None, - created_at: now, - }, - )?; - - tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; - - Ok(NovelGenerateResult { - mode: mode.to_string(), - run_id, - content: generated, - chapter: Some(chapter), - }) - } - - fn fetch_chapter_with_tx( - &self, - tx: &rusqlite::Transaction<'_>, - chapter_id: &str, - ) -> Result { - tx.query_row( - "SELECT id, project_id, chapter_no, title, content, word_count, status, quality_score, - metadata_json, created_at, updated_at - FROM novel_chapters WHERE id = ?1", - params![chapter_id], - row_to_chapter, - ) - .map_err(|e| format!("读取章节失败: {e}")) - } - - fn recalculate_project_word_count_with_tx( - &self, - tx: &rusqlite::Transaction<'_>, - project_id: &str, - now: i64, - ) -> Result<(), String> { - let total_words: i64 = tx - .query_row( - "SELECT COALESCE(SUM(word_count), 0) FROM novel_chapters WHERE project_id = ?1", - params![project_id], - |row| row.get(0), - ) - .map_err(|e| format!("统计章节字数失败: {e}"))?; - - tx.execute( - "UPDATE novel_projects - SET current_word_count = ?1, updated_at = ?2 - WHERE id = ?3", - params![total_words, now, project_id], - ) - .map_err(|e| format!("更新项目字数失败: {e}"))?; - - Ok(()) - } - - fn insert_run_with_tx( - &self, - tx: &rusqlite::Transaction<'_>, - params_data: InsertRunParams<'_>, - ) -> Result<(), String> { - let input_json = serde_json::to_string(¶ms_data.input_snapshot) - .map_err(|e| format!("序列化 input_snapshot 失败: {e}"))?; - let output_json = serde_json::to_string(¶ms_data.output_snapshot) - .map_err(|e| format!("序列化 output_snapshot 失败: {e}"))?; - let token_usage_json = serde_json::to_string(&json!({ "tracked": false })) - .map_err(|e| format!("序列化 token_usage_json 失败: {e}"))?; - - tx.execute( - "INSERT INTO novel_generation_runs - (id, project_id, mode, input_snapshot_json, output_snapshot_json, provider, model, - latency_ms, token_usage_json, result_status, error_message, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, 'local_proxy', ?6, ?7, ?8, ?9, ?10, ?11)", - params![ - params_data.run_id, - params_data.project_id, - params_data.mode, - input_json, - output_json, - params_data.model, - params_data.latency_ms, - token_usage_json, - params_data.status, - params_data.error_message, - params_data.created_at - ], - ) - .map_err(|e| format!("写入生成运行记录失败: {e}"))?; - - Ok(()) - } - - async fn call_llm( - &self, - prompt: &str, - provider: Option<&str>, - model: Option<&str>, - _temperature: Option, - _max_tokens: Option, - ) -> Result<(String, String, i64), String> { - let used_model = model.unwrap_or(DEFAULT_MODEL).to_string(); - let start = Instant::now(); - let pool_service = Arc::new(ProviderPoolService::new()); - let api_key_service = Arc::new(ApiKeyProviderService::new()); - let system_prompt = - "你是专业中文长篇小说创作助手。严格遵守设定,输出稳定、连贯、可直接发布的文本。"; - let preferred_provider = normalize_provider(provider); - - let llm = if let Some(provider_name) = preferred_provider { - LimeLlmProvider::with_preferred_provider( - pool_service, - api_key_service, - self.db.clone(), - provider_name, - ) - } else { - LimeLlmProvider::new(pool_service, api_key_service, self.db.clone()) - }; - - let content = llm - .chat(system_prompt, prompt, Some(&used_model)) - .await - .map_err(|e| format!("调用模型失败: {e}"))?; - - if content.trim().is_empty() { - return Err("模型返回空内容".to_string()); - } - - Ok((used_model, content, start.elapsed().as_millis() as i64)) - } -} - -fn normalize_provider(provider: Option<&str>) -> Option { - provider - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.to_lowercase()) -} - -struct InsertRunParams<'a> { - run_id: &'a str, - project_id: &'a str, - mode: &'a str, - input_snapshot: Value, - output_snapshot: Value, - model: &'a str, - latency_ms: i64, - status: &'a str, - error_message: Option, - created_at: i64, -} - -fn query_next_version( - tx: &rusqlite::Transaction<'_>, - sql: &str, - project_id: &str, -) -> Result { - tx.query_row(sql, params![project_id], |row| row.get(0)) - .map_err(|e| format!("查询版本号失败: {e}")) -} - -fn row_to_project(row: &rusqlite::Row<'_>) -> Result { - let metadata_json: Option = row.get(6)?; - Ok(NovelProject { - id: row.get(0)?, - title: row.get(1)?, - theme: row.get(2)?, - target_words: row.get(3)?, - status: row.get(4)?, - current_word_count: row.get(5)?, - metadata_json: metadata_json.and_then(|s| serde_json::from_str(&s).ok()), - created_at: row.get(7)?, - updated_at: row.get(8)?, - }) -} - -fn row_to_settings(row: &rusqlite::Row<'_>) -> Result { - let settings_json: String = row.get(2)?; - let parsed = serde_json::from_str::(&settings_json).unwrap_or_else(|_| json!({})); - Ok(NovelSettingsRecord { - id: row.get(0)?, - project_id: row.get(1)?, - settings_json: normalize_settings_envelope_from_value(parsed), - version: row.get(3)?, - created_at: row.get(4)?, - }) -} - -fn row_to_outline(row: &rusqlite::Row<'_>) -> Result { - let outline_json: Option = row.get(3)?; - Ok(NovelOutlineRecord { - id: row.get(0)?, - project_id: row.get(1)?, - outline_markdown: row.get(2)?, - outline_json: outline_json.and_then(|s| serde_json::from_str(&s).ok()), - version: row.get(4)?, - created_at: row.get(5)?, - }) -} - -fn row_to_character(row: &rusqlite::Row<'_>) -> Result { - let card_json: String = row.get(4)?; - Ok(NovelCharacterRecord { - id: row.get(0)?, - project_id: row.get(1)?, - name: row.get(2)?, - role_type: row.get(3)?, - card_json: serde_json::from_str(&card_json).unwrap_or_else(|_| json!({})), - version: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - }) -} - -fn row_to_chapter(row: &rusqlite::Row<'_>) -> Result { - let metadata_json: Option = row.get(8)?; - Ok(NovelChapterRecord { - id: row.get(0)?, - project_id: row.get(1)?, - chapter_no: row.get(2)?, - title: row.get(3)?, - content: row.get(4)?, - word_count: row.get(5)?, - status: row.get(6)?, - quality_score: row.get(7)?, - metadata_json: metadata_json.and_then(|s| serde_json::from_str(&s).ok()), - created_at: row.get(9)?, - updated_at: row.get(10)?, - }) -} - -fn row_to_run(row: &rusqlite::Row<'_>) -> Result { - let input_snapshot_json: Option = row.get(3)?; - let output_snapshot_json: Option = row.get(4)?; - let token_usage_json: Option = row.get(8)?; - Ok(NovelGenerationRun { - id: row.get(0)?, - project_id: row.get(1)?, - mode: row.get(2)?, - input_snapshot_json: input_snapshot_json.and_then(|s| serde_json::from_str(&s).ok()), - output_snapshot_json: output_snapshot_json.and_then(|s| serde_json::from_str(&s).ok()), - provider: row.get(5)?, - model: row.get(6)?, - latency_ms: row.get(7)?, - token_usage_json: token_usage_json.and_then(|s| serde_json::from_str(&s).ok()), - result_status: row.get(9)?, - error_message: row.get(10)?, - created_at: row.get(11)?, - }) -} - -fn parse_character_cards(raw: &str) -> Vec { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Vec::new(); - } - - if let Some(cards) = parse_character_cards_from_json_text(trimmed) { - return cards; - } - - let fenced = extract_first_markdown_code_block(trimmed); - if let Some(code) = fenced.as_ref() { - if let Some(cards) = parse_character_cards_from_json_text(code) { - return cards; - } - } - - if let Some(array_text) = extract_json_array_text(trimmed) { - if let Some(cards) = parse_character_cards_from_json_text(&array_text) { - return cards; - } - } - - if let Some(code) = fenced.as_ref() { - if let Some(array_text) = extract_json_array_text(code) { - if let Some(cards) = parse_character_cards_from_json_text(&array_text) { - return cards; - } - } - } - - // 兜底:按行解析成简单角色卡(过滤 JSON 噪音行) - raw.lines() - .filter_map(|line| { - let name = sanitize_fallback_name(line)?; - Some(json!({ - "name": name, - "role_type": "support", - "description": "" - })) - }) - .collect() -} - -fn parse_character_cards_from_json_text(text: &str) -> Option> { - let value = serde_json::from_str::(text).ok()?; - extract_character_cards_from_value(&value) -} - -fn extract_character_cards_from_value(value: &Value) -> Option> { - if let Some(arr) = value.as_array() { - return Some( - arr.iter() - .enumerate() - .map(|(idx, item)| normalize_character_card(item, idx)) - .collect(), - ); - } - - let obj = value.as_object()?; - for key in [ - "characters", - "character_cards", - "cards", - "result", - "data", - "roles", - ] { - if let Some(arr) = obj.get(key).and_then(Value::as_array) { - return Some( - arr.iter() - .enumerate() - .map(|(idx, item)| normalize_character_card(item, idx)) - .collect(), - ); - } - } - - if obj.get("name").is_some() || obj.get("role_type").is_some() { - return Some(vec![normalize_character_card(value, 0)]); - } - - None -} - -fn normalize_character_card(value: &Value, index: usize) -> Value { - let mut obj = value.as_object().cloned().unwrap_or_default(); - let name = extract_character_name(&obj).unwrap_or_else(|| format!("角色{}", index + 1)); - let role_type = extract_role_type(&obj); - obj.insert("name".to_string(), Value::String(name)); - obj.insert("role_type".to_string(), Value::String(role_type)); - Value::Object(obj) -} - -fn extract_character_name(obj: &serde_json::Map) -> Option { - let candidate_keys = ["name", "character_name", "characterName", "角色名", "角色"]; - for key in candidate_keys { - if let Some(raw_name) = obj.get(key).and_then(Value::as_str) { - if let Some(name) = sanitize_candidate_name(raw_name) { - return Some(name); - } - } - } - None -} - -fn extract_role_type(obj: &serde_json::Map) -> String { - let candidate_keys = ["role_type", "roleType", "type", "role", "角色类型"]; - for key in candidate_keys { - if let Some(value) = obj.get(key).and_then(Value::as_str) { - return normalize_role_type(value); - } - } - "support".to_string() -} - -fn normalize_role_type(raw: &str) -> String { - let lowered = raw.trim().to_lowercase(); - if lowered.contains("main") - || lowered.contains("protagonist") - || lowered.contains("主角") - || lowered.contains("主人公") - { - return "main".to_string(); - } - if lowered.contains("antagonist") - || lowered.contains("villain") - || lowered.contains("反派") - || lowered.contains("敌人") - { - return "antagonist".to_string(); - } - "support".to_string() -} - -fn extract_first_markdown_code_block(raw: &str) -> Option { - let start = raw.find("```")?; - let remain = &raw[start + 3..]; - let content_start = remain.find('\n')?; - let content = &remain[content_start + 1..]; - let end = content.find("```")?; - Some(content[..end].trim().to_string()) -} - -fn extract_json_array_text(raw: &str) -> Option { - let mut depth = 0usize; - let mut start_index: Option = None; - let mut in_string = false; - let mut escaped = false; - - for (idx, ch) in raw.char_indices() { - if in_string { - if escaped { - escaped = false; - continue; - } - if ch == '\\' { - escaped = true; - continue; - } - if ch == '"' { - in_string = false; - } - continue; - } - - match ch { - '"' => in_string = true, - '[' => { - if depth == 0 { - start_index = Some(idx); - } - depth += 1; - } - ']' => { - if depth == 0 { - continue; - } - depth -= 1; - if depth == 0 { - if let Some(start) = start_index { - return Some(raw[start..idx + 1].to_string()); - } - } - } - _ => {} - } - } - - None -} - -fn sanitize_fallback_name(line: &str) -> Option { - let mut normalized = line.trim(); - normalized = normalized - .trim_start_matches('-') - .trim_start_matches('*') - .trim(); - if normalized.is_empty() { - return None; - } - sanitize_candidate_name(normalized) -} - -fn sanitize_candidate_name(raw: &str) -> Option { - let normalized = raw.trim().trim_matches('"').trim_end_matches(',').trim(); - if normalized.is_empty() { - return None; - } - if looks_like_json_noise_line(normalized) { - return None; - } - Some(normalized.to_string()) -} - -fn looks_like_json_noise_line(line: &str) -> bool { - let trimmed = line.trim(); - if trimmed.is_empty() { - return true; - } - let lowered = trimmed.to_ascii_lowercase(); - if trimmed.starts_with("```") - || trimmed.ends_with("```") - || matches!( - lowered.as_str(), - "```" | "```json" | "```yaml" | "```yml" | "json" | "yaml" | "yml" - ) - { - return true; - } - if matches!(trimmed, "{" | "}" | "[" | "]" | ",") { - return true; - } - if trimmed.starts_with("//") { - return true; - } - if trimmed.contains("\":") || trimmed.ends_with(':') { - return true; - } - false -} - -fn split_title_and_content(raw: &str, chapter_no: i32) -> (String, String) { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return (format!("第{}章", chapter_no), String::new()); - } - - let mut lines = trimmed.lines(); - let first = lines.next().unwrap_or_default().trim(); - let normalized_title = first - .trim_start_matches('#') - .trim_start_matches('第') - .trim() - .to_string(); - - let has_heading = first.starts_with('#') || first.starts_with("第"); - if has_heading && !normalized_title.is_empty() { - let content = lines.collect::>().join("\n").trim().to_string(); - let title = if first.starts_with('#') { - first.trim_start_matches('#').trim().to_string() - } else { - first.to_string() - }; - return (title, content); - } - - (format!("第{}章", chapter_no), trimmed.to_string()) -} - -fn count_words(text: &str) -> i64 { - let mut count = 0i64; - let mut in_word = false; - for c in text.chars() { - if c.is_whitespace() { - in_word = false; - } else if c.is_ascii_alphanumeric() { - if !in_word { - count += 1; - in_word = true; - } - } else if !c.is_ascii_punctuation() { - count += 1; - in_word = false; - } - } - count -} - -fn build_outline_prompt(settings: Option<&NovelSettingsEnvelope>) -> String { - let settings_context = build_settings_context(settings); - format!( - "请根据以下小说设定生成结构化大纲,输出 Markdown。\n\n【设定】\n{}\n\n要求:\n1. 给出三幕式总结构。\n2. 拆分到至少 12 章。\n3. 每章包含目标、冲突、反转点。\n4. 保证人物弧线与世界规则一致。", - settings_context - ) -} - -fn build_characters_prompt(settings: Option<&NovelSettingsEnvelope>) -> String { - let settings_context = build_settings_context(settings); - format!( - "请根据设定生成角色卡,输出 JSON 数组。\n\n每个元素至少包含:name、role_type、personality、background、motivation、relationship、arc、abilities。\n\n角色数量要求:\n1. 必须包含主角(role_type=main)。\n2. 包含 2-5 个关键配角(role_type=support)。\n3. 如有反派设定,至少包含 1 个 antagonist。\n\n【设定】\n{}", - settings_context - ) -} - -fn build_chapter_prompt( - settings: Option<&NovelSettingsEnvelope>, - outline: Option<&str>, - characters: &[NovelCharacterRecord], - chapters: &[NovelChapterRecord], - chapter_no: i32, -) -> String { - let settings_context = build_settings_context(settings); - let recent_summary = summarize_recent_chapters(chapters, DEFAULT_RECENT_CHAPTERS); - let target_words = extract_target_chapter_words(settings).unwrap_or(3000); - let character_context = summarize_character_cards(characters); - format!( - "你正在创作长篇小说第 {chapter_no} 章。\n\n【创作设定】\n{settings}\n\n【大纲】\n{outline}\n\n【角色卡摘要】\n{characters}\n\n【前文摘要】\n{summary}\n\n写作要求:\n1. 严格遵守设定,不得破坏世界观规则。\n2. 重点推进当前章节冲突,并与前文连续。\n3. 章节目标字数约 {target_words} 字(允许小幅波动)。\n4. 第一行输出章节标题,后续输出正文,不要额外解释。", - settings = settings_context, - outline = outline.unwrap_or("暂无"), - characters = character_context, - summary = recent_summary - ) -} - -fn build_continue_prompt( - settings: Option<&NovelSettingsEnvelope>, - outline: Option<&str>, - characters: &[NovelCharacterRecord], - chapters: &[NovelChapterRecord], - chapter_no: i32, -) -> String { - format!( - "{}\n\n额外要求:保持与上一章情节连续,结尾留下钩子并引向下一章核心矛盾。", - build_chapter_prompt(settings, outline, characters, chapters, chapter_no) - ) -} - -fn build_rewrite_prompt( - settings: Option<&NovelSettingsEnvelope>, - source: &NovelChapterRecord, - instructions: Option<&str>, -) -> String { - let settings_context = build_settings_context(settings); - format!( - "请重写以下章节,保留核心剧情与关键信息点,但优化叙事节奏、人物一致性和可读性。\n\n【设定】\n{}\n\n【重写要求】\n{}\n\n【原章节标题】{}\n【原正文】\n{}\n\n输出要求:第一行标题,后续正文。", - settings_context, - instructions.unwrap_or("提升表现力,不改变关键事件。"), - source.title, - source.content - ) -} - -fn build_polish_prompt( - settings: Option<&NovelSettingsEnvelope>, - source: &NovelChapterRecord, - focus: Option<&str>, -) -> String { - let settings_context = build_settings_context(settings); - format!( - "请润色以下章节,避免口水化和重复表达,修复语病,保持人物语气稳定。\n\n【设定】\n{}\n\n【润色重点】\n{}\n\n【章节标题】{}\n【正文】\n{}\n\n输出要求:第一行标题,后续正文。", - settings_context, - focus.unwrap_or("语言凝练、节奏顺滑、人物语气一致。"), - source.title, - source.content - ) -} - -fn build_settings_context(settings: Option<&NovelSettingsEnvelope>) -> String { - let Some(settings) = settings else { - return "未提供创作设定。".to_string(); - }; - let s = &settings.data; - let mut lines: Vec = Vec::new(); - lines.push("你现在是一名经验丰富的中文网络小说作者,请严格按照以下设定创作:".to_string()); - lines.push(String::new()); - lines.push("【作品信息】".to_string()); - lines.push(format!( - "题材:{}", - if s.genres.is_empty() { - "未指定".to_string() - } else { - s.genres.join("、") - } - )); - lines.push(format!( - "一句话简介:{}", - if s.one_line_pitch.trim().is_empty() { - "未填写".to_string() - } else { - s.one_line_pitch.clone() - } - )); - lines.push(String::new()); - lines.push("【主角设定】".to_string()); - lines.push(format!( - "姓名:{},性别:{},年龄:{},性格:{}", - value_or_placeholder(&s.main_character.name, "未命名"), - value_or_placeholder(&s.main_character.gender, "未填写"), - value_or_placeholder(&s.main_character.age, "未知"), - value_or_placeholder(&s.main_character.personality, "未填写") - )); - lines.push(String::new()); - - if !s.side_characters.is_empty() { - lines.push("【配角设定】".to_string()); - for (idx, c) in s.side_characters.iter().enumerate() { - lines.push(format_side_character_line(idx, c)); - } - lines.push(String::new()); - } - - if !s.antagonists.is_empty() { - lines.push("【反派 / 敌人】".to_string()); - for (idx, c) in s.antagonists.iter().enumerate() { - lines.push(format_antagonist_line(idx, c)); - } - lines.push(String::new()); - } - - lines.push("【世界观与规则】".to_string()); - lines.push(format!( - "整体背景:{}", - value_or_placeholder(&s.world_summary, "未填写") - )); - lines.push(format!( - "核心冲突 / 主题:{}", - value_or_placeholder(&s.conflict_theme, "未填写") - )); - lines.push(format!( - "力量 / 科技 / 修炼体系:{}", - value_or_placeholder(&s.world_details.power_system, "未填写") - )); - lines.push(format!( - "社会结构与势力:{}", - value_or_placeholder(&s.world_details.factions, "未填写") - )); - lines.push(format!( - "历史重大事件:{}", - value_or_placeholder(&s.world_details.history_events, "未填写") - )); - lines.push(format!( - "重要地点:{}", - value_or_placeholder(&s.world_details.important_locations, "未填写") - )); - lines.push(format!( - "文化习俗与禁忌:{}", - value_or_placeholder(&s.world_details.culture_and_taboos, "未填写") - )); - lines.push(String::new()); - - lines.push("【情节大纲】".to_string()); - lines.push(format!( - "开头(前 30%):{}", - value_or_placeholder(&s.opening, "未填写") - )); - if !s.middle_beats.is_empty() { - lines.push("中段高潮与关键转折:".to_string()); - for (idx, beat) in s.middle_beats.iter().enumerate() { - lines.push(format!( - "{}. {}:{}", - idx + 1, - value_or_placeholder(&beat.title, "未命名节点"), - value_or_placeholder(&beat.detail, "未填写") - )); - } - } - if !s.subplots.is_empty() { - lines.push("主要副线:".to_string()); - for (idx, beat) in s.subplots.iter().enumerate() { - lines.push(format!( - "{}. {}:{}", - idx + 1, - value_or_placeholder(&beat.title, "未命名副线"), - value_or_placeholder(&beat.detail, "未填写") - )); - } - } - lines.push(format!( - "结局类型:{}", - value_or_placeholder(&s.ending_type, "未指定") - )); - lines.push(String::new()); - - lines.push("【写作风格与重点】".to_string()); - lines.push(format!( - "叙述视角:{}", - value_or_placeholder(&s.writing_style.narration, "第三人称有限") - )); - lines.push(format!( - "整体语气:{}", - if s.writing_style.tones.is_empty() { - "未指定".to_string() - } else { - s.writing_style.tones.join("、") - } - )); - lines.push(format!( - "金手指程度:{}", - value_or_placeholder(&s.writing_style.cheat_level, "稳步成长") - )); - lines.push(format!( - "重点描写内容:{}", - if s.writing_style.focus_areas.is_empty() { - "未指定".to_string() - } else { - s.writing_style.focus_areas.join("、") - } - )); - lines.push(format!( - "建议篇幅:全书约 {} 字,每章约 {} 字,temperature≈{:.2}", - s.total_words, s.writing_style.words_per_chapter, s.writing_style.temperature - )); - if s.nsfw { - lines.push("允许适度 NSFW 内容。".to_string()); - } - if s.system_novel { - lines.push("这是系统文,主角拥有类似面板/系统等金手指。".to_string()); - } - if s.harem { - lines.push("允许存在后宫元素。".to_string()); - } - lines.push(String::new()); - - if !s.taboos.is_empty() { - lines.push("【写作禁忌】".to_string()); - for (idx, taboo) in s.taboos.iter().enumerate() { - if !taboo.content.trim().is_empty() { - lines.push(format!("{}. {}", idx + 1, taboo.content.trim())); - } - } - lines.push(String::new()); - } - - if !s.references.is_empty() { - lines.push("【参考作品与借鉴点】".to_string()); - for (idx, reference) in s.references.iter().enumerate() { - lines.push(format!( - "{}. 《{}》:{}", - idx + 1, - value_or_placeholder(&reference.title, "未命名"), - value_or_placeholder(&reference.inspiration, "未填写借鉴点") - )); - } - lines.push(String::new()); - } - - lines.push( - "请在创作过程中严格遵守以上所有设定,保证人物行为、世界观规则和情节发展前后一致。" - .to_string(), - ); - lines.join("\n") -} - -fn format_side_character_line(index: usize, character: &SideCharacter) -> String { - let relation = if character.relationship_custom.trim().is_empty() { - value_or_placeholder(&character.relationship, "未填写关系") - } else { - character.relationship_custom.trim() - }; - let tags = if character.personality_tags.is_empty() { - "未填写性格".to_string() - } else { - character.personality_tags.join("、") - }; - let arc = if character.arc_custom.trim().is_empty() { - value_or_placeholder(&character.arc, "未填写") - } else { - character.arc_custom.trim() - }; - - format!( - "{}. {}({}):性格【{}】,背景【{}】,能力/弱点【{}】,故事作用【{}】,人物弧光【{}】", - index + 1, - value_or_placeholder(&character.name, "未命名"), - relation, - tags, - value_or_placeholder(&character.background, "未填写"), - value_or_placeholder(&character.abilities, "未填写"), - value_or_placeholder(&character.role, "未填写"), - arc - ) -} - -fn format_antagonist_line(index: usize, character: &Antagonist) -> String { - let relation = if character.relationship_custom.trim().is_empty() { - value_or_placeholder(&character.relationship, "未填写关系") - } else { - character.relationship_custom.trim() - }; - let tags = if character.personality_tags.is_empty() { - "未填写性格".to_string() - } else { - character.personality_tags.join("、") - }; - let arc = if character.arc_custom.trim().is_empty() { - value_or_placeholder(&character.arc, "未填写") - } else { - character.arc_custom.trim() - }; - - format!( - "{}. {}({}):性格【{}】,背景【{}】,能力/弱点【{}】,动机【{}】,最终下场【{}】,人物弧光【{}】", - index + 1, - value_or_placeholder(&character.name, "未命名"), - relation, - tags, - value_or_placeholder(&character.background, "未填写"), - value_or_placeholder(&character.abilities, "未填写"), - value_or_placeholder(&character.motive, "未填写"), - value_or_placeholder(&character.fate, "未填写"), - arc - ) -} - -fn value_or_placeholder<'a>(value: &'a str, placeholder: &'a str) -> &'a str { - let trimmed = value.trim(); - if trimmed.is_empty() { - placeholder - } else { - trimmed - } -} - -fn summarize_character_cards(characters: &[NovelCharacterRecord]) -> String { - if characters.is_empty() { - return "暂无角色卡".to_string(); - } - characters - .iter() - .enumerate() - .map(|(idx, c)| { - let personality = c - .card_json - .get("personality") - .and_then(Value::as_str) - .unwrap_or("未填写"); - let background = c - .card_json - .get("background") - .and_then(Value::as_str) - .unwrap_or("未填写"); - format!( - "{}. {}({}):性格={},背景={}", - idx + 1, - c.name, - c.role_type, - personality, - background - ) - }) - .collect::>() - .join("\n") -} - -fn summarize_recent_chapters(chapters: &[NovelChapterRecord], limit: usize) -> String { - if chapters.is_empty() { - return "暂无前文".to_string(); - } - chapters - .iter() - .rev() - .take(limit) - .map(|c| { - let excerpt: String = c.content.chars().take(220).collect(); - format!("第{}章 {}:{}", c.chapter_no, c.title, excerpt) - }) - .collect::>() - .join("\n\n") -} - -fn evaluate_consistency( - chapter: &NovelChapterRecord, - settings: Option<&NovelSettingsRecord>, - characters: &[NovelCharacterRecord], -) -> Vec { - let mut issues = Vec::new(); - let content = chapter.content.trim(); - let char_len = content.chars().count(); - - if char_len < 500 { - issues.push(NovelConsistencyIssue { - level: "warn".to_string(), - code: "chapter_too_short".to_string(), - message: "章节长度偏短,建议扩展冲突与场景细节".to_string(), - details: Some(json!({ "current": char_len, "min": 500 })), - }); - } - - if let Some(target) = extract_target_chapter_words(settings.map(|s| &s.settings_json)) { - let lower = (target as f64 * 0.6).round() as usize; - let upper = (target as f64 * 1.4).round() as usize; - if char_len < lower || char_len > upper { - issues.push(NovelConsistencyIssue { - level: "info".to_string(), - code: "chapter_word_target_deviation".to_string(), - message: "章节字数偏离目标区间".to_string(), - details: Some(json!({ - "target": target, - "current": char_len, - "range": { "min": lower, "max": upper } - })), - }); - } - } - - let taboo_words = extract_taboos(settings.map(|s| &s.settings_json)); - for taboo in taboo_words { - if content.contains(&taboo) { - issues.push(NovelConsistencyIssue { - level: "error".to_string(), - code: "taboo_violation".to_string(), - message: format!("命中禁忌词: {}", taboo), - details: None, - }); - } - } - - let mut main_names: Vec = characters - .iter() - .filter(|c| c.role_type == "main") - .map(|c| c.name.trim().to_string()) - .filter(|name| !name.is_empty()) - .collect(); - if main_names.is_empty() { - if let Some(settings) = settings { - let fallback_name = settings.settings_json.data.main_character.name.trim(); - if !fallback_name.is_empty() { - main_names.push(fallback_name.to_string()); - } - } - } - - if !main_names.is_empty() { - let mentioned = main_names.iter().any(|name| content.contains(name)); - if !mentioned { - issues.push(NovelConsistencyIssue { - level: "warn".to_string(), - code: "main_character_missing".to_string(), - message: "本章未出现主要角色姓名,可能存在叙事脱节".to_string(), - details: Some(json!({ "mainCharacters": main_names })), - }); - } - } - - issues -} - -fn calculate_score(issues: &[NovelConsistencyIssue]) -> f64 { - let mut score = 100.0f64; - for issue in issues { - match issue.level.as_str() { - "error" => score -= 25.0, - "warn" => score -= 12.0, - "info" => score -= 5.0, - _ => score -= 3.0, - } - } - score.clamp(0.0, 100.0) -} - -fn extract_target_chapter_words(settings: Option<&NovelSettingsEnvelope>) -> Option { - let settings = settings?; - let chapter_words = settings.data.chapter_words.max(0) as usize; - if chapter_words > 0 { - return Some(chapter_words); - } - let words_per_chapter = settings.data.writing_style.words_per_chapter.max(0) as usize; - if words_per_chapter > 0 { - return Some(words_per_chapter); - } - None -} - -fn extract_taboos(settings: Option<&NovelSettingsEnvelope>) -> Vec { - let Some(settings) = settings else { - return Vec::new(); - }; - settings - .data - .taboos - .iter() - .map(|taboo| taboo.content.trim().to_string()) - .filter(|content| !content.is_empty()) - .collect() -} - -fn normalize_settings_envelope_from_value(value: Value) -> NovelSettingsEnvelope { - if let Some(obj) = value.as_object() { - if let Some(data_value) = obj.get("data") { - let schema_version = - value_as_i32(obj.get("schema_version"), NOVEL_SETTINGS_SCHEMA_VERSION); - return NovelSettingsEnvelope { - schema_version, - data: normalize_novel_settings_v1(data_value), - }; - } - } - - NovelSettingsEnvelope { - schema_version: NOVEL_SETTINGS_SCHEMA_VERSION, - data: normalize_novel_settings_v1(&value), - } -} - -fn normalize_novel_settings_v1(value: &Value) -> NovelSettingsV1 { - let mut normalized = NovelSettingsV1::default(); - let Some(obj) = value.as_object() else { - return normalized; - }; - - normalized.genres = value_as_string_array(obj.get("genres")); - normalized.one_line_pitch = - value_as_string(obj.get("oneLinePitch"), &normalized.one_line_pitch); - normalized.main_character = normalize_main_character(obj.get("mainCharacter")); - normalized.side_characters = value_as_array(obj.get("sideCharacters")) - .iter() - .map(normalize_side_character) - .collect(); - normalized.antagonists = value_as_array(obj.get("antagonists")) - .iter() - .map(normalize_antagonist) - .collect(); - normalized.world_summary = value_as_string(obj.get("worldSummary"), &normalized.world_summary); - normalized.conflict_theme = - value_as_string(obj.get("conflictTheme"), &normalized.conflict_theme); - normalized.world_details = normalize_world_details(obj.get("worldDetails")); - normalized.opening = value_as_string(obj.get("opening"), &normalized.opening); - normalized.middle_beats = value_as_array(obj.get("middleBeats")) - .iter() - .map(normalize_plot_beat) - .collect(); - normalized.ending_type = value_as_string(obj.get("endingType"), &normalized.ending_type); - normalized.subplots = value_as_array(obj.get("subplots")) - .iter() - .map(normalize_plot_beat) - .collect(); - normalized.writing_style = normalize_writing_style(obj.get("writingStyle")); - normalized.total_words = value_as_i64(obj.get("totalWords"), normalized.total_words); - normalized.chapter_words = value_as_i64(obj.get("chapterWords"), normalized.chapter_words); - normalized.nsfw = value_as_bool(obj.get("nsfw"), normalized.nsfw); - normalized.system_novel = value_as_bool(obj.get("systemNovel"), normalized.system_novel); - normalized.harem = value_as_bool(obj.get("harem"), normalized.harem); - normalized.taboos = value_as_array(obj.get("taboos")) - .iter() - .map(normalize_taboo) - .filter(|item| !item.content.trim().is_empty()) - .collect(); - normalized.references = value_as_array(obj.get("references")) - .iter() - .map(normalize_reference) - .filter(|item| !item.title.trim().is_empty() || !item.inspiration.trim().is_empty()) - .collect(); - - if normalized.writing_style.words_per_chapter <= 0 { - normalized.writing_style.words_per_chapter = 3000; - } - if normalized.chapter_words <= 0 { - normalized.chapter_words = normalized.writing_style.words_per_chapter; - } - if normalized.total_words <= 0 { - normalized.total_words = 100_000; - } - - normalized -} - -fn normalize_main_character(value: Option<&Value>) -> MainCharacter { - let mut normalized = MainCharacter::default(); - let Some(obj) = value.and_then(Value::as_object) else { - return normalized; - }; - normalized.name = value_as_string(obj.get("name"), &normalized.name); - normalized.gender = value_as_string(obj.get("gender"), &normalized.gender); - normalized.age = value_as_string(obj.get("age"), &normalized.age); - normalized.personality = value_as_string(obj.get("personality"), &normalized.personality); - normalized -} - -fn normalize_side_character(value: &Value) -> SideCharacter { - let mut normalized = SideCharacter::default(); - let Some(obj) = value.as_object() else { - return normalized; - }; - normalized.id = value_as_string(obj.get("id"), &normalized.id); - normalized.name = value_as_string(obj.get("name"), &normalized.name); - normalized.nickname = value_as_string(obj.get("nickname"), &normalized.nickname); - normalized.gender = value_as_string(obj.get("gender"), &normalized.gender); - normalized.age = value_as_string(obj.get("age"), &normalized.age); - normalized.relationship = value_as_string(obj.get("relationship"), &normalized.relationship); - normalized.relationship_custom = value_as_string( - obj.get("relationshipCustom"), - &normalized.relationship_custom, - ); - normalized.personality_tags = value_as_string_array(obj.get("personalityTags")); - normalized.background = value_as_string(obj.get("background"), &normalized.background); - normalized.abilities = value_as_string(obj.get("abilities"), &normalized.abilities); - normalized.role = value_as_string(obj.get("role"), &normalized.role); - normalized.arc = value_as_string(obj.get("arc"), &normalized.arc); - normalized.arc_custom = value_as_string(obj.get("arcCustom"), &normalized.arc_custom); - normalized -} - -fn normalize_antagonist(value: &Value) -> Antagonist { - let mut normalized = Antagonist::default(); - let Some(obj) = value.as_object() else { - return normalized; - }; - normalized.id = value_as_string(obj.get("id"), &normalized.id); - normalized.name = value_as_string(obj.get("name"), &normalized.name); - normalized.nickname = value_as_string(obj.get("nickname"), &normalized.nickname); - normalized.gender = value_as_string(obj.get("gender"), &normalized.gender); - normalized.age = value_as_string(obj.get("age"), &normalized.age); - normalized.relationship = value_as_string(obj.get("relationship"), &normalized.relationship); - normalized.relationship_custom = value_as_string( - obj.get("relationshipCustom"), - &normalized.relationship_custom, - ); - normalized.personality_tags = value_as_string_array(obj.get("personalityTags")); - normalized.background = value_as_string(obj.get("background"), &normalized.background); - normalized.abilities = value_as_string(obj.get("abilities"), &normalized.abilities); - normalized.role = value_as_string(obj.get("role"), &normalized.role); - normalized.arc = value_as_string(obj.get("arc"), &normalized.arc); - normalized.arc_custom = value_as_string(obj.get("arcCustom"), &normalized.arc_custom); - normalized.motive = value_as_string(obj.get("motive"), &normalized.motive); - normalized.fate = value_as_string(obj.get("fate"), &normalized.fate); - normalized -} - -fn normalize_world_details(value: Option<&Value>) -> WorldDetails { - let mut normalized = WorldDetails::default(); - let Some(obj) = value.and_then(Value::as_object) else { - return normalized; - }; - normalized.power_system = value_as_string(obj.get("powerSystem"), &normalized.power_system); - normalized.factions = value_as_string(obj.get("factions"), &normalized.factions); - normalized.history_events = - value_as_string(obj.get("historyEvents"), &normalized.history_events); - normalized.important_locations = value_as_string( - obj.get("importantLocations"), - &normalized.important_locations, - ); - normalized.culture_and_taboos = - value_as_string(obj.get("cultureAndTaboos"), &normalized.culture_and_taboos); - normalized -} - -fn normalize_plot_beat(value: &Value) -> PlotBeat { - let base = PlotBeat { - id: Uuid::new_v4().to_string(), - title: String::new(), - detail: String::new(), - }; - let Some(obj) = value.as_object() else { - return base; - }; - PlotBeat { - id: value_as_string(obj.get("id"), &base.id), - title: value_as_string(obj.get("title"), &base.title), - detail: value_as_string(obj.get("detail"), &base.detail), - } -} - -fn normalize_writing_style(value: Option<&Value>) -> WritingStyle { - let mut normalized = WritingStyle::default(); - let Some(obj) = value.and_then(Value::as_object) else { - return normalized; - }; - normalized.narration = value_as_string(obj.get("narration"), &normalized.narration); - normalized.tones = value_as_string_array(obj.get("tones")); - normalized.cheat_level = value_as_string(obj.get("cheatLevel"), &normalized.cheat_level); - normalized.focus_areas = value_as_string_array(obj.get("focusAreas")); - normalized.words_per_chapter = - value_as_i64(obj.get("wordsPerChapter"), normalized.words_per_chapter); - normalized.temperature = value_as_f64(obj.get("temperature"), normalized.temperature); - normalized -} - -fn normalize_taboo(value: &Value) -> TabooRule { - if let Some(content) = value.as_str() { - return TabooRule { - id: Uuid::new_v4().to_string(), - content: content.to_string(), - }; - } - let base = TabooRule { - id: Uuid::new_v4().to_string(), - content: String::new(), - }; - let Some(obj) = value.as_object() else { - return base; - }; - TabooRule { - id: value_as_string(obj.get("id"), &base.id), - content: value_as_string(obj.get("content"), &base.content), - } -} - -fn normalize_reference(value: &Value) -> ReferenceWork { - let base = ReferenceWork { - id: Uuid::new_v4().to_string(), - title: String::new(), - inspiration: String::new(), - }; - let Some(obj) = value.as_object() else { - return base; - }; - ReferenceWork { - id: value_as_string(obj.get("id"), &base.id), - title: value_as_string(obj.get("title"), &base.title), - inspiration: value_as_string(obj.get("inspiration"), &base.inspiration), - } -} - -fn value_as_array(value: Option<&Value>) -> Vec { - value.and_then(Value::as_array).cloned().unwrap_or_default() -} - -fn value_as_string(value: Option<&Value>, fallback: &str) -> String { - value - .and_then(Value::as_str) - .map(|v| v.to_string()) - .unwrap_or_else(|| fallback.to_string()) -} - -fn value_as_string_array(value: Option<&Value>) -> Vec { - value - .and_then(Value::as_array) - .map(|arr| { - arr.iter() - .filter_map(|item| item.as_str().map(|s| s.trim().to_string())) - .filter(|s| !s.is_empty()) - .collect() - }) - .unwrap_or_default() -} - -fn value_as_bool(value: Option<&Value>, fallback: bool) -> bool { - value.and_then(Value::as_bool).unwrap_or(fallback) -} - -fn value_as_i64(value: Option<&Value>, fallback: i64) -> i64 { - let Some(value) = value else { - return fallback; - }; - value - .as_i64() - .or_else(|| value.as_u64().map(|v| v as i64)) - .or_else(|| value.as_f64().map(|v| v.round() as i64)) - .unwrap_or(fallback) -} - -fn value_as_i32(value: Option<&Value>, fallback: i32) -> i32 { - value_as_i64(value, fallback as i64) as i32 -} - -fn value_as_f64(value: Option<&Value>, fallback: f64) -> f64 { - let Some(value) = value else { - return fallback; - }; - value - .as_f64() - .or_else(|| value.as_i64().map(|v| v as f64)) - .or_else(|| value.as_u64().map(|v| v as f64)) - .unwrap_or(fallback) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_character_cards_should_support_markdown_json_block() { - let raw = r#" -这里是角色卡: -```json -[ - { - "name": "顾清", - "role_type": "main", - "relationship": "主角" - }, - { - "name": "宿敌", - "role_type": "antagonist" - } -] -``` -"#; - - let cards = parse_character_cards(raw); - assert_eq!(cards.len(), 2); - assert_eq!(cards[0]["name"], Value::String("顾清".to_string())); - assert_eq!(cards[0]["role_type"], Value::String("main".to_string())); - assert_eq!( - cards[1]["role_type"], - Value::String("antagonist".to_string()) - ); - } - - #[test] - fn parse_character_cards_should_filter_json_noise_in_fallback_lines() { - let raw = r#" -{ - "relationship": "上司", - "arc": "成长", - "abilities": "强大的调查能力", -} -"#; - - let cards = parse_character_cards(raw); - assert!(cards.is_empty()); - } - - #[test] - fn parse_character_cards_should_ignore_markdown_fence_noise_in_fallback_lines() { - let raw = r#" -```json -林舟 -``` -"#; - - let cards = parse_character_cards(raw); - assert_eq!(cards.len(), 1); - assert_eq!(cards[0]["name"], Value::String("林舟".to_string())); - } -} diff --git a/src-tauri/src/skills/catalog.rs b/src-tauri/src/skills/catalog.rs index 878db2268..91da3b9af 100644 --- a/src-tauri/src/skills/catalog.rs +++ b/src-tauri/src/skills/catalog.rs @@ -526,6 +526,10 @@ Invalid content "search_query".to_string(), ]) ); + assert_eq!( + skill.metadata.get("lime_surface").map(String::as_str), + Some("workbench") + ); assert!(content.contains(") -> crate::services::novel_service::NovelService { - crate::services::novel_service::NovelService::new(db.inner().clone()) -} - -pub async fn novel_create_project( - db: State<'_, DbConnection>, - request: NovelCreateProjectRequest, -) -> Result { - service(&db).create_project(request) -} - -pub async fn novel_update_settings( - db: State<'_, DbConnection>, - request: NovelUpdateSettingsRequest, -) -> Result { - service(&db).update_settings(request) -} - -pub async fn novel_generate_outline( - db: State<'_, DbConnection>, - request: NovelGenerateRequest, -) -> Result { - service(&db).generate_outline(request).await -} - -pub async fn novel_generate_characters( - db: State<'_, DbConnection>, - request: NovelGenerateRequest, -) -> Result { - service(&db).generate_characters(request).await -} - -pub async fn novel_generate_chapter( - db: State<'_, DbConnection>, - request: NovelGenerateChapterRequest, -) -> Result { - service(&db).generate_chapter(request).await -} - -pub async fn novel_continue_chapter( - db: State<'_, DbConnection>, - request: NovelGenerateRequest, -) -> Result { - service(&db).continue_chapter(request).await -} - -pub async fn novel_rewrite_chapter( - db: State<'_, DbConnection>, - request: NovelRewriteChapterRequest, -) -> Result { - service(&db).rewrite_chapter(request).await -} - -pub async fn novel_polish_chapter( - db: State<'_, DbConnection>, - request: NovelPolishChapterRequest, -) -> Result { - service(&db).polish_chapter(request).await -} - -pub async fn novel_check_consistency( - db: State<'_, DbConnection>, - request: NovelCheckConsistencyRequest, -) -> Result { - service(&db).check_consistency(request) -} - -pub async fn novel_get_project_snapshot( - db: State<'_, DbConnection>, - project_id: String, -) -> Result { - service(&db).get_project_snapshot(&project_id) -} - -pub async fn novel_list_runs( - db: State<'_, DbConnection>, - request: NovelListRunsRequest, -) -> Result, String> { - service(&db).list_runs(request) -} - -pub async fn novel_delete_character( - db: State<'_, DbConnection>, - request: NovelDeleteCharacterRequest, -) -> Result { - service(&db).delete_character(request) -} diff --git a/src-tauri/src/theme/novel/mod.rs b/src-tauri/src/theme/novel/mod.rs deleted file mode 100644 index 9eb3016ea..000000000 --- a/src-tauri/src/theme/novel/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! 小说主题模块 - -pub mod command; diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index fb6f734f6..9694f1bb4 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.0.0-beta", + "version": "1.0.1", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 50aa29852..532bac66d 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.0.0-beta", + "version": "1.0.1", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 66df7c0ee..de3f59a99 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -126,11 +126,8 @@ function AppContent() { (shortLabel: string, fullPrompt: string, currentTheme: string) => { const themeLabels: Record = { "social-media": "社媒", - poster: "海报", - music: "音乐", knowledge: "知识", planning: "计划", - novel: "小说", document: "文档", video: "视频", general: "对话", diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx index f555d6bcb..6ec4a2acb 100644 --- a/src/components/agent/chat/AgentChatHomeShell.test.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.test.tsx @@ -6,6 +6,10 @@ import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders"; import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; import { AgentChatHomeShell } from "./AgentChatHomeShell"; import { SettingsTabs } from "@/types/settings"; +import { + readTeamMemorySnapshot, + writeTeamMemorySnapshot, +} from "@/lib/teamMemorySync"; const { mockBuildClawAgentParams, @@ -15,6 +19,7 @@ const { mockHomeShellModel, mockHomeShellProviderType, mockHomeShellRecentExecutionRuntime, + mockGetProject, mockListProjects, mockSetExecutionStrategy, mockSetModel, @@ -255,6 +260,18 @@ const { mockHomeShellModel: { current: "mock-model" }, mockHomeShellExecutionStrategy: { current: "react" }, mockHomeShellRecentExecutionRuntime: { current: null as unknown }, + mockGetProject: vi.fn(async (projectId: string) => ({ + id: projectId, + name: `项目 ${projectId}`, + workspaceType: "general", + rootPath: `/tmp/${projectId}`, + isDefault: false, + createdAt: 0, + updatedAt: 0, + isFavorite: false, + isArchived: false, + tags: [], + })), mockListProjects: vi.fn(async () => [ { id: "project-1", @@ -334,6 +351,8 @@ vi.mock("./components/EmptyState", () => ({ supportingSlotOverride, serviceSkills, onSelectServiceSkill, + onSelectTeam, + selectedTeam, }: { onSend: ( value: string, @@ -345,8 +364,23 @@ vi.mock("./components/EmptyState", () => ({ supportingSlotOverride?: React.ReactNode; serviceSkills?: Array<{ id: string; title: string }>; onSelectServiceSkill?: (skill: { id: string; title: string }) => void; + onSelectTeam?: (team: { + id: string; + source: string; + label: string; + description?: string; + roles?: Array<{ + id: string; + label: string; + summary?: string; + }>; + }) => void; + selectedTeam?: { label?: string | null } | null; }) => ( <> +
+ {selectedTeam?.label || "none"} +
+ {onSelectTeam ? ( + + ) : null} {onLaunchBrowserAssist ? ( )} - {showHistoryToggle && (showCanvasToggle || novelCanvasControls) ? ( + {showHistoryToggle && showCanvasToggle ? ( diff --git a/src/components/agent/chat/components/DecisionPanel.test.tsx b/src/components/agent/chat/components/DecisionPanel.test.tsx index bb5ce49d2..eb592e424 100644 --- a/src/components/agent/chat/components/DecisionPanel.test.tsx +++ b/src/components/agent/chat/components/DecisionPanel.test.tsx @@ -84,6 +84,39 @@ function createElicitationRequest(requestId: string): ActionRequired { }; } +function createRichElicitationRequest(requestId: string): ActionRequired { + return { + requestId, + actionType: "elicitation", + prompt: "继续前请确认执行模式", + questions: [ + { + question: "请选择执行模式", + header: "mode", + options: [ + { + label: "自动执行", + description: "直接继续推进", + }, + { + label: "确认后执行", + description: "每一步都等我确认", + }, + ], + }, + ], + requestedSchema: { + type: "object", + properties: { + mode: { + type: "string", + enum: ["自动执行", "确认后执行"], + }, + }, + }, + }; +} + function createAskUserRequest(requestId: string): ActionRequired { return { requestId, @@ -97,6 +130,29 @@ function createAskUserRequest(requestId: string): ActionRequired { }; } +function createAskUserMultiSelectRequest(requestId: string): ActionRequired { + return { + requestId, + actionType: "ask_user", + questions: [ + { + question: "请选择希望启用的能力", + multiSelect: true, + options: [ + { + label: "分析", + description: "先收集上下文", + }, + { + label: "编码", + description: "直接修改实现", + }, + ], + }, + ], + }; +} + function createAskUserNumberedRequest(requestId: string): ActionRequired { return { requestId, @@ -188,6 +244,30 @@ describe("DecisionPanel elicitation", () => { expect(payload.response).toBe("用户拒绝了请求"); expect(payload.userData).toBe(""); }); + + it("带 questions 的 elicitation 应走问题卡片 UI 并提交结构化答案", () => { + const request = createRichElicitationRequest("req-elicitation-rich"); + const { container, onSubmit } = renderDecisionPanel(request); + + expect(container.textContent).toContain("需要你提供信息"); + expect(container.textContent).toContain("请选择执行模式"); + expect(container.textContent).toContain("每一步都等我确认"); + expect( + container.querySelector('input[placeholder="请输入回答..."]'), + ).toBeNull(); + + clickButton(findButtonByText(container, "确认后执行")); + clickButton(findButtonByText(container, "提交答案")); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + requestId: "req-elicitation-rich", + confirmed: true, + response: JSON.stringify({ answer: "确认后执行" }), + actionType: "elicitation", + userData: { answer: "确认后执行" }, + }); + }); }); describe("DecisionPanel ask_user", () => { @@ -261,6 +341,24 @@ describe("DecisionPanel ask_user", () => { }); }); + it("multiSelect 问题应提交结构化数组,避免选项值映射丢失", () => { + const request = createAskUserMultiSelectRequest("req-ask-user-multi"); + const { container, onSubmit } = renderDecisionPanel(request); + + clickButton(findButtonByText(container, "分析")); + clickButton(findButtonByText(container, "编码")); + clickButton(findButtonByText(container, "提交答案")); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + requestId: "req-ask-user-multi", + confirmed: true, + response: JSON.stringify({ answer: ["分析", "编码"] }), + actionType: "ask_user", + userData: { answer: ["分析", "编码"] }, + }); + }); + it("fallback ask 在 request_id 未就绪时应允许先记录答案", () => { const request: ActionRequired = { requestId: "fallback:tool-1", @@ -376,7 +474,7 @@ describe("DecisionPanel copywriting", () => { const denyButton = findButtonByText(container, "拒绝"); await act(async () => { - allowButton.click(); + allowButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); diff --git a/src/components/agent/chat/components/DecisionPanel.tsx b/src/components/agent/chat/components/DecisionPanel.tsx index 16fcf19e3..887ce11bb 100644 --- a/src/components/agent/chat/components/DecisionPanel.tsx +++ b/src/components/agent/chat/components/DecisionPanel.tsx @@ -25,11 +25,7 @@ import { Globe, Loader2, } from "lucide-react"; -import type { - ActionRequired, - ConfirmResponse, - QuestionOption, -} from "../types"; +import type { ActionRequired, ConfirmResponse, QuestionOption } from "../types"; interface DecisionPanelProps { request: ActionRequired; @@ -217,7 +213,8 @@ function extractAskUserOptionsFromText(text?: string): QuestionOption[] { if (options.length > 0) return options; - const optionLinePattern = /(options?|choices?|可选项?|选项)\s*[::]\s*([^\n]+)/i; + const optionLinePattern = + /(options?|choices?|可选项?|选项)\s*[::]\s*([^\n]+)/i; const lineMatch = normalizedText.match(optionLinePattern); if (lineMatch?.[2]) { const fragments = splitFragments(lineMatch[2]); @@ -270,7 +267,29 @@ function normalizeQuestionOptions(rawOptions: unknown): QuestionOption[] { return normalized; } -function resolveSubmittedAnswerText(request: ActionRequired): string | undefined { +function summarizeSubmittedValue(value: unknown): string | undefined { + if (typeof value === "string") { + const normalized = value.trim(); + return normalized || undefined; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (Array.isArray(value)) { + const normalized = value + .map((item) => summarizeSubmittedValue(item)) + .filter((item): item is string => Boolean(item)); + return normalized.length > 0 ? normalized.join("、") : undefined; + } + + return undefined; +} + +function resolveSubmittedAnswerText( + request: ActionRequired, +): string | undefined { const userData = request.submittedUserData; if (typeof userData === "string") { const value = userData.trim(); @@ -280,17 +299,17 @@ function resolveSubmittedAnswerText(request: ActionRequired): string | undefined if (userData && typeof userData === "object") { const record = userData as Record; - if (typeof record.answer === "string" && record.answer.trim()) { - return record.answer.trim(); + const directAnswer = summarizeSubmittedValue(record.answer); + if (directAnswer) { + return directAnswer; } if (request.questions && request.questions.length > 0) { const firstQuestion = request.questions[0]?.question; if ( typeof firstQuestion === "string" && - typeof record[firstQuestion] === "string" && - (record[firstQuestion] as string).trim() + summarizeSubmittedValue(record[firstQuestion]) ) { - return (record[firstQuestion] as string).trim(); + return summarizeSubmittedValue(record[firstQuestion]); } } try { @@ -310,8 +329,9 @@ function resolveSubmittedAnswerText(request: ActionRequired): string | undefined } if (parsed && typeof parsed === "object") { const record = parsed as Record; - if (typeof record.answer === "string" && record.answer.trim()) { - return record.answer.trim(); + const directAnswer = summarizeSubmittedValue(record.answer); + if (directAnswer) { + return directAnswer; } } } catch { @@ -337,7 +357,10 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { } const fallbackText = [question.question, question.header, request.prompt] - .filter((item): item is string => typeof item === "string" && item.trim().length > 0) + .filter( + (item): item is string => + typeof item === "string" && item.trim().length > 0, + ) .join("\n"); return extractAskUserOptionsFromText(fallbackText); }); @@ -359,6 +382,9 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { const submittedAnswer = resolveSubmittedAnswerText(request); const isFallbackAskPending = request.actionType === "ask_user" && request.isFallback; + const usesQuestionnaireUi = + questions.length > 0 && + (request.actionType === "ask_user" || request.actionType === "elicitation"); // 重置状态当请求变化时 useEffect(() => { @@ -418,37 +444,63 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { // 构建答案 const buildAnswers = () => { - const answers: Record = {}; + const answers: Record = {}; questions.forEach((q, qIndex) => { const selected = selectedOptions[qIndex] ?? []; const otherText = otherInputs[qIndex]?.trim() ?? ""; - let value = ""; if (q.multiSelect) { const combined = [...selected]; if (otherText) combined.push(otherText); - value = combined.join(", "); - } else { - value = otherText || selected[0] || ""; + if (combined.length > 0) { + answers[q.question] = combined; + } + return; + } + + const value = otherText || selected[0] || ""; + if (value) { + answers[q.question] = value; } - if (value) answers[q.question] = value; }); return answers; }; // 检查是否���以提交 - const canSubmit = - request.actionType === "elicitation" + const canSubmit = usesQuestionnaireUi + ? questions.every((_, qIndex) => { + const selected = selectedOptions[qIndex] ?? []; + const otherText = otherInputs[qIndex]?.trim() ?? ""; + return selected.length > 0 || otherText.length > 0; + }) + : request.actionType === "elicitation" ? elicitationAnswer.trim().length > 0 || elicitationOther.trim().length > 0 - : questions.length === 0 || - questions.every((_, qIndex) => { - const selected = selectedOptions[qIndex] ?? []; - const otherText = otherInputs[qIndex]?.trim() ?? ""; - return selected.length > 0 || otherText.length > 0; - }); + : questions.length === 0; // 处理允许 const handleAllow = () => { + if (usesQuestionnaireUi) { + const answers = buildAnswers(); + const firstAnswer = Object.values(answers)[0]; + const normalizedAnswers = + questions.length === 1 && firstAnswer !== undefined + ? { answer: firstAnswer } + : answers; + const response = + questions.length > 0 ? JSON.stringify(normalizedAnswers) : undefined; + void submitResponse( + { + requestId: request.requestId, + confirmed: true, + response, + actionType: request.actionType, + userData: questions.length > 0 ? normalizedAnswers : undefined, + }, + { key: "allow", kind: "allow" }, + ); + return; + } + if (request.actionType === "elicitation") { const answer = elicitationAnswer.trim(); const other = elicitationOther.trim(); @@ -477,20 +529,12 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { return; } - const answers = buildAnswers(); - const normalizedAnswers = - questions.length === 1 && typeof Object.values(answers)[0] === "string" - ? { answer: Object.values(answers)[0] as string } - : answers; - const response = - questions.length > 0 ? JSON.stringify(normalizedAnswers) : undefined; void submitResponse( { requestId: request.requestId, confirmed: true, - response, + response: "允许", actionType: request.actionType, - userData: questions.length > 0 ? normalizedAnswers : undefined, }, { key: "allow", kind: "allow" }, ); @@ -513,16 +557,14 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { }; if (isSubmitted || isQueued) { - const submittedTitle = - isQueued - ? "已记录你的回答" - : request.actionType === "tool_confirmation" + const submittedTitle = isQueued + ? "已记录你的回答" + : request.actionType === "tool_confirmation" ? "已处理权限请求" : "已提交你的回答"; - const submittedClassName = - isQueued - ? "border-sky-200 bg-sky-50/50 dark:border-sky-800 dark:bg-sky-950/20" - : request.actionType === "tool_confirmation" + const submittedClassName = isQueued + ? "border-sky-200 bg-sky-50/50 dark:border-sky-800 dark:bg-sky-950/20" + : request.actionType === "tool_confirmation" ? "border-amber-200 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20" : request.actionType === "elicitation" ? "border-indigo-200 bg-indigo-50/50 dark:border-indigo-800 dark:bg-indigo-950/20" @@ -648,7 +690,11 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
{isLaunching ? ( - @@ -701,10 +747,13 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { - ); - })} -
- )} + {option.description && ( +
+ {option.description} +
+ )} + + ); + })} + + )} {/* 其他输入 */}
@@ -921,7 +993,8 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { {isFallbackAskPending && (

- 如果系统请求 ID 还没就绪,你现在提交的答案会先被记录,并在就绪后自动提交。 + 如果系统请求 ID + 还没就绪,你现在提交的答案会先被记录,并在就绪后自动提交。

)} @@ -931,7 +1004,7 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { size="sm" onClick={handleAllow} disabled={!canSubmit || isSubmitting} - className="bg-blue-600 hover:bg-blue-700" + className={primaryButtonClassName} > {submissionState?.key === "allow" ? ( diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 06d1f25fb..ff871b3a6 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -2,14 +2,12 @@ import React, { useState, useEffect, useMemo } from "react"; import styled, { keyframes } from "styled-components"; import { Lightbulb, - ImageIcon, Video, FileText, PenTool, BrainCircuit, CalendarRange, Globe, - Music, ListChecks, Settings2, Workflow, @@ -209,8 +207,6 @@ const ALL_CATEGORIES = [ label: "社媒内容", icon: , }, - { id: "poster", label: "图文海报", icon: }, - { id: "music", label: "歌词曲谱", icon: }, { id: "knowledge", label: "知识探索", @@ -223,27 +219,16 @@ const ALL_CATEGORIES = [ }, { id: "document", label: "办公文档", icon: }, { id: "video", label: "短视频", icon: