From 1148bf9213cae37a5a13a8fffaea836e7451f699 Mon Sep 17 00:00:00 2001 From: baiyanyun Date: Mon, 13 Jul 2026 09:12:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=89=8D=E7=AB=AF=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8F=B0=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- qiming/package.json | 16 +- qiming/src/assets/icons/chat/voice.svg | 4 + .../assets/icons/nav/history_conversation.svg | 3 + .../src/assets/icons/nav/recommend_manage.svg | 7 + qiming/src/assets/images/app-icon.png | Bin 0 -> 172467 bytes .../src/assets/images/empty_state_no_file.svg | 4 + qiming/src/assets/workflow/workflow-Agent.svg | 1 + .../src/assets/workflow/workflow-HumanAsk.svg | 1 + .../workflow/workflow-KnowledgeInsert.svg | 1 + .../workflow/workflow-RouteDecision.svg | 1 + .../components/AcpPermissionCard/index.less | 94 + .../components/AcpPermissionCard/index.tsx | 141 ++ .../src/components/AgentChatEmpty/index.tsx | 7 +- .../AgentSidebar/AgentContent/index.tsx | 5 +- .../ChatInputHome/SpaceSelector/index.less | 114 + .../ChatInputHome/SpaceSelector/index.tsx | 155 ++ .../CodeViewer/MonacoDiffEditor/index.less | 37 + .../CodeViewer/MonacoDiffEditor/index.tsx | 107 + .../KnowledgeAccuracyTest/service.ts | 55 + .../components/KnowledgeAccuracyTest/types.ts | 44 + .../MarkdownRenderer/TaskResult/context.ts | 5 + .../MarkdownRenderer/__tests__/utils.test.ts | 141 ++ .../ProComponents/XProTable/index.tsx | 63 +- .../src/components/SelectionList/index.less | 218 ++ qiming/src/components/SelectionList/index.tsx | 153 ++ qiming/src/components/SiteFooter/index.tsx | 10 +- .../SkillDetailView/SkillHeader/index.less | 41 + .../SkillDetailView/SkillHeader/index.tsx | 88 + .../src/components/SkillDetailView/index.less | 25 + .../src/components/SkillDetailView/index.tsx | 133 + .../AcpPermissionCard/index.less | 415 ++++ .../AcpPermissionCard/index.test.tsx | 183 ++ .../AcpPermissionCard/index.tsx | 313 +++ .../useAcpPermissionShortcuts.test.tsx | 40 + .../useAcpPermissionShortcuts.ts | 100 + .../AgentInterventionChatLayer.less | 6 + .../AgentInterventionChatLayer/DockPanel.less | 65 + .../AgentInterventionChatLayer/DockPanel.tsx | 138 ++ .../AgentInterventionChatLayer/index.test.tsx | 72 + .../AgentInterventionChatLayer/index.tsx | 103 + .../intervention-dock-card.module.less | 23 + .../McpAskQuestionCard/McpAskFileUpload.tsx | 84 + .../McpAskQuestionCard/McpAskFormField.less | 227 ++ .../McpAskFormField.test.tsx | 76 + .../McpAskQuestionCard/McpAskFormField.tsx | 249 ++ .../McpAskQuestionCard/index.less | 339 +++ .../McpAskQuestionCard/index.test.tsx | 122 + .../McpAskQuestionCard/index.tsx | 376 +++ .../AgentIntervention/README.md | 563 +++++ .../hooks/useActiveInterventionQueue.test.ts | 541 +++++ .../hooks/useActiveInterventionQueue.ts | 139 ++ .../useAgentInterventionHandlers.test.ts | 473 ++++ .../hooks/useAgentInterventionHandlers.ts | 191 ++ .../hooks/useAgentInterventionLayer.test.ts | 160 ++ .../hooks/useAgentInterventionLayer.ts | 305 +++ .../hooks/useInterventionEscapeKey.ts | 47 + .../AgentIntervention/index.ts | 28 + .../types/acpIntervention.ts | 108 + .../types/mcpAskIntervention.ts | 164 ++ .../utils/applyAcpPermissionSseEvent.test.ts | 427 ++++ .../utils/applyAcpPermissionSseEvent.ts | 168 ++ .../utils/applyMcpAskToolCallSseEvent.test.ts | 511 ++++ .../utils/applyMcpAskToolCallSseEvent.ts | 144 ++ .../extractMcpAskStructuredInput.test.ts | 40 + .../utils/extractMcpAskStructuredInput.ts | 35 + .../utils/interventionTrigger.ts | 5 + .../utils/mcpAskExecutedComponent.test.ts | 35 + .../utils/mcpAskExecutedComponent.ts | 64 + .../utils/mcpAskHydrateMessage.test.ts | 129 + .../utils/mcpAskHydrateMessage.ts | 82 + .../utils/mcpAskResumeMessage.test.ts | 678 ++++++ .../utils/mcpAskResumeMessage.ts | 691 ++++++ .../utils/normalizeMcpAskFormData.test.ts | 310 +++ .../utils/normalizeMcpAskFormData.ts | 305 +++ .../utils/parseMcpAskSchema.test.ts | 155 ++ .../utils/parseMcpAskSchema.ts | 145 ++ .../utils/parseMcpAskToolInput.ts | 64 + .../utils/parseSseEventEnvelope.ts | 55 + .../utils/processInterventionSsePatch.ts | 26 + .../reconcileAcpPermissionStatus.test.ts | 235 ++ .../utils/reconcileAcpPermissionStatus.ts | 206 ++ .../utils/reconcileFinalMessageState.test.ts | 122 + .../utils/reconcileFinalMessageState.ts | 137 ++ .../reconcileMcpAskHydratedStatus.test.ts | 104 + .../utils/reconcileMcpAskHydratedStatus.ts | 82 + .../ChangeFileGitDiffView/index.less | 45 + .../ChangeFileGitDiffView/index.tsx | 120 + .../DevLogActions.tsx | 94 + .../DevLogPanel/index.less | 138 ++ .../DevLogPanel/index.tsx | 254 ++ .../ConversationBottomConsole/index.less | 302 +++ .../ConversationBottomConsole/index.tsx | 944 +++++++ .../terminalTheme.ts | 75 + .../FileTreePanel/FileContextMenu/index.less | 92 + .../FileTreePanel/FileContextMenu/index.tsx | 467 ++++ .../FileTreePanel/FileContextMenu/types.ts | 55 + .../FileTreePanel/FileTree/index.less | 135 ++ .../FileTreePanel/FileTree/index.tsx | 471 ++++ .../FileTreePanel/FileTree/types.ts | 47 + .../FileTreePanel/SearchView/index.less | 148 ++ .../FileTreePanel/SearchView/index.tsx | 211 ++ .../FileTreePanel/index.less | 53 + .../FileTreePanel/index.tsx | 232 ++ .../FileTreeToolbar/index.less | 47 + .../FileTreeToolbar/index.tsx | 205 ++ .../FileTreeGitSourcePanel/PanelCore.tsx | 186 ++ .../ChangeFileContextMenu/index.less | 27 + .../ChangeFileContextMenu/index.tsx | 213 ++ .../ChangeFileListSection/index.less | 321 +++ .../ChangeFileListSection/index.tsx | 516 ++++ .../SourceControlPanel/index.less | 88 + .../SourceControlPanel/index.tsx | 532 ++++ .../hooks/useAppDevFileTree.tsx | 347 +++ .../hooks/useSourceControl.ts | 759 ++++++ .../FileTreeGitSourcePanel/index.less | 122 + .../FileTreeGitSourcePanel/index.tsx | 31 + .../services/git-version-management.ts | 310 +++ .../types/file-tree-git-source.ts | 157 ++ .../types/git-version-management.ts | 308 +++ .../utils/buildChangeFileTree.ts | 92 + .../utils/buildGitWorkspaceParams.ts | 35 + .../utils/changeFileStatus.ts | 156 ++ .../utils/gitStatusUtils.ts | 217 ++ .../utils/sourceControlGitActions.ts | 74 + .../FilePathHeader/MoreActionsMenu/index.less | 9 + .../FilePathHeader/MoreActionsMenu/index.tsx | 152 ++ .../ShareDesktopModal/index.tsx | 339 +++ .../FilePathHeader/index.less | 148 ++ .../FilePathHeader/index.tsx | 448 ++++ .../FilePathHeader/type.ts | 72 + .../FileTreeViewPanel.tsx | 328 +++ .../hooks/useFileTreePreviewPanel.tsx | 196 ++ .../hooks/useFileTreePreviewView.tsx | 2158 +++++++++++++++++ .../FileTreePreviewPanel/index.less | 169 ++ .../FileTreePreviewPanel/index.tsx | 145 ++ .../FileTreePreviewPanel/types.ts | 235 ++ .../FileTreePreviewPanel/types/file-tree.ts | 104 + .../GitVersionCommitChangesPanel/index.less | 320 +++ .../GitVersionCommitChangesPanel/index.tsx | 453 ++++ .../GitVersionCommitTimeline/index.less | 165 ++ .../GitVersionCommitTimeline/index.tsx | 115 + .../GitVersionRollbackConfirmModal/index.less | 90 + .../GitVersionRollbackConfirmModal/index.tsx | 110 + .../GitVersionRecordPanel/commitListUtils.ts | 52 + .../gitCommitDiffUtils.ts | 311 +++ .../GitVersionRecordPanel/gitRollbackUtils.ts | 61 + .../GitVersionRecordPanel/index.less | 41 + .../GitVersionRecordPanel/index.tsx | 338 +++ .../GitVersionRecordPanel/types.ts | 8 + .../useGitVersionRollback.ts | 86 + .../GuardedFormModal/FormModalForm.tsx | 30 + .../GuardedFormModal/index.tsx | 82 + .../GuardedFormModal/submitGuard.ts | 126 + .../GuardedFormModal/types.ts | 8 + .../MessageQueue/QueuedMessageItem.tsx | 123 + .../MessageQueue/index.less | 296 +++ .../business-component/MessageQueue/index.tsx | 132 + .../MessageQueue/queueStorage.ts | 67 + .../business-component/MessageQueue/types.ts | 22 + .../MessageQueue/useChatMessageQueue.ts | 293 +++ .../MessageQueue/useMessageQueue.ts | 145 ++ .../MessageQueue/useUnifiedChatQueue.ts | 136 ++ .../ModelPriceTierList/index.less | 41 + .../ModelPriceTierList/index.tsx | 55 + .../Terminal/EmbeddedConsoleTerminal.tsx | 852 +++++++ .../business-component/Terminal/index.less | 234 ++ .../business-component/Terminal/index.tsx | 1250 ++++++++++ .../Terminal/terminalMouseUtils.ts | 36 + .../Terminal/terminalReconnect.ts | 74 + .../business-component/Terminal/ttydWire.ts | 47 + .../business-component/Terminal/type.ts | 238 ++ .../business-component/Terminal/xterm.css | 252 ++ .../Terminal/xtermBundle.ts | 5 + .../components/ChatContentArea/index.less | 89 + .../components/ChatContentArea/index.tsx | 205 ++ .../ChatInputHomeIndependent/index.tsx | 1088 +++++++++ .../hooks/useConversationStreamResume.ts | 311 +++ .../hooks/useLoadMoreHistory.ts | 49 + .../hooks/useUnifiedChatScroll.ts | 331 +++ .../UnifiedChatSession/index.less | 77 + .../UnifiedChatSession/index.tsx | 412 ++++ .../UnifiedChatSession/types.ts | 171 ++ .../VoiceInput/chatInputFooter/Expand.tsx | 19 + .../chatInputFooter/HideWhenActive.tsx | 17 + .../VoiceInput/chatInputFooter/Provider.tsx | 77 + .../VoiceInput/chatInputFooter/Right.tsx | 140 ++ .../VoiceInput/chatInputFooter/context.ts | 31 + .../VoiceInput/chatInputFooter/index.less | 68 + .../VoiceInput/chatInputFooter/index.ts | 20 + .../VoiceInput/components/Button.tsx | 326 +++ .../VoiceInput/components/RecordingBar.tsx | 117 + .../VoiceInput/components/index.less | 214 ++ .../business-component/VoiceInput/config.ts | 37 + .../VoiceInput/hooks/useAudioRecorder.ts | 426 ++++ .../VoiceInput/hooks/useMockAudioRecorder.ts | 97 + .../business-component/VoiceInput/index.tsx | 17 + .../VoiceInput/loaders/recorderContext.ts | 107 + .../VoiceInput/loaders/recorderWorklet.ts | 18 + .../business-component/VoiceInput/types.ts | 20 + .../VoiceInput/utils/resample.ts | 42 + .../VoiceInput/utils/wavEncoder.ts | 67 + .../VoiceInput/utils/waveLevels.ts | 74 + .../components/business-component/index.ts | 53 + qiming/src/constants/agent.constants.tsx | 5 + qiming/src/constants/common.constants.ts | 5 + qiming/src/constants/feature.constants.ts | 14 + qiming/src/constants/home.constants.ts | 3 + qiming/src/constants/hook.constants.ts | 109 + .../src/contexts/CanvasFullscreenContext.tsx | 29 + qiming/src/contexts/FlowKindContext.tsx | 23 + .../examples/AgentInterventionDemo/index.tsx | 139 ++ .../AgentInterventionDemo/mockData.ts | 367 +++ .../examples/McpAskDuplicateDemo/index.less | 68 + .../examples/McpAskDuplicateDemo/index.tsx | 396 +++ .../McpAskDuplicateDemo/mockSsePayloads.ts | 133 + qiming/src/examples/MessageQueueDemo.less | 127 + qiming/src/examples/MessageQueueDemo.tsx | 433 ++++ qiming/src/examples/VoiceInputDemo/index.less | 35 + qiming/src/examples/VoiceInputDemo/index.tsx | 134 + .../src/examples/VoiceInputDemo/mockData.ts | 71 + qiming/src/hooks/useAppDevChat.ts | 113 +- qiming/src/hooks/useAppDevFileManagement.ts | 339 ++- qiming/src/hooks/useAppDevInitialAutoSend.ts | 283 +++ qiming/src/hooks/useAppDevModelSelector.ts | 10 +- qiming/src/hooks/useAppDevProjectInfo.ts | 102 +- qiming/src/hooks/useAuthProtectedImageSrc.ts | 72 + .../hooks/useChatFinishedWhenListExecuting.ts | 40 + qiming/src/hooks/useConversation.ts | 4 + .../hooks/useConversationScrollDetection.ts | 21 +- qiming/src/hooks/useEventPolling.ts | 7 +- .../src/hooks/useExecutingTaskStatusPoll.ts | 50 + qiming/src/hooks/useInitProjectMetadata.ts | 56 + qiming/src/hooks/useMcp.tsx | 9 + qiming/src/hooks/useMessageEventDelegate.ts | 16 - qiming/src/hooks/useResumeStreamHandlers.ts | 170 ++ qiming/src/hooks/useSubscription.ts | 59 +- qiming/src/hooks/useTerminalWsUrl.ts | 15 + qiming/src/hooks/useUnifiedTheme.ts | 4 +- .../components/ConversationItem/index.less | 146 ++ .../components/ConversationItem/index.tsx | 84 + .../components/EmptyState/index.less | 45 + .../components/EmptyState/index.tsx | 39 + .../components/SearchHeader/index.less | 72 + .../components/SearchHeader/index.tsx | 61 + .../NewHomeSection/index.less | 55 + .../NewHomeSection/index.tsx | 366 +++ .../NewHomeSection/utils.ts | 26 + qiming/src/locales/i18n/en-US.ts | 517 ++++ qiming/src/locales/i18n/ja-JP.ts | 522 ++++ qiming/src/locales/i18n/zh-CN.ts | 518 ++++ qiming/src/locales/i18n/zh-HK.ts | 523 ++++ qiming/src/locales/i18n/zh-TW.ts | 523 ++++ qiming/src/models/conversationAgent.ts | 1060 ++++++++ qiming/src/models/conversationInfo.ts | 3 + .../conversationInfoMessageList.test.ts | 56 + .../src/models/conversationInfoMessageList.ts | 21 + qiming/src/models/pageHandoffContext.ts | 89 + qiming/src/pages/Antv-X6/component/graph.tsx | 20 +- .../Antv-X6/component/registerCustomNodes.tsx | 12 +- qiming/src/pages/Antv-X6/header.tsx | 6 +- .../agentFlow/__tests__/createdPicker.test.ts | 120 + .../agentFlow/__tests__/edgeConnect.test.ts | 50 + .../v3/agentFlow/__tests__/edgeSync.test.ts | 88 + .../__tests__/humanInteraction.test.ts | 394 +++ .../__tests__/middleNodeEdgeCleanup.test.ts | 54 + .../__tests__/nodeTypeMapping.test.ts | 278 +++ .../__tests__/qaConfigAdapter.test.ts | 260 ++ .../v3/agentFlow/__tests__/register.test.ts | 42 + .../__tests__/resolveNodePresentation.test.ts | 35 + .../__tests__/routeConditionAdapter.test.ts | 74 + .../agentFlow/__tests__/routeDecision.test.ts | 360 +++ .../__tests__/startInsertHandlers.test.ts | 127 + .../v3/agentFlow/adapters/qaConfigAdapter.ts | 289 +++ .../adapters/routeConditionAdapter.ts | 89 + .../Antv-X6/v3/agentFlow/createdPicker.ts | 106 + .../pages/Antv-X6/v3/agentFlow/edgeConnect.ts | 68 + .../pages/Antv-X6/v3/agentFlow/edgeSync.ts | 88 + .../v3/agentFlow/enums/formArgInputType.ts | 12 + .../v3/agentFlow/enums/hitlAnswerType.ts | 9 + .../Antv-X6/v3/agentFlow/enums/hitlMode.ts | 7 + .../v3/agentFlow/forms/AgentFlowEndForm.tsx | 20 + .../v3/agentFlow/forms/AgentFlowStartForm.tsx | 20 + .../agentFlow/forms/AgentFlowWorkflowForm.tsx | 48 + .../v3/agentFlow/forms/AgentNodeForm.tsx | 112 + .../forms/HumanInteractionAskForm.less | 80 + .../forms/HumanInteractionAskForm.tsx | 343 +++ .../v3/agentFlow/forms/RouteBranchList.less | 149 ++ .../v3/agentFlow/forms/RouteBranchList.tsx | 161 ++ .../agentFlow/forms/RouteConditionMatch.tsx | 149 ++ .../v3/agentFlow/forms/RouteDecisionForm.tsx | 73 + .../Antv-X6/v3/agentFlow/forms/index.tsx | 30 + .../handlers/__tests__/portLayout.test.ts | 199 ++ .../v3/agentFlow/handlers/humanInteraction.ts | 210 ++ .../v3/agentFlow/handlers/portLayout.ts | 132 + .../v3/agentFlow/handlers/routeDecision.ts | 262 ++ .../useAgentFlowExitFullscreenZoomFit.test.ts | 119 + .../useAgentFlowExitFullscreenZoomFit.ts | 29 + .../v3/agentFlow/middleNodeEdgeCleanup.ts | 31 + .../Antv-X6/v3/agentFlow/nodeTypeMapping.ts | 155 ++ .../pages/Antv-X6/v3/agentFlow/register.ts | 19 + .../v3/agentFlow/resolveNodePresentation.ts | 30 + .../v3/agentFlow/startInsertHandlers.ts | 192 ++ .../v3/agentFlow/types/agentNodeConfig.ts | 19 + .../v3/agentFlow/types/hitlAskConfig.ts | 15 + .../v3/component/agentFlowPortChips.tsx | 134 + .../src/pages/Antv-X6/v3/component/graph.tsx | 20 +- .../KnowledgeInsertNodePanel.tsx | 126 + .../__tests__/knowledgeInsertNode.test.ts | 82 + .../v3/component/knowledgeInsert/index.ts | 10 + .../knowledgeInsert/knowledgeInsertNode.ts | 128 + .../v3/component/registerCustomNodes.tsx | 12 +- .../Antv-X6/v3/components/layout/Header.tsx | 6 +- .../v3/constants/__tests__/canvasZoom.test.ts | 49 + .../pages/Antv-X6/v3/constants/canvasZoom.ts | 37 + .../Antv-X6/v3/constants/editorConfig.ts | 6 + .../v3/docs/AGENTFLOW_NODE_BACKEND_FIELDS.md | 432 ++++ .../v3/docs/AGENTFLOW_NODE_CONFIG_GUIDE.md | 355 +++ .../Antv-X6/v3/docs/AGENTFLOW_NODE_FIELDS.md | 503 ++++ ...TFLOW_START_INSERT_RISKS_AND_REGRESSION.md | 162 ++ .../v3/docs/PORT_CLICK_BUGFIX_2026-06-27.md | 296 +++ .../v3/extensions/__tests__/registry.test.ts | 59 + .../pages/Antv-X6/v3/extensions/registry.ts | 29 + .../src/pages/Antv-X6/v3/extensions/types.ts | 88 + .../flowKind/__tests__/flowKindRules.test.ts | 119 + .../Antv-X6/v3/flowKind/flowKindConfig.ts | 156 ++ .../Antv-X6/v3/flowKind/flowKindRules.ts | 96 + .../pages/Antv-X6/v3/flowKind/useFlowKind.ts | 25 + .../useRegisterWorkflowRefresh.test.ts | 24 + .../v3/hooks/useRegisterWorkflowRefresh.ts | 14 + .../v3/utils/__tests__/canvasPosition.test.ts | 213 ++ .../pages/Antv-X6/v3/utils/canvasPosition.ts | 174 ++ .../Antv-X6/v3/utils/nodeCreateSession.ts | 12 + .../v3/utils/workflowFormImeGuard.test.ts | 20 + .../Antv-X6/v3/utils/workflowFormImeGuard.ts | 44 + .../components/DataResourceList/index.less | 3 + .../components/DataResourceList/index.tsx | 239 ++ .../components/DiffViewItem/index.less | 113 + .../components/DiffViewItem/index.tsx | 146 ++ .../FileTreeGitSourceSidebar/index.less | 67 + .../FileTreeGitSourceSidebar/index.tsx | 53 + qiming/src/pages/ApplySkillDetails/index.tsx | 17 + .../components/DropdownChangeName/index.less | 17 + .../components/DropdownChangeName/index.tsx | 339 +++ .../Chat/components/LeftContent/index.less | 203 ++ .../Chat/components/LeftContent/index.tsx | 259 ++ .../pages/Chat/components/ShowArea/index.less | 28 + .../pages/Chat/components/ShowArea/index.tsx | 48 + .../pages/Chat/hooks/useChatConversation.ts | 206 ++ qiming/src/pages/Chat/hooks/useChatFiles.ts | 325 +++ qiming/src/pages/Chat/hooks/useChatSandbox.ts | 97 + .../src/pages/Chat/hooks/useChatVariables.ts | 67 + .../src/pages/Chat/hooks/useChatViewMode.ts | 92 + qiming/src/pages/Chat/index.less | 241 +- qiming/src/pages/Chat/index.tsx | 2126 ++++++++-------- .../AgentConversationChatPanel/index.tsx | 175 ++ .../ConversationAgentChatSession/index.less | 18 + .../ConversationAgentChatSession/index.tsx | 51 + .../FilePathHeader/index.less | 143 ++ .../FilePathHeader/index.tsx | 212 ++ .../FilePathHeader/previewCodeToggle.ts | 21 + .../FilePathHeader/type.ts | 37 + .../PreviewTabContextMenu/index.less | 66 + .../PreviewTabContextMenu/index.tsx | 186 ++ .../PreviewTabBar/PreviewTabLabel.tsx | 80 + .../PreviewTabModelSelect/index.less | 89 + .../PreviewTabModelSelect/index.tsx | 154 ++ .../PreviewTabBar/PreviewTabTooltipDrag.tsx | 279 +++ .../PreviewTabBar/index.less | 270 +++ .../PreviewTabBar/index.tsx | 729 ++++++ .../ToolTabContent/index.less | 8 + .../ToolTabContent/index.tsx | 42 + .../hooks/usePreviewTabs.ts | 551 +++++ .../ConversationAgentFilePreview/index.less | 52 + .../ConversationAgentFilePreview/index.tsx | 171 ++ .../ConversationAgentHeader/index.less | 74 + .../ConversationAgentHeader/index.tsx | 220 ++ .../ImportProjectModal/index.tsx | 176 ++ .../hooks/useConversationAgentChatSession.ts | 316 +++ .../hooks/useConversationAgentDevLogs.ts | 283 +++ qiming/src/pages/ConversationAgent/index.less | 211 ++ qiming/src/pages/ConversationAgent/index.tsx | 1866 ++++++++++++++ .../ConversationAgent/services/agent-dev.ts | 105 + .../CallApproval/index.less | 30 + .../CallApproval/index.tsx | 78 + .../CreateHooks/CreateHookModal/index.less | 10 + .../CreateHooks/CreateHookModal/index.tsx | 306 +++ .../AgentArrangeConfig/CreateHooks/index.less | 68 + .../AgentArrangeConfig/CreateHooks/index.tsx | 454 ++++ .../AgentArrangeConfig/HookList/index.less | 14 + .../AgentArrangeConfig/HookList/index.tsx | 43 + .../agentFlow/__tests__/arrangePolicy.test.ts | 23 + .../agentFlow/arrangePolicy.ts | 92 + .../EditAgent/AgentFlowCanvas/index.less | 20 + .../pages/EditAgent/AgentFlowCanvas/index.tsx | 143 ++ qiming/src/pages/GlobalModelManage/index.less | 105 + qiming/src/pages/GlobalModelManage/index.tsx | 31 +- .../components/ChatBoxRecommendNav/index.less | 69 + .../components/ChatBoxRecommendNav/index.tsx | 55 + qiming/src/pages/Home/index.legacy.less | 102 + qiming/src/pages/Home/index.legacy.tsx | 338 +++ qiming/src/pages/Login/BasicLayout/index.tsx | 19 +- qiming/src/pages/Login/SiteProtocol/index.tsx | 73 +- qiming/src/pages/Login/index.less | 48 +- qiming/src/pages/Login/index.tsx | 285 +-- .../components/ApiPermissionTab/index.tsx | 157 ++ .../components/ModelPermissionTab/index.tsx | 112 + .../PermissionOperationsBar/index.tsx | 91 + .../ModalitiesTagsCell/index.less | 20 + .../ModalitiesTagsCell/index.tsx | 82 + .../MorePage/ModelPermissions/index.less | 50 + .../pages/MorePage/ModelPermissions/index.tsx | 365 +++ .../src/pages/OpenApp/CreditRecords/index.tsx | 16 + .../OpenApp/HistoryConversation/index.less | 17 + qiming/src/pages/OpenApp/MyOrders/index.tsx | 8 + .../pages/OpenApp/MySubscriptions/index.less | 10 + .../pages/OpenApp/MySubscriptions/index.tsx | 46 + qiming/src/pages/OpenApp/UsageStats/index.tsx | 8 + .../src/pages/PublishedSkillDetails/index.tsx | 19 + .../components/MoreActionsMenu/index.tsx | 82 + .../components/SkillHeader/index.less | 47 + .../components/SkillHeader/index.tsx | 129 + .../pages/SkillDetails/hooks/useSkillFiles.ts | 415 ++++ .../components/SkillHeader/index.less | 61 + .../components/SkillHeader/index.tsx | 113 + .../components/SkillPublishAction/index.less | 18 + .../components/SkillPublishAction/index.tsx | 87 + .../pages/SkillDetailsConversation/index.less | 64 + .../pages/SkillDetailsConversation/index.tsx | 138 ++ .../components/GreetingHeader/index.less | 16 + .../components/GreetingHeader/index.tsx | 23 + .../PromptBox/components/TabsList/index.less | 77 + .../PromptBox/components/TabsList/index.tsx | 39 + .../components/PromptBox/index.less | 24 + .../components/PromptBox/index.tsx | 254 ++ .../src/pages/SpaceCreateProject/index.less | 17 + qiming/src/pages/SpaceCreateProject/index.tsx | 62 + .../utils/projectCreateStrategy.ts | 105 + .../KnowledgeAccuracyTest/index.less | 482 ++++ .../KnowledgeAccuracyTest/index.tsx | 460 ++++ .../KnowledgeAccuracyTest/index.tsx.bak | 429 ++++ .../components/PluginChatSession/index.tsx | 257 ++ .../components/PluginHeader/index.less | 77 + .../components/PluginHeader/index.tsx | 136 ++ .../components/PluginInputTable/index.tsx | 221 ++ .../components/PluginOutputTable/index.tsx | 159 ++ .../PluginRequestConfigForm/index.tsx | 100 + .../pages/SpaceResource/Knowledge/index.tsx | 227 ++ .../Plugin/components/HeaderArea/index.less | 43 + .../Plugin/components/HeaderArea/index.tsx | 120 + .../src/pages/SpaceResource/Plugin/index.tsx | 280 +++ .../src/pages/SpaceResource/Storage/index.tsx | 272 +++ .../Workflow/components/HeaderArea/index.less | 43 + .../Workflow/components/HeaderArea/index.tsx | 121 + .../pages/SpaceResource/Workflow/index.tsx | 283 +++ .../components/MoveToGroupModal/index.tsx | 122 + .../components/ComponentList/index.less | 18 + .../components/ComponentList/index.tsx | 275 +++ .../components/GroupEditModal/index.tsx | 230 ++ .../components/HorizontalGroupList/index.less | 140 ++ .../components/HorizontalGroupList/index.tsx | 226 ++ .../SpaceResource/styles/resource-list.less | 56 + qiming/src/pages/Square/index.less | 12 + qiming/src/pages/Square/index.tsx | 123 +- .../MenuPermission/MenuManage/index.less | 52 + .../MenuPermission/MenuManage/index.tsx | 135 +- .../RecommendManage/Chatbox/index.tsx | 17 + .../RecommendManage/HomeRecommend/index.tsx | 17 + .../OfficialRecommend/index.tsx | 17 + .../components/RecommendAddModal/index.less | 172 ++ .../components/RecommendAddModal/index.tsx | 441 ++++ .../SelectedAgentCard/index.less | 51 + .../SelectedAgentCard/index.tsx | 54 + .../components/RecommendFormModal/index.less | 24 + .../components/RecommendFormModal/index.tsx | 331 +++ .../components/RecommendListPage/index.tsx | 474 ++++ .../RecommendManage/constants.ts | 70 + .../RecommendManage/services/recomment.ts | 70 + .../RecommendManage/types/index.ts | 124 + .../utils/chatboxFunctionTypeLabel.ts | 28 + .../utils/chatboxFunctionTypeRules.ts | 48 + .../utils/fetchPublishedAgentByTargetId.ts | 31 + .../utils/publishedTargetSource.ts | 84 + .../utils/squareTargetTypeLabel.ts | 23 + .../UserCredits/DeductCreditModal/index.tsx | 234 ++ .../UserCredits/GrantCreditModal/index.tsx | 239 ++ .../SubscriptionCredits/creditTypeOptions.ts | 47 + .../SystemConfig/BaseFormItem/index.tsx | 42 +- .../SystemConfig/BaseTab/index.tsx | 6 +- .../SystemConfig/SystemConfig/utils.ts | 24 + .../components/ResetPasswordModal.tsx | 135 ++ qiming/src/routes/index.ts | 111 + qiming/src/services/agentConfig.ts | 58 +- qiming/src/services/agentDev.ts | 7 +- qiming/src/services/appDev.ts | 75 +- qiming/src/services/audio.ts | 50 + qiming/src/services/common.ts | 20 +- qiming/src/services/created.ts | 9 + qiming/src/services/displayRecommend.ts | 12 + qiming/src/services/library.ts | 73 + qiming/src/services/mcp.ts | 9 + qiming/src/services/menuService.ts | 7 + qiming/src/services/modelConfig.ts | 14 + qiming/src/services/plugin.ts | 3 +- qiming/src/services/skill.ts | 18 + qiming/src/services/systemManage.ts | 32 +- qiming/src/services/unifiedThemeService.ts | 111 +- qiming/src/services/vncDesktop.ts | 37 + qiming/src/services/workflow.ts | 2 + qiming/src/styles/pro-components.less | 58 +- qiming/src/types/enums/agent.ts | 10 + qiming/src/types/enums/common.ts | 14 + qiming/src/types/enums/event.ts | 4 + qiming/src/types/enums/library.ts | 2 + qiming/src/types/enums/mcp.ts | 8 + qiming/src/types/enums/menus.ts | 3 + qiming/src/types/enums/modelConfig.ts | 3 +- qiming/src/types/enums/space.ts | 38 +- qiming/src/types/interfaces/account.ts | 3 + qiming/src/types/interfaces/acpPermission.ts | 79 + qiming/src/types/interfaces/agent.ts | 102 +- qiming/src/types/interfaces/agentConfig.ts | 76 +- qiming/src/types/interfaces/appDev.ts | 23 +- qiming/src/types/interfaces/common.ts | 72 +- .../src/types/interfaces/conversationInfo.ts | 29 + .../src/types/interfaces/displayRecommend.ts | 43 + qiming/src/types/interfaces/fileTree.ts | 18 + qiming/src/types/interfaces/graph.ts | 36 +- qiming/src/types/interfaces/library.ts | 34 + qiming/src/types/interfaces/login.ts | 8 + qiming/src/types/interfaces/mcp.ts | 4 + qiming/src/types/interfaces/node.ts | 54 +- qiming/src/types/interfaces/publishManage.ts | 2 + qiming/src/types/interfaces/request.ts | 12 + qiming/src/types/interfaces/skill.ts | 2 + qiming/src/types/interfaces/space.ts | 14 +- qiming/src/types/interfaces/square.ts | 8 +- qiming/src/types/interfaces/systemManage.ts | 58 +- qiming/src/types/interfaces/theme.ts | 3 + qiming/src/types/interfaces/vncDesktop.ts | 22 +- qiming/src/utils/appDevUtils.ts | 129 + qiming/src/utils/applyGeneratedIcon.ts | 164 ++ qiming/src/utils/authProtectedFileUrl.test.ts | 26 + qiming/src/utils/authProtectedFileUrl.ts | 86 + .../src/utils/conversationTaskStatusSync.ts | 96 + qiming/src/utils/defaultIcon.ts | 10 + qiming/src/utils/fileChangeDiff.test.ts | 62 + qiming/src/utils/fileChangeDiff.ts | 182 ++ qiming/src/utils/generatedMetadata.ts | 49 + qiming/src/utils/logContentUtils.ts | 21 + qiming/src/utils/priceInput.ts | 73 + qiming/src/utils/resolveCreateIcon.ts | 40 + qiming/src/utils/resourcePricing.ts | 46 + qiming/src/utils/terminalWsUrl.ts | 52 + 553 files changed, 78232 insertions(+), 2270 deletions(-) create mode 100644 qiming/src/assets/icons/chat/voice.svg create mode 100644 qiming/src/assets/icons/nav/history_conversation.svg create mode 100644 qiming/src/assets/icons/nav/recommend_manage.svg create mode 100644 qiming/src/assets/images/app-icon.png create mode 100644 qiming/src/assets/images/empty_state_no_file.svg create mode 100644 qiming/src/assets/workflow/workflow-Agent.svg create mode 100644 qiming/src/assets/workflow/workflow-HumanAsk.svg create mode 100644 qiming/src/assets/workflow/workflow-KnowledgeInsert.svg create mode 100644 qiming/src/assets/workflow/workflow-RouteDecision.svg create mode 100644 qiming/src/components/AcpPermissionCard/index.less create mode 100644 qiming/src/components/AcpPermissionCard/index.tsx create mode 100644 qiming/src/components/ChatInputHome/SpaceSelector/index.less create mode 100644 qiming/src/components/ChatInputHome/SpaceSelector/index.tsx create mode 100644 qiming/src/components/CodeViewer/MonacoDiffEditor/index.less create mode 100644 qiming/src/components/CodeViewer/MonacoDiffEditor/index.tsx create mode 100644 qiming/src/components/KnowledgeAccuracyTest/service.ts create mode 100644 qiming/src/components/KnowledgeAccuracyTest/types.ts create mode 100644 qiming/src/components/MarkdownRenderer/TaskResult/context.ts create mode 100644 qiming/src/components/MarkdownRenderer/__tests__/utils.test.ts create mode 100644 qiming/src/components/SelectionList/index.less create mode 100644 qiming/src/components/SelectionList/index.tsx create mode 100644 qiming/src/components/SkillDetailView/SkillHeader/index.less create mode 100644 qiming/src/components/SkillDetailView/SkillHeader/index.tsx create mode 100644 qiming/src/components/SkillDetailView/index.less create mode 100644 qiming/src/components/SkillDetailView/index.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.less create mode 100644 qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.test.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/useAcpPermissionShortcuts.test.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/useAcpPermissionShortcuts.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/AgentInterventionChatLayer.less create mode 100644 qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/DockPanel.less create mode 100644 qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/DockPanel.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/index.test.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/index.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/intervention-dock-card.module.less create mode 100644 qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFileUpload.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.less create mode 100644 qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.test.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.less create mode 100644 qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.test.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.tsx create mode 100644 qiming/src/components/business-component/AgentIntervention/README.md create mode 100644 qiming/src/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionHandlers.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionHandlers.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionLayer.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionLayer.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/hooks/useInterventionEscapeKey.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/index.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/types/acpIntervention.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/types/mcpAskIntervention.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/applyAcpPermissionSseEvent.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/applyAcpPermissionSseEvent.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/applyMcpAskToolCallSseEvent.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/applyMcpAskToolCallSseEvent.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/extractMcpAskStructuredInput.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/extractMcpAskStructuredInput.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/interventionTrigger.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/mcpAskExecutedComponent.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/mcpAskExecutedComponent.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/mcpAskHydrateMessage.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/mcpAskHydrateMessage.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/mcpAskResumeMessage.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/mcpAskResumeMessage.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/normalizeMcpAskFormData.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/normalizeMcpAskFormData.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskSchema.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskSchema.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskToolInput.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/parseSseEventEnvelope.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/processInterventionSsePatch.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/reconcileAcpPermissionStatus.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/reconcileAcpPermissionStatus.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/reconcileFinalMessageState.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/reconcileFinalMessageState.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/reconcileMcpAskHydratedStatus.test.ts create mode 100644 qiming/src/components/business-component/AgentIntervention/utils/reconcileMcpAskHydratedStatus.ts create mode 100644 qiming/src/components/business-component/ChangeFileGitDiffView/index.less create mode 100644 qiming/src/components/business-component/ChangeFileGitDiffView/index.tsx create mode 100644 qiming/src/components/business-component/ConversationBottomConsole/DevLogActions.tsx create mode 100644 qiming/src/components/business-component/ConversationBottomConsole/DevLogPanel/index.less create mode 100644 qiming/src/components/business-component/ConversationBottomConsole/DevLogPanel/index.tsx create mode 100644 qiming/src/components/business-component/ConversationBottomConsole/index.less create mode 100644 qiming/src/components/business-component/ConversationBottomConsole/index.tsx create mode 100644 qiming/src/components/business-component/ConversationBottomConsole/terminalTheme.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/index.less create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/index.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/types.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/index.less create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/index.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/types.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/SearchView/index.less create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/SearchView/index.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/index.less create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/index.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreeToolbar/index.less create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreeToolbar/index.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/PanelCore.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileContextMenu/index.less create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileContextMenu/index.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileListSection/index.less create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileListSection/index.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/index.less create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/index.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/hooks/useAppDevFileTree.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/hooks/useSourceControl.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/index.less create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/index.tsx create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/services/git-version-management.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/types/file-tree-git-source.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/types/git-version-management.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/utils/buildChangeFileTree.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/utils/buildGitWorkspaceParams.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/utils/changeFileStatus.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/utils/gitStatusUtils.ts create mode 100644 qiming/src/components/business-component/FileTreeGitSourcePanel/utils/sourceControlGitActions.ts create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/MoreActionsMenu/index.less create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/MoreActionsMenu/index.tsx create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/ShareDesktopModal/index.tsx create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/index.less create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/index.tsx create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/type.ts create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/FileTreeViewPanel.tsx create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/hooks/useFileTreePreviewPanel.tsx create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/hooks/useFileTreePreviewView.tsx create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/index.less create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/index.tsx create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/types.ts create mode 100644 qiming/src/components/business-component/FileTreePreviewPanel/types/file-tree.ts create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitChangesPanel/index.less create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitChangesPanel/index.tsx create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitTimeline/index.less create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitTimeline/index.tsx create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/GitVersionRollbackConfirmModal/index.less create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/GitVersionRollbackConfirmModal/index.tsx create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/commitListUtils.ts create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/gitCommitDiffUtils.ts create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/gitRollbackUtils.ts create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/index.less create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/index.tsx create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/types.ts create mode 100644 qiming/src/components/business-component/GitVersionRecordPanel/useGitVersionRollback.ts create mode 100644 qiming/src/components/business-component/GuardedFormModal/FormModalForm.tsx create mode 100644 qiming/src/components/business-component/GuardedFormModal/index.tsx create mode 100644 qiming/src/components/business-component/GuardedFormModal/submitGuard.ts create mode 100644 qiming/src/components/business-component/GuardedFormModal/types.ts create mode 100644 qiming/src/components/business-component/MessageQueue/QueuedMessageItem.tsx create mode 100644 qiming/src/components/business-component/MessageQueue/index.less create mode 100644 qiming/src/components/business-component/MessageQueue/index.tsx create mode 100644 qiming/src/components/business-component/MessageQueue/queueStorage.ts create mode 100644 qiming/src/components/business-component/MessageQueue/types.ts create mode 100644 qiming/src/components/business-component/MessageQueue/useChatMessageQueue.ts create mode 100644 qiming/src/components/business-component/MessageQueue/useMessageQueue.ts create mode 100644 qiming/src/components/business-component/MessageQueue/useUnifiedChatQueue.ts create mode 100644 qiming/src/components/business-component/ModelPriceTierList/index.less create mode 100644 qiming/src/components/business-component/ModelPriceTierList/index.tsx create mode 100644 qiming/src/components/business-component/Terminal/EmbeddedConsoleTerminal.tsx create mode 100644 qiming/src/components/business-component/Terminal/index.less create mode 100644 qiming/src/components/business-component/Terminal/index.tsx create mode 100644 qiming/src/components/business-component/Terminal/terminalMouseUtils.ts create mode 100644 qiming/src/components/business-component/Terminal/terminalReconnect.ts create mode 100644 qiming/src/components/business-component/Terminal/ttydWire.ts create mode 100644 qiming/src/components/business-component/Terminal/type.ts create mode 100644 qiming/src/components/business-component/Terminal/xterm.css create mode 100644 qiming/src/components/business-component/Terminal/xtermBundle.ts create mode 100644 qiming/src/components/business-component/UnifiedChatSession/components/ChatContentArea/index.less create mode 100644 qiming/src/components/business-component/UnifiedChatSession/components/ChatContentArea/index.tsx create mode 100644 qiming/src/components/business-component/UnifiedChatSession/components/ChatInputHomeIndependent/index.tsx create mode 100644 qiming/src/components/business-component/UnifiedChatSession/hooks/useConversationStreamResume.ts create mode 100644 qiming/src/components/business-component/UnifiedChatSession/hooks/useLoadMoreHistory.ts create mode 100644 qiming/src/components/business-component/UnifiedChatSession/hooks/useUnifiedChatScroll.ts create mode 100644 qiming/src/components/business-component/UnifiedChatSession/index.less create mode 100644 qiming/src/components/business-component/UnifiedChatSession/index.tsx create mode 100644 qiming/src/components/business-component/UnifiedChatSession/types.ts create mode 100644 qiming/src/components/business-component/VoiceInput/chatInputFooter/Expand.tsx create mode 100644 qiming/src/components/business-component/VoiceInput/chatInputFooter/HideWhenActive.tsx create mode 100644 qiming/src/components/business-component/VoiceInput/chatInputFooter/Provider.tsx create mode 100644 qiming/src/components/business-component/VoiceInput/chatInputFooter/Right.tsx create mode 100644 qiming/src/components/business-component/VoiceInput/chatInputFooter/context.ts create mode 100644 qiming/src/components/business-component/VoiceInput/chatInputFooter/index.less create mode 100644 qiming/src/components/business-component/VoiceInput/chatInputFooter/index.ts create mode 100644 qiming/src/components/business-component/VoiceInput/components/Button.tsx create mode 100644 qiming/src/components/business-component/VoiceInput/components/RecordingBar.tsx create mode 100644 qiming/src/components/business-component/VoiceInput/components/index.less create mode 100644 qiming/src/components/business-component/VoiceInput/config.ts create mode 100644 qiming/src/components/business-component/VoiceInput/hooks/useAudioRecorder.ts create mode 100644 qiming/src/components/business-component/VoiceInput/hooks/useMockAudioRecorder.ts create mode 100644 qiming/src/components/business-component/VoiceInput/index.tsx create mode 100644 qiming/src/components/business-component/VoiceInput/loaders/recorderContext.ts create mode 100644 qiming/src/components/business-component/VoiceInput/loaders/recorderWorklet.ts create mode 100644 qiming/src/components/business-component/VoiceInput/types.ts create mode 100644 qiming/src/components/business-component/VoiceInput/utils/resample.ts create mode 100644 qiming/src/components/business-component/VoiceInput/utils/wavEncoder.ts create mode 100644 qiming/src/components/business-component/VoiceInput/utils/waveLevels.ts create mode 100644 qiming/src/constants/feature.constants.ts create mode 100644 qiming/src/constants/hook.constants.ts create mode 100644 qiming/src/contexts/CanvasFullscreenContext.tsx create mode 100644 qiming/src/contexts/FlowKindContext.tsx create mode 100644 qiming/src/examples/AgentInterventionDemo/index.tsx create mode 100644 qiming/src/examples/AgentInterventionDemo/mockData.ts create mode 100644 qiming/src/examples/McpAskDuplicateDemo/index.less create mode 100644 qiming/src/examples/McpAskDuplicateDemo/index.tsx create mode 100644 qiming/src/examples/McpAskDuplicateDemo/mockSsePayloads.ts create mode 100644 qiming/src/examples/MessageQueueDemo.less create mode 100644 qiming/src/examples/MessageQueueDemo.tsx create mode 100644 qiming/src/examples/VoiceInputDemo/index.less create mode 100644 qiming/src/examples/VoiceInputDemo/index.tsx create mode 100644 qiming/src/examples/VoiceInputDemo/mockData.ts create mode 100644 qiming/src/hooks/useAppDevInitialAutoSend.ts create mode 100644 qiming/src/hooks/useAuthProtectedImageSrc.ts create mode 100644 qiming/src/hooks/useChatFinishedWhenListExecuting.ts create mode 100644 qiming/src/hooks/useExecutingTaskStatusPoll.ts create mode 100644 qiming/src/hooks/useInitProjectMetadata.ts create mode 100644 qiming/src/hooks/useResumeStreamHandlers.ts create mode 100644 qiming/src/hooks/useTerminalWsUrl.ts create mode 100644 qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/ConversationItem/index.less create mode 100644 qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/ConversationItem/index.tsx create mode 100644 qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/EmptyState/index.less create mode 100644 qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/EmptyState/index.tsx create mode 100644 qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/SearchHeader/index.less create mode 100644 qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/SearchHeader/index.tsx create mode 100644 qiming/src/layouts/DynamicMenusLayout/NewHomeSection/index.less create mode 100644 qiming/src/layouts/DynamicMenusLayout/NewHomeSection/index.tsx create mode 100644 qiming/src/layouts/DynamicMenusLayout/NewHomeSection/utils.ts create mode 100644 qiming/src/models/conversationAgent.ts create mode 100644 qiming/src/models/conversationInfoMessageList.test.ts create mode 100644 qiming/src/models/conversationInfoMessageList.ts create mode 100644 qiming/src/models/pageHandoffContext.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/createdPicker.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/edgeConnect.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/edgeSync.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/humanInteraction.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/middleNodeEdgeCleanup.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/nodeTypeMapping.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/qaConfigAdapter.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/register.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/resolveNodePresentation.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/routeConditionAdapter.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/routeDecision.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/startInsertHandlers.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/adapters/qaConfigAdapter.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/adapters/routeConditionAdapter.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/createdPicker.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/edgeConnect.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/edgeSync.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/enums/formArgInputType.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/enums/hitlAnswerType.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/enums/hitlMode.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowEndForm.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowStartForm.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowWorkflowForm.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentNodeForm.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/HumanInteractionAskForm.less create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/HumanInteractionAskForm.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/RouteBranchList.less create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/RouteBranchList.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/RouteConditionMatch.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/RouteDecisionForm.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/forms/index.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/handlers/__tests__/portLayout.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/handlers/humanInteraction.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/handlers/portLayout.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/handlers/routeDecision.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/hooks/__tests__/useAgentFlowExitFullscreenZoomFit.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/hooks/useAgentFlowExitFullscreenZoomFit.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/middleNodeEdgeCleanup.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/nodeTypeMapping.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/register.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/resolveNodePresentation.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/startInsertHandlers.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/types/agentNodeConfig.ts create mode 100644 qiming/src/pages/Antv-X6/v3/agentFlow/types/hitlAskConfig.ts create mode 100644 qiming/src/pages/Antv-X6/v3/component/agentFlowPortChips.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/component/knowledgeInsert/KnowledgeInsertNodePanel.tsx create mode 100644 qiming/src/pages/Antv-X6/v3/component/knowledgeInsert/__tests__/knowledgeInsertNode.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/component/knowledgeInsert/index.ts create mode 100644 qiming/src/pages/Antv-X6/v3/component/knowledgeInsert/knowledgeInsertNode.ts create mode 100644 qiming/src/pages/Antv-X6/v3/constants/__tests__/canvasZoom.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/constants/canvasZoom.ts create mode 100644 qiming/src/pages/Antv-X6/v3/constants/editorConfig.ts create mode 100644 qiming/src/pages/Antv-X6/v3/docs/AGENTFLOW_NODE_BACKEND_FIELDS.md create mode 100644 qiming/src/pages/Antv-X6/v3/docs/AGENTFLOW_NODE_CONFIG_GUIDE.md create mode 100644 qiming/src/pages/Antv-X6/v3/docs/AGENTFLOW_NODE_FIELDS.md create mode 100644 qiming/src/pages/Antv-X6/v3/docs/AGENTFLOW_START_INSERT_RISKS_AND_REGRESSION.md create mode 100644 qiming/src/pages/Antv-X6/v3/docs/PORT_CLICK_BUGFIX_2026-06-27.md create mode 100644 qiming/src/pages/Antv-X6/v3/extensions/__tests__/registry.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/extensions/registry.ts create mode 100644 qiming/src/pages/Antv-X6/v3/extensions/types.ts create mode 100644 qiming/src/pages/Antv-X6/v3/flowKind/__tests__/flowKindRules.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/flowKind/flowKindConfig.ts create mode 100644 qiming/src/pages/Antv-X6/v3/flowKind/flowKindRules.ts create mode 100644 qiming/src/pages/Antv-X6/v3/flowKind/useFlowKind.ts create mode 100644 qiming/src/pages/Antv-X6/v3/hooks/__tests__/useRegisterWorkflowRefresh.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/hooks/useRegisterWorkflowRefresh.ts create mode 100644 qiming/src/pages/Antv-X6/v3/utils/__tests__/canvasPosition.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/utils/canvasPosition.ts create mode 100644 qiming/src/pages/Antv-X6/v3/utils/nodeCreateSession.ts create mode 100644 qiming/src/pages/Antv-X6/v3/utils/workflowFormImeGuard.test.ts create mode 100644 qiming/src/pages/Antv-X6/v3/utils/workflowFormImeGuard.ts create mode 100644 qiming/src/pages/AppDev/components/ChatArea/components/DataResourceList/index.less create mode 100644 qiming/src/pages/AppDev/components/ChatArea/components/DataResourceList/index.tsx create mode 100644 qiming/src/pages/AppDev/components/ChatArea/components/ToolCallProcess/components/DiffViewItem/index.less create mode 100644 qiming/src/pages/AppDev/components/ChatArea/components/ToolCallProcess/components/DiffViewItem/index.tsx create mode 100644 qiming/src/pages/AppDev/components/FileTreeGitSourceSidebar/index.less create mode 100644 qiming/src/pages/AppDev/components/FileTreeGitSourceSidebar/index.tsx create mode 100644 qiming/src/pages/ApplySkillDetails/index.tsx create mode 100644 qiming/src/pages/Chat/components/DropdownChangeName/index.less create mode 100644 qiming/src/pages/Chat/components/DropdownChangeName/index.tsx create mode 100644 qiming/src/pages/Chat/components/LeftContent/index.less create mode 100644 qiming/src/pages/Chat/components/LeftContent/index.tsx create mode 100644 qiming/src/pages/Chat/components/ShowArea/index.less create mode 100644 qiming/src/pages/Chat/components/ShowArea/index.tsx create mode 100644 qiming/src/pages/Chat/hooks/useChatConversation.ts create mode 100644 qiming/src/pages/Chat/hooks/useChatFiles.ts create mode 100644 qiming/src/pages/Chat/hooks/useChatSandbox.ts create mode 100644 qiming/src/pages/Chat/hooks/useChatVariables.ts create mode 100644 qiming/src/pages/Chat/hooks/useChatViewMode.ts create mode 100644 qiming/src/pages/ConversationAgent/AgentConversationChatPanel/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentChatSession/index.less create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentChatSession/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/FilePathHeader/index.less create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/FilePathHeader/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/FilePathHeader/previewCodeToggle.ts create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/FilePathHeader/type.ts create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/PreviewTabBar/PreviewTabContextMenu/index.less create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/PreviewTabBar/PreviewTabContextMenu/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/PreviewTabBar/PreviewTabLabel.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/PreviewTabBar/PreviewTabModelSelect/index.less create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/PreviewTabBar/PreviewTabModelSelect/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/PreviewTabBar/PreviewTabTooltipDrag.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/PreviewTabBar/index.less create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/PreviewTabBar/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/ToolTabContent/index.less create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/ToolTabContent/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/hooks/usePreviewTabs.ts create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/index.less create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentFilePreview/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentHeader/index.less create mode 100644 qiming/src/pages/ConversationAgent/ConversationAgentHeader/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/ImportProjectModal/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/hooks/useConversationAgentChatSession.ts create mode 100644 qiming/src/pages/ConversationAgent/hooks/useConversationAgentDevLogs.ts create mode 100644 qiming/src/pages/ConversationAgent/index.less create mode 100644 qiming/src/pages/ConversationAgent/index.tsx create mode 100644 qiming/src/pages/ConversationAgent/services/agent-dev.ts create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/ComponentSettingModal/CallApproval/index.less create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/ComponentSettingModal/CallApproval/index.tsx create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/CreateHooks/CreateHookModal/index.less create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/CreateHooks/CreateHookModal/index.tsx create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/CreateHooks/index.less create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/CreateHooks/index.tsx create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/HookList/index.less create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/HookList/index.tsx create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/agentFlow/__tests__/arrangePolicy.test.ts create mode 100644 qiming/src/pages/EditAgent/AgentArrangeConfig/agentFlow/arrangePolicy.ts create mode 100644 qiming/src/pages/EditAgent/AgentFlowCanvas/index.less create mode 100644 qiming/src/pages/EditAgent/AgentFlowCanvas/index.tsx create mode 100644 qiming/src/pages/Home/components/ChatBoxRecommendNav/index.less create mode 100644 qiming/src/pages/Home/components/ChatBoxRecommendNav/index.tsx create mode 100644 qiming/src/pages/Home/index.legacy.less create mode 100644 qiming/src/pages/Home/index.legacy.tsx create mode 100644 qiming/src/pages/MorePage/ApiKey/ApiKeyPermissionModal/components/ApiPermissionTab/index.tsx create mode 100644 qiming/src/pages/MorePage/ApiKey/ApiKeyPermissionModal/components/ModelPermissionTab/index.tsx create mode 100644 qiming/src/pages/MorePage/ApiKey/ApiKeyPermissionModal/components/PermissionOperationsBar/index.tsx create mode 100644 qiming/src/pages/MorePage/ModelPermissions/ModalitiesTagsCell/index.less create mode 100644 qiming/src/pages/MorePage/ModelPermissions/ModalitiesTagsCell/index.tsx create mode 100644 qiming/src/pages/MorePage/ModelPermissions/index.less create mode 100644 qiming/src/pages/MorePage/ModelPermissions/index.tsx create mode 100644 qiming/src/pages/OpenApp/CreditRecords/index.tsx create mode 100644 qiming/src/pages/OpenApp/HistoryConversation/index.less create mode 100644 qiming/src/pages/OpenApp/MyOrders/index.tsx create mode 100644 qiming/src/pages/OpenApp/MySubscriptions/index.less create mode 100644 qiming/src/pages/OpenApp/MySubscriptions/index.tsx create mode 100644 qiming/src/pages/OpenApp/UsageStats/index.tsx create mode 100644 qiming/src/pages/PublishedSkillDetails/index.tsx create mode 100644 qiming/src/pages/SkillDetails/components/MoreActionsMenu/index.tsx create mode 100644 qiming/src/pages/SkillDetails/components/SkillHeader/index.less create mode 100644 qiming/src/pages/SkillDetails/components/SkillHeader/index.tsx create mode 100644 qiming/src/pages/SkillDetails/hooks/useSkillFiles.ts create mode 100644 qiming/src/pages/SkillDetailsConversation/components/SkillHeader/index.less create mode 100644 qiming/src/pages/SkillDetailsConversation/components/SkillHeader/index.tsx create mode 100644 qiming/src/pages/SkillDetailsConversation/components/SkillPublishAction/index.less create mode 100644 qiming/src/pages/SkillDetailsConversation/components/SkillPublishAction/index.tsx create mode 100644 qiming/src/pages/SkillDetailsConversation/index.less create mode 100644 qiming/src/pages/SkillDetailsConversation/index.tsx create mode 100644 qiming/src/pages/SpaceCreateProject/components/GreetingHeader/index.less create mode 100644 qiming/src/pages/SpaceCreateProject/components/GreetingHeader/index.tsx create mode 100644 qiming/src/pages/SpaceCreateProject/components/PromptBox/components/TabsList/index.less create mode 100644 qiming/src/pages/SpaceCreateProject/components/PromptBox/components/TabsList/index.tsx create mode 100644 qiming/src/pages/SpaceCreateProject/components/PromptBox/index.less create mode 100644 qiming/src/pages/SpaceCreateProject/components/PromptBox/index.tsx create mode 100644 qiming/src/pages/SpaceCreateProject/index.less create mode 100644 qiming/src/pages/SpaceCreateProject/index.tsx create mode 100644 qiming/src/pages/SpaceCreateProject/utils/projectCreateStrategy.ts create mode 100644 qiming/src/pages/SpaceKnowledge/KnowledgeAccuracyTest/index.less create mode 100644 qiming/src/pages/SpaceKnowledge/KnowledgeAccuracyTest/index.tsx create mode 100644 qiming/src/pages/SpaceKnowledge/KnowledgeAccuracyTest/index.tsx.bak create mode 100644 qiming/src/pages/SpacePluginTool/components/PluginChatSession/index.tsx create mode 100644 qiming/src/pages/SpacePluginTool/components/PluginHeader/index.less create mode 100644 qiming/src/pages/SpacePluginTool/components/PluginHeader/index.tsx create mode 100644 qiming/src/pages/SpacePluginTool/components/PluginInputTable/index.tsx create mode 100644 qiming/src/pages/SpacePluginTool/components/PluginOutputTable/index.tsx create mode 100644 qiming/src/pages/SpacePluginTool/components/PluginRequestConfigForm/index.tsx create mode 100644 qiming/src/pages/SpaceResource/Knowledge/index.tsx create mode 100644 qiming/src/pages/SpaceResource/Plugin/components/HeaderArea/index.less create mode 100644 qiming/src/pages/SpaceResource/Plugin/components/HeaderArea/index.tsx create mode 100644 qiming/src/pages/SpaceResource/Plugin/index.tsx create mode 100644 qiming/src/pages/SpaceResource/Storage/index.tsx create mode 100644 qiming/src/pages/SpaceResource/Workflow/components/HeaderArea/index.less create mode 100644 qiming/src/pages/SpaceResource/Workflow/components/HeaderArea/index.tsx create mode 100644 qiming/src/pages/SpaceResource/Workflow/index.tsx create mode 100644 qiming/src/pages/SpaceResource/components/ComponentList/components/MoveToGroupModal/index.tsx create mode 100644 qiming/src/pages/SpaceResource/components/ComponentList/index.less create mode 100644 qiming/src/pages/SpaceResource/components/ComponentList/index.tsx create mode 100644 qiming/src/pages/SpaceResource/components/GroupEditModal/index.tsx create mode 100644 qiming/src/pages/SpaceResource/components/HorizontalGroupList/index.less create mode 100644 qiming/src/pages/SpaceResource/components/HorizontalGroupList/index.tsx create mode 100644 qiming/src/pages/SpaceResource/styles/resource-list.less create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/Chatbox/index.tsx create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/HomeRecommend/index.tsx create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/OfficialRecommend/index.tsx create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/components/RecommendAddModal/index.less create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/components/RecommendAddModal/index.tsx create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/components/RecommendFormModal/SelectedAgentCard/index.less create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/components/RecommendFormModal/SelectedAgentCard/index.tsx create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/components/RecommendFormModal/index.less create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/components/RecommendFormModal/index.tsx create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/components/RecommendListPage/index.tsx create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/constants.ts create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/services/recomment.ts create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/types/index.ts create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/utils/chatboxFunctionTypeLabel.ts create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/utils/chatboxFunctionTypeRules.ts create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/utils/fetchPublishedAgentByTargetId.ts create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/utils/publishedTargetSource.ts create mode 100644 qiming/src/pages/SystemManagement/RecommendManage/utils/squareTargetTypeLabel.ts create mode 100644 qiming/src/pages/SystemManagement/SubscriptionCredits/UserCredits/DeductCreditModal/index.tsx create mode 100644 qiming/src/pages/SystemManagement/SubscriptionCredits/UserCredits/GrantCreditModal/index.tsx create mode 100644 qiming/src/pages/SystemManagement/SubscriptionCredits/creditTypeOptions.ts create mode 100644 qiming/src/pages/SystemManagement/SystemConfig/SystemConfig/utils.ts create mode 100644 qiming/src/pages/UserManage/components/ResetPasswordModal.tsx create mode 100644 qiming/src/services/audio.ts create mode 100644 qiming/src/services/displayRecommend.ts create mode 100644 qiming/src/types/interfaces/acpPermission.ts create mode 100644 qiming/src/types/interfaces/displayRecommend.ts create mode 100644 qiming/src/types/interfaces/fileTree.ts create mode 100644 qiming/src/utils/applyGeneratedIcon.ts create mode 100644 qiming/src/utils/authProtectedFileUrl.test.ts create mode 100644 qiming/src/utils/authProtectedFileUrl.ts create mode 100644 qiming/src/utils/conversationTaskStatusSync.ts create mode 100644 qiming/src/utils/defaultIcon.ts create mode 100644 qiming/src/utils/fileChangeDiff.test.ts create mode 100644 qiming/src/utils/fileChangeDiff.ts create mode 100644 qiming/src/utils/generatedMetadata.ts create mode 100644 qiming/src/utils/logContentUtils.ts create mode 100644 qiming/src/utils/priceInput.ts create mode 100644 qiming/src/utils/resolveCreateIcon.ts create mode 100644 qiming/src/utils/resourcePricing.ts create mode 100644 qiming/src/utils/terminalWsUrl.ts diff --git a/qiming/package.json b/qiming/package.json index 03a5df8b..64d4b95b 100644 --- a/qiming/package.json +++ b/qiming/package.json @@ -51,7 +51,7 @@ "@ant-design/pro-components": "2.8.10", "@ant-design/x": "^1.0.5", "@antv/g6": "^4.8.25", - "@antv/x6": "^2.18.1", + "@antv/x6": "^3.1.7", "@antv/x6-plugin-clipboard": "^2.1.6", "@antv/x6-plugin-history": "^2.2.4", "@antv/x6-plugin-keyboard": "^2.2.3", @@ -59,12 +59,14 @@ "@antv/x6-plugin-snapline": "^2.1.7", "@antv/x6-plugin-stencil": "^2.1.5", "@antv/x6-plugin-transform": "^2.1.8", - "@antv/x6-react-shape": "^2.2.3", + "@antv/x6-react-shape": "^3.0.0", "@babel/standalone": "^7.28.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^6.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@git-diff-view/file": "^0.1.5", + "@git-diff-view/react": "^0.1.5", "@js-preview/docx": "^1.6.4", "@js-preview/excel": "^1.7.14", "@js-preview/pdf": "^2.0.10", @@ -78,6 +80,16 @@ "@types/markdown-it": "^14.1.2", "@umijs/max": "^4.4.2", "@umijs/utils": "^4.4.11", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-image": "^0.8.0", + "@xterm/addon-ligatures": "^0.9.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-serialize": "^0.13.0", + "@xterm/addon-unicode11": "^0.8.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "5.5.0", "ahooks": "^3.8.4", "antd": "^5.4.0", "bignumber.js": "^11.1.1", diff --git a/qiming/src/assets/icons/chat/voice.svg b/qiming/src/assets/icons/chat/voice.svg new file mode 100644 index 00000000..5cbd566e --- /dev/null +++ b/qiming/src/assets/icons/chat/voice.svg @@ -0,0 +1,4 @@ + + + + diff --git a/qiming/src/assets/icons/nav/history_conversation.svg b/qiming/src/assets/icons/nav/history_conversation.svg new file mode 100644 index 00000000..9c658503 --- /dev/null +++ b/qiming/src/assets/icons/nav/history_conversation.svg @@ -0,0 +1,3 @@ + + + diff --git a/qiming/src/assets/icons/nav/recommend_manage.svg b/qiming/src/assets/icons/nav/recommend_manage.svg new file mode 100644 index 00000000..6a2fa72b --- /dev/null +++ b/qiming/src/assets/icons/nav/recommend_manage.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/qiming/src/assets/images/app-icon.png b/qiming/src/assets/images/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..115bae6554f7ffe6bbb20415bc8c4fd166c0a97b GIT binary patch literal 172467 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 f0{q{n?hoILOjfehH6# + + + diff --git a/qiming/src/assets/workflow/workflow-Agent.svg b/qiming/src/assets/workflow/workflow-Agent.svg new file mode 100644 index 00000000..62f75d27 --- /dev/null +++ b/qiming/src/assets/workflow/workflow-Agent.svg @@ -0,0 +1 @@ + diff --git a/qiming/src/assets/workflow/workflow-HumanAsk.svg b/qiming/src/assets/workflow/workflow-HumanAsk.svg new file mode 100644 index 00000000..9e62e914 --- /dev/null +++ b/qiming/src/assets/workflow/workflow-HumanAsk.svg @@ -0,0 +1 @@ + diff --git a/qiming/src/assets/workflow/workflow-KnowledgeInsert.svg b/qiming/src/assets/workflow/workflow-KnowledgeInsert.svg new file mode 100644 index 00000000..e4557992 --- /dev/null +++ b/qiming/src/assets/workflow/workflow-KnowledgeInsert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qiming/src/assets/workflow/workflow-RouteDecision.svg b/qiming/src/assets/workflow/workflow-RouteDecision.svg new file mode 100644 index 00000000..f7f8fed4 --- /dev/null +++ b/qiming/src/assets/workflow/workflow-RouteDecision.svg @@ -0,0 +1 @@ + diff --git a/qiming/src/components/AcpPermissionCard/index.less b/qiming/src/components/AcpPermissionCard/index.less new file mode 100644 index 00000000..3b85ccd3 --- /dev/null +++ b/qiming/src/components/AcpPermissionCard/index.less @@ -0,0 +1,94 @@ +@import '@/styles/color.less'; +@import '@/styles/token.less'; + +.card { + margin-top: @paddingSm; + padding: @padding; + border: @lineWidth solid @colorBorderSecondary; + border-radius: @borderRadius; + background: @colorBgContainer; + box-shadow: @boxShadowTertiary; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: @paddingSm; + margin-bottom: @paddingSm; +} + +.eyebrow { + color: @colorTextTertiary; + font-size: @fontSizeSm; + line-height: @lineHeightSm; +} + +.title { + margin-top: @paddingXxs; + color: @colorTextBase; + font-size: @fontSize; + font-weight: @fontWeightStrong; + line-height: @lineHeight; +} + +.meta { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: @paddingXs; + align-items: center; + margin-bottom: @paddingSm; +} + +.rawInput { + margin-bottom: @paddingSm; + + pre { + max-height: 180px; + margin: @paddingXxs 0 0; + padding: @paddingSm; + overflow: auto; + color: @colorText; + font-size: @fontSizeSm; + line-height: @lineHeightSm; + white-space: pre-wrap; + word-break: break-word; + background: @colorFillQuaternary; + border-radius: @borderRadiusSm; + } +} + +.sectionTitle { + color: @colorTextSecondary; + font-size: @fontSizeSm; +} + +.saveRule { + margin-bottom: @paddingSm; +} + +.ruleDescription { + margin-top: @paddingXxs; + color: @colorTextTertiary; + font-size: @fontSizeSm; + line-height: @lineHeightSm; + word-break: break-word; +} + +.options { + display: flex; + flex-wrap: wrap; + gap: @paddingXs; +} + +.error { + margin-top: @paddingSm; + color: @colorError; + font-size: @fontSizeSm; +} + +.submitted { + margin-top: @paddingSm; + color: @colorSuccess; + font-size: @fontSizeSm; +} diff --git a/qiming/src/components/AcpPermissionCard/index.tsx b/qiming/src/components/AcpPermissionCard/index.tsx new file mode 100644 index 00000000..869848f8 --- /dev/null +++ b/qiming/src/components/AcpPermissionCard/index.tsx @@ -0,0 +1,141 @@ +import { t } from '@/services/i18nRuntime'; +import type { + RcoderAcpPermissionInteraction, + RcoderRequestPermissionResponse, +} from '@/types/interfaces/acpPermission'; +import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; +import { Button, Tag, Typography } from 'antd'; +import React, { useMemo } from 'react'; +import styles from './index.less'; + +const { Text } = Typography; + +interface AcpPermissionCardProps { + interaction: RcoderAcpPermissionInteraction; + onRespond?: (response: RcoderRequestPermissionResponse) => void; +} + +const formatJson = (value: unknown): string => { + if (value === undefined || value === null) { + return t('PC.Common.Global.noData'); + } + + if (typeof value === 'string') { + return value; + } + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const AcpPermissionCard: React.FC = ({ + interaction, + onRespond, +}) => { + const request = interaction.intervention.acp.request; + const toolCall = request.toolCall || {}; + const isSubmitting = interaction.responseStatus === 'submitting'; + const isSubmitted = interaction.responseStatus === 'submitted'; + const disabled = isSubmitting || isSubmitted || !onRespond; + + const rawInputText = useMemo( + () => formatJson(toolCall.rawInput), + [toolCall.rawInput], + ); + + const handleSelect = (optionId: string) => { + onRespond?.({ + outcome: { + outcome: 'selected', + optionId, + }, + }); + }; + + const handleCancel = () => { + onRespond?.({ + outcome: { + outcome: 'cancelled', + }, + }); + }; + + const toolCallId = toolCall.toolCallId || ''; + const toolCallKind = toolCall.kind || ''; + const options = request.options || []; + + return ( +

+
+
+
+ {t('PC.Components.AcpPermissionCard.eyebrow')} +
+
+ {toolCall.title?.trim() || + t('PC.Components.AcpPermissionCard.defaultTitle')} +
+
+ {toolCallKind && {toolCallKind}} +
+ +
+ + {t('PC.Components.AcpPermissionCard.toolCallId')} + + + {toolCallId || '-'} + +
+ +
+
+ {t('PC.Components.AcpPermissionCard.rawInput')} +
+
{rawInputText}
+
+ +
+ {options.map((option) => { + const optionId = option.optionId || ''; + const kind = option.kind || ''; + const name = option.name || optionId; + const isAllow = kind.startsWith('allow'); + const isReject = kind.startsWith('reject'); + return ( + + ); + })} + +
+ + {interaction.responseStatus === 'failed' && ( +
{interaction.errorMessage}
+ )} + {isSubmitted && ( +
+ {t('PC.Components.AcpPermissionCard.submitted')} +
+ )} +
+ ); +}; + +export default AcpPermissionCard; diff --git a/qiming/src/components/AgentChatEmpty/index.tsx b/qiming/src/components/AgentChatEmpty/index.tsx index 4218d2c0..f20a998d 100644 --- a/qiming/src/components/AgentChatEmpty/index.tsx +++ b/qiming/src/components/AgentChatEmpty/index.tsx @@ -1,5 +1,6 @@ import agentImage from '@/assets/images/agent_image.png'; import type { AgentChatEmptyProps } from '@/types/interfaces/agentConfig'; +import { getUploadedIconOrDefault } from '@/utils/defaultIcon'; import classNames from 'classnames'; import React from 'react'; import styles from './index.less'; @@ -27,8 +28,12 @@ const AgentChatEmpty: React.FC = ({ > { + e.currentTarget.onerror = null; + e.currentTarget.src = agentImage as string; + }} />

{name}

{extra} diff --git a/qiming/src/components/AgentSidebar/AgentContent/index.tsx b/qiming/src/components/AgentSidebar/AgentContent/index.tsx index 8b3ceb87..c9e541ba 100644 --- a/qiming/src/components/AgentSidebar/AgentContent/index.tsx +++ b/qiming/src/components/AgentSidebar/AgentContent/index.tsx @@ -3,6 +3,7 @@ import { SvgIcon } from '@/components/base'; import ConditionRender from '@/components/ConditionRender'; import { dict } from '@/services/i18nRuntime'; import { AgentContentProps } from '@/types/interfaces/agentTask'; +import { getUploadedIconOrDefault } from '@/utils/defaultIcon'; import { Typography } from 'antd'; import classNames from 'classnames'; import React, { useRef } from 'react'; @@ -24,13 +25,13 @@ const AgentContent: React.FC = ({ agentDetail }) => {
{ const imgEl = e.currentTarget; // 仅在首次失败时回退到默认头像,避免无限重试 if (!hasRetriedRef.current) { hasRetriedRef.current = true; - imgEl.src = agentImage; + imgEl.src = agentImage as string; } else { // 第二次依旧失败时,移除 onError,防止死循环 imgEl.onerror = null; diff --git a/qiming/src/components/ChatInputHome/SpaceSelector/index.less b/qiming/src/components/ChatInputHome/SpaceSelector/index.less new file mode 100644 index 00000000..de893ff0 --- /dev/null +++ b/qiming/src/components/ChatInputHome/SpaceSelector/index.less @@ -0,0 +1,114 @@ +@import '@/styles/token'; + +.space-selector-container { + display: flex; + align-items: flex-start; +} + +.space-selector { + display: inline-flex; + align-items: center; + margin-top: 3px; + height: 28px; + + .selector-btn { + padding: @paddingXxs @paddingXs; + border-radius: @borderRadius; + box-sizing: border-box; + color: @colorTextSecondary; + font-size: @fontSizeSm; + transition: background-color ease-in-out 0.3s; + word-wrap: normal; + white-space: nowrap; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + span { + display: inline-block; + max-width: 82px; + color: @colorTextSecondary; + font-size: @fontSizeSm; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &:hover { + background: @colorFillTertiary; + } + + .selector-arrow { + transition: transform @motionDurationFast ease; + } + + &.open .selector-arrow { + transform: rotate(180deg); + } + } +} + +.space-menu { + :global { + .ant-dropdown-menu { + max-height: 240px; + overflow-y: auto; + overflow-x: hidden; + } + + .ant-dropdown-menu-item { + background: transparent !important; + + &:hover { + background: @colorFillTertiary !important; + } + + &.ant-dropdown-menu-item-selected { + background: transparent !important; + + &:hover { + background: @colorFillTertiary !important; + } + } + } + } + + .menu-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 200px; + + .item-content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + + .item-name { + color: @colorTextSecondary; + font-size: @fontSizeSm; + font-weight: 500; + line-height: 28px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .item-desc { + font-size: 11px; + color: @colorTextQuaternary; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .item-check { + margin-left: @marginSm; + color: @colorPrimary; + font-size: @fontSizeSm; + } + } +} diff --git a/qiming/src/components/ChatInputHome/SpaceSelector/index.tsx b/qiming/src/components/ChatInputHome/SpaceSelector/index.tsx new file mode 100644 index 00000000..619da3fe --- /dev/null +++ b/qiming/src/components/ChatInputHome/SpaceSelector/index.tsx @@ -0,0 +1,155 @@ +import { SvgIcon } from '@/components/base'; +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { t } from '@/services/i18nRuntime'; +import { apiSpaceList } from '@/services/workspace'; +import type { SpaceInfo } from '@/types/interfaces/workspace'; +import { CheckOutlined } from '@ant-design/icons'; +import { Dropdown, MenuProps, Typography } from 'antd'; +import classNames from 'classnames'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +interface SpaceSelectorProps { + selectedSpaceId?: number; + onSpaceSelect?: (spaceId: number) => void; + className?: string; +} + +const SpaceSelector: React.FC = ({ + selectedSpaceId, + onSpaceSelect, + className, +}) => { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [initialized, setInitialized] = useState(false); + const [spaceList, setSpaceList] = useState([]); + + const fetchSpaceList = useCallback(async () => { + setLoading(true); + try { + const result = await apiSpaceList(); + if (result.code === SUCCESS_CODE) { + setSpaceList(result.data || []); + } else { + setSpaceList([]); + } + } catch (error) { + console.error('Failed to get space list:', error); + setSpaceList([]); + } finally { + setInitialized(true); + setLoading(false); + } + }, []); + + useEffect(() => { + fetchSpaceList(); + }, [fetchSpaceList]); + + useEffect(() => { + if (!initialized || !spaceList.length) return; + + const isSelectedInList = spaceList.some( + (space) => space.id === selectedSpaceId, + ); + if (!selectedSpaceId || !isSelectedInList) { + onSpaceSelect?.(spaceList[0].id); + } + }, [initialized, onSpaceSelect, selectedSpaceId, spaceList]); + + const selectedSpace = useMemo(() => { + if (selectedSpaceId) { + const found = spaceList.find((space) => space.id === selectedSpaceId); + if (found) return found; + } + return spaceList[0] || null; + }, [selectedSpaceId, spaceList]); + + const handleSelect = useCallback( + (space: SpaceInfo) => { + if (space.id !== selectedSpaceId) { + onSpaceSelect?.(space.id); + } + setOpen(false); + }, + [onSpaceSelect, selectedSpaceId], + ); + + const menuItems: MenuProps['items'] = useMemo(() => { + if (!spaceList.length && initialized) { + return [ + { + key: 'empty', + label: ( +
+ + {t('PC.Components.ChatInputHomeSpaceSelector.noAvailableSpace')} + +
+ ), + disabled: true, + }, + ]; + } + + return spaceList.map((space) => { + const isSelected = space.id === selectedSpace?.id; + return { + key: space.id, + label: ( +
+
+ {space.name} + {space.description && ( + + {space.description} + + )} +
+ {isSelected && ( + + )} +
+ ), + onClick: () => handleSelect(space), + }; + }); + }, [handleSelect, initialized, selectedSpace?.id, spaceList]); + + if (!initialized || loading || !spaceList.length) { + return null; + } + + return ( +
+ + + + {selectedSpace?.name || '--'} + + + + +
+ ); +}; + +export default SpaceSelector; diff --git a/qiming/src/components/CodeViewer/MonacoDiffEditor/index.less b/qiming/src/components/CodeViewer/MonacoDiffEditor/index.less new file mode 100644 index 00000000..43ecafb5 --- /dev/null +++ b/qiming/src/components/CodeViewer/MonacoDiffEditor/index.less @@ -0,0 +1,37 @@ +.diff-editor-wrapper { + width: 100%; + height: 100%; + overflow: hidden; +} + +.diff-editor-container { + width: 100%; + height: 100%; + + :global(.monaco-editor) { + padding: 0 !important; + margin: 0 !important; + } + + :global(.monaco-editor .view-lines) { + padding: 0 !important; + } + + :global(.monaco-editor .margin) { + padding: 0 !important; + } + + :global(.monaco-editor .overflow-guard) { + padding: 0 !important; + } + + // diff 高亮样式优化 + :global(.monaco-diff-editor .diffOverview) { + cursor: pointer; + } + + :global(.monaco-editor .insert-sign), + :global(.monaco-editor .delete-sign) { + font-size: 11px !important; + } +} diff --git a/qiming/src/components/CodeViewer/MonacoDiffEditor/index.tsx b/qiming/src/components/CodeViewer/MonacoDiffEditor/index.tsx new file mode 100644 index 00000000..c5be4617 --- /dev/null +++ b/qiming/src/components/CodeViewer/MonacoDiffEditor/index.tsx @@ -0,0 +1,107 @@ +import { getLanguageFromFile } from '@/utils/monacoConfig'; +import * as monaco from 'monaco-editor'; +import React, { useEffect, useRef, useState } from 'react'; +import styles from './index.less'; + +interface MonacoDiffEditorProps { + /** 文件ID */ + fileId: string; + /** 文件名(用于语法高亮) */ + fileName: string; + /** 原始文件内容(左侧) */ + originalContent: string; + /** 修改后的文件内容(右侧) */ + modifiedContent: string; + /** 类名 */ + className?: string; +} + +/** + * Monaco DiffEditor 组件 + * 使用 monaco.editor.createDiffEditor 展示文件修改前后的 diff 对比 + * 左侧为原始内容(只读),右侧为修改后内容(只读) + */ +const MonacoDiffEditor: React.FC = ({ + fileId, + fileName, + originalContent, + modifiedContent, + className, +}) => { + const containerRef = useRef(null); + const diffEditorRef = useRef( + null, + ); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + if (!containerRef.current) return; + + const language = getLanguageFromFile(fileName); + + // 创建 DiffEditor 实例 + const diffEditor = monaco.editor.createDiffEditor(containerRef.current, { + automaticLayout: true, + readOnly: true, + renderSideBySide: true, + originalEditable: false, + scrollBeyondLastLine: false, + fontSize: 12, + fontFamily: + "'JetBrains Mono', monospace, 'Fira Code','Consolas', 'Monaco', 'Courier New'", + lineHeight: 20, + tabSize: 2, + minimap: { enabled: false }, + wordWrap: 'on', + lineNumbers: 'on', + renderLineHighlight: 'all', + folding: true, + contextmenu: false, + // diff 特有配置 + enableSplitViewResizing: true, + renderOverviewRuler: true, + renderMarginRevertIcon: false, + // 禁用验证装饰 + renderValidationDecorations: 'off', + }); + + // 设置 diff model + const originalModel = monaco.editor.createModel(originalContent, language); + const modifiedModel = monaco.editor.createModel(modifiedContent, language); + + diffEditor.setModel({ + original: originalModel, + modified: modifiedModel, + }); + + diffEditorRef.current = diffEditor; + setIsReady(true); + + return () => { + // 清理 + diffEditor.dispose(); + originalModel.dispose(); + modifiedModel.dispose(); + diffEditorRef.current = null; + }; + }, [fileId]); + + // 内容变化时更新 model + useEffect(() => { + if (!diffEditorRef.current || !isReady) return; + + const model = diffEditorRef.current.getModel(); + if (model) { + model.original.setValue(originalContent); + model.modified.setValue(modifiedContent); + } + }, [originalContent, modifiedContent, isReady]); + + return ( +
+
+
+ ); +}; + +export default MonacoDiffEditor; diff --git a/qiming/src/components/KnowledgeAccuracyTest/service.ts b/qiming/src/components/KnowledgeAccuracyTest/service.ts new file mode 100644 index 00000000..45ab56db --- /dev/null +++ b/qiming/src/components/KnowledgeAccuracyTest/service.ts @@ -0,0 +1,55 @@ +/** + * 命中测试API服务 + */ +import { request } from 'umi'; + +/** + * 获取知识库文档列表 + */ +export async function getAccuracyTestDocuments(knowledgeBaseId: number) { + return request('/api/knowledge/accuracytest/documents', { + method: 'GET', + params: { knowledgeBaseId }, + }); +} + +/** + * 获取测试历史记录 + */ +export async function getAccuracyTestHistory(knowledgeBaseId: number) { + return request('/api/knowledge/accuracytest/history', { + method: 'GET', + params: { knowledgeBaseId }, + }); +} + +/** + * 执行命中搜索 + */ +export async function performAccuracySearch(data: { + knowledgeBaseId: number; + query: string; + searchStrategy: 'SEMANTIC' | 'FULL_TEXT' | 'MIXED'; + topK: number; + matchingDegree: number; +}) { + return request('/api/knowledge/accuracytest/search', { + method: 'POST', + data, + }); +} + +/** + * 保存测试记录 + */ +export async function saveAccuracyTestRecord(data: { + knowledgeBaseId: number; + query: string; + searchStrategy: string; + results: any[]; +}) { + return request('/api/knowledge/accuracytest/record', { + method: 'POST', + data, + }); +} \ No newline at end of file diff --git a/qiming/src/components/KnowledgeAccuracyTest/types.ts b/qiming/src/components/KnowledgeAccuracyTest/types.ts new file mode 100644 index 00000000..cb038bc9 --- /dev/null +++ b/qiming/src/components/KnowledgeAccuracyTest/types.ts @@ -0,0 +1,44 @@ +/** + * 命中测试类型定义 + */ + +// 搜索策略类型 +export type SearchStrategy = 'SEMANTIC' | 'FULL_TEXT' | 'MIXED'; + +// 文档项 +export interface DocumentItem { + id: number; + name: string; + fileType: string; + knowledgeBaseId: number; +} + +// 测试历史项 +export interface TestHistoryItem { + id: number; + knowledgeBaseId: number; + query: string; + searchStrategy: string; + results: string; + createTime: string; +} + +// 召回结果项 +export interface RecallResultItem { + docId: number; + docName: string; + score: number; + content: string; + rank: number; + metadata?: any; + isExpanded?: boolean; +} + +// 命中测试请求参数 +export interface AccuracySearchParams { + knowledgeBaseId: number; + query: string; + searchStrategy: SearchStrategy; + topK: number; + matchingDegree: number; +} \ No newline at end of file diff --git a/qiming/src/components/MarkdownRenderer/TaskResult/context.ts b/qiming/src/components/MarkdownRenderer/TaskResult/context.ts new file mode 100644 index 00000000..f12b0f9f --- /dev/null +++ b/qiming/src/components/MarkdownRenderer/TaskResult/context.ts @@ -0,0 +1,5 @@ +import React from 'react'; + +export const TaskResultContext = React.createContext<{ + onTaskResultClick?: (fileId: string) => boolean | void; +}>({}); diff --git a/qiming/src/components/MarkdownRenderer/__tests__/utils.test.ts b/qiming/src/components/MarkdownRenderer/__tests__/utils.test.ts new file mode 100644 index 00000000..8b2ca71e --- /dev/null +++ b/qiming/src/components/MarkdownRenderer/__tests__/utils.test.ts @@ -0,0 +1,141 @@ +/** + * MarkdownRenderer utils 单元测试 + * + * 重点覆盖 e179d3ae9 引入的性能优化路径: + * - replaceMathBracket:base64 data URL 占位符 round-trip + 数学定界符替换 + * - groupMarkdownProcesses:快速短路(不含过程标签时直接返回) + */ +import { describe, expect, it } from 'vitest'; +import { groupMarkdownProcesses, replaceMathBracket } from '../utils'; + +/** 构造一个合法的 base64 data URL(仅长度可配置,内容不要求是真实图片) */ +const makeDataUrl = (bodyLen = 100) => + `data:image/png;base64,${'A'.repeat(bodyLen)}`; + +describe('replaceMathBracket', () => { + it('空输入返回空串', () => { + expect(replaceMathBracket('')).toBe(''); + }); + + it('无定界符文本原样返回', () => { + const text = 'hello world,没有数学公式'; + expect(replaceMathBracket(text)).toBe(text); + }); + + it('替换行内定界符 \\( \\) 为 $...$', () => { + expect(replaceMathBracket('公式 \\(a + b\\) 结束')).toBe( + '公式 $a + b$ 结束', + ); + }); + + it('替换块级定界符 \\[ \\] 为 $$...$$', () => { + expect(replaceMathBracket('块 \\[\\int_0^1 x\\,dx\\] 尾')).toBe( + '块 $$\\int_0^1 x\\,dx$$ 尾', + ); + }); + + it('base64 data URL 原样还原,不被当作定界符扫描', () => { + const url = makeDataUrl(200); + const text = `前缀 ![img](${url}) 后缀`; + expect(replaceMathBracket(text)).toBe(text); + }); + + it('单个 data URL 内部即便含有形似定界符的片段也不被替换', () => { + // base64 正文只用 A-Za-z0-9+/=,不会出现反斜杠,因此 \\/\\[ 这类定界符 + // 不可能落在 data URL 内部。这里构造一段不含反斜杠、但形似数学符号的正文, + // 验证它被当作 data URL 整段跳过,内部的 ( ) 不被误判为行内定界符。 + const url = `data:image/png;base64,abc(def)ghi==`; + const text = `${url}`; + expect(replaceMathBracket(text)).toBe(text); + }); + + it('多个 data URL 共存:每个都完整还原,互不吞并(验证 \\s 被移除)', () => { + // 旧正则字符类含 \s,会把 "URL1 + 空格 + URL2 的 data 前缀" 一起吞进第一个匹配, + // 导致第二个 URL 丢失 "data" 前缀。这里用空格分隔两个 URL 来回归这一情况。 + const url1 = makeDataUrl(50); + const url2 = makeDataUrl(60); + const text = `${url1} ${url2}`; + const result = replaceMathBracket(text); + expect(result).toContain(url1); + expect(result).toContain(url2); + // 第二个 URL 必须仍以 "data:" 开头出现在结果里 + expect(result).toContain(' data:image/png;base64,'); + }); + + it('data URL 与数学公式混合:两边各得其所', () => { + const url = makeDataUrl(80); + const text = `![img](${url}) 然后 \\(c = a + b\\)`; + expect(replaceMathBracket(text)).toBe(`![img](${url}) 然后 $c = a + b$`); + }); +}); + +describe('groupMarkdownProcesses', () => { + it('空输入返回空串', () => { + expect(groupMarkdownProcesses('')).toBe(''); + }); + + it('不含 markdown-custom-process 标签时原样返回(短路)', () => { + const text = `![img](${makeDataUrl(500)}) 普通文本,没有过程标签`; + // 短路返回的是同一个引用,既验证行为也验证未进入正则扫描 + expect(groupMarkdownProcesses(text)).toBe(text); + }); + + it('含 base64 data URL 但无过程标签时不进入正则扫描', () => { + const url = makeDataUrl(2000); + const text = `![big](${url}) 文本`; + // 大字符串下若误入正则扫描会明显变慢甚至改变内容,这里断言原样返回 + expect(groupMarkdownProcesses(text)).toBe(text); + }); + + it('单个过程标签被 div 包装保留', () => { + const tag = + ''; + const result = groupMarkdownProcesses(`前文\n${tag}\n后文`); + expect(result).toContain('
'); + expect(result).toContain(' { + const t1 = + ''; + const t2 = + ''; + const result = groupMarkdownProcesses(`${t1}\n${t2}`); + expect(result).toContain('markdown-custom-process-group'); + }); + + it('type=Plan 标签单独成块,type 排在 name 之前时不并入 group', () => { + const plan = + ''; + const action = + ''; + const result = groupMarkdownProcesses(`${plan}\n${action}`); + // Plan 单独成块,仅剩 1 个 Action 也单独成块,不形成 group + expect(result).not.toContain('markdown-custom-process-group'); + }); + + it('type=Plan 标签单独成块,type 排在 name 之后时也不并入 group', () => { + // 回归:归一化把 name 之后的属性编码进 name 值, + // 若 isPlan 在归一化后才判断,会因 type 被吞入 name 而漏判,导致 Plan 被误并入 group。 + const plan = + ''; + const action = + ''; + const result = groupMarkdownProcesses(`${plan}\n${action}`); + // Plan 单独成块,仅剩 1 个 Action 也单独成块,不形成 group + expect(result).not.toContain('markdown-custom-process-group'); + }); + + it('同一 executeId 的重复标签只保留最后一个(去重)', () => { + // SSE 流式追加可能产生同 executeId 的重复标签,实现应只保留最新一项 + const dup1 = + ''; + const dup2 = + ''; + const result = groupMarkdownProcesses(`${dup1}\n${dup2}`); + expect(result).toContain('name="v2"'); + expect(result).not.toContain('name="v1"'); + }); +}); diff --git a/qiming/src/components/ProComponents/XProTable/index.tsx b/qiming/src/components/ProComponents/XProTable/index.tsx index 990fff12..b874e771 100644 --- a/qiming/src/components/ProComponents/XProTable/index.tsx +++ b/qiming/src/components/ProComponents/XProTable/index.tsx @@ -15,6 +15,33 @@ import { useIntl } from 'umi'; import { enhanceColumnsForLightFilterEnterConfirm } from './enhanceLightFilterEnterConfirm'; import { getProIntlMessage } from './i18n'; +const DEFAULT_AUTO_SCROLL_COLUMN_WIDTH = 160; + +const getColumnWidthNumber = (width: unknown): number => { + if (typeof width === 'number' && Number.isFinite(width)) { + return width; + } + if (typeof width === 'string') { + const parsed = Number.parseFloat(width); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return DEFAULT_AUTO_SCROLL_COLUMN_WIDTH; +}; + +const flattenVisibleColumns = (columns: readonly any[] = []): any[] => { + return columns.flatMap((column) => { + if (column?.hideInTable) { + return []; + } + if (Array.isArray(column?.children) && column.children.length > 0) { + return flattenVisibleColumns(column.children); + } + return [column]; + }); +}; + /** * XProTable - 预配置的 ProTable 封装组件 * @@ -67,13 +94,6 @@ function XProTable< */ const currentLang = intl.locale || getCurrentLang(); - // 合并 scroll 配置 - // 如果开启了 fullHeight,则使用计算出的 scrollY - const scroll = { - ...restProps.scroll, - y: fullHeight ? scrollY : restProps.scroll?.y, - } as ProTableProps['scroll']; - // 使用传入的 formRef/actionRef 或内部创建的 const internalFormRef = useRef(); const internalActionRef = useRef(); @@ -128,6 +148,35 @@ function XProTable< return cols; }, [restProps.columns, showIndex, restProps.search, currentLang]); + const autoScrollX = useMemo(() => { + if (restProps.scroll?.x !== undefined) { + return restProps.scroll.x; + } + + const visibleColumns = flattenVisibleColumns(mergedColumns); + const hasFixedColumn = visibleColumns.some((column) => column?.fixed); + if (!hasFixedColumn) { + return undefined; + } + + const totalWidth = visibleColumns.reduce( + (sum, column) => sum + getColumnWidthNumber(column?.width), + 0, + ); + return Math.max(totalWidth, 960); + }, [mergedColumns, restProps.scroll?.x]); + + // 合并 scroll 配置:固定列必须带横向滚动宽度,否则窄容器下会覆盖普通列文本。 + const scroll = useMemo( + () => + ({ + ...restProps.scroll, + x: restProps.scroll?.x ?? autoScrollX, + y: fullHeight ? scrollY : restProps.scroll?.y, + } as ProTableProps['scroll']), + [autoScrollX, fullHeight, restProps.scroll, scrollY], + ); + // 合并 toolBarRender,添加查询/重置按钮 const mergedToolBarRender = useMemo(() => { return (action: any, rows: any) => { diff --git a/qiming/src/components/SelectionList/index.less b/qiming/src/components/SelectionList/index.less new file mode 100644 index 00000000..ef801574 --- /dev/null +++ b/qiming/src/components/SelectionList/index.less @@ -0,0 +1,218 @@ +@import '@/styles/token.less'; + +@default-max-lines: 1; +@margin-name-bottom: 2px; + +// 局部 Less 变量定义,以 100% 还原重构前视觉效果且严格满足不硬编码规范 +@selectionContainerBorderRadius: @borderRadiusLg; +@selectionContainerBorderColor: #e4e6eb; +@selectionContainerGap: @marginSm; // 增加间距到 12px + +@selectionHeaderPadding: @padding @paddingMd; +@selectionHeaderFontSize: @fontSizeLg; +@selectionHeaderColor: #1d2129; +@selectionHeaderBorderBottom: 1px solid #f2f3f5; + +@selectionItemPadding: @paddingSm @paddingSm; +@selectionItemBorderRadius: @borderRadius; +@selectionItemHoverBg: #f7f8fa; +@selectionItemActiveBg: #f0f5ff; +@selectionItemActiveBorderColor: #3370ff; +@selectionItemActiveBoxShadow: 0 4px 12px rgba(51, 112, 255, 0.12); + +@actionsGap: @marginXxs; + +@selectionIconSize: 44px; +@selectionIconMarginRight: 14px; + +@selectionAllIconBg: #666; +@selectionAllIconColor: #fff; +@selectionAllIconFontSize: 18px; + +@selectionNameFontSize: 15px; +@selectionNameColor: #1d2129; +@selectionNameActiveColor: #3370ff; + +@selectionDescFontSize: 13px; +@selectionDescColor: #86909c; + +@selectionItemTransition: background-color 0.2s ease, border-color 0.2s ease, + box-shadow 0.2s ease; + +.container { + height: 100%; + display: flex; + flex-direction: column; + background-color: @colorBgContainer; + border-radius: @selectionContainerBorderRadius; + border: @lineWidth solid @selectionContainerBorderColor; + overflow: hidden; + + .header { + padding: @selectionHeaderPadding; + font-size: @selectionHeaderFontSize; + font-weight: 600; + color: @selectionHeaderColor; + background-color: @colorBgContainer; + border-bottom: @selectionHeaderBorderBottom; + flex-shrink: 0; + } + + .list-wrapper { + flex: 1; + padding: @paddingSm; + display: flex; + flex-direction: column; + gap: @selectionContainerGap; + overflow: hidden; + + &:hover { + overflow-y: auto; + } + } +} + +.item { + display: flex; + align-items: center; + padding: @selectionItemPadding; + border-radius: @selectionItemBorderRadius; + cursor: pointer; + transition: @selectionItemTransition; + border: @lineWidth solid @colorBorderSecondary; + background-color: @colorFillAlter; + position: relative; + z-index: 1; + + &:hover { + background-color: @selectionItemHoverBg; + } + + &.active { + background-color: @selectionItemActiveBg; + border-color: @selectionItemActiveBorderColor; + box-shadow: @selectionItemActiveBoxShadow; + + .info { + .name { + color: @selectionNameActiveColor !important; + transition: color 0.2s ease; + } + } + } + + .icon { + width: @selectionIconSize; + height: @selectionIconSize; + border-radius: @selectionItemBorderRadius; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-right: @selectionIconMarginRight; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + .all-icon { + width: 100%; + height: 100%; + background-color: @selectionAllIconBg; + color: @selectionAllIconColor; + display: flex; + align-items: center; + justify-content: center; + font-size: @selectionAllIconFontSize; + font-weight: 500; + } + } + + .info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + + .name-wrap { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: @margin-name-bottom; + gap: @marginXs; + + .name { + font-size: @selectionNameFontSize; + font-weight: 500; + color: @selectionNameColor !important; + line-height: @lineHeightLg; + transition: color 0.2s; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .extra { + font-size: @selectionDescFontSize; + color: @selectionDescColor; + flex-shrink: 0; + } + } + + .desc-wrap { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: @marginSm; + + .description { + font-size: @selectionDescFontSize; + color: @selectionDescColor; + line-height: @lineHeight; + word-break: break-all; + margin-bottom: 0 !important; + flex: 1; + min-width: 0; + } + + .more-action-wrap { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + padding: 2px 4px; + border-radius: @borderRadiusSm; + transition: background-color 0.2s; + + &:hover { + background-color: @colorFillAlter; + + .more-icon { + color: @colorPrimaryHover; + } + } + + .more-icon { + font-size: 16px; + color: @selectionDescColor; + transition: color 0.2s; + } + } + } + } +} + +.empty-wrapper { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: @padding; +} diff --git a/qiming/src/components/SelectionList/index.tsx b/qiming/src/components/SelectionList/index.tsx new file mode 100644 index 00000000..b40d9155 --- /dev/null +++ b/qiming/src/components/SelectionList/index.tsx @@ -0,0 +1,153 @@ +import { dict } from '@/services/i18nRuntime'; +import { + DeleteOutlined, + EditOutlined, + EllipsisOutlined, +} from '@ant-design/icons'; +import { Dropdown, Empty, Typography } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +export interface SelectionListItem { + icon: string | React.ReactNode; + label: string | React.ReactNode; + description?: string | React.ReactNode; + extra?: React.ReactNode; + value: T; + allowEdit?: boolean; + allowDelete?: boolean; +} + +export interface SelectionListProps { + title: React.ReactNode; + list: SelectionListItem[]; + value: T; + onChange: (value: T) => void; + className?: string; + onEdit?: (item: SelectionListItem, e: React.MouseEvent) => void; + onDelete?: (item: SelectionListItem, e: React.MouseEvent) => void; + emptyText?: React.ReactNode; + maxDescriptionLines?: number; +} + +const SelectionList: React.FC = ({ + title, + list = [], + value, + onChange, + className, + onEdit, + onDelete, + emptyText, + maxDescriptionLines = 1, +}) => { + return ( +
+
{title}
+
+ {list.length > 0 ? ( + list.map((item) => { + const isActive = value === item.value; + const menuItems = []; + if (item.allowEdit && onEdit) { + menuItems.push({ + key: 'edit', + label: dict('PC.Common.Global.edit'), + icon: , + onClick: (info: any) => { + info.domEvent.stopPropagation(); + onEdit(item, info.domEvent); + }, + }); + } + if (item.allowDelete && onDelete) { + menuItems.push({ + key: 'delete', + label: dict('PC.Common.Global.delete'), + danger: true, + icon: , + onClick: (info: any) => { + info.domEvent.stopPropagation(); + onDelete(item, info.domEvent); + }, + }); + } + + return ( +
onChange(item.value)} + > +
+ {typeof item.icon === 'string' ? ( + {String(item.label)} + ) : ( + item.icon + )} +
+
+
+ + {item.label} + + {item.extra && ( +
{item.extra}
+ )} +
+ {(item.description || menuItems.length > 0) && ( +
+ + {item.description || ' '} + + {menuItems.length > 0 && ( + +
e.stopPropagation()} + > + +
+
+ )} +
+ )} +
+
+ ); + }) + ) : ( +
+ +
+ )} +
+
+ ); +}; + +export default SelectionList; diff --git a/qiming/src/components/SiteFooter/index.tsx b/qiming/src/components/SiteFooter/index.tsx index e05f6761..8d71f175 100644 --- a/qiming/src/components/SiteFooter/index.tsx +++ b/qiming/src/components/SiteFooter/index.tsx @@ -1,4 +1,3 @@ -import { dict } from '@/services/i18nRuntime'; import { SiteFooterProps } from '@/types/interfaces/common'; import classNames from 'classnames'; import React from 'react'; @@ -15,13 +14,10 @@ const SiteFooter: React.FC = ({ text, className }) => { __html: text || '', }} /> -
- {dict('PC.Components.SiteFooter.poweredBy')} + + Powered by{' '} + 思极飞天 ); }; diff --git a/qiming/src/components/SkillDetailView/SkillHeader/index.less b/qiming/src/components/SkillDetailView/SkillHeader/index.less new file mode 100644 index 00000000..a7892300 --- /dev/null +++ b/qiming/src/components/SkillDetailView/SkillHeader/index.less @@ -0,0 +1,41 @@ +@import '@/styles/color.less'; +@import '@/styles/token.less'; + +.header { + height: 56px; + min-height: 56px; + padding: 0 16px; + border-bottom: 1px solid @colorBorderSecondary; + background-color: @colorBgContainer; + display: flex; + align-items: center; + gap: 12px; + + .avatar { + width: 32px; + height: 32px; + border-radius: @borderRadius; + flex-shrink: 0; + } + + .header-info { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + + .title { + max-width: 300px; + font-size: 16px; + font-weight: @fontWeightStrong; + color: @colorText; + white-space: nowrap; + margin: 0; + } + } + + .status-tag { + flex-shrink: 0; + } +} diff --git a/qiming/src/components/SkillDetailView/SkillHeader/index.tsx b/qiming/src/components/SkillDetailView/SkillHeader/index.tsx new file mode 100644 index 00000000..99b5bc42 --- /dev/null +++ b/qiming/src/components/SkillDetailView/SkillHeader/index.tsx @@ -0,0 +1,88 @@ +import agentImage from '@/assets/images/agent_image.png'; +import { t } from '@/services/i18nRuntime'; +import type { SkillDetailInfo } from '@/types/interfaces/skill'; +import { CheckCircleFilled, LeftOutlined } from '@ant-design/icons'; +import { Button, Tag } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import { history } from 'umi'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +/** 详情页模式:apply — 待审核 / published — 已发布 */ +export type SkillDetailViewMode = 'apply' | 'published'; + +export interface SkillHeaderProps { + skillInfo?: SkillDetailInfo | null; + mode: SkillDetailViewMode; +} + +/** mode 对应的配置 */ +const modeConfig: Record< + SkillDetailViewMode, + { + backPath: string; + tagColor: string; + tagIcon?: React.ReactNode; + tagText: string; + } +> = { + apply: { + backPath: '/system/publish/audit', + tagColor: 'processing', + tagText: t('PC.Pages.PublishAudit.statusApplying'), + }, + published: { + backPath: '/system/published/manage', + tagColor: 'success', + tagIcon: , + tagText: t('PC.Pages.SkillDetailsHeader.published'), + }, +}; + +/** + * 技能详情顶部 header 组件(待审核 / 已发布 共用) + */ +const SkillHeader: React.FC = ({ skillInfo, mode }) => { + const config = modeConfig[mode]; + + return ( +
+
+ ); +}; + +export default SkillHeader; diff --git a/qiming/src/components/SkillDetailView/index.less b/qiming/src/components/SkillDetailView/index.less new file mode 100644 index 00000000..441b0639 --- /dev/null +++ b/qiming/src/components/SkillDetailView/index.less @@ -0,0 +1,25 @@ +@import '@/styles/token.less'; + +.page-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; +} + +.layout-wrapper { + display: flex; + width: 100%; + overflow: hidden; + flex: 1; + min-height: 0; + + .detail-section { + flex: 1; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + } +} diff --git a/qiming/src/components/SkillDetailView/index.tsx b/qiming/src/components/SkillDetailView/index.tsx new file mode 100644 index 00000000..51121119 --- /dev/null +++ b/qiming/src/components/SkillDetailView/index.tsx @@ -0,0 +1,133 @@ +import { FileTreeViewPanel } from '@/components/business-component'; +import { + apiPublishedSkillDetail, + apiPublishSkillDetail, +} from '@/services/skill'; +import type { SkillDetailInfo } from '@/types/interfaces/skill'; +import classNames from 'classnames'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useRequest } from 'umi'; +import SkillHeader, { type SkillDetailViewMode } from './SkillHeader'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +export type { SkillDetailViewMode }; + +interface SkillDetailViewProps { + spaceId: number; + skillId: number; + /** apply — 待审核 / published — 已发布 */ + mode: SkillDetailViewMode; +} + +/** mode 对应的 API 函数映射 */ +const apiMap: Record any> = { + apply: apiPublishSkillDetail, + published: apiPublishedSkillDetail, +}; + +/** + * 技能详情公共组件 + * - 根据 mode 自动调用对应接口(待审核 / 已发布) + * - 只读查看,不提供编辑/发布/文件操作等能力 + */ +const SkillDetailView: React.FC = ({ skillId, mode }) => { + const [skillInfo, setSkillInfo] = useState(null); + + // 文件树数据加载状态 + const [fileTreeDataLoading, setFileTreeDataLoading] = + useState(false); + + // 根据 mode 选择对应的 API + const apiFn = apiMap[mode]; + + // 查询技能信息 + const { run: runSkillInfo } = useRequest(apiFn, { + manual: true, + debounceInterval: 300, + onSuccess: async (result: SkillDetailInfo) => { + setFileTreeDataLoading(false); + const { files } = result || {}; + if (Array.isArray(files) && files.length > 0) { + setSkillInfo(() => ({ + ...result, + files: files.map((item) => ({ + ...item, + fileId: item.name, + })), + })); + } else { + setSkillInfo(result); + } + }, + onError: () => { + setFileTreeDataLoading(false); + }, + }); + + useEffect(() => { + if (skillId) { + setFileTreeDataLoading(true); + runSkillInfo(skillId); + } + }, [skillId]); + + /** 刷新文件树:重新拉取技能详情并更新 files 列表 */ + const handleRefreshFileTree = useCallback(async () => { + if (!skillId) { + return; + } + setFileTreeDataLoading(true); + try { + await runSkillInfo(skillId); + } catch { + setFileTreeDataLoading(false); + } + }, [skillId, runSkillInfo]); + + return ( +
+ {/* 技能头部 */} + + +
+ {/* 详情内容区域 */} +
+
+
+ {/* 文件树视图(只读模式) */} + +
+
+
+
+
+ ); +}; + +export default SkillDetailView; diff --git a/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.less b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.less new file mode 100644 index 00000000..b969a483 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.less @@ -0,0 +1,415 @@ +@import '@/styles/token.less'; + +@button-max-width: 160px; + +/* ─── Card Shell ─────────────────────────────────────── */ +.card { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + border: 1px solid @colorBorderSecondary; + border-radius: @borderRadiusLg; + background: @colorBgContainer; + overflow: hidden; + + &.cardDocked { + height: auto; + max-height: none; + + .title { + white-space: normal; + overflow: hidden; + display: box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + } + + /* ─── Header ─────────────────────────────────────────── */ + .header { + flex-shrink: 0; + display: flex; + align-items: flex-start; + gap: @marginXs; + padding: @paddingSm @paddingSm @paddingXs; + // background: @colorBgContainer; + + .icon { + display: none; + } + + .iconWrap { + display: none; + } + + .headerMain { + flex: 1; + min-width: 0; + + .eyebrow { + display: block; + margin-bottom: @marginXxs; + color: @colorTextTertiary; + font-size: @fontSizeSm; + font-weight: 500; + line-height: 1.3; + } + + .title { + display: block; + min-width: 0; + color: @colorTextBase; + font-size: @fontSize; + font-weight: 600; + line-height: @lineHeight; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .kindTag { + display: none; + } + + .statusTag { + flex-shrink: 0; + align-self: flex-start; + margin: 2px 0 0; + font-size: @fontSizeSm; + line-height: 1.4; + } + } + + &:not(.cardDocked) .header { + padding: @paddingXs @paddingSm; + border-bottom: none; + } + + /* ─── Body / Options List ─────────────────────────────── */ + .body { + padding: 0; + + .filePreview { + display: flex; + flex-direction: column; + gap: @marginXs; + padding: 0 @paddingSm @paddingXs; + } + + .filePreviewItem { + min-width: 0; + border: 1px solid @colorBorderSecondary; + border-radius: @borderRadius; + overflow: hidden; + background: @colorBgContainer; + } + + .filePath { + display: block; + padding: @paddingXxs @paddingXs; + border-bottom: 1px solid @colorBorderSecondary; + color: @colorTextSecondary; + font-family: @fontFamilyCode; + font-size: @fontSizeSm; + line-height: 1.4; + background: @colorFillQuaternary; + overflow: hidden; + } + + .diffPreview { + max-height: 220px; + min-height: 80px; + overflow: auto; + } + + .actions { + flex-shrink: 0; + display: flex; + flex-direction: column; + padding: 0 @paddingSm @paddingXs; // 增加左右边距 + gap: 6px; // 每一项之间增加微小的间距 + + .actionBtn { + width: 100%; + height: auto; + min-height: 30px; // 减少高度 + padding: 2px @paddingXs; // 减少内边距 + border: none; + border-radius: 10px !important; + background: transparent; + font-size: @fontSize; + font-weight: 400; + text-align: left; + display: flex; + align-items: center; + box-shadow: none !important; + + &:hover { + background: @colorFillTertiary !important; + } + + .buttonLabel { + display: inline-flex; + align-items: center; + font-size: inherit; + width: 100%; + gap: @marginXxs; + + .option-index { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: @colorFill; + color: @colorTextSecondary; + font-size: 11px; + font-weight: 600; + line-height: 1; + } + + .button-text { + display: inline-block; + flex: 1; + min-width: 0; + max-width: @button-max-width; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; + color: inherit !important; + } + + .arrow-indicators { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: auto; + color: @colorTextTertiary; + font-size: 11px; + flex-shrink: 0; + + .arrowIcon { + font-size: 10px; + } + } + + .shortcutGroup { + display: inline-flex; + align-items: center; + gap: @marginXxs; + margin-left: auto; + flex-shrink: 0; + } + + .multiCheck { + display: inline-flex; + align-items: center; + margin-right: 2px; + + .multiCheckItem { + font-size: 11px; + + &:not(:first-child) { + margin-left: -6px; + } + } + } + } + + &.allowBtn { + background-color: transparent !important; + border-color: transparent !important; + color: @colorText !important; + + &:hover { + background-color: @colorFillTertiary !important; + } + + .buttonLabel .shortcutGroup .shortcut { + border: none; + background: rgba(0, 0, 0, 8%); + color: @colorTextSecondary; + } + } + + &.rejectBtn { + background-color: transparent !important; + border-color: transparent !important; + color: @colorTextSecondary !important; + + &:hover { + background: @colorFillTertiary !important; + color: @colorText !important; + } + + .buttonLabel .shortcutGroup .shortcut { + border: 1px solid @colorBorderSecondary; + background: @colorBgContainer; + color: @colorTextTertiary; + } + } + + &.active-btn { + border-radius: 10px !important; // 胶囊形状 + background-color: @colorFillSecondary !important; // 浅灰背景色 + color: @colorText !important; + border-color: transparent !important; + + &:hover { + background-color: @colorFill !important; + } + } + } + } + } + + .active-btn { + .buttonLabel .option-index { + background: @colorTextBase !important; + color: @colorBgContainer !important; + } + } + + &:not(.cardDocked) { + .body { + display: none; + } + + .actions { + flex-flow: row wrap; + gap: @marginXs; + padding: @paddingXs @paddingSm; + border-top: none; + + .actionBtn { + width: auto; + height: @controlHeightSm; + padding: 0 @paddingSm; + border-radius: @borderRadius; + font-weight: 500; + font-size: @fontSizeSm; + border: 1px solid @colorBorder; + + &.allowBtn { + background-color: @colorBgSolid !important; + border-color: @colorBgSolid !important; + color: @colorTextLightSolid !important; + + &:hover { + background-color: @colorBgSolidHover !important; + border-color: @colorBgSolidHover !important; + } + + .buttonLabel .shortcutGroup .shortcut { + border: none; + background: rgba(255, 255, 255, 20%); + color: rgba(255, 255, 255, 80%); + } + } + + &.rejectBtn { + color: @colorError !important; + border-color: @colorErrorBorder !important; + background-color: @colorBgContainer !important; + + &:hover { + background-color: @colorErrorBg !important; + border-color: @colorErrorBorderHover !important; + } + } + } + } + } + + /* ─── Footer ─────────────────────────────────────────── */ + .footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: @marginXs; + padding: @paddingXs @paddingSm; + border-top: 1px solid @colorBorderSecondary; + background: @colorBgContainer; + border-radius: 0 0 @borderRadiusLg @borderRadiusLg; + + .buttonLabel { + display: inline-flex; + align-items: center; + min-width: 0; + gap: @marginXxs; + } + + .submit-btn { + background: @colorBgSolid !important; + border-color: @colorBgSolid !important; + color: @colorTextLightSolid !important; + border-radius: @borderRadiusLg !important; + font-weight: 500; + + &:hover { + background: @colorBgSolidHover !important; + border-color: @colorBgSolidHover !important; + } + + &:disabled { + opacity: 0.5; + } + + .shortcut { + border: 1px solid rgba(255, 255, 255, 25%) !important; + background: rgba(255, 255, 255, 15%) !important; + color: rgba(255, 255, 255, 75%) !important; + } + } + + .cancel-btn { + border: none !important; + background: transparent !important; + box-shadow: none !important; + color: @colorTextSecondary !important; + padding-left: @paddingSm !important; // 增加内边距 + padding-right: @paddingSm !important; // 增加内边距 + margin-right: @marginXs; // 增加右侧外边距以和确定按钮拉开距离 + border-radius: @borderRadiusLg !important; + + &:hover { + color: @colorText !important; + background: @colorFillTertiary !important; + } + + .shortcut { + border: 1px solid @colorBorderSecondary !important; + background: @colorFillSecondary !important; + color: @colorTextSecondary !important; + } + } + } + + /* ─── Error ─────────────────────────────────────────── */ + .error { + display: block; + padding: 0 @paddingSm @paddingXs; + font-size: @fontSizeSm; + } +} + +.shortcut { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 @paddingXxs; + border-radius: @borderRadiusSm; + font-size: 11px; + font-family: inherit; + font-weight: 600; + line-height: 1; + pointer-events: none; +} diff --git a/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.test.tsx b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.test.tsx new file mode 100644 index 00000000..7bcb98e1 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.test.tsx @@ -0,0 +1,183 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { AcpPermissionInteraction } from '../types/acpIntervention'; +import AcpPermissionCard from './index'; + +vi.mock('@/services/i18nRuntime', () => ({ + t: (key: string, ...args: string[]) => { + const dict: Record = { + 'PC.Components.AcpPermissionCard.defaultTitle': '权限审批', + 'PC.Components.AcpPermissionCard.eyebrow': '安全确认', + 'PC.Components.AcpPermissionCard.submitted': '已提交', + 'PC.Components.AcpPermissionCard.shortcutHint': '{0} ({1})', + }; + const template = dict[key] ?? key; + return args.reduce( + (text, item, index) => text.replace(`{${index}}`, item), + template, + ); + }, +})); + +vi.mock('./index.less', () => ({ + default: new Proxy({}, { get: (_: any, key: string) => String(key) }), +})); + +vi.mock('./useAcpPermissionShortcuts', () => ({ + useAcpPermissionShortcuts: vi.fn(), + getAcpPermissionShortcutHint: vi.fn(() => ''), +})); + +function createInteraction( + overrides: Partial = {}, +): AcpPermissionInteraction { + return { + intervention: { + id: 'itv-001', + revision: 1, + kind: 'approval', + status: 'pending', + sessionId: 'sess-001', + source: 'acp_permission', + engine: 'claude-code', + protocol: 'acp', + callbackTarget: { kind: 'electron', targetId: 'tgt-1' }, + schemaRef: 'acp/permission/v1', + acp: { + method: 'session/request_permission', + request: { + sessionId: 'sess-001', + toolCall: { + toolCallId: 'tc-001', + title: '执行 bash 命令', + kind: 'bash', + }, + options: [ + { optionId: 'opt-1', kind: 'allow_once', name: '允许一次' }, + { optionId: 'opt-2', kind: 'allow_always', name: '始终允许' }, + { optionId: 'opt-3', kind: 'reject_once', name: '拒绝一次' }, + { optionId: 'opt-4', kind: 'reject_always', name: '始终拒绝' }, + ], + }, + }, + createdAt: Date.now(), + }, + responseStatus: 'pending', + selectedOptionId: undefined, + errorMessage: undefined, + ...overrides, + } as AcpPermissionInteraction; +} + +describe('AcpPermissionCard', () => { + it('renders only visible options (excludes reject_always)', () => { + const onRespond = vi.fn(); + render( + , + ); + + expect(screen.getByText('允许一次')).toBeTruthy(); + expect(screen.getByText('始终允许')).toBeTruthy(); + expect(screen.getByText('拒绝一次')).toBeTruthy(); + expect(screen.queryByText('始终拒绝')).toBeNull(); + }); + + it('calls onRespond with correct payload when an option is clicked', () => { + const onRespond = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText('允许一次')); + + expect(onRespond).toHaveBeenCalledTimes(1); + expect(onRespond).toHaveBeenCalledWith({ + outcome: { outcome: 'selected', optionId: 'opt-1' }, + }); + }); + + it('shows submitted tag and disables buttons when submitted', () => { + const onRespond = vi.fn(); + render( + , + ); + + expect(screen.getByText('已提交')).toBeTruthy(); + + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => { + expect(btn).toBeDisabled(); + }); + }); + + it('shows loading on the selected option when submitting', () => { + render( + , + ); + + const buttons = screen.getAllByRole('button'); + // antd applies "ant-btn-loading" class when loading + const allowOnceBtn = buttons[0]; + expect(allowOnceBtn.className).toContain('loading'); + }); + + it('shows error message when failed', () => { + render( + , + ); + + expect(screen.getByText('网络错误')).toBeTruthy(); + }); + + it('disables all buttons when onRespond is undefined', () => { + render( + , + ); + + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => { + expect(btn).toBeDisabled(); + }); + }); + + it('renders eyebrow text when docked', () => { + render( + , + ); + + expect(screen.getByText('安全确认')).toBeTruthy(); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.tsx b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.tsx new file mode 100644 index 00000000..87db85b7 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/index.tsx @@ -0,0 +1,313 @@ +import ChangeFileGitDiffView, { + DiffModeEnum, +} from '@/components/business-component/ChangeFileGitDiffView'; +import { EllipsisTooltip } from '@/components/custom/EllipsisTooltip'; +import { t } from '@/services/i18nRuntime'; +import { normalizeFileDiffItems } from '@/utils/fileChangeDiff'; +import { + ArrowDownOutlined, + ArrowUpOutlined, + SafetyOutlined, +} from '@ant-design/icons'; +import { Button, Tag, Typography } from 'antd'; +import classNames from 'classnames'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { + AcpPermissionInteraction, + AcpRequestPermissionResponse, +} from '../types/acpIntervention'; +import styles from './index.less'; +import { + getAcpPermissionShortcutHint, + useAcpPermissionShortcuts, +} from './useAcpPermissionShortcuts'; + +const { Text } = Typography; + +const HIDDEN_OPTION_KINDS = new Set(['reject_always']); + +interface AcpPermissionCardProps { + interaction: AcpPermissionInteraction; + docked?: boolean; + dockShellClassName?: string; + keyboardShortcutsEnabled?: boolean; + onRespond?: (response: AcpRequestPermissionResponse) => void; +} + +const AcpPermissionCard: React.FC = ({ + interaction, + docked = false, + dockShellClassName, + keyboardShortcutsEnabled = true, + onRespond, +}) => { + const request = interaction.intervention.acp.request; + const toolCall = request.toolCall || {}; + const isSubmitting = interaction.responseStatus === 'submitting'; + const isSubmitted = interaction.responseStatus === 'submitted'; + const isFailed = interaction.responseStatus === 'failed'; + const disabled = isSubmitting || isSubmitted || !onRespond; + + const [activeIndex, setActiveIndex] = useState(0); + const [submitType, setSubmitType] = useState<'confirm' | 'cancel' | null>( + null, + ); + const cardRef = useRef(null); + + // 卡片弹出后自动获取焦点 + useEffect(() => { + if (!disabled) { + cardRef.current?.focus(); + } + }, [disabled]); + + // 保持焦点始终在卡片上,防止失焦导致快捷键失效 + const handleBlur = (e: React.FocusEvent) => { + if ( + !disabled && + cardRef.current && + !cardRef.current.contains(e.relatedTarget) + ) { + e.preventDefault(); + cardRef.current.focus(); + } + }; + + const handleSelect = useCallback( + (optionId: string, type: 'confirm' | 'cancel' = 'confirm') => { + if (isSubmitting || isSubmitted || !onRespond) { + return; + } + setSubmitType(type); + onRespond?.({ + outcome: { + outcome: 'selected', + optionId, + }, + }); + }, + [onRespond, isSubmitting, isSubmitted], + ); + + useEffect(() => { + if (!isSubmitting) { + setSubmitType(null); + } + }, [isSubmitting]); + + const title = + toolCall.title?.trim() || t('PC.Components.AcpPermissionCard.defaultTitle'); + const rawCommand = (toolCall.rawInput as any)?.command; + const displayTitle = title === 'bash' && rawCommand ? rawCommand : title; + const fileDiffItems = useMemo( + () => + normalizeFileDiffItems({ + input: toolCall.rawInput, + locations: toolCall.locations, + }), + [toolCall.rawInput, toolCall.locations], + ); + + const visibleOptions = useMemo( + () => + (request.options ?? []) + .filter((option) => !HIDDEN_OPTION_KINDS.has(option.kind)) + .sort((a, b) => { + const orderA = getAcpPermissionShortcutHint(a.kind) || '9'; + const orderB = getAcpPermissionShortcutHint(b.kind) || '9'; + return orderA.localeCompare(orderB); + }), + [request.options], + ); + + const rejectOption = useMemo( + () => (request.options ?? []).find((o) => o.kind.startsWith('reject')), + [request.options], + ); + + useEffect(() => { + setActiveIndex(0); + }, [visibleOptions]); + + const handleCancel = useCallback(() => { + if (rejectOption) { + handleSelect(rejectOption.optionId, 'cancel'); + } + }, [rejectOption, handleSelect]); + + const isSubmitLoading = + isSubmitting && (submitType === 'confirm' || !submitType); + const isCancelLoading = isSubmitting && submitType === 'cancel'; + + useAcpPermissionShortcuts({ + enabled: !disabled && keyboardShortcutsEnabled, + options: visibleOptions, + onSelect: handleSelect, + onCancel: handleCancel, + activeIndex, + setActiveIndex, + }); + + return ( +
+
+ {docked ? ( +
+ +
+ ) : ( + + )} +
+ {docked ? ( + + {t('PC.Components.AcpPermissionCard.eyebrow')} + + ) : null} + + {displayTitle} + +
+ {toolCall.kind ? ( + + {toolCall.kind} + + ) : null} + {isSubmitted ? ( + + {t('PC.Components.AcpPermissionCard.submitted')} + + ) : null} +
+ +
+ {fileDiffItems.length ? ( +
+ {fileDiffItems.map((item) => ( +
+
+ + {item.path} + +
+
+ +
+
+ ))} +
+ ) : null} +
+ {visibleOptions.map((option, index) => { + const isAllow = option.kind.startsWith('allow'); + const isActive = index === activeIndex; + const camelCaseKind = option.kind.replace( + /_([a-z])/g, + (_, letter) => letter.toUpperCase(), + ); + const label = + t(`PC.Components.AcpPermissionCard.${camelCaseKind}` as any) || + option.name || + option.optionId; + return ( + + ); + })} +
+
+ +
+ + +
+ + {isFailed && interaction.errorMessage ? ( + + {interaction.errorMessage} + + ) : null} +
+ ); +}; + +export default AcpPermissionCard; diff --git a/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/useAcpPermissionShortcuts.test.tsx b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/useAcpPermissionShortcuts.test.tsx new file mode 100644 index 00000000..1ba92c75 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/useAcpPermissionShortcuts.test.tsx @@ -0,0 +1,40 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useAcpPermissionShortcuts } from './useAcpPermissionShortcuts'; + +function ShortcutHarness({ + onSelect, + onCancel, +}: { + onSelect: (optionId: string) => void; + onCancel: () => void; +}) { + useAcpPermissionShortcuts({ + enabled: true, + options: [ + { optionId: 'allow-once', kind: 'allow_once', name: 'Allow once' }, + { optionId: 'allow-always', kind: 'allow_always', name: 'Allow always' }, + { optionId: 'reject-once', kind: 'reject_once', name: 'Reject once' }, + ], + onSelect, + onCancel, + }); + return
shortcuts
; +} + +describe('useAcpPermissionShortcuts', () => { + it('selects only explicit permission options', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + render(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + window.dispatchEvent(new KeyboardEvent('keydown', { key: '3' })); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(onSelect).toHaveBeenNthCalledWith(1, 'allow-once'); + expect(onSelect).toHaveBeenNthCalledWith(2, 'reject-once'); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/useAcpPermissionShortcuts.ts b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/useAcpPermissionShortcuts.ts new file mode 100644 index 00000000..be5d5337 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AcpPermissionCard/useAcpPermissionShortcuts.ts @@ -0,0 +1,100 @@ +import { useEffect } from 'react'; +import { + isFormFieldTarget, + useInterventionEscapeKey, +} from '../hooks/useInterventionEscapeKey'; +import type { AcpPermissionOption } from '../types/acpIntervention'; + +const KIND_DIGIT_KEY: Partial> = { + allow_once: '1', + allow_always: '2', + reject_once: '3', +}; + +interface UseAcpPermissionShortcutsOptions { + enabled: boolean; + options: AcpPermissionOption[]; + onSelect: (optionId: string) => void; + onCancel: () => void; + activeIndex: number; + setActiveIndex: React.Dispatch>; +} + +export function useAcpPermissionShortcuts({ + enabled, + options, + onSelect, + onCancel, + activeIndex, + setActiveIndex, +}: UseAcpPermissionShortcutsOptions): void { + useInterventionEscapeKey({ + enabled, + onEscape: onCancel, + respectFormFieldFocus: true, + }); + + useEffect(() => { + if (!enabled || !options.length) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (isFormFieldTarget(event.target)) { + return; + } + + if (event.key === 'Escape') { + return; + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setActiveIndex((prev) => { + const count = options.length; + if (count === 0) return prev; + if (event.key === 'ArrowDown') { + return (prev + 1) % count; + } else { + return (prev - 1 + count) % count; + } + }); + return; + } + + if ( + event.key === 'Enter' && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey + ) { + event.preventDefault(); + const activeOption = options[activeIndex]; + if (activeOption) { + onSelect(activeOption.optionId); + } + return; + } + + const digit = event.key.length === 1 ? event.key : ''; + if (!/^[1-9]$/.test(digit)) { + return; + } + + const matched = options.find( + (option) => KIND_DIGIT_KEY[option.kind] === digit, + ); + if (matched) { + event.preventDefault(); + onSelect(matched.optionId); + } + }; + + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [enabled, onSelect, options, activeIndex, setActiveIndex]); +} + +export function getAcpPermissionShortcutHint(kind: string): string | undefined { + return KIND_DIGIT_KEY[kind]; +} diff --git a/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/AgentInterventionChatLayer.less b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/AgentInterventionChatLayer.less new file mode 100644 index 00000000..16a51ec0 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/AgentInterventionChatLayer.less @@ -0,0 +1,6 @@ +@import '@/styles/token.less'; + +.host { + box-sizing: border-box; + width: 100%; +} diff --git a/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/DockPanel.less b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/DockPanel.less new file mode 100644 index 00000000..bd0c47e4 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/DockPanel.less @@ -0,0 +1,65 @@ +@import '@/styles/token.less'; + +.cardSlot { + display: block; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; +} + +/* ─── 多干预卡片堆叠 ─────────────────────────────── */ +.stackRoot { + position: relative; + width: 100%; +} + +.stackBadge { + position: absolute; + top: 5px; + right: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + border-radius: 10px; + background: @colorPrimary; + color: @colorTextLightSolid; + font-size: 11px; + font-weight: 600; + line-height: 1; + box-shadow: 0 2px 5px rgba(0, 0, 0, 10%); + pointer-events: none; +} + +.stack { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + width: 100%; + padding-top: var(--stack-offset-total, 0); + padding-right: var(--stack-offset-total, 0); + overflow: visible; +} + +.stackLayer { + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; +} + +.stackLayerFront { + position: relative; + flex-shrink: 0; +} + +.stackLayerBack { + position: absolute; + left: 0; + right: 0; + pointer-events: none; +} diff --git a/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/DockPanel.tsx b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/DockPanel.tsx new file mode 100644 index 00000000..0c8574d2 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/DockPanel.tsx @@ -0,0 +1,138 @@ +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import AcpPermissionCard from '../AcpPermissionCard'; +import type { InterventionQueueItem } from '../hooks/useActiveInterventionQueue'; +import McpAskQuestionCard from '../McpAskQuestionCard'; +import type { + AcpPermissionInteraction, + AcpRequestPermissionResponse, +} from '../types/acpIntervention'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '../types/mcpAskIntervention'; +import styles from './DockPanel.less'; +import dockCardStyles from './intervention-dock-card.module.less'; + +const STACK_OFFSET_PX = 5; + +interface InterventionDockPanelProps { + items: InterventionQueueItem[]; + onRespondAcpPermission: ( + interaction: AcpPermissionInteraction, + response: AcpRequestPermissionResponse, + ) => void | Promise; + onRespondMcpAsk: ( + interaction: McpAskInteraction, + payload: McpAskRespondPayload, + ) => void | Promise; +} + +function getItemKey(item: InterventionQueueItem): string { + if (item.kind === 'acp_permission') { + return `acp-${item.interaction.intervention.id}`; + } + return `ask-${item.interaction.input.requestId}`; +} + +const DockPanel: React.FC = ({ + items, + onRespondAcpPermission, + onRespondMcpAsk, +}) => { + const renderCard = useCallback( + (item: InterventionQueueItem, keyboardShortcutsEnabled: boolean) => { + const dockShellClassName = dockCardStyles.root; + if (item.kind === 'acp_permission') { + return ( + + onRespondAcpPermission(item.interaction, response) + } + /> + ); + } + return ( + onRespondMcpAsk(item.interaction, payload)} + /> + ); + }, + [onRespondAcpPermission, onRespondMcpAsk], + ); + + if (!items.length) { + return null; + } + + const stackDepth = items.length; + + if (stackDepth === 1) { + return ( +
+ {renderCard(items[0], true)} +
+ ); + } + + const offsetTotal = (stackDepth - 1) * STACK_OFFSET_PX; + + // FIFO:items 已按 sortKey 升序,最早触发的在 front(index 0),逐个审批 + return ( +
+ {stackDepth > 2 ? ( + + {stackDepth - 1} + + ) : null} + +
+ {items.map((item, index) => { + const isFront = index === 0; + return ( +
+
{renderCard(item, isFront)}
+
+ ); + })} +
+
+ ); +}; + +export default DockPanel; diff --git a/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/index.test.tsx b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/index.test.tsx new file mode 100644 index 00000000..4b2d7b41 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/index.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import AgentInterventionChatLayer from './index'; + +vi.mock('../AcpPermissionCard', () => ({ + __esModule: true, + default: ({ interaction }: any) => ( +
acp:{interaction.intervention.id}
+ ), +})); + +describe('AgentInterventionChatLayer', () => { + it('renders nothing when no pending interventions', () => { + const { container } = render( + , + ); + expect(container.innerHTML).toBe(''); + }); + + it('shows stacked approvals and a remaining-count badge when more than two are pending', () => { + const createInteraction = (id: string, createdAt: number) => ({ + intervention: { + id, + createdAt, + acp: { + request: { + toolCall: { + toolCallId: `${id}-call`, + rawInput: null, + }, + }, + }, + }, + responseStatus: 'pending', + }); + + render( + , + ); + + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('acp:itv-1')).toBeInTheDocument(); + expect(screen.getByText('acp:itv-2')).toBeInTheDocument(); + expect(screen.getByText('acp:itv-3')).toBeInTheDocument(); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/index.tsx b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/index.tsx new file mode 100644 index 00000000..0bb6306b --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/index.tsx @@ -0,0 +1,103 @@ +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import classNames from 'classnames'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useActiveInterventionQueue } from '../hooks/useActiveInterventionQueue'; +import type { + AcpPermissionInteraction, + AcpRequestPermissionResponse, +} from '../types/acpIntervention'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '../types/mcpAskIntervention'; +import styles from './AgentInterventionChatLayer.less'; +import DockPanel from './DockPanel'; + +export interface AgentInterventionChatLayerProps { + className?: string; + messageList: MessageInfo[]; + onRespondAcpPermission: ( + interaction: AcpPermissionInteraction, + response: AcpRequestPermissionResponse, + ) => void | Promise; + onRespondMcpAsk: ( + interaction: McpAskInteraction, + payload: McpAskRespondPayload, + ) => void | Promise; +} + +const AgentInterventionChatLayer: React.FC = ({ + className, + messageList, + onRespondAcpPermission, + onRespondMcpAsk, +}) => { + const [dismissedMcpAskRequestIds, setDismissedMcpAskRequestIds] = useState< + Set + >(() => new Set()); + + const activeQueueItems = useActiveInterventionQueue(messageList); + const queueItems = useMemo( + () => + activeQueueItems.filter( + (item) => + item.kind !== 'mcp_ask' || + !dismissedMcpAskRequestIds.has(item.interaction.input.requestId), + ), + [activeQueueItems, dismissedMcpAskRequestIds], + ); + + useEffect(() => { + if (!dismissedMcpAskRequestIds.size) { + return; + } + const activeRequestIds = new Set( + activeQueueItems + .filter((item) => item.kind === 'mcp_ask') + .map((item) => item.interaction.input.requestId), + ); + setDismissedMcpAskRequestIds((prev) => { + const next = new Set( + [...prev].filter((requestId) => activeRequestIds.has(requestId)), + ); + return next.size === prev.size ? prev : next; + }); + }, [activeQueueItems, dismissedMcpAskRequestIds.size]); + + const handleRespondMcpAsk = useCallback( + async (interaction: McpAskInteraction, payload: McpAskRespondPayload) => { + const requestId = interaction.input.requestId; + setDismissedMcpAskRequestIds((prev) => new Set(prev).add(requestId)); + try { + await onRespondMcpAsk(interaction, payload); + } catch (error) { + setDismissedMcpAskRequestIds((prev) => { + const next = new Set(prev); + next.delete(requestId); + return next; + }); + console.error('[agentIntervention] Failed to respond MCP ask', error); + } + }, + [onRespondMcpAsk], + ); + + if (!queueItems.length) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default AgentInterventionChatLayer; diff --git a/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/intervention-dock-card.module.less b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/intervention-dock-card.module.less new file mode 100644 index 00000000..14a73aeb --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/AgentInterventionChatLayer/intervention-dock-card.module.less @@ -0,0 +1,23 @@ +@import '@/styles/color.less'; +@import '@/styles/token.less'; + +/** + * 固定栏干预卡片统一外壳(ACP / MCP Ask 根节点共用同一 class) + */ +.root { + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: visible; + border: 1px solid rgba(0, 0, 0, 8%); + border-radius: 14px; + background: @colorBgContainer; + box-shadow: 0 2px 8px -3px rgba(0, 0, 0, 6%), 0 1px 2px -1px rgba(0, 0, 0, 4%); +} + +/* 与 Chat 页 intervention-dock 配合:内容区占满 dock 宽度 */ +.host { + box-sizing: border-box; + width: 100%; +} diff --git a/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFileUpload.tsx b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFileUpload.tsx new file mode 100644 index 00000000..18513f64 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFileUpload.tsx @@ -0,0 +1,84 @@ +import ChatUploadFile from '@/components/ChatUploadFile'; +import { UPLOAD_FILE_ACTION } from '@/constants/common.constants'; +import { ACCESS_TOKEN } from '@/constants/home.constants'; +import { t } from '@/services/i18nRuntime'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import { handleUploadFileList } from '@/utils/upload'; +import { PaperClipOutlined } from '@ant-design/icons'; +import { Button, Upload, type UploadProps } from 'antd'; +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import { limitMcpAskUploadFileList } from '../utils/normalizeMcpAskFormData'; +import styles from './McpAskFormField.less'; + +const cx = classNames.bind(styles); + +export interface McpAskFileUploadProps { + /** Ant Design Form 注入的 fileList */ + value?: UploadFileInfo[]; + onChange?: (fileList: UploadFileInfo[]) => void; + disabled?: boolean; + multiple?: boolean; + accept?: string; +} + +/** + * MCP Ask 文件字段上传:对齐聊天输入框附件上传(UPLOAD_FILE_ACTION + ChatUploadFile 列表)。 + */ +const McpAskFileUpload: React.FC = ({ + value = [], + onChange, + disabled, + multiple, + accept, +}) => { + const token = localStorage.getItem(ACCESS_TOKEN) ?? ''; + const fileList = value ?? []; + + const emitChange = useCallback( + (nextList: UploadFileInfo[]) => { + onChange?.(limitMcpAskUploadFileList(nextList, multiple)); + }, + [onChange, multiple], + ); + + const handleChange: UploadProps['onChange'] = (info) => { + emitChange(handleUploadFileList(info.fileList ?? [])); + }; + + const handleDel = (uid: string) => { + emitChange(fileList.filter((item) => item.uid !== uid)); + }; + + return ( +
+ {fileList.length > 0 ? ( + + ) : null} + + + +
+ ); +}; + +export default McpAskFileUpload; diff --git a/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.less b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.less new file mode 100644 index 00000000..7642e933 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.less @@ -0,0 +1,227 @@ +@import '@/styles/token.less'; +@import '@/styles/color.less'; + +.option-group { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; + min-width: 0; + max-width: 100%; + + :global { + .ant-radio-wrapper, + .ant-checkbox-wrapper { + align-items: center; + max-width: 100%; + margin: 0; + padding: 5px 10px; + background: @colorBgContainer; + color: @colorTextBase; + font-size: 12px; + line-height: 1.4; + white-space: normal; + transition: background 0.15s ease, color 0.15s ease; + } + + .ant-radio-wrapper span:last-child, + .ant-checkbox-wrapper span:last-child { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; + } + + .ant-radio-wrapper:hover, + .ant-checkbox-wrapper:hover { + background: @colorBgContainer !important; + color: @colorTextBase !important; + } + + .ant-radio-wrapper-checked, + .ant-checkbox-wrapper-checked { + background: @colorBgContainer !important; + color: @colorTextBase !important; + } + + .ant-radio-wrapper::after, + .ant-checkbox-wrapper::after { + display: none; + } + + .ant-radio-checked .ant-radio-inner { + border-color: @colorBgSolid !important; + background-color: @colorBgContainer !important; + } + + .ant-radio-checked .ant-radio-inner::after { + background-color: @colorBgSolid !important; + } + + .ant-radio-wrapper:hover .ant-radio-inner, + .ant-radio:hover .ant-radio-inner, + .ant-radio-input:focus + .ant-radio-inner { + border-color: @colorBgSolid !important; + } + + .ant-checkbox-checked .ant-checkbox-inner { + border-color: @colorBgSolid !important; + background-color: @colorBgSolid !important; + } + + .ant-checkbox-wrapper:hover .ant-checkbox-inner, + .ant-checkbox:hover .ant-checkbox-inner, + .ant-checkbox-input:focus + .ant-checkbox-inner { + border-color: @colorBgSolid !important; + } + } +} + +.text-control { + width: 100%; + min-width: 0; + max-width: 100%; + border-radius: 6px !important; + font-size: 12px !important; + + &:hover { + border-color: @colorBgSolid !important; + } + + &:focus, + &:focus-within { + border-color: @colorBgSolid !important; + box-shadow: 0 0 0 2px @colorFillSecondary !important; + } + + // 2. Global Ant Design wrapper hover & focus states (where .text-control is on the wrapper) + &:global(.ant-input-affix-wrapper):hover, + &:global(.ant-input-number):hover, + &:global(.ant-select):not(:global(.ant-select-disabled)):hover + :global(.ant-select-selector) { + border-color: @colorBgSolid !important; + } + + &:global(.ant-input-affix-wrapper-focused), + &:global(.ant-input-number-focused), + &:global(.ant-select-focused) :global(.ant-select-selector), + &:global(.ant-select-open) :global(.ant-select-selector) { + border-color: @colorBgSolid !important; + box-shadow: 0 0 0 2px @colorFillSecondary !important; + } + + // 3. Child elements selectors (just in case) + :global { + .ant-input, + .ant-input-number-input, + .ant-select-selector { + border-radius: 6px !important; + font-size: 12px !important; + + &:hover { + border-color: @colorBgSolid !important; + } + + &:focus { + border-color: @colorBgSolid !important; + box-shadow: 0 0 0 2px @colorFillSecondary !important; + } + } + + .ant-input-textarea textarea { + font-size: 12px !important; + } + } +} + +.mcp-ask-file-upload { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + min-width: 0; + max-width: 100%; +} + +.upload-trigger { + align-self: flex-start; + font-size: 12px !important; +} + +.upload-control { + width: 100%; + min-width: 0; + max-width: 100%; + + :global { + .ant-upload, + .ant-upload-list, + .ant-upload-list-item, + .ant-upload-drag { + max-width: 100%; + } + + .ant-upload-text { + overflow-wrap: anywhere; + word-break: break-word; + font-size: @fontSizeSm !important; + color: @colorTextSecondary !important; + } + + .ant-upload-list-item-name { + overflow-wrap: anywhere; + word-break: break-word; + } + + // Hover border color + .ant-upload-drag:not(.ant-upload-disabled):hover { + border-color: @colorBgSolid !important; + } + + // Icon color + .ant-upload-drag-icon { + .anticon { + color: @colorBgSolid !important; + } + } + + // Active/focus border color + .ant-upload-drag-hover { + border-color: @colorBgSolid !important; + } + + // Upload list active/progress color + .ant-upload-list-item-done { + .anticon { + color: @colorBgSolid !important; + } + } + } +} + +.list-radio { + display: flex !important; + align-items: center; + width: 100%; + min-height: 32px; + + :global { + .ant-radio { + display: flex; + align-items: center; + } + } +} + +.select-dropdown { + :global { + .ant-select-item-option-selected:not(.ant-select-item-option-disabled) { + background-color: @colorFillTertiary !important; + color: @colorTextBase !important; + font-weight: 500; + } + + .ant-select-item-option-active:not(.ant-select-item-option-disabled) { + background-color: @colorFillSecondary !important; + } + } +} diff --git a/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.test.tsx b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.test.tsx new file mode 100644 index 00000000..f4d3b98c --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Form } from 'antd'; +import { describe, expect, it, vi } from 'vitest'; +import type { ParsedMcpAskField } from '../utils/parseMcpAskSchema'; +import McpAskFormField from './McpAskFormField'; + +vi.mock('@/services/i18nRuntime', () => ({ + t: (key: string) => key, + dict: (key: string) => key, +})); + +vi.mock('@/constants/common.constants', () => ({ + UPLOAD_FILE_ACTION: '/upload', +})); + +vi.mock('@/constants/home.constants', () => ({ + ACCESS_TOKEN: 'access-token', +})); + +vi.mock('@/utils/upload', () => ({ + handleUploadFileList: (list: unknown) => list, +})); + +vi.mock('./McpAskFormField.less', () => ({ + default: new Proxy({}, { get: (_, key) => String(key) }), +})); + +function renderNumberField(overrides: Partial = {}) { + const field: ParsedMcpAskField = { + name: 'count', + property: { + type: 'integer', + title: '并发数', + minimum: 1, + maximum: 10, + }, + widget: 'number', + required: true, + options: {}, + enumValues: [], + enumLabels: [], + ...overrides, + }; + + render( +
+ + , + ); +} + +describe('McpAskFormField number widget', () => { + it('renders InputNumber for number widget', () => { + renderNumberField(); + expect(screen.getByRole('spinbutton')).toBeInTheDocument(); + expect(screen.getByText('并发数')).toBeInTheDocument(); + }); + + it('accepts numeric input', async () => { + const user = userEvent.setup(); + renderNumberField(); + const input = screen.getByRole('spinbutton'); + await user.clear(input); + await user.type(input, '5'); + expect(input).toHaveValue('5'); + }); + + it('uses integer precision for integer schema type', () => { + renderNumberField({ + property: { type: ['integer', 'null'], title: '数量', minimum: 0 }, + }); + const input = screen.getByRole('spinbutton'); + expect(input).toBeInTheDocument(); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.tsx b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.tsx new file mode 100644 index 00000000..380b3937 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/McpAskFormField.tsx @@ -0,0 +1,249 @@ +import { t } from '@/services/i18nRuntime'; +import { Checkbox, Form, Input, InputNumber, Radio, Select } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import { validateMcpAskRequiredFileField } from '../utils/normalizeMcpAskFormData'; +import type { ParsedMcpAskField } from '../utils/parseMcpAskSchema'; +import { getJsonSchemaPrimaryType } from '../utils/parseMcpAskSchema'; +import McpAskFileUpload from './McpAskFileUpload'; +import styles from './McpAskFormField.less'; + +const cx = classNames.bind(styles); + +interface McpAskFormFieldProps { + field: ParsedMcpAskField; + disabled?: boolean; +} + +const CUSTOM_OPTION_VALUE = '__custom__'; + +const McpAskFormField: React.FC = ({ + field, + disabled, +}) => { + const { name, property, widget, required, options, enumValues, enumLabels } = + field; + const label = property.title || name; + const rules = required + ? [ + { + required: true, + message: t('PC.Components.McpAskQuestionCard.fieldRequired'), + }, + ] + : []; + + if (widget === 'checkboxes' && enumValues.length) { + return ( + + + {enumValues.map((value, index) => ( + + {enumLabels[index] ?? value} + + ))} + + + ); + } + + if (widget === 'radio-with-custom' && enumValues.length) { + const otherValue = options.otherValue ?? CUSTOM_OPTION_VALUE; + const otherField = options.otherField ?? `${name}Custom`; + + return ( + <> + + + {enumValues.map((value, index) => ( + + {enumLabels[index] ?? value} + + ))} + {options.allowCustom !== false && ( + + {t('PC.Components.McpAskQuestionCard.customOption')} + + )} + + + prev[name] !== cur[name]} + > + {({ getFieldValue }) => + getFieldValue(name) === otherValue ? ( + + + + ) : null + } + + + ); + } + + if ((widget === 'radio' || widget === 'select') && enumValues.length) { + if (widget === 'select') { + return ( + + + + ); +}; + +export default McpAskFormField; diff --git a/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.less b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.less new file mode 100644 index 00000000..bd70a1dd --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.less @@ -0,0 +1,339 @@ +@import '@/styles/token.less'; + +/* ─── Shell ─────────────────────────────────────────── */ +.shell { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + max-width: 100%; + height: auto; + max-height: min(440px, 48vh); + overflow: hidden; + border: 1px solid @colorBorderSecondary; + border-radius: @borderRadiusLg; + background: @colorBgContainer; + + /* ─── Header ─────────────────────────────────────────── */ + .header { + flex-shrink: 0; + display: flex; + align-items: flex-start; + min-width: 0; + gap: @marginXs; + padding: @paddingSm @paddingSm @paddingXs; + background: @colorBgContainer; + border-bottom: 1px solid @colorBorderSecondary; + border-radius: @borderRadiusLg @borderRadiusLg 0 0; + + .iconWrap { + display: none; + } + + .headerMain { + flex: 1; + min-width: 0; + + .eyebrow { + display: block; + margin-bottom: @marginXxs; + color: @colorTextTertiary; + font-size: @fontSizeSm; + font-weight: 500; + line-height: 1.3; + } + + .title { + display: block; + min-width: 0; + max-width: 100%; + color: @colorTextBase; + font-size: @fontSize; + font-weight: 600; + line-height: @lineHeight; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .desc { + display: block; + min-width: 0; + max-width: 100%; + margin-top: @marginXxs; + font-size: @fontSizeSm; + color: @colorTextSecondary; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .statusTag { + flex-shrink: 0; + align-self: flex-start; + margin: 2px 0 0; + } + } + + /* ─── Wizard ─────────────────────────────────────────── */ + .wizardBlock { + flex-shrink: 0; + padding: @paddingXs @paddingSm 0; + border-bottom: 1px solid @colorBorderSecondary; + + .steps { + margin-bottom: @marginXs; + + :global { + .ant-steps-item-container { + display: flex !important; + align-items: center !important; + } + + .ant-steps-item-icon { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + margin-top: 0 !important; + margin-bottom: 0 !important; + } + + .ant-steps-item-content { + display: inline-flex !important; + align-items: center !important; + } + + .ant-steps-item-title { + font-size: @fontSizeSm !important; + line-height: 1 !important; + display: inline-flex !important; + align-items: center !important; + height: 24px !important; + } + + .ant-steps-item-description { + max-width: 120px; + font-size: 11px !important; + } + + .ant-steps-item-process .ant-steps-item-icon { + background: @colorBgSolid; + border-color: @colorBgSolid; + } + } + } + + .stepCallout { + margin-bottom: @marginXs; + padding: @paddingXxs @paddingXs; + border-radius: @borderRadiusSm; + background: @colorFillQuaternary; + + .stepCalloutText { + font-size: @fontSizeSm; + color: @colorTextSecondary; + line-height: 1.4; + } + } + } + + /* ─── Form Surface (scrollable) ─────────────────────── */ + .formSurface { + flex: 1; + min-height: 0; + padding: @paddingXs @paddingSm; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 10%) transparent; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 10%); + border-radius: 4px; + } + + .form { + margin: 0; + min-width: 0; + + :global { + .ant-form-item, + .ant-form-item-control, + .ant-form-item-control-input, + .ant-form-item-control-input-content { + min-width: 0; + max-width: 100%; + } + + .ant-form-item { + margin-bottom: @marginXs; + } + + .ant-form-item:last-child { + margin-bottom: 0; + } + + .ant-form-item-label > label { + color: @colorTextSecondary; + font-size: @fontSizeSm; + font-weight: 500; + padding-bottom: 3px; + } + + .ant-form-item-explain-error { + font-size: 11px; + margin-top: 2px; + } + } + } + } + + /* ─── Footer ─────────────────────────────────────────── */ + .footer { + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: @marginXs; + padding: @paddingXs @paddingSm; + border-top: 1px solid @colorBorderSecondary; + background: @colorBgContainer; + border-radius: 0 0 @borderRadiusLg @borderRadiusLg; + + .stepMeta { + font-size: @fontSizeSm; + color: @colorTextSecondary; + } + + .footerActions { + margin-left: auto; + max-width: 100%; + display: flex; + align-items: center; + gap: @marginXs; + justify-content: flex-end; + + .submit-btn { + background: @colorBgSolid !important; + border-color: @colorBgSolid !important; + color: @colorTextLightSolid !important; + border-radius: @borderRadiusLg !important; + font-weight: 500; + + &:hover { + background: @colorBgSolidHover !important; + border-color: @colorBgSolidHover !important; + } + + &:disabled { + opacity: 0.5; + } + + .shortcut { + border: 1px solid rgba(255, 255, 255, 25%) !important; + background: rgba(255, 255, 255, 15%) !important; + color: rgba(255, 255, 255, 75%) !important; + } + } + + .cancel-btn { + border: none !important; + background: transparent !important; + box-shadow: none !important; + color: @colorTextSecondary !important; + padding-left: @paddingSm !important; // 增加内边距 + padding-right: @paddingSm !important; // 增加内边距 + margin-right: @marginXs; // 增加右侧外边距以和确定按钮拉开距离 + border-radius: @borderRadiusLg !important; + + &:hover { + color: @colorText !important; + background: @colorFillTertiary !important; + } + + .shortcut { + border: 1px solid @colorBorderSecondary !important; + background: @colorFillSecondary !important; + color: @colorTextSecondary !important; + } + } + } + } + + /* ─── Error ─────────────────────────────────────────── */ + .error { + display: block; + padding: 0 @paddingSm @paddingXs; + font-size: @fontSizeSm; + } +} + +/* ─── 通用按钮快捷键角标 ─── */ +.buttonLabel { + display: inline-flex; + align-items: center; + min-width: 0; + gap: @marginXxs; +} + +.shortcut { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 @paddingXxs; + border: 1px solid rgba(255, 255, 255, 25%); + border-radius: @borderRadiusSm; + background: rgba(255, 255, 255, 15%); + color: rgba(255, 255, 255, 75%); + font-size: 11px; + font-family: inherit; + font-weight: 600; + line-height: 1; + pointer-events: none; +} + +/* ─── 媒体查询响应式 ─── */ +@media screen and (max-width: 520px) { + .shell { + max-height: min(480px, 58vh); + + .header { + align-items: flex-start; + gap: @marginXs; + padding: @paddingXs @paddingXs @paddingXxs; + } + + .wizardBlock, + .formSurface { + padding-left: @paddingXs; + padding-right: @paddingXs; + } + + .footer { + justify-content: flex-end; + padding: @paddingXs @paddingXs; + + .stepMeta { + width: 100%; + } + + .footerActions { + margin-left: 0; + } + } + } +} diff --git a/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.test.tsx b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.test.tsx new file mode 100644 index 00000000..c4425cd0 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { McpAskInteraction } from '../types/mcpAskIntervention'; +import McpAskQuestionCard from './index'; + +vi.mock('@/services/i18nRuntime', () => ({ + t: (key: string, ...args: string[]) => { + const dict: Record = { + 'PC.Common.Global.confirm': '确认', + 'PC.Common.Global.cancel': '取消', + 'PC.Components.McpAskQuestionCard.cancelShortcutHint': '取消(Esc)', + 'PC.Components.McpAskQuestionCard.eyebrow': '补充回复', + 'PC.Components.McpAskQuestionCard.fieldRequired': '请填写此项', + 'PC.Components.McpAskQuestionCard.multiSelectMin': '请至少选择一项', + 'PC.Components.McpAskQuestionCard.skip': '跳过', + }; + const template = dict[key] ?? key; + return args.reduce( + (text, item, index) => text.replace(`{${index}}`, item), + template, + ); + }, +})); + +vi.mock('./index.less', () => ({ + default: new Proxy({}, { get: (_, key) => String(key) }), +})); + +vi.mock('./McpAskFormField.less', () => ({ + default: new Proxy({}, { get: (_, key) => String(key) }), +})); + +const interaction: McpAskInteraction = { + toolCallId: 'tc-ask-1', + responseStatus: 'pending', + input: { + toolName: 'nuwax_ask_question', + schemaVersion: 'nuwax.mcp_ask.v2', + requestId: 'ask-1', + revision: 1, + sessionId: 'session-1', + title: '请选择继续方式', + description: 'Agent 需要你确认下一步。', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'inline', + title: '请选择继续方式', + fields: [ + { + name: 'choice', + title: '选项', + widget: 'radio', + required: true, + options: [ + { value: 'deploy', label: '直接部署' }, + { value: 'test', label: '先跑测试' }, + { value: 'cancel', label: '取消任务' }, + ], + }, + { name: 'notes', title: '补充说明', widget: 'textarea' }, + { + name: 'checks', + title: '检查项', + widget: 'checkboxes', + type: 'array', + options: [ + { value: 'lint', label: '代码检查' }, + { value: 'unit', label: '单元测试' }, + ], + }, + ], + submitLabel: '提交', + cancelLabel: '取消', + }, + }, +}; + +describe('McpAskQuestionCard', () => { + it('renders MCP Ask fields and submits form data as a normal response payload', async () => { + const onRespond = vi.fn(); + render( + , + ); + + expect(screen.getByRole('region', { name: '请选择继续方式' })).toBeTruthy(); + expect(screen.getByText('Agent 需要你确认下一步。')).toBeTruthy(); + expect(screen.getByText('选项')).toBeTruthy(); + expect(screen.getByText('先跑测试')).toBeTruthy(); + expect(screen.getByText('补充说明')).toBeTruthy(); + expect(screen.getByText('检查项')).toBeTruthy(); + expect(screen.getByText('代码检查')).toBeTruthy(); + + fireEvent.click(screen.getByText('先跑测试')); + fireEvent.change(screen.getByPlaceholderText('补充说明'), { + target: { value: '先跑关键链路' }, + }); + fireEvent.click(screen.getByText('代码检查')); + fireEvent.click(screen.getByRole('button', { name: '提 交' })); + + await waitFor(() => expect(onRespond).toHaveBeenCalledTimes(1)); + expect(onRespond).toHaveBeenCalledWith( + expect.objectContaining({ + interventionId: 'ask-1', + toolCallId: 'tc-ask-1', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { + choice: 'test', + notes: '先跑关键链路', + checks: ['lint'], + }, + answeredBy: { kind: 'web' }, + }), + ); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.tsx b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.tsx new file mode 100644 index 00000000..4240a69b --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/McpAskQuestionCard/index.tsx @@ -0,0 +1,376 @@ +import { t } from '@/services/i18nRuntime'; +import { QuestionCircleOutlined } from '@ant-design/icons'; +import { Button, Form, Steps, Tag, Typography } from 'antd'; +import classNames from 'classnames'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useInterventionEscapeKey } from '../hooks/useInterventionEscapeKey'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '../types/mcpAskIntervention'; +import { + extractMcpAskFormAttachments, + hydrateMcpAskFormValues, + normalizeMcpAskFormData, +} from '../utils/normalizeMcpAskFormData'; +import { + getInteractionSteps, + getSkipLabel, + isSkipAllowed, + isWizardPresentation, + parseInteractionFields, +} from '../utils/parseMcpAskSchema'; +import styles from './index.less'; +import McpAskFormField from './McpAskFormField'; + +const { Text } = Typography; + +interface McpAskQuestionCardProps { + interaction: McpAskInteraction; + dockShellClassName?: string; + keyboardShortcutsEnabled?: boolean; + onRespond?: (payload: McpAskRespondPayload) => void; +} + +const McpAskQuestionCard: React.FC = ({ + interaction, + dockShellClassName, + keyboardShortcutsEnabled = true, + onRespond, +}) => { + const [form] = Form.useForm>(); + const [currentStep, setCurrentStep] = useState(0); + const { input, toolCallId } = interaction; + const ui = input.ui; + const cardRef = useRef(null); + + const isSubmitting = interaction.responseStatus === 'submitting'; + const isSubmitted = interaction.responseStatus === 'submitted'; + const isCancelled = interaction.responseStatus === 'cancelled'; + const isSkipped = interaction.responseStatus === 'skipped'; + const isFailed = interaction.responseStatus === 'failed'; + const disabled = + isSubmitting || isSubmitted || isCancelled || isSkipped || !onRespond; + + const steps = useMemo(() => getInteractionSteps(ui), [ui]); + const isWizard = isWizardPresentation(ui); + const allowSkip = isSkipAllowed(ui); + const skipLabel = + getSkipLabel(ui) || t('PC.Components.McpAskQuestionCard.skip'); + + const activeStep = steps[currentStep]; + const visibleFields = useMemo(() => { + if (isWizard && steps.length > 1) { + return parseInteractionFields(ui, activeStep?.fields); + } + return parseInteractionFields(ui); + }, [ui, isWizard, steps.length, activeStep?.fields]); + const isLastStep = currentStep >= steps.length - 1; + + const title = input.title || ui.title; + const description = input.description || ui.description; + + useEffect(() => { + setCurrentStep(0); + }, [input.requestId]); + + useEffect(() => { + const fieldInitials = (ui.fields ?? []).reduce>( + (acc, f) => + f.initialValue !== undefined + ? { ...acc, [f.name]: f.initialValue } + : acc, + {}, + ); + const initial = + Object.keys(fieldInitials).length > 0 + ? { ...fieldInitials, ...(interaction.formData ?? {}) } + : interaction.formData; + if (initial) { + form.setFieldsValue(hydrateMcpAskFormValues(initial, ui)); + } + }, [form, ui.fields, interaction.formData, input.requestId]); + + const buildPayload = ( + action: McpAskRespondPayload['action'], + formData?: Record, + files?: McpAskRespondPayload['files'], + ): McpAskRespondPayload => ({ + interventionId: input.requestId, + toolCallId, + revision: input.revision, + source: 'mcp_ask', + protocol: 'mcp', + action, + formData, + files, + answeredAt: Date.now(), + answeredBy: { kind: 'web' }, + }); + + const validateStepFields = async (stepIndex: number) => { + const step = steps[stepIndex]; + if (!step?.fields?.length) { + return; + } + await form.validateFields(step.fields); + }; + + const handleNext = async () => { + await validateStepFields(currentStep); + setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1)); + }; + + const handlePrev = () => { + setCurrentStep((prev) => Math.max(prev - 1, 0)); + }; + + const handleSubmit = async () => { + if (isWizard) { + for (let i = 0; i < steps.length; i += 1) { + await validateStepFields(i); + } + } else { + await form.validateFields(); + } + const rawValues = form.getFieldsValue(true); + const files = extractMcpAskFormAttachments(rawValues, ui); + const values = normalizeMcpAskFormData(rawValues, ui); + onRespond?.(buildPayload('submit', values, files)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (disabled) return; + if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) { + if ( + e.target instanceof HTMLElement && + (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON') + ) { + return; + } + e.preventDefault(); + void handleSubmit(); + } + }; + + // 卡片弹出后自动获取焦点 + useEffect(() => { + if (!disabled) { + cardRef.current?.focus(); + } + }, [disabled]); + + // 保持焦点始终在卡片上,防止失焦导致快捷键失效 + const handleBlur = (e: React.FocusEvent) => { + if ( + !disabled && + cardRef.current && + !cardRef.current.contains(e.relatedTarget) + ) { + e.preventDefault(); + cardRef.current.focus(); + } + }; + + const handleCancel = useCallback(() => { + onRespond?.({ + interventionId: input.requestId, + toolCallId, + revision: input.revision, + source: 'mcp_ask', + protocol: 'mcp', + action: 'cancel', + answeredAt: Date.now(), + answeredBy: { kind: 'web' }, + }); + }, [onRespond, input.requestId, input.revision, toolCallId]); + + useInterventionEscapeKey({ + enabled: !disabled && keyboardShortcutsEnabled, + onEscape: handleCancel, + respectFormFieldFocus: false, + }); + + const handleSkip = () => { + onRespond?.(buildPayload('skip')); + }; + + const stepItems = steps.map((step) => ({ + title: step.title, + })); + + const renderStatusTag = () => { + if (isSubmitted) { + return ( + + {t('PC.Components.McpAskQuestionCard.submitted')} + + ); + } + if (isSkipped) { + return ( + + {t('PC.Components.McpAskQuestionCard.skipped')} + + ); + } + if (isCancelled) { + return ( + + {t('PC.Components.McpAskQuestionCard.cancelled')} + + ); + } + return null; + }; + + return ( +
+
+
+ +
+
+ + {t('PC.Components.McpAskQuestionCard.eyebrow')} + + + {title} + + {description ? ( + + {description} + + ) : null} +
+ {renderStatusTag()} +
+ + {isWizard && steps.length > 1 ? ( +
+ + {activeStep?.description ? ( +
+ + {activeStep.description} + +
+ ) : null} +
+ ) : null} + +
+
+ {visibleFields.map((field) => ( + + ))} + +
+ + {!isSubmitted && !isCancelled && !isSkipped ? ( +
+ {isWizard && steps.length > 1 ? ( + + {t( + 'PC.Components.McpAskQuestionCard.stepOf', + String(currentStep + 1), + String(steps.length), + )} + + ) : ( + + )} +
+ {isWizard && currentStep > 0 ? ( + + ) : null} + + {allowSkip ? ( + + ) : null} + {isWizard && !isLastStep ? ( + + ) : ( + + )} +
+
+ ) : null} + + {isFailed && interaction.errorMessage ? ( + + {interaction.errorMessage} + + ) : null} +
+ ); +}; + +export default McpAskQuestionCard; diff --git a/qiming/src/components/business-component/AgentIntervention/README.md b/qiming/src/components/business-component/AgentIntervention/README.md new file mode 100644 index 00000000..c421081b --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/README.md @@ -0,0 +1,563 @@ +# AgentIntervention + +AI Agent 运行时干预模块,支持两种暂停-恢复流程:**ACP 权限审批** 和 **MCP Ask 结构化提问**。 + +## 示例页面 + +本地开发时可通过以下地址预览干预卡片 UI(Mock 数据,免登录,仅用于开发预览): + +| 说明 | 路由 | 本地地址(默认端口 8000) | +| --- | --- | --- | +| 干预卡片 UI Demo(ACP + MCP Ask) | `/examples/agent-intervention-demo` | http://localhost:8000/examples/agent-intervention-demo | +| 全部示例索引 | `/examples` | http://localhost:8000/examples | + +示例源码:`src/examples/AgentInterventionDemo/`(`index.tsx` + `mockData.ts`)。Mock 使用模块内 **camelCase** 字段;真实 SSE 入参多为 **snake_case**,由 `applyAcpPermissionSseEvent` / `parseMcpAskToolInput` 归一化。 + +## 模块结构 + +``` +AgentIntervention/ + index.ts + AgentInterventionChatLayer/ # 对外入口(DockPanel.tsx 堆叠渲染卡片) + AcpPermissionCard/ # 业务卡片;示例页直连 + McpAskQuestionCard/ + hooks/ + types/ + utils/ +``` + +## 对外导出(`index.ts`) + +| 导出 | 说明 | +| --- | --- | +| `AgentInterventionChatLayer` | 聊天页干预层(队列 + 停靠卡片) | +| `useAgentInterventionLayer` | 页面接入(含 `agentMode`、Chat 层 props) | +| `useAgentInterventionHandlers` | model 内 ACP/MCP 响应(写 `messageList`) | +| `processInterventionSsePatch` | SSE 拦截,挂载 interaction 到当前消息 | +| `hydrateMcpAskInteractionsInMessageList` | 历史消息 MCP Ask 状态重建 | +| 类型 | `AcpPermissionInteraction`、`McpAskInteraction`、`AgentMode` 等 | + +卡片组件(`AcpPermissionCard`、`McpAskQuestionCard`)**未**从 barrel 导出;示例页或调试可 deep import。 + +## 已接入位置 + +| 文件 | 职责 | +| --- | --- | +| `src/models/conversationInfo.ts` | 主会话:`processInterventionSsePatch`、`hydrateMcpAskInteractionsInMessageList`、`useAgentInterventionHandlers` | +| `src/models/conversationAgent.ts` | **ConversationAgent 预览 Tab** 隔离会话:同上三项(与 `conversationInfo` 对齐) | +| `src/pages/Chat/index.tsx` | `useAgentInterventionLayer` + `AgentInterventionChatLayer` | +| `src/pages/EditAgent/PreviewAndDebug/index.tsx` | 同上(编排预览调试) | +| `src/pages/ConversationAgent/` | 左侧主聊天气泡区(`conversationInfo`)+ 右侧「预览」Tab(`conversationAgent` + `interventionHandlers` 注入) | +| `src/components/business-component/UnifiedChatSession` | 统一挂载 `AgentInterventionChatLayer`(DockPanel) | +| `src/services/agentConfig.ts` | `apiAgentInterventionRespond`(ACP 回执 HTTP) | +| `src/types/interfaces/conversationInfo.ts` | `MessageInfo.acpPermissionInteractions` / `mcpAskInteractions` | + +## 快速接入 + +```tsx +import { + AgentInterventionChatLayer, + useAgentInterventionLayer, +} from '@/components/business-component/AgentIntervention'; + +const { agentMode, chatLayerProps, agentModeInputProps } = useAgentInterventionLayer({ + conversationId: id, + messageList, + onSendMessage: handleMessageSend, +}); + + + +``` + +发送消息时在参数中带上 `agentMode: 'ask' | 'yolo'`(见 `AgentMode`)。 + +**model 侧必须同时完成:** + +1. SSE 循环中优先调用 `processInterventionSsePatch`,命中则替换当前消息并 `return`。 +2. 拉取历史消息后调用 `hydrateMcpAskInteractionsInMessageList`。 + +`conversationInfo` 与 `conversationAgent` 均已接入上述逻辑。 + +**ConversationAgent 预览 Tab(`conversationAgent` model)额外要求:** + +向 `UnifiedChatSession` 传入 `interventionHandlers`(由 `useConversationAgentChatSession` 组装),避免 Dock 回执误写入全局 `conversationInfo` 的 `messageList`: + +```tsx + +``` + +`interventionHandlers` 类型:`AgentInterventionHandlersOverride`(`respondAcpPermission` / `respondMcpAsk`)。 + +## `MessageInfo` 挂载字段 + +归一化后挂在**当前流式消息**上(与 `processingList` 同级): + +```typescript +interface MessageInfo { + acpPermissionInteractions?: AcpPermissionInteraction[]; + mcpAskInteractions?: McpAskInteraction[]; +} +``` + +### `AcpPermissionInteraction`(内存模型,camelCase) + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `intervention` | `AcpPermissionInterventionRequest` | 审批元数据 + `acp.request` | +| `responseStatus` | `'pending' \| 'submitting' \| 'submitted' \| 'failed'` | 前端响应状态 | +| `selectedOptionId` | `string?` | 用户选中的 `optionId` | +| `errorMessage` | `string?` | 回执失败文案 | +| `triggeredAt` | `number?` | 入队时间戳(`createInterventionTriggeredAt`) | + +`intervention` 主要字段: + +| 字段 | 说明 | +| --- | --- | +| `id` | 干预 ID;无后端 `_intervention` 时为 `itv_{sessionId}_{toolCallId}` | +| `revision` | 修订号;可从 `_meta.qimingclaw_revision` 补齐 | +| `kind` | 固定 `'approval'` | +| `status` | 固定 `'pending'` | +| `sessionId` | 会话 ID | +| `source` | 固定 `'acp_permission'` | +| `engine` | `'claude-code' \| 'nuwaxcode' \| 'codex'`(SSE 若带 `codex-cli` 等别名会规范为 `codex`) | +| `protocol` | 固定 `'acp'` | +| `callbackTarget` | `{ kind: 'electron' \| 'rcoder', targetId: string }` | +| `schemaRef` | 模式引用字符串 | +| `acp.method` | 固定 `'session/request_permission'` | +| `acp.request.sessionId` | 同 `sessionId` | +| `acp.request.toolCall` | `toolCallId`, `title`, `kind`, `rawInput`, `status` 等 | +| `acp.request.options[]` | `{ optionId, kind, name }`,`kind` 见 `AcpPermissionOptionKind` | +| `createdAt` | 时间戳;PROCESSING 事件可取 `result.startTime` | + +`AcpPermissionOptionKind`:`allow_once` | `allow_always` | `reject_once` | `reject_always`。卡片 UI **隐藏** `reject_always`。 + +### `McpAskInteraction`(内存模型,camelCase) + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `input` | `McpAskUserToolInput` | 工具 rawInput 归一化结果 | +| `toolCallId` | `string` | 与 SSE `tool_call_id` / `executeId` 对齐 | +| `responseStatus` | `'pending' \| 'submitting' \| 'submitted' \| 'cancelled' \| 'skipped' \| 'failed'` | | +| `formData` | `Record?` | 用户提交值 | +| `errorMessage` | `string?` | | +| `triggeredAt` | `number?` | | + +`McpAskUserToolInput` 必填(`parseMcpAskToolInput`): + +| 字段 | 约束 | +| --- | --- | +| `schemaVersion` | `qimingclaw.mcp_ask.v1` 或别名 `nuwax.mcp_ask.v1` | +| `toolName` | `nuwax_ask_question`(缺省同值;其它工具名拒绝) | +| `requestId` | 去重键;**同时作为** `McpAskRespondPayload.interventionId` | +| `revision` | 数字 | +| `sessionId` | 字符串 | +| `title` | 字符串 | +| `description` | 可选 | +| `ui.version` | `qimingclaw.interaction.v1` 或 `nuwax.interaction.v1` | +| `ui.presentation` | 类型含 `modal \| inline \| wizard \| table`;**实际 UI** 仅区分 wizard / 非 wizard | +| `ui.schema` | JSON Schema 对象 | +| `ui.uiSchema` | 可选,含 `ui:widget`、`ui:options` | +| `ui.steps` | wizard 步骤 | +| `timeoutMs` | 可选 | + +## SSE 识别与归一化 + +`parseSseEventEnvelope` 兼容多层包裹:`res.data.{messageType,subType,data}` 或扁平字段(含 `message_type` / `sub_type`)。 + +### ACP:何时命中 `applyAcpPermissionSseEvent` + +满足其一即可: + +- `res.eventType === 'ACP_REQUEST_PERMISSION'` +- `message_type` / `messageType === 'acpRequestPermission'` 且 `sub_type` / `subType` 为 `request_permission` 或 `AcpRequestPermission` +- `res.eventType === 'PROCESSING'` 且(`subEventType === 'REQUEST_PERMISSION'` 或 `result.input.request_permission_request` 存在) + +从 `eventData` 读取(snake_case 优先): + +- `request_permission_request`(或 PROCESSING `result.input.request_permission_request`) +- `tool_call_id`、`session_id` +- 可选 `_intervention` / `interventionRequest`(完整 camelCase 干预体) +- 可选 `_meta.qimingclaw_intervention_id`、`qimingclaw_revision` + +**入参示例(snake_case,与单测一致):** + +```json +{ + "message_type": "acpRequestPermission", + "sub_type": "request_permission", + "data": { + "request_permission_request": { + "session_id": "session-snake", + "tool_call": { + "tool_call_id": "tool-call-snake", + "title": "Write file", + "kind": "edit", + "status": "pending", + "raw_input": { "command": "touch approval-test.txt" } + }, + "options": [ + { + "option_id": "allow-once", + "kind": "allow_once", + "name": "Allow once" + }, + { "option_id": "reject-once", "kind": "reject_once", "name": "Reject" } + ] + }, + "tool_call_id": "tool-call-snake" + } +} +``` + +同一 `intervention.id` 不会重复挂载。 + +### MCP Ask:何时命中 `applyMcpAskToolCallSseEvent` + +- `message_type` / `messageType` 为 `tool_call` 或 `sub_type` / `subType` 为 `tool_call` / `tool_call_update`,且存在 `tool_call_id` / `toolCallId` / `raw_input` / `rawInput`(或 `ext` 内 rawInput) +- `messageType === 'agentSessionUpdate'` + `tool_call` 子类型(同上) +- `res.eventType === 'PROCESSING'` 且存在 `executeId` 或 `result.executeId` / `result.input` +- 后端沙箱 **`Backend.Sandbox.Event.AskQuestion`**:`subEventType === 'ASK_QUESTION'`,表单在 `data.result.input`(与 ToolCall 形态等价,由 `isProcessingToolCallEvent` 识别) + +`raw_input` / `rawInput` / `result.input` 经 `parseMcpAskToolInput` 校验通过后写入 `mcpAskInteractions`。同一 `input.requestId` 不重复挂载。 + +**入参示例(ASK_QUESTION Event,与 demo2.json 对齐):** + +```json +{ + "eventType": "PROCESSING", + "data": { + "name": "Backend.Sandbox.Event.AskQuestion", + "type": "Event", + "status": "FINISHED", + "subEventType": "ASK_QUESTION", + "result": { + "executeId": "call_272edddbb5e140128d146826", + "input": { + "requestId": "demo_form_1", + "ui": { + "version": "nuwax.interaction.v2", + "presentation": "inline", + "fields": [{ "name": "choice", "title": "选项", "widget": "text" }] + } + } + } + } +} +``` + +**入参示例(PROCESSING + executeId):** + +```json +{ + "eventType": "PROCESSING", + "data": { + "executeId": "tool-call-1", + "result": { + "executeId": "tool-call-1", + "input": { + "toolName": "nuwax_ask_question", + "schemaVersion": "qimingclaw.mcp_ask.v1", + "requestId": "ask-1", + "revision": 1, + "sessionId": "session-1", + "title": "Need input", + "ui": { + "version": "qimingclaw.interaction.v1", + "presentation": "inline", + "title": "Need input", + "schema": { "type": "object", "properties": {} } + } + } + } + } +} +``` + +**入参示例(agentSessionUpdate / snake_case):** + +```json +{ + "messageType": "agentSessionUpdate", + "subType": "tool_call", + "data": { + "tool_call_id": "tool-call-3", + "raw_input": { + "schemaVersion": "qimingclaw.mcp_ask.v1", + "requestId": "ask-3", + "revision": 1, + "sessionId": "session-1", + "title": "请选择继续方式", + "ui": { + "version": "qimingclaw.interaction.v1", + "presentation": "inline", + "title": "请选择继续方式", + "schema": { + "type": "object", + "properties": { + "choice": { + "type": "string", + "enum": ["deploy", "test"], + "title": "选项" + } + }, + "required": ["choice"] + }, + "uiSchema": { + "choice": { + "ui:widget": "radio", + "ui:options": { "enumNames": ["部署", "测试"] } + } + } + } + } + } +} +``` + +## 用户操作与回执 + +### ACP 卡片 → `AcpRequestPermissionResponse` → HTTP + +卡片 `onRespond` 传出(camelCase): + +```typescript +// 选中某项 +{ outcome: { outcome: 'selected', optionId: 'allow-once' } } +// 取消(卡片取消按钮,非 Esc 伪造 Cancelled) +{ outcome: { outcome: 'cancelled' } } +``` + +`useAgentInterventionHandlers.respondAcpPermission` 组装 `AgentInterventionRespondRequest` 后调用 **`apiAgentInterventionRespond`**。 + +**实际请求(`src/services/agentConfig.ts`):** + +``` +POST /api/agent/conversation/chat/permission-request/response +``` + +```json +{ + "conversationId": 43, + "toolId": "tool-call-snake", + "option": { + "optionId": "allow-once", + "outcome": "selected" + } +} +``` + +取消时 `outcome` 为 `"cancelled"`,`optionId` 回退为 `"reject"`(见 service 内 fallback)。 + +类型层 `permission_resolve_request` 仍保留 QimingClaw 形状(`Selected.option_id` / `Cancelled`),由 service 映射为上述 body。 + +### MCP Ask → 普通聊天消息(无单独干预 API) + +`McpAskQuestionCard` 构建的 `McpAskRespondPayload`: + +| 字段 | 来源 | +| --- | --- | +| `interventionId` | **`input.requestId`**(非后端 intervention 表 ID) | +| `toolCallId` | interaction.`toolCallId` | +| `revision` | `input.revision` | +| `source` | `'mcp_ask'` | +| `protocol` | `'mcp'` | +| `action` | `'submit' \| 'cancel' \| 'skip' \| 'timeout'` | +| `formData` | 提交时表单值;`file` 字段为统一上传服务返回的 `https://` URL(见 `normalizeMcpAskFormData`) | +| `answeredBy` | `{ kind: 'web' }` | +| `answeredAt` | `Date.now()` | + +`respondMcpAsk` 更新 `mcpAskInteractions` 状态后,返回 `buildMcpAskResumeMessage` 字符串,由 `useAgentInterventionLayer` 调用 `onSendMessage` 发回会话。 + +**resume 文案规则(`mcpAskResumeMessage.ts`):** + +```text +我已填写「{title}」,表单内容如下: + +{字段 title}:{展示值} +``` + +- `cancel` → `我取消了「{title}」。` +- `skip` → `我跳过了「{title}」。` +- `timeout` → `「{title}」已超时,没有收到表单答案。` +- 枚举展示优先 `uiSchema[field].ui:options.enumNames`;布尔为 `是/否`;空为 `未填写`。 + +### `uiSchema` 已落地 widget + +| `ui:widget` | 渲染 | +| --- | --- | +| `radio` | Radio.Group | +| `checkboxes` | Checkbox.Group(含 items.enum) | +| `select` | Select | +| `text` | Input | +| `textarea` | Input.TextArea | +| `number` | InputNumber(`minimum` / `maximum` / `multipleOf`) | +| `radio-with-custom` | Radio + 自定义输入(`otherValue` / `otherField`) | +| `list` | 纵向单选列表 | +| `file` | 上传(`accept`、`multiple`);走 `/api/file/upload`,**提交值为远程 URL**(单文件 `string`,多文件 `string[]`) | + +未写 `ui:widget` 时按 schema 推断(如 `enum` → `radio`,`items.enum` → `checkboxes`,`number`/`integer` → `number`)。 + +### `presentation` 实际行为 + +| 条件 | UI | +| --- | --- | +| `presentation === 'wizard'` 或 `steps.length > 1` | 分步 Steps + 上一步/下一步 | +| 其他(含 `inline`、`modal`、`table`) | 单页表单 | + +根级 `uiSchema['ui:options'].allowSkip === true` 时显示跳过;文案来自 `ui.skipLabel` 或字段 `skipLabel`。 + +## 核心数据结构 + +干预数据挂载在 `MessageInfo` 上: + +```typescript +interface MessageInfo { + acpPermissionInteractions?: AcpPermissionInteraction[]; + mcpAskInteractions?: McpAskInteraction[]; + processingList?: ProcessingInfo[]; // 累积 PROCESSING 事件(含 Plan/ToolCall,不含 ASK_QUESTION/REQUEST_PERMISSION) +} +``` + +## 数据写入路径 + +### 实时流(live SSE) + +``` +SSE stream → processInterventionSsePatch(chunk, currentMessage) + ├─ isAcpPermissionEvent? → applyAcpPermissionSseEvent → message.acpPermissionInteractions.push + ├─ isMcpAskToolCallEvent? → applyMcpAskToolCallSseEvent → message.mcpAskInteractions.push + └─ else → processingList.push / text 累积 ... +``` + +ACP 和 MCP Ask 事件被优先拦截,**不会进入** `processingList`。 + +### 消息恢复(sub stream) + +刷新页面 / 重进会话且任务仍 EXECUTING 时,由 `useConversationStreamResume` 轮询 `taskStatus` 检测后调用 `resumeConversationStream`: + +``` +resumeConversationStream(conversationId, currentList) + 1. 可选 reloadHistoryAsync → 更新 messageList(含 resume 消息) + 2. 追加占位 assistant message(id=currentMessageId, status=Loading) + 3. GET /api/agent/conversation/chat/sub/:id + → processInterventionSsePatch(chunk, currentMessage) ← 与 live 复用 +``` + +sub 流重放历史 SSE 时,已有 resume 消息(步骤 1 加载),`hasMcpAskResumeMessage` 可过滤已回答的 MCP Ask,避免重复弹出。 + +### 历史消息 hydrate + +``` +拉取历史列表后:hydrateMcpAskInteractionsInMessageList(messageList) + → 遍历所有 assistant 消息,提取 mcpAskInteractions + → 在后续消息中搜索 resume 文本,有则置 responseStatus = 'submitted' / 'cancelled' 等 +``` + +ACP 权限审批**不从历史 hydrate**,仅存在于实时流/sub 流消息中。 + +## DockPanel 显隐规则(`useActiveInterventionQueue` + `DockPanel`) + +`useActiveInterventionQueue` 从 **整个 `messageList`** 收集 `responseStatus` 为 `pending` / `submitting` 的干预项,按 `triggeredAt` / `createdAt` **升序(FIFO)** 排序后送入 `DockPanel`。`DockPanel` 堆叠渲染,**仅 front(最早一项)可交互**,其余 `aria-hidden`;用户逐项审批并调用 `permission-request/response`(→ QimingClaw `notify-resolved`)。 + +### 队列收集(`useActiveInterventionQueue`) + +- **跨消息**:不限于最新一条消息;并行工具可在多条 assistant 消息上各挂审批,统一入队。 +- **ACP**:`pending` / `submitting` 进队;`submitted` / `failed` 不进队(`failed` 不阻塞后续审批)。 +- **MCP Ask 抑制 ACP**(双路):pending ACP 的 `toolCallId` 或 `rawInput.requestId` 命中时,对应 MCP Ask 不进队。 +- **MCP Ask**:另需 `!hasMcpAskResumeMessage`;**不受**消息 `Complete` 态影响。 + +### sub 恢复 / 跨页签:ACP 已审批判定(`reconcileAcpPermissionStatus`) + +ACP **不从历史 hydrate**;sub 流重放会把 `request_permission` 再次写入为 `pending`。每次 `processInterventionSsePatch` 与 `handleChangeMessageList` 更新后调用 `reconcileAcpPermissionStatusesInMessageList`: + +| 条件 | 动作 | +| --- | --- | +| 同 `toolCallId` / `executeId` 在 `processingList` 中已为 `FINISHED` | `responseStatus` → `submitted` | +| 关联 MCP Ask 已有 resume 消息(`hasMcpAskResumeMessage`) | `submitted` | +| 用户本端审批 API 成功 | `submitted`(`useAgentInterventionHandlers`) | +| API 返回 permission not found / already resolved | 幂等视为 `submitted` | + +### ACP 权限审批关闭时机 + +| 原因 | 机制 | +| ----------------- | ------------------------------------------------------- | +| 用户审批 | `responseStatus` → `submitted` | +| sub 重放 / 跨页签 | `reconcileAcpPermissionStatuses` 根据 processing 完成态 | +| API 幂等 | Host pending 已不存在 → `submitted` | + +### MCP Ask 关闭时机 + +| 原因 | 机制 | +| ------------ | ------------------------------------------------------------ | +| 本端用户提交 | `dismissedMcpAskRequestIds` 或 `responseStatus` 更新 | +| 跨端感知 | `hasMcpAskResumeMessage` | +| Agent 推进 | 由 `responseStatus` 独立控制(非 ACP 的 executeId 过期逻辑) | + +### 规则速查 + +``` +队列顺序 = sortKey 升序(FIFO),DockPanel front = items[0] + +ACP 进队 = pending/submitting(failed 不进队) + +MCP 进队 = pending/submitting + && !hasMcpAskResumeMessage + && toolCallId ∉ permissionPendingToolCallIds + && requestId ∉ permissionPendingAskRequestIds +``` + +### Resume 消息签名(`hasMcpAskResumeMessage` 匹配依据) + +`collectResumeMessageSignatures` 从 5 个语言包(ZH / ZH-TW / ZH-HK / EN / JA)提取模板格式化签名 + legacy 硬编码片段,用 `text.includes(signature)` 匹配: + +| 操作 | 中文签名示例 | +| ------- | --------------------------------------- | +| submit | `我已填写「{title}」,表单内容如下:` | +| cancel | `我取消了「{title}」。` | +| skip | `我跳过了「{title}」。` | +| timeout | `「{title}」已超时,没有收到表单答案。` | + +**变更风险**:修改 `buildMcpAskResumeMessage` 格式或 i18n key 时,须确保新格式前缀仍能被 `collectResumeMessageSignatures` 收集,否则历史 resume 消息无法识别,已回答的卡片会重新出现。 + +### 与 Mobile 的对应关系 + +| 机制 | PC(此文件) | Mobile(`mcpAskInterventionState.uts`) | +| --- | --- | --- | +| 过期检查 | `isExpired(executeId)` vs `focusExecuteId` | `processingList.length > processingListLengthAtAdd` | +| 本端提交关闭 | `dismissedMcpAskRequestIds` Set,API 失败可回滚 | `removeMcpAskInteractionFromMessageList` 直接移除 | +| 跨端感知关闭 | `hasMcpAskResumeMessage` | 同左 | +| 历史状态恢复 | `hydrateMcpAskInteractionsInMessageList` | `applyMcpAskResumeStatusesInMessageList` | +| ACP 抑制 MCP Ask | `permissionPendingToolCallIds` + `permissionPendingAskRequestIds` | 同左 | +| 卡片渲染 | `DockPanel` FIFO 堆叠,最早在前 | ACP + MCP Ask 分列 `v-for`(待 mobile 同步) | +| MCP Ask `file` 提交 | `normalizeMcpAskFormData` 转为 URL 后写入 `formData` | Mobile 侧 `mcpAskInterventionState.uts` 需同样在上传完成后提交 URL | +| ACP sub 恢复 | `reconcileAcpPermissionStatuses` | 待 mobile 同步 | + +## 数据流 + +``` +SSE (ConversationChatResponse) + → processInterventionSsePatch (+ reconcileAcpPermissionStatuses) + → applyAcpPermissionSseEvent → message.acpPermissionInteractions[] + → applyMcpAskToolCallSseEvent → message.mcpAskInteractions[] + → useActiveInterventionQueue(FIFO;pending/submitting 进队) + → AgentInterventionChatLayer → DockPanel(front 可点,逐个 notify-resolved) + → AcpPermissionCard / McpAskQuestionCard + → respondAcpPermission → POST .../permission-request/response + → respondMcpAsk → buildMcpAskResumeMessage → onSendMessage +``` + +历史加载:`hydrateMcpAskInteractionsInMessageList` 根据已有用户消息推断 MCP 是否已回复。 + +## 测试 + +关键单测与上述字段对齐: + +- `utils/applyAcpPermissionSseEvent.test.ts` — ACP snake_case / PROCESSING / `_meta` +- `utils/applyMcpAskToolCallSseEvent.test.ts` — MCP tool_call / PROCESSING +- `utils/parseMcpAskSchema.test.ts` — widget 解析 +- `utils/reconcileAcpPermissionStatus.test.ts` — ACP sub 恢复 reconcile / API 幂等 +- `hooks/useAgentInterventionHandlers.test.ts` — ACP 回执与 MCP resume diff --git a/qiming/src/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue.test.ts b/qiming/src/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue.test.ts new file mode 100644 index 00000000..8b116240 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue.test.ts @@ -0,0 +1,541 @@ +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { AcpPermissionInteraction } from '../types/acpIntervention'; +import type { McpAskInteraction } from '../types/mcpAskIntervention'; +import { buildMcpAskRequestIdMarker } from '../utils/mcpAskResumeMessage'; +import { useActiveInterventionQueue } from './useActiveInterventionQueue'; + +function createAskInteraction( + overrides: Partial = {}, +): McpAskInteraction { + return { + input: { + toolName: 'nuwax_ask_question', + schemaVersion: 'nuwax.mcp_ask.v2', + requestId: 'ask-1', + revision: 1, + sessionId: 'session-1', + title: '毕业论文 PPT 前置信息收集', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'wizard', + title: '毕业论文 PPT 前置信息收集', + fields: [], + }, + }, + toolCallId: 'call-ask-1', + responseStatus: 'pending', + ...overrides, + }; +} + +function createAcpPermissionInteraction( + status: AcpPermissionInteraction['responseStatus'] = 'pending', + rawInput: Record = { + schemaVersion: 'nuwax.mcp_ask.v1', + requestId: 'ask-1', + }, +): AcpPermissionInteraction { + return { + intervention: { + id: 'itv-ask-permission', + revision: 1, + kind: 'approval', + status: 'pending', + sessionId: 'session-1', + source: 'acp_permission', + engine: 'nuwaxcode', + protocol: 'acp', + callbackTarget: { kind: 'electron', targetId: '' }, + schemaRef: '', + acp: { + method: 'session/request_permission', + request: { + sessionId: 'session-1', + toolCall: { + toolCallId: 'call-ask-1', + title: 'mcp__ask-question__nuwax_ask_question', + kind: 'other', + status: 'pending', + rawInput, + }, + options: [ + { + optionId: 'allow', + kind: 'allow_once', + name: 'Allow', + }, + ], + }, + }, + createdAt: 100, + }, + responseStatus: status, + }; +} + +describe('useActiveInterventionQueue', () => { + it('does not show an ask/question interaction after its resume message exists', () => { + const askInteraction = createAskInteraction(); + const messageList = [ + { + id: 'assistant-ask', + index: 1, + mcpAskInteractions: [askInteraction], + }, + { + id: 'user-resume', + index: 2, + text: '我已填写「毕业论文 PPT 前置信息收集」,表单内容如下:\n\n```json\n{}\n```', + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(0); + }); + + it('filters resolved ask/question interactions even when backend indexes are out of order', () => { + const askInteraction = createAskInteraction(); + const messageList = [ + { + id: 'user-resume', + index: 1, + text: '我已填写「毕业论文 PPT 前置信息收集」,表单内容如下:\n\n```json\n{}\n```', + }, + { + id: 'assistant-ask', + index: 2, + mcpAskInteractions: [askInteraction], + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(0); + }); + + it('keeps an unanswered ask/question interaction active', () => { + const messageList = [ + { + id: 'assistant-ask', + index: 1, + mcpAskInteractions: [createAskInteraction()], + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].kind).toBe('mcp_ask'); + }); + + it('prefers the latest message that still has an active intervention', () => { + const permission = createAcpPermissionInteraction('pending', { + filePath: '/tmp/a.txt', + }); + permission.executeId = 'exec-write-1'; + const messageList = [ + { + id: 'assistant-with-approval', + index: 1, + status: 'loading', + acpPermissionInteractions: [permission], + }, + { + id: 'later-message-without-approval', + index: 2, + status: 'complete', + text: 'later message', + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].kind).toBe('acp_permission'); + }); + + it('hides ask/question while matching ACP permission is pending', () => { + const messageList = [ + { + id: 'assistant-ask', + index: 1, + mcpAskInteractions: [createAskInteraction()], + acpPermissionInteractions: [createAcpPermissionInteraction('pending')], + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].kind).toBe('acp_permission'); + }); + + it('shows ask/question after matching ACP permission is submitted', () => { + const messageList = [ + { + id: 'assistant-ask', + index: 1, + mcpAskInteractions: [createAskInteraction()], + acpPermissionInteractions: [ + createAcpPermissionInteraction('submitted'), + ], + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].kind).toBe('mcp_ask'); + }); + + it('drops a failed ACP permission so later approvals can proceed', () => { + const failed = createAcpPermissionInteraction('failed'); + failed.intervention.id = 'itv-failed'; + failed.intervention.acp.request.toolCall.toolCallId = 'call-failed'; + + const pending = createAcpPermissionInteraction('pending'); + pending.intervention.id = 'itv-pending'; + pending.intervention.createdAt = 200; + pending.intervention.acp.request.toolCall.toolCallId = 'call-pending'; + + const messageList = [ + { + id: 'assistant', + index: 1, + acpPermissionInteractions: [failed, pending], + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + // The failed approval must leave the queue; only the later pending one stays. + expect(result.current).toHaveLength(1); + expect((result.current[0] as any).interaction.intervention.id).toBe( + 'itv-pending', + ); + }); + + it('keeps ask/question visible when pending ACP permission is unrelated', () => { + const unrelatedPermission = createAcpPermissionInteraction('pending', { + command: 'ls', + }); + unrelatedPermission.intervention.acp.request.toolCall.toolCallId = + 'call-bash-1'; + + const messageList = [ + { + id: 'assistant-ask', + index: 1, + mcpAskInteractions: [createAskInteraction()], + acpPermissionInteractions: [unrelatedPermission], + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(2); + expect(result.current.map((item) => item.kind)).toEqual([ + 'acp_permission', + 'mcp_ask', + ]); + }); + + it('keeps the approval whose executeId matches the latest processing focus', () => { + // 当前焦点 executeId = 最新 processingList 末尾的 executeId;审批不再受此影响,始终渲染 + const interaction = createAcpPermissionInteraction('pending'); + interaction.executeId = 'exec-1'; + const messageList = [ + { + id: 'assistant', + index: 1, + acpPermissionInteractions: [interaction], + processingList: [{ executeId: 'exec-1' }] as any, + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + }); + + it('keeps the approval card even when its executeId is no longer the focus', () => { + // 最新 processing 已推进到另一个 executeId → 仍保留弹窗队列,不再按 executeId 精确关闭 + const interaction = createAcpPermissionInteraction('pending'); + interaction.executeId = 'exec-old'; + const messageList = [ + { + id: 'assistant', + index: 1, + acpPermissionInteractions: [interaction], + processingList: [ + { executeId: 'exec-old' }, + { executeId: 'exec-new' }, + ] as any, + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + }); + + it('keeps a fresh approval before its tool processing block arrives', () => { + const interaction = createAcpPermissionInteraction('pending'); + interaction.executeId = 'exec-edit'; + const messageList = [ + { + id: 'assistant', + index: 1, + acpPermissionInteractions: [interaction], + processingList: [{ executeId: 'exec-previous' }] as any, + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].kind).toBe('acp_permission'); + if (result.current[0].kind === 'acp_permission') { + expect(result.current[0].interaction.executeId).toBe('exec-edit'); + } + }); + + it('keeps an approval with unknown executeId (fallback, no false close)', () => { + // 无 processingList / 审批无 executeId 时无法判定过期 → 保守显示,避免误关 + const messageList = [ + { + id: 'assistant', + index: 1, + acpPermissionInteractions: [createAcpPermissionInteraction('pending')], + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + }); + + it('keeps a pending approval visible even if the host message becomes complete', () => { + const interaction = createAcpPermissionInteraction('pending'); + interaction.executeId = 'exec-approval'; + const messageList = [ + { + id: 'assistant', + index: 1, + status: 'complete', + acpPermissionInteractions: [interaction], + processingList: [{ executeId: 'exec-approval' }] as any, + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].kind).toBe('acp_permission'); + }); + + it('keeps multiple pending approvals instead of hiding earlier ones', () => { + const first = createAcpPermissionInteraction('pending', { + filePath: '/tmp/a.txt', + }); + first.intervention.id = 'itv-first'; + first.intervention.createdAt = 100; + first.intervention.acp.request.toolCall.toolCallId = 'call-first'; + first.executeId = 'exec-first'; + + const second = createAcpPermissionInteraction('pending', { + filePath: '/tmp/b.txt', + }); + second.intervention.id = 'itv-second'; + second.intervention.createdAt = 200; + second.intervention.acp.request.toolCall.toolCallId = 'call-second'; + second.executeId = 'exec-second'; + + const messageList = [ + { + id: 'assistant-1', + index: 1, + status: 'complete', + acpPermissionInteractions: [first], + processingList: [{ executeId: 'exec-first' }] as any, + }, + { + id: 'assistant-2', + index: 2, + status: 'loading', + acpPermissionInteractions: [second], + processingList: [{ executeId: 'exec-second' }] as any, + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(2); + expect( + result.current.map((item) => + item.kind === 'acp_permission' + ? item.interaction.intervention.id + : item.interaction.input.requestId, + ), + ).toEqual(['itv-first', 'itv-second']); + }); + + it('closes the dock after the approval is answered', () => { + // 审批已应答(submitted),不再 pending → DockPanel 关闭 + const messageList = [ + { + id: 'assistant', + index: 1, + acpPermissionInteractions: [ + createAcpPermissionInteraction('submitted'), + ], + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(0); + }); + + it('keeps a second ask active after the first same-title ask was answered', () => { + const sharedTitle = '补充回复'; + const firstAsk = createAskInteraction({ + input: { + ...createAskInteraction().input, + requestId: 'ask-first', + title: sharedTitle, + ui: { + ...createAskInteraction().input.ui, + title: sharedTitle, + }, + }, + responseStatus: 'submitted', + }); + const secondAsk = createAskInteraction({ + input: { + ...createAskInteraction().input, + requestId: 'ask-second', + title: sharedTitle, + ui: { + ...createAskInteraction().input.ui, + title: sharedTitle, + }, + }, + toolCallId: 'call-ask-2', + responseStatus: 'pending', + }); + const resumeText = `我已填写「${sharedTitle}」,表单内容如下:\n选项:test${buildMcpAskRequestIdMarker( + 'ask-first', + )}`; + const messageList = [ + { + id: 'assistant-ask', + index: 1, + mcpAskInteractions: [firstAsk, secondAsk], + }, + { + id: 'user-resume', + index: 2, + text: resumeText, + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].kind).toBe('mcp_ask'); + if (result.current[0].kind === 'mcp_ask') { + expect(result.current[0].interaction.input.requestId).toBe('ask-second'); + } + }); + + it('keeps a later ask active when an earlier assistant message had legacy same-title resume', () => { + const sharedTitle = '补充回复'; + const firstAsk = createAskInteraction({ + input: { + ...createAskInteraction().input, + requestId: 'ask-first', + title: sharedTitle, + ui: { + ...createAskInteraction().input.ui, + title: sharedTitle, + }, + }, + responseStatus: 'submitted', + }); + const secondAsk = createAskInteraction({ + input: { + ...createAskInteraction().input, + requestId: 'ask-second', + title: sharedTitle, + ui: { + ...createAskInteraction().input.ui, + title: sharedTitle, + }, + }, + toolCallId: 'call-ask-2', + responseStatus: 'pending', + }); + const messageList = [ + { + id: 'assistant-ask-1', + index: 2, + mcpAskInteractions: [firstAsk], + }, + { + id: 'user-resume', + index: 3, + text: `我已填写「${sharedTitle}」,表单内容如下:\n选项:test`, + }, + { + id: 'assistant-ask-2', + index: 4, + mcpAskInteractions: [secondAsk], + }, + ] as unknown as MessageInfo[]; + + const { result } = renderHook(() => + useActiveInterventionQueue(messageList), + ); + + expect(result.current).toHaveLength(1); + if (result.current[0].kind === 'mcp_ask') { + expect(result.current[0].interaction.input.requestId).toBe('ask-second'); + } + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue.ts b/qiming/src/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue.ts new file mode 100644 index 00000000..cee19c7a --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue.ts @@ -0,0 +1,139 @@ +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { useMemo } from 'react'; +import type { AcpPermissionInteraction } from '../types/acpIntervention'; +import type { McpAskInteraction } from '../types/mcpAskIntervention'; +import { + hasMcpAskResumeMessage, + sortMessagesByConversationIndex, +} from '../utils/mcpAskResumeMessage'; + +export type InterventionQueueKind = 'acp_permission' | 'mcp_ask'; + +export interface AcpInterventionQueueItem { + kind: 'acp_permission'; + messageId: string; + messageIndex: number; + interaction: AcpPermissionInteraction; + sortKey: number; +} + +export interface McpAskInterventionQueueItem { + kind: 'mcp_ask'; + messageId: string; + messageIndex: number; + interaction: McpAskInteraction; + sortKey: number; +} + +export type InterventionQueueItem = + | AcpInterventionQueueItem + | McpAskInterventionQueueItem; + +function isActiveResponseStatus(status: string | undefined): boolean { + const value = status ?? 'pending'; + // `failed` is terminal: once the user has responded, the card closes + // regardless of whether the API call succeeded, so a failed approval no + // longer pins the front of the dock and blocks later approvals. The error + // itself is still surfaced via toast in respondAcpPermission. + return value === 'pending' || value === 'submitting'; +} + +function readMcpAskRequestId(rawInput: unknown): string | undefined { + if (!rawInput || typeof rawInput !== 'object') { + return undefined; + } + const input = rawInput as Record; + return typeof input.requestId === 'string' && input.requestId + ? input.requestId + : undefined; +} + +function getAcpPermissionInteractions( + message: MessageInfo, +): AcpPermissionInteraction[] { + return (message.acpPermissionInteractions ?? + []) as unknown as AcpPermissionInteraction[]; +} + +export function useActiveInterventionQueue( + messageList: MessageInfo[], +): InterventionQueueItem[] { + return useMemo(() => { + const pendingMap = new Map(); + const permissionPendingToolCallIds = new Set(); + const permissionPendingAskRequestIds = new Set(); + let fallbackSeq = 0; + + const messages = sortMessagesByConversationIndex(messageList ?? []); + + messages.forEach((message) => { + getAcpPermissionInteractions(message).forEach((interaction) => { + if (!isActiveResponseStatus(interaction.responseStatus)) { + return; + } + + const toolCall = interaction.intervention.acp.request.toolCall; + if (toolCall.toolCallId) { + permissionPendingToolCallIds.add(toolCall.toolCallId); + } + const askRequestId = readMcpAskRequestId(toolCall.rawInput); + if (askRequestId) { + permissionPendingAskRequestIds.add(askRequestId); + } + }); + }); + + messages.forEach((message, messageIndex) => { + const messageId = String(message.id ?? message.index); + const messageIndexValue = message.index ?? messageIndex; + + getAcpPermissionInteractions(message).forEach((interaction) => { + if (!isActiveResponseStatus(interaction.responseStatus)) { + return; + } + + const sortKey = + interaction.triggeredAt ?? + interaction.intervention.createdAt ?? + fallbackSeq++; + pendingMap.set(`acp-${interaction.intervention.id}`, { + kind: 'acp_permission', + messageId, + messageIndex: messageIndexValue, + interaction, + sortKey, + }); + }); + + message.mcpAskInteractions?.forEach((interaction) => { + if (!isActiveResponseStatus(interaction.responseStatus)) { + return; + } + if ( + hasMcpAskResumeMessage(messages, interaction, { + containingMessageIndex: messageIndex, + }) + ) { + return; + } + + if ( + permissionPendingToolCallIds.has(interaction.toolCallId) || + permissionPendingAskRequestIds.has(interaction.input.requestId) + ) { + return; + } + const sortKey = interaction.triggeredAt ?? fallbackSeq++; + pendingMap.set(`ask-${interaction.input.requestId}`, { + kind: 'mcp_ask', + messageId, + messageIndex: messageIndexValue, + interaction, + sortKey, + }); + }); + }); + + return [...pendingMap.values()].sort((a, b) => a.sortKey - b.sortKey); + }, [messageList]); +} diff --git a/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionHandlers.test.ts b/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionHandlers.test.ts new file mode 100644 index 00000000..5d1f7356 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionHandlers.test.ts @@ -0,0 +1,473 @@ +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { renderHook } from '@testing-library/react'; +import type { + AcpPermissionInteraction, + AcpRequestPermissionResponse, +} from '../types/acpIntervention'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '../types/mcpAskIntervention'; +import { useAgentInterventionHandlers } from './useAgentInterventionHandlers'; + +const mockApiAgentInterventionRespond = vi.fn(); +vi.mock('@/services/agentConfig', () => ({ + apiAgentInterventionRespond: (...args: unknown[]) => + mockApiAgentInterventionRespond(...args), +})); + +vi.mock('@/services/i18nRuntime', () => ({ + dict: (key: string) => key, + getCurrentLang: () => 'zh-CN', +})); + +vi.mock('antd', () => ({ + message: { error: vi.fn() }, +})); + +/* ------------------------------------------------------------------ */ +/* helpers */ +/* ------------------------------------------------------------------ */ + +function createAcpInteraction( + overrides: Partial = {}, +): AcpPermissionInteraction { + return { + intervention: { + id: 'itv-001', + sessionId: 'sess-001', + acp: { + method: 'session/request_permission', + request: { + sessionId: 'sess-001', + toolCall: { toolCallId: 'tc-001' }, + options: [], + }, + }, + }, + responseStatus: 'pending', + selectedOptionId: undefined, + errorMessage: undefined, + ...overrides, + } as AcpPermissionInteraction; +} + +function createMcpAskInteraction( + overrides: Partial = {}, +): McpAskInteraction { + return { + input: { + toolName: 'nuwax_ask_question', + schemaVersion: 'nuwax.mcp_ask.v2', + requestId: 'req-001', + revision: 1, + sessionId: 'sess-001', + title: '测试问题', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'inline', + title: 'Q', + fields: [], + }, + }, + toolCallId: 'tc-001', + responseStatus: 'pending', + ...overrides, + } as McpAskInteraction; +} + +/** Simulate the setMessageList functional updater pattern. */ +function createMockSetMessageList(existing: MessageInfo[] = []) { + return vi.fn((fn: (list: MessageInfo[]) => MessageInfo[]) => fn(existing)); +} + +/* ------------------------------------------------------------------ */ +/* ACP respondAcpPermission tests */ +/* ------------------------------------------------------------------ */ + +describe('useAgentInterventionHandlers', () => { + describe('respondAcpPermission', () => { + it('sets responseStatus to submitted on SUCCESS_CODE', async () => { + mockApiAgentInterventionRespond.mockResolvedValueOnce({ + code: '0000', + }); + + const existing = [ + { + id: 'msg-1', + acpPermissionInteractions: [createAcpInteraction()], + }, + ] as MessageInfo[]; + const setMessageList = createMockSetMessageList(existing); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createAcpInteraction(); + const acpResponse: AcpRequestPermissionResponse = { + outcome: { outcome: 'selected', optionId: 'opt-1' }, + }; + + await result.current.respondAcpPermission(interaction, acpResponse); + + // First call: submitting status + expect(setMessageList).toHaveBeenCalledTimes(2); + const firstUpdate = setMessageList.mock.calls[0][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + const result1 = firstUpdate(existing); + expect(result1[0].acpPermissionInteractions![0].responseStatus).toBe( + 'submitting', + ); + + // Second call: submitted status + const secondUpdate = setMessageList.mock.calls[1][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + const result2 = secondUpdate(existing); + expect(result2[0].acpPermissionInteractions![0].responseStatus).toBe( + 'submitted', + ); + }); + + it('sets responseStatus to failed when API returns non-SUCCESS code', async () => { + mockApiAgentInterventionRespond.mockResolvedValueOnce({ + code: '9999', + message: '失败', + }); + + const existing = [ + { + id: 'msg-1', + acpPermissionInteractions: [createAcpInteraction()], + }, + ] as MessageInfo[]; + const setMessageList = createMockSetMessageList(existing); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createAcpInteraction(); + const acpResponse: AcpRequestPermissionResponse = { + outcome: { outcome: 'selected', optionId: 'opt-1' }, + }; + + await result.current.respondAcpPermission(interaction, acpResponse); + + expect(setMessageList).toHaveBeenCalledTimes(2); + const failUpdate = setMessageList.mock.calls[1][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + const updated = failUpdate(existing); + const updatedInteraction = updated[0].acpPermissionInteractions![0]; + expect(updatedInteraction.responseStatus).toBe('failed'); + expect(updatedInteraction.errorMessage).toBe('失败'); + }); + + it('sets responseStatus to submitted when API reports permission already resolved', async () => { + mockApiAgentInterventionRespond.mockResolvedValueOnce({ + code: '9999', + message: 'pending permission not found', + }); + + const existing = [ + { + id: 'msg-1', + acpPermissionInteractions: [createAcpInteraction()], + }, + ] as MessageInfo[]; + const setMessageList = createMockSetMessageList(existing); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + await result.current.respondAcpPermission(createAcpInteraction(), { + outcome: { outcome: 'selected', optionId: 'opt-1' }, + }); + + expect(setMessageList).toHaveBeenCalledTimes(2); + const submittedUpdate = setMessageList.mock.calls[1][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + expect( + submittedUpdate(existing)[0].acpPermissionInteractions![0] + .responseStatus, + ).toBe('submitted'); + }); + + it('sets responseStatus to failed on network error and calls message.error', async () => { + const { message } = await import('antd'); + mockApiAgentInterventionRespond.mockRejectedValueOnce( + new Error('network timeout'), + ); + + const existing = [ + { + id: 'msg-1', + acpPermissionInteractions: [createAcpInteraction()], + }, + ] as MessageInfo[]; + const setMessageList = createMockSetMessageList(existing); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createAcpInteraction(); + const acpResponse: AcpRequestPermissionResponse = { + outcome: { outcome: 'selected', optionId: 'opt-1' }, + }; + + await result.current.respondAcpPermission(interaction, acpResponse); + + expect(setMessageList).toHaveBeenCalledTimes(2); + const failUpdate = setMessageList.mock.calls[1][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + const updated = failUpdate(existing); + expect(updated[0].acpPermissionInteractions![0].responseStatus).toBe( + 'failed', + ); + expect(updated[0].acpPermissionInteractions![0].errorMessage).toBe( + 'network timeout', + ); + // toast 始终是统一友好文案,不再透传原始 message + expect(message.error).toHaveBeenCalledWith( + 'PC.Models.ConversationInfo.permissionResponseFailed', + ); + }); + + it('sets selectedOptionId when outcome is selected', async () => { + mockApiAgentInterventionRespond.mockResolvedValueOnce({ + code: '0000', + }); + + const existing = [ + { + id: 'msg-1', + acpPermissionInteractions: [createAcpInteraction()], + }, + ] as MessageInfo[]; + const setMessageList = createMockSetMessageList(existing); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createAcpInteraction(); + const acpResponse: AcpRequestPermissionResponse = { + outcome: { outcome: 'selected', optionId: 'opt-1' }, + }; + + await result.current.respondAcpPermission(interaction, acpResponse); + + const firstUpdate = setMessageList.mock.calls[0][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + const updated = firstUpdate(existing); + expect(updated[0].acpPermissionInteractions![0].selectedOptionId).toBe( + 'opt-1', + ); + }); + + it('leaves selectedOptionId undefined when outcome is cancelled', async () => { + mockApiAgentInterventionRespond.mockResolvedValueOnce({ + code: '0000', + }); + + const existing = [ + { + id: 'msg-1', + acpPermissionInteractions: [createAcpInteraction()], + }, + ] as MessageInfo[]; + const setMessageList = createMockSetMessageList(existing); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createAcpInteraction(); + const acpResponse: AcpRequestPermissionResponse = { + outcome: { outcome: 'cancelled' }, + }; + + await result.current.respondAcpPermission(interaction, acpResponse); + + const firstUpdate = setMessageList.mock.calls[0][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + const updated = firstUpdate(existing); + expect( + updated[0].acpPermissionInteractions![0].selectedOptionId, + ).toBeUndefined(); + }); + }); + + /* ---------------------------------------------------------------- */ + /* MCP respondMcpAsk tests */ + /* ---------------------------------------------------------------- */ + + describe('respondMcpAsk', () => { + it('sets responseStatus to submitted for submit action', () => { + const existing = [ + { + id: 'msg-1', + mcpAskInteractions: [createMcpAskInteraction()], + }, + ] as MessageInfo[]; + const setMessageList = createMockSetMessageList(existing); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createMcpAskInteraction(); + const payload: McpAskRespondPayload = { + interventionId: 'itv-001', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { name: 'test' }, + }; + + result.current.respondMcpAsk(interaction, payload); + + const update = setMessageList.mock.calls[0][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + const updated = update(existing); + expect(updated[0].mcpAskInteractions![0].responseStatus).toBe( + 'submitted', + ); + expect(updated[0].mcpAskInteractions![0].formData).toEqual({ + name: 'test', + }); + }); + + it('sets responseStatus to cancelled for cancel action', () => { + const existing = [ + { + id: 'msg-1', + mcpAskInteractions: [createMcpAskInteraction()], + }, + ] as MessageInfo[]; + const setMessageList = createMockSetMessageList(existing); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createMcpAskInteraction(); + const payload: McpAskRespondPayload = { + interventionId: 'itv-001', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'cancel', + }; + + result.current.respondMcpAsk(interaction, payload); + + const update = setMessageList.mock.calls[0][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + const updated = update(existing); + expect(updated[0].mcpAskInteractions![0].responseStatus).toBe( + 'cancelled', + ); + }); + + it('sets responseStatus to skipped for skip action', () => { + const existing = [ + { + id: 'msg-1', + mcpAskInteractions: [createMcpAskInteraction()], + }, + ] as MessageInfo[]; + const setMessageList = createMockSetMessageList(existing); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createMcpAskInteraction(); + const payload: McpAskRespondPayload = { + interventionId: 'itv-001', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'skip', + }; + + result.current.respondMcpAsk(interaction, payload); + + const update = setMessageList.mock.calls[0][0] as ( + list: MessageInfo[], + ) => MessageInfo[]; + const updated = update(existing); + expect(updated[0].mcpAskInteractions![0].responseStatus).toBe('skipped'); + }); + + it('returns the result of buildMcpAskResumeMessage', async () => { + const setMessageList = createMockSetMessageList([]); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createMcpAskInteraction(); + const payload: McpAskRespondPayload = { + interventionId: 'itv-001', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { name: 'test' }, + }; + + const resume = await result.current.respondMcpAsk(interaction, payload); + + expect(resume?.text).toContain( + 'PC.Components.McpAskQuestionCard.resumeSubmitted', + ); + expect(resume?.files).toBeUndefined(); + }); + + it('returns files from payload on submit', async () => { + const setMessageList = createMockSetMessageList([]); + + const { result } = renderHook(() => + useAgentInterventionHandlers({ setMessageList, conversationId: 123 }), + ); + + const interaction = createMcpAskInteraction(); + const payload: McpAskRespondPayload = { + interventionId: 'itv-001', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { screenshot: 'https://cdn.example.com/shot.png' }, + files: [ + { + uid: 'f1', + name: 'shot.png', + url: 'https://cdn.example.com/shot.png', + type: 'image/png', + size: 0, + }, + ], + }; + + const resume = await result.current.respondMcpAsk(interaction, payload); + + expect(resume?.files).toHaveLength(1); + expect(resume?.files?.[0].name).toBe('shot.png'); + }); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionHandlers.ts b/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionHandlers.ts new file mode 100644 index 00000000..ce065476 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionHandlers.ts @@ -0,0 +1,191 @@ +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { apiAgentInterventionRespond } from '@/services/agentConfig'; +import { dict } from '@/services/i18nRuntime'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { message } from 'antd'; +import { useCallback, type Dispatch, type SetStateAction } from 'react'; +import type { + AcpPermissionInteraction, + AcpRequestPermissionResponse, +} from '../types/acpIntervention'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '../types/mcpAskIntervention'; +import { buildMcpAskResumeMessage } from '../utils/mcpAskResumeMessage'; +import { isIdempotentAcpPermissionResolveError } from '../utils/reconcileAcpPermissionStatus'; + +/** MCP Ask resume 发送结果:正文 + 可选附件(走 chat attachments) */ +export interface McpAskResumeSendResult { + text: string; + files?: UploadFileInfo[]; +} + +export interface UseAgentInterventionHandlersOptions { + setMessageList: Dispatch>; + conversationId?: number | null; +} + +export function useAgentInterventionHandlers({ + setMessageList, + conversationId, +}: UseAgentInterventionHandlersOptions) { + const updateAcpPermissionInteraction = useCallback( + (interventionId: string, updates: Partial) => { + setMessageList((list) => + list.map((item) => { + const interactions = item.acpPermissionInteractions; + if ( + !interactions?.some( + (interaction) => interaction.intervention.id === interventionId, + ) + ) { + return item; + } + return { + ...item, + acpPermissionInteractions: interactions.map((interaction) => + interaction.intervention.id === interventionId + ? { ...interaction, ...updates } + : interaction, + ), + }; + }), + ); + }, + [setMessageList], + ); + + const updateMcpAskInteraction = useCallback( + (requestId: string, updates: Partial) => { + setMessageList((list) => + list.map((item) => { + const interactions = item.mcpAskInteractions; + if (!interactions?.some((x) => x.input.requestId === requestId)) { + return item; + } + return { + ...item, + mcpAskInteractions: interactions.map((interaction) => + interaction.input.requestId === requestId + ? { ...interaction, ...updates } + : interaction, + ), + }; + }), + ); + }, + [setMessageList], + ); + + const respondAcpPermission = useCallback( + async ( + interaction: AcpPermissionInteraction, + acpResponse: AcpRequestPermissionResponse, + ) => { + const { intervention } = interaction; + updateAcpPermissionInteraction(intervention.id, { + responseStatus: 'submitting', + selectedOptionId: + acpResponse.outcome.outcome === 'selected' + ? acpResponse.outcome.optionId + : undefined, + errorMessage: undefined, + }); + + try { + const isSelected = acpResponse.outcome.outcome === 'selected'; + const sessionId = + intervention.acp?.request?.sessionId || intervention.sessionId; + const toolCallId = intervention.acp?.request?.toolCall?.toolCallId; + + const response = await apiAgentInterventionRespond({ + interventionId: intervention.id, + permission_resolve_request: { + request_permission_response: { + outcome: isSelected + ? { Selected: { option_id: acpResponse.outcome.optionId } } + : { Cancelled: null }, + }, + session_id: sessionId, + tool_call_id: toolCallId, + save_rule: false, + }, + user_id: String(conversationId), + conversation_id: conversationId || undefined, + }); + + if (response?.code && response.code !== SUCCESS_CODE) { + const apiMessage = response.message || ''; + if (isIdempotentAcpPermissionResolveError(apiMessage)) { + updateAcpPermissionInteraction(intervention.id, { + responseStatus: 'submitted', + }); + return; + } + throw new Error( + apiMessage || + dict('PC.Models.ConversationInfo.permissionResponseFailed'), + ); + } + + updateAcpPermissionInteraction(intervention.id, { + responseStatus: 'submitted', + }); + } catch (error) { + if (isIdempotentAcpPermissionResolveError(error)) { + updateAcpPermissionInteraction(intervention.id, { + responseStatus: 'submitted', + errorMessage: undefined, + }); + return; + } + const errorMessage = + error instanceof Error + ? error.message + : dict('PC.Models.ConversationInfo.permissionResponseFailed'); + updateAcpPermissionInteraction(intervention.id, { + responseStatus: 'failed', + errorMessage, + }); + // 始终展示统一的友好提示,避免透传后端原始 message(已通过 + // skipErrorHandler 关闭全局 toast,确保只弹一次)。 + message.error( + dict('PC.Models.ConversationInfo.permissionResponseFailed'), + ); + } + }, + [updateAcpPermissionInteraction, conversationId], + ); + + const respondMcpAsk = useCallback( + async (interaction: McpAskInteraction, payload: McpAskRespondPayload) => { + const requestId = interaction.input.requestId; + const { action } = payload; + + const resolveStatus = (): McpAskInteraction['responseStatus'] => { + if (action === 'cancel') return 'cancelled'; + if (action === 'skip') return 'skipped'; + return 'submitted'; + }; + + updateMcpAskInteraction(requestId, { + responseStatus: resolveStatus(), + formData: payload.formData, + }); + const text = buildMcpAskResumeMessage(interaction, payload); + const files = + action === 'submit' && payload.files?.length + ? payload.files + : undefined; + return { text, files }; + }, + [updateMcpAskInteraction], + ); + + return { + respondAcpPermission, + respondMcpAsk, + }; +} diff --git a/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionLayer.test.ts b/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionLayer.test.ts new file mode 100644 index 00000000..9d9e40cc --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionLayer.test.ts @@ -0,0 +1,160 @@ +import { DefaultSelectedEnum } from '@/types/enums/agent'; +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAgentInterventionLayer } from './useAgentInterventionLayer'; + +const conversationInfoHandlers = { + respondAcpPermission: vi.fn(), + respondMcpAsk: vi + .fn() + .mockResolvedValue({ text: 'resume-from-conversation-info' }), +}; + +vi.mock('umi', () => ({ + useModel: () => conversationInfoHandlers, +})); + +describe('useAgentInterventionLayer', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + conversationInfoHandlers.respondMcpAsk.mockResolvedValue({ + text: 'resume-from-conversation-info', + }); + }); + it('uses conversationInfo handlers by default', async () => { + const onSendMessage = vi.fn(); + const { result } = renderHook(() => + useAgentInterventionLayer({ + conversationId: 1, + messageList: [], + onSendMessage, + }), + ); + + await act(async () => { + await result.current.chatLayerProps.onRespondMcpAsk?.( + { + input: { requestId: 'ask-1', toolName: 'nuwax_ask_question' }, + toolCallId: 'tc-1', + responseStatus: 'pending', + } as any, + { action: 'submit', formData: {} }, + ); + }); + + expect(conversationInfoHandlers.respondMcpAsk).toHaveBeenCalled(); + expect(onSendMessage).toHaveBeenCalledWith( + 'resume-from-conversation-info', + undefined, + ); + }); + + it('uses injected interventionHandlers for isolated session sources', async () => { + const onSendMessage = vi.fn(); + const respondMcpAsk = vi + .fn() + .mockResolvedValue({ text: 'resume-from-preview' }); + + const { result } = renderHook(() => + useAgentInterventionLayer({ + conversationId: 99, + messageList: [], + onSendMessage, + interventionHandlers: { + respondAcpPermission: vi.fn(), + respondMcpAsk, + }, + }), + ); + + await act(async () => { + await result.current.chatLayerProps.onRespondMcpAsk?.( + { + input: { requestId: 'ask-2', toolName: 'nuwax_ask_question' }, + toolCallId: 'tc-2', + responseStatus: 'pending', + } as any, + { action: 'submit', formData: {} }, + ); + }); + + expect(respondMcpAsk).toHaveBeenCalled(); + expect(conversationInfoHandlers.respondMcpAsk).not.toHaveBeenCalled(); + expect(onSendMessage).toHaveBeenCalledWith( + 'resume-from-preview', + undefined, + ); + }); + + it('syncs agentMode cache between sessions of the same agent', () => { + const createHook = () => + renderHook(() => + useAgentInterventionLayer({ + conversationId: 1, + agentId: 1001, + messageList: [], + allowChooseMode: DefaultSelectedEnum.Yes, + onSendMessage: vi.fn(), + }), + ); + + const first = createHook(); + const second = createHook(); + + expect(first.result.current.agentMode).toBe('yolo'); + expect(second.result.current.agentMode).toBe('yolo'); + + act(() => { + first.result.current.agentModeInputProps.onAgentModeChange('ask'); + window.dispatchEvent( + new StorageEvent('storage', { + key: 'nuwax_agent_mode_cache', + }), + ); + }); + + expect(first.result.current.agentMode).toBe('ask'); + expect(second.result.current.agentMode).toBe('ask'); + expect( + JSON.parse(localStorage.getItem('nuwax_agent_mode_cache') || '{}'), + ).toMatchObject({ + agents: { + '1001': 'ask', + }, + }); + }); + + it('keeps agentMode cache isolated between different agents', () => { + const first = renderHook(() => + useAgentInterventionLayer({ + conversationId: 1, + agentId: 1001, + messageList: [], + allowChooseMode: DefaultSelectedEnum.Yes, + onSendMessage: vi.fn(), + }), + ); + const second = renderHook(() => + useAgentInterventionLayer({ + conversationId: 2, + agentId: 1002, + messageList: [], + allowChooseMode: DefaultSelectedEnum.Yes, + onSendMessage: vi.fn(), + }), + ); + + act(() => { + first.result.current.agentModeInputProps.onAgentModeChange('ask'); + window.dispatchEvent( + new StorageEvent('storage', { + key: 'nuwax_agent_mode_cache', + }), + ); + }); + + expect(first.result.current.agentMode).toBe('ask'); + expect(second.result.current.agentMode).toBe('yolo'); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionLayer.ts b/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionLayer.ts new file mode 100644 index 00000000..9723fcdc --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/hooks/useAgentInterventionLayer.ts @@ -0,0 +1,305 @@ +import { GLOBAL_POLLING_INTERVAL } from '@/constants/home.constants'; +import { DefaultSelectedEnum } from '@/types/enums/agent'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useModel } from 'umi'; +import type { AgentInterventionChatLayerProps } from '../AgentInterventionChatLayer'; +import type { + AcpPermissionInteraction, + AcpRequestPermissionResponse, + AgentMode, +} from '../types/acpIntervention'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '../types/mcpAskIntervention'; +import type { McpAskResumeSendResult } from './useAgentInterventionHandlers'; + +/** + * 隔离会话源(如 ConversationAgent 预览 Tab / conversationAgent model)时注入, + * 避免干预回执误写入全局 conversationInfo 的 messageList。 + */ +export interface AgentInterventionHandlersOverride { + respondAcpPermission: ( + interaction: AcpPermissionInteraction, + response: AcpRequestPermissionResponse, + ) => void | Promise; + respondMcpAsk: ( + interaction: McpAskInteraction, + payload: McpAskRespondPayload, + ) => Promise; +} + +export interface UseAgentInterventionLayerOptions { + conversationId?: number | string | null; + /** 智能体 ID;开启模式切换时按智能体维度读写 Agent 模式缓存 */ + agentId?: number | string | null; + messageList: MessageInfo[]; + /** 由上层(如新建项目页)透传的初始 Agent 模式;无智能体缓存时用于初始化 */ + initialAgentMode?: AgentMode; + /** + * 是否允许选择 Agent 模式(智能体 allowChooseMode 配置)。仅 === Yes 时启用模式切换: + * 读写 localStorage 缓存 + storage 同步;否则 agentMode 固定 yolo,不污染/读取全局缓存, + * 避免没展示模式切换的会话框误用别的会话切换留下的 ask。 + */ + allowChooseMode?: DefaultSelectedEnum | number; + onSendMessage: (message: string, files?: UploadFileInfo[]) => void; + /** + * 非 conversationInfo 会话源时传入(如 ConversationAgent 预览 Tab), + * 使 Dock 回执与停止会话作用于正确的 model。 + */ + interventionHandlers?: AgentInterventionHandlersOverride; +} + +export interface AgentModeInputProps { + agentMode: AgentMode; + onAgentModeChange: (mode: AgentMode) => void; +} + +export interface UseAgentInterventionLayerResult { + agentMode: AgentMode; + chatLayerProps: Pick< + AgentInterventionChatLayerProps, + 'messageList' | 'onRespondAcpPermission' | 'onRespondMcpAsk' + >; + agentModeInputProps: AgentModeInputProps; +} + +const AGENT_MODE_STORAGE_KEY = 'nuwax_agent_mode_cache'; + +type AgentModeCacheObject = { + version?: number; + defaultMode?: AgentMode; + agents?: Record; +}; + +const isAgentMode = (mode: unknown): mode is AgentMode => + mode === 'yolo' || mode === 'ask'; + +const normalizeAgentModeCacheAgentId = ( + agentId?: number | string | null, +): string | undefined => { + if (agentId === undefined || agentId === null) { + return undefined; + } + const normalized = String(agentId).trim(); + return normalized ? normalized : undefined; +}; + +const parseAgentModeCache = ( + raw: string | null, +): AgentModeCacheObject | AgentMode | null => { + if (!raw) { + return null; + } + if (isAgentMode(raw)) { + return raw; + } + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed as AgentModeCacheObject; + } + } catch (e) { + // ignore invalid cache + } + return null; +}; + +export const readAgentModeCache = ( + agentId?: number | string | null, +): AgentMode | null => { + const agentKey = normalizeAgentModeCacheAgentId(agentId); + const parsed = parseAgentModeCache( + localStorage.getItem(AGENT_MODE_STORAGE_KEY), + ); + + if (isAgentMode(parsed)) { + return parsed; + } + + if (!parsed) { + return null; + } + + const agentMode = agentKey ? parsed.agents?.[agentKey] : undefined; + if (isAgentMode(agentMode)) { + return agentMode; + } + + if (!agentKey && isAgentMode(parsed.defaultMode)) { + return parsed.defaultMode; + } + + return null; +}; + +export const writeAgentModeCache = ( + mode: AgentMode, + agentId?: number | string | null, +) => { + const agentKey = normalizeAgentModeCacheAgentId(agentId); + const parsed = parseAgentModeCache( + localStorage.getItem(AGENT_MODE_STORAGE_KEY), + ); + const nextCache: AgentModeCacheObject = + parsed && !isAgentMode(parsed) + ? { + ...parsed, + agents: { ...(parsed.agents || {}) }, + } + : { + version: 1, + defaultMode: isAgentMode(parsed) ? parsed : undefined, + agents: {}, + }; + + if (agentKey) { + nextCache.agents = { + ...(nextCache.agents || {}), + [agentKey]: mode, + }; + } else { + nextCache.defaultMode = mode; + } + + localStorage.setItem(AGENT_MODE_STORAGE_KEY, JSON.stringify(nextCache)); +}; + +export function useAgentInterventionLayer( + options: UseAgentInterventionLayerOptions, +): UseAgentInterventionLayerResult { + const { + agentId, + messageList, + initialAgentMode, + allowChooseMode, + onSendMessage, + interventionHandlers, + } = options; + // 仅当智能体开启了模式切换(allowChooseMode===Yes)且非预览模式时,才读写/同步 localStorage 缓存; + // 否则 agentMode 固定 yolo,避免没展示模式切换的会话框误用别的会话切换留下的 ask。 + const agentModeEnabled = allowChooseMode === DefaultSelectedEnum.Yes; + const skipStorage = !!interventionHandlers || !agentModeEnabled; + const [agentMode, setAgentModeState] = useState(() => { + // 未开启模式切换 / 预览模式:仅内存态,固定 yolo,不读写 localStorage + if (skipStorage) { + return 'yolo'; + } + try { + const cached = readAgentModeCache(agentId); + if (cached) { + return cached; + } + } catch (e) { + // ignore localStorage errors + } + if (initialAgentMode === 'yolo' || initialAgentMode === 'ask') { + return initialAgentMode; + } + return 'yolo'; + }); + + const setAgentMode = useCallback( + (mode: AgentMode) => { + setAgentModeState(mode); + if (skipStorage) { + return; + } + try { + writeAgentModeCache(mode, agentId); + } catch (e) { + // ignore localStorage errors + } + }, + [agentId, skipStorage], + ); + + // 用 ref 跟踪最新 agentMode,避免同步 effect 依赖它而频繁重建定时器 + const agentModeRef = useRef(agentMode); + agentModeRef.current = agentMode; + + // 当智能体切换或其模式选择权限发生变化时,重置/更新 agentMode 状态, + // 特别是在未开启模式切换时强制回归 'yolo',避免上一个智能体遗留下来的 'ask' 被错误使用。 + useEffect(() => { + if (!agentModeEnabled) { + setAgentModeState('yolo'); + } else { + try { + const cached = readAgentModeCache(agentId); + if (cached === 'yolo' || cached === 'ask') { + setAgentModeState(cached); + } else if (initialAgentMode === 'yolo' || initialAgentMode === 'ask') { + setAgentModeState(initialAgentMode); + } else { + setAgentModeState('yolo'); + } + } catch { + setAgentModeState('yolo'); + } + } + }, [agentId, agentModeEnabled, initialAgentMode]); + + // 模式切换已开启:轮询 + storage 事件把 localStorage 的 agentMode 同步到 state, + // 使恢复中的会话使用最新模式(多标签下 A 切换后 B 能及时同步)。 + // 未开启模式切换 / 预览模式:仅内存态、不读写 localStorage,跳过。 + useEffect(() => { + if (skipStorage) { + return; + } + const syncFromCache = () => { + try { + const cached = readAgentModeCache(agentId); + if ( + (cached === 'yolo' || cached === 'ask') && + cached !== agentModeRef.current + ) { + // 仅更新 state,不回写缓存,避免与写入方形成循环 + setAgentModeState(cached); + } + } catch (e) { + // ignore localStorage errors + } + }; + // 跨标签:storage 事件即时同步 + window.addEventListener('storage', syncFromCache); + // 同标签兜底:与全局轮询同频定期读 + const timer = window.setInterval(syncFromCache, GLOBAL_POLLING_INTERVAL); + return () => { + window.removeEventListener('storage', syncFromCache); + window.clearInterval(timer); + }; + }, [agentId, skipStorage]); + + const conversationInfoModel = useModel('conversationInfo'); + + const respondAcpPermission = + interventionHandlers?.respondAcpPermission ?? + conversationInfoModel.respondAcpPermission; + const respondMcpAsk = + interventionHandlers?.respondMcpAsk ?? conversationInfoModel.respondMcpAsk; + + const handleRespondMcpAsk = useCallback( + async (interaction: McpAskInteraction, payload: McpAskRespondPayload) => { + const resume = await respondMcpAsk(interaction, payload); + if (resume?.text) { + onSendMessage(resume.text, resume.files); + } + }, + [respondMcpAsk, onSendMessage], + ); + + return { + agentMode, + chatLayerProps: { + messageList, + onRespondAcpPermission: respondAcpPermission, + onRespondMcpAsk: handleRespondMcpAsk, + }, + agentModeInputProps: { + agentMode, + onAgentModeChange: setAgentMode, + }, + }; +} diff --git a/qiming/src/components/business-component/AgentIntervention/hooks/useInterventionEscapeKey.ts b/qiming/src/components/business-component/AgentIntervention/hooks/useInterventionEscapeKey.ts new file mode 100644 index 00000000..f3aadaa1 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/hooks/useInterventionEscapeKey.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; + +export interface UseInterventionEscapeKeyOptions { + enabled: boolean; + onEscape: () => void; + respectFormFieldFocus?: boolean; +} + +/** 判断键盘事件是否发生在表单控件内(供快捷键 hook 复用) */ +export function isFormFieldTarget(target: EventTarget | null): boolean { + if (!target || !(target instanceof HTMLElement)) { + return false; + } + return ( + target.isContentEditable || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' + ); +} + +export function useInterventionEscapeKey({ + enabled, + onEscape, + respectFormFieldFocus = true, +}: UseInterventionEscapeKeyOptions): void { + useEffect(() => { + if (!enabled) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + if (respectFormFieldFocus && isFormFieldTarget(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onEscape(); + }; + + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [enabled, onEscape, respectFormFieldFocus]); +} diff --git a/qiming/src/components/business-component/AgentIntervention/index.ts b/qiming/src/components/business-component/AgentIntervention/index.ts new file mode 100644 index 00000000..b1a05d1d --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/index.ts @@ -0,0 +1,28 @@ +// Components +export { default as AgentInterventionChatLayer } from './AgentInterventionChatLayer'; + +// Hooks +export { useAgentInterventionHandlers } from './hooks/useAgentInterventionHandlers'; +export { + useAgentInterventionLayer, + type AgentInterventionHandlersOverride, +} from './hooks/useAgentInterventionLayer'; + +// Utils +export { + hydrateMcpAskInteractionsInMessageList, + prependAndHydrateMcpAskMessageList, +} from './utils/mcpAskHydrateMessage'; +export { processInterventionSsePatch } from './utils/processInterventionSsePatch'; + +// Types +export type { + AcpPermissionInteraction, + AcpRequestPermissionResponse, + AgentInterventionRespondRequest, + AgentMode, +} from './types/acpIntervention'; +export type { + McpAskInteraction, + McpAskRespondPayload, +} from './types/mcpAskIntervention'; diff --git a/qiming/src/components/business-component/AgentIntervention/types/acpIntervention.ts b/qiming/src/components/business-component/AgentIntervention/types/acpIntervention.ts new file mode 100644 index 00000000..6777e5b7 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/types/acpIntervention.ts @@ -0,0 +1,108 @@ +import type { McpAskRespondPayload } from './mcpAskIntervention'; + +export type AgentMode = 'ask' | 'yolo'; + +export type AcpPermissionOptionKind = + | 'allow_once' + | 'allow_always' + | 'reject_once' + | 'reject_always'; + +export type AcpToolKind = + | 'read' + | 'edit' + | 'delete' + | 'move' + | 'search' + | 'execute' + | 'think' + | 'fetch' + | 'switch_mode' + | 'other'; + +export interface AcpPermissionOption { + optionId: string; + kind: AcpPermissionOptionKind; + name: string; + _meta?: Record | null; +} + +export interface AcpToolCallUpdate { + toolCallId: string; + title?: string | null; + kind?: AcpToolKind | string | null; + rawInput?: unknown; + rawOutput?: unknown; + locations?: Array<{ path: string; line?: number | null }> | null; + content?: unknown[] | null; + status?: 'pending' | 'in_progress' | 'completed' | 'failed' | string | null; +} + +export interface AcpRequestPermissionRequest { + sessionId: string; + toolCall: AcpToolCallUpdate; + options: AcpPermissionOption[]; + _meta?: Record | null; +} + +export interface AcpRequestPermissionResponse { + outcome: + | { + outcome: 'selected'; + optionId: string; + _meta?: Record | null; + } + | { outcome: 'cancelled' }; + _meta?: Record | null; +} + +export interface AcpPermissionInterventionRequest { + id: string; + revision: number; + kind: 'approval'; + status: 'pending'; + sessionId: string; + source: 'acp_permission'; + engine: 'claude-code' | 'nuwaxcode' | 'codex'; + protocol: 'acp'; + callbackTarget: { + kind: 'electron' | 'rcoder'; + targetId: string; + }; + schemaRef: string; + acp: { + method: 'session/request_permission'; + request: AcpRequestPermissionRequest; + }; + timeoutMs?: number; + createdAt: number; +} + +export interface AcpPermissionInteraction { + intervention: AcpPermissionInterventionRequest; + /** 关联的 processing executeId(与 processingList 同源),用于按 executeId 判断审批是否已过期 */ + executeId?: string; + responseStatus?: 'pending' | 'submitting' | 'submitted' | 'failed'; + selectedOptionId?: string; + errorMessage?: string; + triggeredAt?: number; +} + +export type AgentInterventionAction = 'submit' | 'cancel' | 'skip' | 'timeout'; + +export interface AgentInterventionRespondRequest { + interventionId: string; + permission_resolve_request?: { + request_permission_response: { + outcome: + | { Selected: { option_id: string } } + | { Cancelled: Record | null }; + }; + session_id: string; + tool_call_id: string; + save_rule?: boolean; + }; + mcp_ask_resolve?: McpAskRespondPayload; + user_id?: string; + conversation_id?: number; +} diff --git a/qiming/src/components/business-component/AgentIntervention/types/mcpAskIntervention.ts b/qiming/src/components/business-component/AgentIntervention/types/mcpAskIntervention.ts new file mode 100644 index 00000000..34fe8f2b --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/types/mcpAskIntervention.ts @@ -0,0 +1,164 @@ +/** + * MCP Ask v2 契约类型(nuwax.interaction.v2 / nuwax.mcp_ask.v2)。 + * 表单为有序自描述数组 ui.fields[];enum/enumNames 合并为 options,控件/约束/required/initialValue 下沉到字段。 + * 仅支持最新 v2,不兼容旧版本(历史 v1 rawInput 不再渲染)。 + */ +export const MCP_ASK_SCHEMA_VERSION = 'nuwax.mcp_ask.v2'; +export const INTERACTION_UI_SCHEMA_VERSION = 'nuwax.interaction.v2'; + +export type McpAskToolName = 'nuwax_ask_question'; + +export interface InteractionUiStep { + id: string; + title: string; + description?: string; + /** 本步展示的字段 name 数组,引用 ui.fields 中字段的 name */ + fields: string[]; +} + +/** v2 字段选项(合并旧 enum + enumNames) */ +export interface FieldOption { + value: string; + label: string; +} + +/** 渲染层(ParsedMcpAskField.options)读取的 UI 配置 */ +export interface InteractionUiOptions { + placeholder?: string; + allowCustom?: boolean; + otherValue?: string; + otherField?: string; + accept?: string; + multiple?: boolean; +} + +/** v2 单个表单字段(自描述:控件/选项/约束合并进同一对象) */ +export interface FormField { + name: string; + title: string; + widget: McpAskFieldWidget; + description?: string; + required?: boolean; + placeholder?: string; + initialValue?: unknown; + type?: 'string' | 'integer' | 'number' | 'array'; + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + multipleOf?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + options?: FieldOption[]; + accept?: string; + multiple?: boolean; + maxFileSize?: number; + allowCustom?: boolean; + otherValue?: string; + otherField?: string; +} + +/** v2 交互 UI 定义;数组顺序即渲染顺序,不再有 schema/uiSchema/ui:order */ +export interface InteractionUiSchema { + version: typeof INTERACTION_UI_SCHEMA_VERSION; + presentation: 'modal' | 'inline' | 'wizard'; + title: string; + description?: string; + fields?: FormField[]; + steps?: InteractionUiStep[]; + submitLabel?: string; + cancelLabel?: string; + allowSkip?: boolean; + skipLabel?: string; + fallback?: { + text: string; + webUrl?: string; + mobileUrl?: string; + }; +} + +export interface McpAskUserToolInput { + toolName: McpAskToolName; + schemaVersion: typeof MCP_ASK_SCHEMA_VERSION; + requestId: string; + revision: number; + sessionId: string; + title: string; + description?: string; + ui: InteractionUiSchema; + business?: Record; + timeoutMs?: number; + priority?: 'normal' | 'high'; +} + +import type { UploadFileInfo } from '@/types/interfaces/common'; + +export type McpAskRespondAction = 'submit' | 'cancel' | 'skip' | 'timeout'; + +export interface McpAskRespondPayload { + interventionId: string; + toolCallId?: string; + revision: number; + source: 'mcp_ask'; + protocol: 'mcp'; + action: McpAskRespondAction; + formData?: Record; + /** submit 时从 file 字段提取的附件,走 chat attachments 发送 */ + files?: UploadFileInfo[]; + answeredBy?: { + kind: 'web' | 'mobile'; + userId?: string; + clientId?: string; + }; + answeredAt?: number; +} + +export type McpAskResponseStatus = + | 'pending' + | 'submitting' + | 'submitted' + | 'cancelled' + | 'skipped' + | 'failed'; + +export interface McpAskInteraction { + input: McpAskUserToolInput; + toolCallId: string; + /** 关联的 processing executeId(与 processingList 同源),用于按 executeId 判断审批是否已过期 */ + executeId?: string; + responseStatus?: McpAskResponseStatus; + formData?: Record; + errorMessage?: string; + triggeredAt?: number; +} + +/** + * JsonSchema 约束视图:parser 从 v2 FormField 重建, + * 供 McpAskFormField 读取(property.title/minimum/maximum/multipleOf/maxLength/minItems/type)。 + */ +export interface JsonSchemaProperty { + type?: string | string[]; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + minItems?: number; + minimum?: number; + maximum?: number; + multipleOf?: number; +} + +export const MCP_ASK_WIDGET_TYPES = [ + 'text', + 'textarea', + 'number', + 'radio', + 'checkboxes', + 'select', + 'list', + 'file', + 'radio-with-custom', +] as const; + +export type McpAskFieldWidget = (typeof MCP_ASK_WIDGET_TYPES)[number]; diff --git a/qiming/src/components/business-component/AgentIntervention/utils/applyAcpPermissionSseEvent.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/applyAcpPermissionSseEvent.test.ts new file mode 100644 index 00000000..893b26a6 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/applyAcpPermissionSseEvent.test.ts @@ -0,0 +1,427 @@ +import { ConversationEventTypeEnum } from '@/types/enums/agent'; +import { MessageStatusEnum } from '@/types/enums/common'; +import { describe, expect, it } from 'vitest'; +import { applyAcpPermissionSseEvent } from './applyAcpPermissionSseEvent'; + +describe('applyAcpPermissionSseEvent', () => { + it('accepts documented snake_case ACP request_permission events', () => { + const patched = applyAcpPermissionSseEvent( + { + session_id: 'session-snake', + message_type: 'acpRequestPermission', + sub_type: 'request_permission', + data: { + request_permission_request: { + session_id: 'session-snake', + tool_call: { + tool_call_id: 'tool-call-snake', + title: 'Write file', + kind: 'edit', + status: 'pending', + raw_input: { + command: 'touch approval-test.txt', + }, + }, + options: [ + { + option_id: 'allow-once', + kind: 'allow_once', + name: 'Allow once', + }, + { + option_id: 'reject-once', + kind: 'reject_once', + name: 'Reject', + }, + ], + }, + tool_call_id: 'tool-call-snake', + }, + } as any, + { id: 'msg-1' } as any, + ); + + const request = + patched?.acpPermissionInteractions?.[0]?.intervention.acp.request; + expect(request?.sessionId).toBe('session-snake'); + expect(request?.toolCall.toolCallId).toBe('tool-call-snake'); + expect(request?.toolCall.rawInput).toEqual({ + command: 'touch approval-test.txt', + }); + expect(request?.options.map((option) => option.optionId)).toEqual([ + 'allow-once', + 'reject-once', + ]); + }); + + it('accepts raw ACP request_permission progress events', () => { + const patched = applyAcpPermissionSseEvent( + { + messageType: 'acpRequestPermission', + subType: 'request_permission', + data: { + request_permission_request: { + session_id: 'session-1', + tool_call: { + tool_call_id: 'tool-call-1', + title: 'Write file', + kind: 'edit', + status: 'pending', + }, + options: [ + { + option_id: 'allow_once', + kind: 'allow_once', + name: 'Allow once', + }, + ], + }, + tool_call_id: 'tool-call-1', + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.acpPermissionInteractions?.[0]?.intervention.id).toBe( + 'itv_session-1_tool-call-1', + ); + expect( + patched?.acpPermissionInteractions?.[0]?.intervention.acp.request.options + .length, + ).toBe(1); + }); + + it('accepts backend-wrapped ACP request_permission progress events', () => { + const patched = applyAcpPermissionSseEvent( + { + eventType: ConversationEventTypeEnum.MESSAGE, + data: { + messageType: 'acpRequestPermission', + subType: 'request_permission', + data: { + request_permission_request: { + session_id: 'session-2', + tool_call: { + tool_call_id: 'tool-call-2', + title: 'Run command', + kind: 'execute', + status: 'pending', + }, + options: [ + { + option_id: 'approved', + kind: 'allow_once', + name: 'Yes, proceed', + }, + ], + }, + tool_call_id: 'tool-call-2', + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.acpPermissionInteractions?.[0]?.intervention.id).toBe( + 'itv_session-2_tool-call-2', + ); + expect( + patched?.acpPermissionInteractions?.[0]?.intervention.acp.request.toolCall + .toolCallId, + ).toBe('tool-call-2'); + }); + + it('accepts PROCESSING request permission component events', () => { + const patched = applyAcpPermissionSseEvent( + { + requestId: 'request-1', + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + targetId: -1, + name: 'Backend.Sandbox.Event.RequestPermission', + type: 'Event', + status: 'FINISHED', + result: { + id: -1, + name: 'Backend.Sandbox.Event.RequestPermission', + type: 'Event', + startTime: 1780479903686, + endTime: 1780479903686, + input: { + request_permission_request: { + session_id: 'session-processing', + tool_call: { + tool_call_id: 'tool-call-processing', + kind: 'other', + status: 'pending', + title: 'external_directory', + raw_input: { + filepath: '/Users/apple/Desktop/acp-verify-test.txt', + parentDir: '/Users/apple/Desktop', + }, + }, + options: [ + { + option_id: 'once', + name: 'Allow once', + kind: 'allow_once', + }, + { + option_id: 'reject', + name: 'Reject', + kind: 'reject_once', + }, + ], + }, + tool_call_id: 'tool-call-processing', + _meta: { + nuwaclaw_intervention_id: 'itv-processing', + nuwaclaw_revision: 2, + }, + }, + executeId: 'tool-call-processing', + }, + subEventType: 'REQUEST_PERMISSION', + }, + completed: false, + } as any, + { id: 'msg-1' } as any, + ); + + const interaction = patched?.acpPermissionInteractions?.[0]; + expect(interaction?.intervention.id).toBe('itv-processing'); + expect(interaction?.intervention.revision).toBe(2); + expect(interaction?.intervention.createdAt).toBe(1780479903686); + expect(interaction?.intervention.acp.request.sessionId).toBe( + 'session-processing', + ); + expect(interaction?.intervention.acp.request.toolCall.toolCallId).toBe( + 'tool-call-processing', + ); + expect(interaction?.intervention.acp.request.toolCall.rawInput).toEqual({ + filepath: '/Users/apple/Desktop/acp-verify-test.txt', + parentDir: '/Users/apple/Desktop', + }); + expect( + interaction?.intervention.acp.request.options.map( + (option) => option.optionId, + ), + ).toEqual(['once', 'reject']); + }); + + it('forces the message back to loading so the approval card can render', () => { + const patched = applyAcpPermissionSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + result: { + input: { + request_permission_request: { + session_id: 'session-loading', + tool_call: { + tool_call_id: 'tool-call-loading', + kind: 'edit', + status: 'pending', + title: '编辑文件', + }, + options: [ + { + option_id: 'once', + name: 'Allow once', + kind: 'allow_once', + }, + ], + }, + tool_call_id: 'tool-call-loading', + }, + executeId: 'tool-call-loading', + }, + subEventType: 'REQUEST_PERMISSION', + }, + } as any, + { id: 'msg-loading', status: MessageStatusEnum.Complete } as any, + ); + + expect(patched?.status).toBe(MessageStatusEnum.Loading); + }); + + it('accepts camelCase ACP request_permission events (ACP standard)', () => { + const patched = applyAcpPermissionSseEvent( + { + requestId: 'req-camel', + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + targetId: -1, + name: 'Backend.Sandbox.Event.RequestPermission', + type: 'Event', + status: 'FINISHED', + result: { + id: -1, + name: 'Backend.Sandbox.Event.RequestPermission', + type: 'Event', + startTime: 1780574185241, + endTime: 1780574185241, + input: { + request_permission_request: { + sessionId: 'session-camel', + toolCall: { + toolCallId: 'tool-call-camel', + title: 'Read /etc/hosts', + kind: 'read', + status: 'pending', + rawInput: { file_path: '/etc/hosts' }, + locations: [{ path: '/etc/hosts', line: 1 }], + content: [], + }, + options: [ + { + optionId: 'allow_always', + kind: 'allow_always', + name: 'Always Allow Read(//etc/**)', + }, + { + optionId: 'allow', + kind: 'allow_once', + name: 'Allow', + }, + { + optionId: 'reject', + kind: 'reject_once', + name: 'Reject', + }, + ], + }, + tool_call_id: 'tool-call-camel', + }, + executeId: 'tool-call-camel', + }, + subEventType: 'REQUEST_PERMISSION', + }, + completed: false, + } as any, + { id: 'msg-camel' } as any, + ); + + const request = + patched?.acpPermissionInteractions?.[0]?.intervention.acp.request; + expect(request?.sessionId).toBe('session-camel'); + expect(request?.toolCall.toolCallId).toBe('tool-call-camel'); + expect(request?.toolCall.rawInput).toEqual({ file_path: '/etc/hosts' }); + expect(request?.toolCall.locations).toEqual([ + { path: '/etc/hosts', line: 1 }, + ]); + expect(request?.options.map((o) => o.optionId)).toEqual([ + 'allow_always', + 'allow', + 'reject', + ]); + }); + + it('accepts camelCase requestPermissionRequest payloads', () => { + const patched = applyAcpPermissionSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + name: 'Backend.Sandbox.Event.RequestPermission', + result: { + name: 'Backend.Sandbox.Event.RequestPermission', + executeId: 'tool-call-camel-root', + input: { + requestPermissionRequest: { + sessionId: 'session-camel-root', + toolCall: { + toolCallId: 'tool-call-camel-root', + title: '写入文件', + kind: 'edit', + status: 'pending', + }, + options: [ + { + optionId: 'once', + kind: 'allow_once', + name: 'Allow once', + }, + ], + }, + toolCallId: 'tool-call-camel-root', + }, + }, + }, + } as any, + { id: 'msg-camel-root' } as any, + ); + + const request = + patched?.acpPermissionInteractions?.[0]?.intervention.acp.request; + expect(request?.sessionId).toBe('session-camel-root'); + expect(request?.toolCall.toolCallId).toBe('tool-call-camel-root'); + expect(request?.options[0]?.optionId).toBe('once'); + }); + + it('recognizes RequestPermission events by event name even without subEventType', () => { + const patched = applyAcpPermissionSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + name: 'Backend.Sandbox.Event.RequestPermission', + result: { + input: { + request_permission_request: { + session_id: 'session-by-name', + tool_call: { + tool_call_id: 'tool-call-by-name', + title: '写入文件', + kind: 'edit', + status: 'pending', + }, + options: [ + { + option_id: 'once', + kind: 'allow_once', + name: 'Allow once', + }, + ], + }, + tool_call_id: 'tool-call-by-name', + }, + executeId: 'tool-call-by-name', + }, + }, + } as any, + { id: 'msg-by-name' } as any, + ); + + expect( + patched?.acpPermissionInteractions?.[0]?.intervention.acp.request.toolCall + .toolCallId, + ).toBe('tool-call-by-name'); + }); + + it('normalizes codex-cli engine aliases to codex', () => { + const patched = applyAcpPermissionSseEvent( + { + messageType: 'acpRequestPermission', + subType: 'request_permission', + data: { + request_permission_request: { + session_id: 'session-engine', + tool_call: { + tool_call_id: 'tool-call-engine', + title: 'Run', + kind: 'execute', + status: 'pending', + }, + options: [], + }, + tool_call_id: 'tool-call-engine', + _engine: 'codex-cli', + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.acpPermissionInteractions?.[0]?.intervention.engine).toBe( + 'codex', + ); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/applyAcpPermissionSseEvent.ts b/qiming/src/components/business-component/AgentIntervention/utils/applyAcpPermissionSseEvent.ts new file mode 100644 index 00000000..ba1130de --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/applyAcpPermissionSseEvent.ts @@ -0,0 +1,168 @@ +import { ConversationEventTypeEnum } from '@/types/enums/agent'; +import { MessageStatusEnum } from '@/types/enums/common'; +import type { + ConversationChatResponse, + MessageInfo, +} from '@/types/interfaces/conversationInfo'; +import type { AcpPermissionInterventionRequest } from '../types/acpIntervention'; +import { createInterventionTriggeredAt } from './interventionTrigger'; +import { + extractEventData, + parseSseEventEnvelope, +} from './parseSseEventEnvelope'; + +function normalizeAcpEngine( + raw: unknown, +): AcpPermissionInterventionRequest['engine'] { + const value = typeof raw === 'string' ? raw : ''; + if (value === 'claude-code' || value === 'nuwaxcode' || value === 'codex') { + return value; + } + return 'codex'; +} + +export function applyAcpPermissionSseEvent( + res: ConversationChatResponse, + currentMessage: MessageInfo, +): MessageInfo | null { + const envelope = parseSseEventEnvelope(res); + const subType = envelope.subType ?? envelope.sub_type; + const eventData = extractEventData(envelope, res); + const processingResult = eventData.result as + | Record + | undefined; + const processingInput = processingResult?.input as + | Record + | undefined; + const eventName = + (eventData.name as string) || (processingResult?.name as string) || ''; + const eventSubType = + (eventData.subEventType as string) || + (envelope.subEventType as string) || + ''; + const requestPermissionRequest = (eventData.request_permission_request ?? + eventData.requestPermissionRequest ?? + processingInput?.request_permission_request ?? + processingInput?.requestPermissionRequest) as + | Record + | undefined; + + const isAcpPermissionEvent = + res.eventType === ConversationEventTypeEnum.ACP_REQUEST_PERMISSION || + ((envelope.message_type === 'acpRequestPermission' || + envelope.messageType === 'acpRequestPermission') && + (subType === 'AcpRequestPermission' || + subType === 'request_permission')) || + (res.eventType === ConversationEventTypeEnum.PROCESSING && + (eventSubType === 'REQUEST_PERMISSION' || + eventName === 'Backend.Sandbox.Event.RequestPermission' || + !!requestPermissionRequest)); + + if (!isAcpPermissionEvent) { + return null; + } + + const reqPerm = requestPermissionRequest; + const requestMeta = (reqPerm?._meta ?? + processingInput?._meta ?? + eventData._meta) as Record | undefined; + const intervention = (eventData._intervention ?? + eventData.interventionRequest) as + | AcpPermissionInterventionRequest + | undefined; + + // Support both camelCase (ACP standard) and snake_case (legacy) field names + const sessionId = + (reqPerm?.sessionId as string) || + (reqPerm?.session_id as string) || + intervention?.sessionId || + (eventData.sessionId as string) || + (eventData.session_id as string); + const toolCall = (reqPerm?.toolCall ?? + reqPerm?.tool_call ?? + processingInput?.toolCall ?? + processingInput?.tool_call ?? + intervention?.acp?.request?.toolCall) as + | Record + | undefined; + const options = (reqPerm?.options ?? + processingInput?.options ?? + intervention?.acp?.request?.options) as + | Array> + | undefined; + const toolCallId = + (eventData.tool_call_id as string) || + (eventData.toolCallId as string) || + (processingInput?.tool_call_id as string) || + (processingInput?.toolCallId as string) || + (toolCall?.toolCallId as string) || + (toolCall?.tool_call_id as string); + const executeId = + (eventData.executeId as string) || + (processingResult?.executeId as string) || + undefined; + + if ((!intervention?.id && !sessionId) || !toolCall) { + return null; + } + + const interventionId = + intervention?.id || + (requestMeta?.nuwaclaw_intervention_id as string) || + `itv_${sessionId}_${toolCallId}`; + const interactions = currentMessage.acpPermissionInteractions || []; + if (interactions.some((item) => item.intervention.id === interventionId)) { + return null; + } + + const normalizedIntervention: AcpPermissionInterventionRequest = + intervention || { + id: interventionId, + revision: (requestMeta?.nuwaclaw_revision as number) || 1, + kind: 'approval', + status: 'pending', + sessionId: sessionId, + source: 'acp_permission', + engine: normalizeAcpEngine(eventData._engine), + protocol: 'acp', + callbackTarget: { kind: 'electron', targetId: '' }, + schemaRef: '', + acp: { + method: 'session/request_permission', + request: { + sessionId: sessionId, + toolCall: { + toolCallId: toolCallId, + kind: toolCall?.kind as string, + title: toolCall?.title as string, + rawInput: (toolCall?.rawInput ?? toolCall?.raw_input) as unknown, + status: toolCall?.status as string, + locations: toolCall?.locations as Array<{ + path: string; + line?: number | null; + }> | null, + }, + options: (options || []).map((o) => ({ + optionId: (o.optionId ?? o.option_id) as string, + kind: o.kind as string, + name: o.name as string, + })), + }, + }, + createdAt: (processingResult?.startTime as number) || Date.now(), + }; + + return { + ...currentMessage, + acpPermissionInteractions: [ + ...interactions, + { + intervention: normalizedIntervention, + executeId, + responseStatus: 'pending', + triggeredAt: createInterventionTriggeredAt(), + }, + ], + status: MessageStatusEnum.Loading, + }; +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/applyMcpAskToolCallSseEvent.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/applyMcpAskToolCallSseEvent.test.ts new file mode 100644 index 00000000..75e1c15f --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/applyMcpAskToolCallSseEvent.test.ts @@ -0,0 +1,511 @@ +import { ConversationEventTypeEnum } from '@/types/enums/agent'; +import { MessageStatusEnum } from '@/types/enums/common'; +import { describe, expect, it } from 'vitest'; +import { applyMcpAskToolCallSseEvent } from './applyMcpAskToolCallSseEvent'; + +const baseAskInput = { + schemaVersion: 'nuwax.mcp_ask.v2', + requestId: 'ask-1', + revision: 1, + sessionId: 'session-1', + title: 'Need input', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'inline', + title: 'Need input', + fields: [{ name: 'choice', title: '选项', widget: 'text' }], + }, +}; + +describe('applyMcpAskToolCallSseEvent', () => { + it('accepts backend PROCESSING tool calls for nuwax_ask_question', () => { + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + executeId: 'tool-call-1', + result: { + executeId: 'tool-call-1', + input: { + ...baseAskInput, + toolName: 'nuwax_ask_question', + }, + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-1'); + expect(patched?.mcpAskInteractions?.[0]?.input.toolName).toBe( + 'nuwax_ask_question', + ); + }); + + it('forces the message back to loading for pending ask interactions', () => { + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + executeId: 'ask-loading', + result: { + executeId: 'ask-loading', + input: { + ...baseAskInput, + requestId: 'ask-loading', + toolName: 'nuwax_ask_question', + }, + }, + }, + } as any, + { id: 'msg-ask-loading', status: MessageStatusEnum.Complete } as any, + ); + + expect(patched?.status).toBe(MessageStatusEnum.Loading); + }); + + it('defaults missing toolName to nuwax_ask_question', () => { + const patched = applyMcpAskToolCallSseEvent( + { + messageType: 'agentSessionUpdate', + subType: 'tool_call', + data: { + toolCallId: 'tool-call-2', + rawInput: baseAskInput, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.input.toolName).toBe( + 'nuwax_ask_question', + ); + }); + + it('accepts backend-wrapped tool_call progress events', () => { + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.MESSAGE, + data: { + messageType: 'agentSessionUpdate', + subType: 'tool_call', + data: { + tool_call_id: 'tool-call-3', + raw_input: { + ...baseAskInput, + requestId: 'ask-3', + toolName: 'nuwax_ask_question', + }, + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-3'); + expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-3'); + }); + + it('accepts tool_call_update in-progress events with MCP Ask raw input', () => { + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.MESSAGE, + data: { + messageType: 'agentSessionUpdate', + subType: 'tool_call_update', + data: { + toolCallId: 'tool-call-4', + status: 'in_progress', + rawInput: { + ...baseAskInput, + requestId: 'ask-4', + toolName: 'nuwax_ask_question', + }, + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-4'); + expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-4'); + }); + + it('keeps an active MCP Ask interaction when the MCP call completes pending user input', () => { + const currentMessage = { + id: 'msg-1', + mcpAskInteractions: [ + { + input: { + ...baseAskInput, + requestId: 'ask-5', + toolName: 'nuwax_ask_question', + }, + toolCallId: 'tool-call-5', + responseStatus: 'pending', + }, + ], + }; + + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.MESSAGE, + data: { + messageType: 'agentSessionUpdate', + subType: 'tool_call_update', + data: { + toolCallId: 'tool-call-5', + status: 'completed', + rawInput: { + ...baseAskInput, + requestId: 'ask-5', + toolName: 'nuwax_ask_question', + }, + }, + }, + } as any, + currentMessage as any, + ); + + expect(patched).toBeNull(); + }); + + it('creates an MCP Ask card from a completed tool_call_update if that is the first event received', () => { + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.MESSAGE, + data: { + messageType: 'agentSessionUpdate', + subType: 'tool_call_update', + data: { + toolCallId: 'tool-call-6', + status: 'completed', + rawInput: { + ...baseAskInput, + requestId: 'ask-6', + toolName: 'nuwax_ask_question', + }, + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-6'); + expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-6'); + }); + + it('accepts rawInput nested under data.ext', () => { + const patched = applyMcpAskToolCallSseEvent( + { + messageType: 'agentSessionUpdate', + subType: 'tool_call', + data: { + toolCallId: 'tool-call-7', + ext: { + rawInput: { + ...baseAskInput, + requestId: 'ask-7', + toolName: 'nuwax_ask_question', + }, + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-7'); + expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-7'); + }); + + it('accepts agent input missing schemaVersion when ui.version is present', () => { + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + name: 'ask-question__nuwax_ask_question', + type: 'ToolCall', + status: 'EXECUTING', + result: { + executeId: 'call-775', + input: { + requestId: 'weather-plan-confirm-5', + revision: 1, + sessionId: 'weather-dev', + title: '确认方案', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'modal', + title: '确认方案', + fields: [{ name: 'confirm', title: '确认', widget: 'text' }], + }, + }, + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.input.schemaVersion).toBe( + 'nuwax.mcp_ask.v2', + ); + expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe( + 'weather-plan-confirm-5', + ); + }); + + it('prefers canonical input from result.data structuredContent over agent result.input', () => { + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + name: 'ask-question__nuwax_ask_question', + type: 'ToolCall', + status: 'FINISHED', + result: { + executeId: 'call-775', + input: { + requestId: 'weather-plan-confirm-5', + revision: 1, + sessionId: 'weather-dev', + title: 'agent title', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'modal', + title: 'agent title', + fields: [{ name: 'confirm', title: '确认', widget: 'text' }], + }, + }, + data: [ + { + type: 'content', + content: { + type: 'text', + text: JSON.stringify({ + status: 'pending', + requestId: 'weather-plan-confirm-5', + revision: 1, + message: 'presented', + input: { + toolName: 'nuwax_ask_question', + schemaVersion: 'nuwax.mcp_ask.v2', + requestId: 'weather-plan-confirm-5', + revision: 1, + sessionId: 'weather-dev', + title: 'canonical title', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'modal', + title: 'canonical title', + fields: [ + { name: 'confirm', title: '确认', widget: 'text' }, + ], + }, + }, + }), + }, + }, + ], + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.input.title).toBe( + 'canonical title', + ); + expect(patched?.mcpAskInteractions?.[0]?.input.toolName).toBe( + 'nuwax_ask_question', + ); + }); + + it('accepts Backend.Sandbox.Event.AskQuestion ASK_QUESTION PROCESSING event', () => { + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + targetId: -1, + name: 'Backend.Sandbox.Event.AskQuestion', + type: 'Event', + status: 'FINISHED', + subEventType: 'ASK_QUESTION', + result: { + executeId: 'call_272edddbb5e140128d146826', + input: { + requestId: 'demo_form_1', + sessionId: 'demo_session_1', + title: 'nuwax_ask_question 演示', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'inline', + title: '演示表单', + fields: [ + { + name: 'favorite_color', + title: '最喜欢的颜色', + widget: 'radio', + options: [{ value: 'red', label: '红色' }], + }, + ], + }, + }, + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe( + 'call_272edddbb5e140128d146826', + ); + expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe( + 'demo_form_1', + ); + }); + + it('accepts bare v2 input with no schemaVersion/ui.version (agent omits stamp fields)', () => { + const patched = applyMcpAskToolCallSseEvent( + { + messageType: 'agentSessionUpdate', + subType: 'tool_call', + data: { + toolCallId: 'tool-call-bare-v2', + rawInput: { + requestId: 'ask-bare', + revision: 1, + sessionId: 'sess-bare', + title: 'Bare v2', + // 无 schemaVersion、无 ui.version(agent 按"服务端盖戳"约定省略) + ui: { + presentation: 'inline', + title: 'Bare v2', + fields: [ + { + name: 'choice', + title: '选项', + widget: 'radio', + options: [ + { value: 'a', label: '甲' }, + { value: 'b', label: '乙' }, + ], + }, + ], + }, + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe( + 'tool-call-bare-v2', + ); + expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-bare'); + expect(patched?.mcpAskInteractions?.[0]?.input.schemaVersion).toBe( + 'nuwax.mcp_ask.v2', + ); + }); + + it('accepts AgentFlow ASK_QUESTION payload with result.data and nuwax.mcp_ask.v1', () => { + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + name: 'AskQuestion', + type: 'Event', + status: 'EXECUTING', + subEventType: 'ASK_QUESTION', + result: { + executeId: '733e52ee48a8406b8f148de386092f47', + data: { + schemaVersion: 'nuwax.mcp_ask.v1', + requestId: '13f030d0c07547fe83fd6d43b624f0e0', + title: '补充回复', + revision: 1, + ui: { + presentation: 'inline', + title: '请选择\n\n', + fields: [ + { widget: 'text', name: 'name', title: '你的名字' }, + { + widget: 'radio', + name: 'ddw', + title: '单选', + options: [ + { label: '1', value: '1' }, + { label: '2', value: '2' }, + ], + }, + ], + }, + }, + }, + }, + } as any, + { id: 'msg-1' } as any, + ); + + expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe( + '13f030d0c07547fe83fd6d43b624f0e0', + ); + expect(patched?.mcpAskInteractions?.[0]?.executeId).toBe( + '733e52ee48a8406b8f148de386092f47', + ); + expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe( + '13f030d0c07547fe83fd6d43b624f0e0', + ); + expect(patched?.mcpAskInteractions?.[0]?.input.schemaVersion).toBe( + 'nuwax.mcp_ask.v2', + ); + }); + + it('creates a second ask interaction when requestId differs', () => { + const currentMessage = { + id: 'msg-1', + mcpAskInteractions: [ + { + input: { + ...baseAskInput, + requestId: '13f030d0c07547fe83fd6d43b624f0e0', + title: '补充回复', + toolName: 'nuwax_ask_question', + }, + toolCallId: '13f030d0c07547fe83fd6d43b624f0e0', + executeId: '733e52ee48a8406b8f148de386092f47', + responseStatus: 'submitted', + }, + ], + }; + + const patched = applyMcpAskToolCallSseEvent( + { + eventType: ConversationEventTypeEnum.PROCESSING, + data: { + subEventType: 'ASK_QUESTION', + result: { + executeId: '449db35f985747bf8cd1645627bc2d8c', + data: { + schemaVersion: 'nuwax.mcp_ask.v1', + requestId: 'ed325c9eec724bce95ca6a05974b42d6', + title: '补充回复', + revision: 1, + ui: { + presentation: 'inline', + title: '请选择\n\n', + fields: [{ widget: 'text', name: 'name', title: '你的名字' }], + }, + }, + }, + }, + } as any, + currentMessage as any, + ); + + expect(patched?.mcpAskInteractions).toHaveLength(2); + expect(patched?.mcpAskInteractions?.[1]?.input.requestId).toBe( + 'ed325c9eec724bce95ca6a05974b42d6', + ); + expect(patched?.mcpAskInteractions?.[1]?.responseStatus).toBe('pending'); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/applyMcpAskToolCallSseEvent.ts b/qiming/src/components/business-component/AgentIntervention/utils/applyMcpAskToolCallSseEvent.ts new file mode 100644 index 00000000..d472cf1c --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/applyMcpAskToolCallSseEvent.ts @@ -0,0 +1,144 @@ +import { ConversationEventTypeEnum } from '@/types/enums/agent'; +import { MessageStatusEnum } from '@/types/enums/common'; +import type { + ConversationChatResponse, + MessageInfo, +} from '@/types/interfaces/conversationInfo'; +import { extractMcpAskStructuredInputFromResult } from './extractMcpAskStructuredInput'; +import { createInterventionTriggeredAt } from './interventionTrigger'; +import { parseMcpAskToolInput } from './parseMcpAskToolInput'; +import { + extractEventData, + parseSseEventEnvelope, +} from './parseSseEventEnvelope'; + +function readRawInput( + eventData: Record, + result?: Record, +) { + const ext = + eventData.ext && typeof eventData.ext === 'object' + ? (eventData.ext as Record) + : {}; + // 优先选择非空对象,避免后端把 `result.input` 设为 `{}` 时误匹配空对象, + // 导致 `parseMcpAskToolInput` 解析失败。 + const nonEmpty = (value: unknown): Record | undefined => + value && typeof value === 'object' && Object.keys(value).length > 0 + ? (value as Record) + : undefined; + return ( + nonEmpty(extractMcpAskStructuredInputFromResult(result)) ?? + nonEmpty(eventData.raw_input) ?? + nonEmpty(eventData.rawInput) ?? + nonEmpty(ext.raw_input) ?? + nonEmpty(ext.rawInput) ?? + nonEmpty(result?.input) ?? + nonEmpty((result?.ext as Record | undefined)?.raw_input) ?? + nonEmpty((result?.ext as Record | undefined)?.rawInput) ?? + (result?.input as Record | undefined) + ); +} + +export function applyMcpAskToolCallSseEvent( + res: ConversationChatResponse, + currentMessage: MessageInfo, +): MessageInfo | null { + const envelope = parseSseEventEnvelope(res); + const eventData = extractEventData(envelope, res); + const subType = envelope.subType ?? envelope.sub_type; + + const isToolCallLikeSubType = + subType === 'tool_call' || subType === 'tool_call_update'; + const isToolCallEvent = + (envelope.message_type === 'tool_call' || + envelope.messageType === 'tool_call' || + isToolCallLikeSubType) && + !!( + eventData.tool_call_id || + eventData.toolCallId || + eventData.raw_input || + eventData.rawInput || + ((eventData.ext as Record | undefined)?.raw_input ?? + (eventData.ext as Record | undefined)?.rawInput) + ); + const isProcessingToolCallEvent = + res.eventType === ConversationEventTypeEnum.PROCESSING && + !!( + eventData.executeId || + (eventData.result as Record | undefined)?.executeId || + (eventData.result as Record | undefined)?.input + ); + + // 识别 subEventType=ASK_QUESTION 的 PROCESSING 事件。 + // 此类事件的 result.executeId 和 result.input 均为 null, + // MCP Ask 数据在 result.data 中(含 schemaVersion、ui、requestId)。 + const isAskQuestionEvent = + res.eventType === ConversationEventTypeEnum.PROCESSING && + envelope.subEventType === 'ASK_QUESTION'; + + if (!isToolCallEvent && !isProcessingToolCallEvent && !isAskQuestionEvent) { + return null; + } + + const result = eventData.result as Record | undefined; + + // ASK_QUESTION 事件:MCP Ask 数据直接在 result.data 中, + // 不遵循 ToolCall 的 result.input 结构。 + let rawInput: Record | undefined; + let toolCallId: string | undefined; + + if (isAskQuestionEvent && result) { + const resultData = result.data as Record | undefined; + if (resultData && typeof resultData === 'object') { + rawInput = resultData; + toolCallId = + (resultData.requestId as string) || + (eventData.executeId as string) || + (result.executeId as string); + } + } + + if (!rawInput) { + rawInput = readRawInput(eventData, result); + } + if (!toolCallId) { + toolCallId = + (eventData.tool_call_id as string) || + (eventData.toolCallId as string) || + (eventData.executeId as string) || + (result?.executeId as string); + } + + const executeId = + (eventData.executeId as string) || + (result?.executeId as string) || + undefined; + + const mcpAskInput = parseMcpAskToolInput(rawInput); + + if (!mcpAskInput || !toolCallId) { + return null; + } + + const interactions = currentMessage.mcpAskInteractions || []; + if ( + interactions.some((item) => item.input.requestId === mcpAskInput.requestId) + ) { + return null; + } + + return { + ...currentMessage, + mcpAskInteractions: [ + ...interactions, + { + input: mcpAskInput, + toolCallId, + executeId, + responseStatus: 'pending', + triggeredAt: createInterventionTriggeredAt(), + }, + ], + status: MessageStatusEnum.Loading, + }; +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/extractMcpAskStructuredInput.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/extractMcpAskStructuredInput.test.ts new file mode 100644 index 00000000..bc2de1e2 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/extractMcpAskStructuredInput.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { extractMcpAskStructuredInputFromResult } from './extractMcpAskStructuredInput'; + +describe('extractMcpAskStructuredInputFromResult', () => { + it('extracts canonical input from MCP structuredContent text block', () => { + const input = extractMcpAskStructuredInputFromResult({ + data: [ + { + type: 'content', + content: { + type: 'text', + text: JSON.stringify({ + status: 'pending', + requestId: 'weather-plan-confirm-5', + revision: 1, + message: 'presented', + input: { + toolName: 'nuwax_ask_question', + schemaVersion: 'nuwax.mcp_ask.v2', + requestId: 'weather-plan-confirm-5', + revision: 1, + sessionId: 'weather-dev', + title: '确认方案', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'modal', + title: '确认方案', + fields: [], + }, + }, + }), + }, + }, + ], + }); + + expect(input?.schemaVersion).toBe('nuwax.mcp_ask.v2'); + expect(input?.requestId).toBe('weather-plan-confirm-5'); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/extractMcpAskStructuredInput.ts b/qiming/src/components/business-component/AgentIntervention/utils/extractMcpAskStructuredInput.ts new file mode 100644 index 00000000..db634115 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/extractMcpAskStructuredInput.ts @@ -0,0 +1,35 @@ +/** + * 从 PROCESSING ToolCall 的 result.data 文本块中提取 MCP structuredContent.input。 + * MCP 3.3.2+ 在工具返回 JSON 中附带完整 rawInput;平台 SSE 的 result.input 可能仍是 agent 原始参数。 + */ +export function extractMcpAskStructuredInputFromResult( + result?: Record, +): Record | undefined { + const data = result?.data; + if (!Array.isArray(data)) { + return undefined; + } + + for (const item of data) { + if (!item || typeof item !== 'object') { + continue; + } + const entry = item as Record; + const content = entry.content as Record | undefined; + if (content?.type !== 'text' || typeof content.text !== 'string') { + continue; + } + + try { + const parsed = JSON.parse(content.text) as Record; + const input = parsed.input; + if (input && typeof input === 'object' && Object.keys(input).length > 0) { + return input as Record; + } + } catch { + // 非 JSON 文本,跳过 + } + } + + return undefined; +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/interventionTrigger.ts b/qiming/src/components/business-component/AgentIntervention/utils/interventionTrigger.ts new file mode 100644 index 00000000..2bbb1e12 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/interventionTrigger.ts @@ -0,0 +1,5 @@ +let triggerSequence = 0; + +export function createInterventionTriggeredAt(): number { + return Date.now() + (triggerSequence += 1); +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/mcpAskExecutedComponent.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskExecutedComponent.test.ts new file mode 100644 index 00000000..e6d622cd --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskExecutedComponent.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + isMcpAskFailedComponent, + resolveMcpAskHydratedResponseStatus, +} from './mcpAskExecutedComponent'; + +describe('mcpAskExecutedComponent', () => { + it('treats SUCCESS components as pending(是否已回答由后续 resume 消息决定)', () => { + // component status(SUCCESS/FINISHED)只代表「问」完成,不代表用户已回答; + // hydrate 默认 pending(交给 reconcile 按 resume 消息判 submitted),让历史最后一条 ASK_QUESTION 能恢复渲染 + expect( + resolveMcpAskHydratedResponseStatus({ + status: 'SUCCESS', + result: { success: true }, + }), + ).toBe('pending'); + }); + + it('treats EXECUTING components as pending', () => { + expect( + resolveMcpAskHydratedResponseStatus({ + status: 'EXECUTING', + }), + ).toBe('pending'); + }); + + it('treats FAILED components as failed', () => { + expect( + isMcpAskFailedComponent({ + status: 'FAILED', + result: { success: false }, + }), + ).toBe(true); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/mcpAskExecutedComponent.ts b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskExecutedComponent.ts new file mode 100644 index 00000000..72c70de1 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskExecutedComponent.ts @@ -0,0 +1,64 @@ +import type { McpAskInteraction } from '../types/mcpAskIntervention'; + +/** 后端持久化的 componentExecutedList 条目(结构因版本略有差异) */ +export type McpAskExecutedComponent = { + status?: string; + success?: boolean; + executeId?: string; + toolCallId?: string; + input?: unknown; + result?: { + status?: string; + success?: boolean; + executeId?: string; + toolCallId?: string; + input?: unknown; + }; +}; + +export function getMcpAskComponentInput( + component: McpAskExecutedComponent, +): unknown { + return component.input ?? component.result?.input; +} + +export function getMcpAskComponentToolCallId( + component: McpAskExecutedComponent, +): string | undefined { + const id = + component.executeId ?? + component.result?.executeId ?? + component.toolCallId ?? + component.result?.toolCallId; + return typeof id === 'string' && id ? id : undefined; +} + +function readComponentStatus(component: McpAskExecutedComponent): string { + return String(component.status ?? component.result?.status ?? '') + .trim() + .toLowerCase(); +} + +export function isMcpAskFailedComponent( + component: McpAskExecutedComponent, +): boolean { + const status = readComponentStatus(component); + return ( + status === 'failed' || + status === 'error' || + component.success === false || + component.result?.success === false + ); +} + +export function resolveMcpAskHydratedResponseStatus( + component: McpAskExecutedComponent, +): McpAskInteraction['responseStatus'] { + if (isMcpAskFailedComponent(component)) { + return 'failed'; + } + // ASK_QUESTION 的 component status(FINISHED/EXECUTING/SUCCESS)只代表「问」这一步完成, + // 不代表用户已回答——回答由后续 resume 用户消息标志(reconcileMcpAskHydratedStatus 据此判 submitted)。 + // 默认 pending,让历史最后一条 ASK_QUESTION 能恢复渲染 dockpanel;reconcile 检测到 resume 才置 submitted(关闭)。 + return 'pending'; +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/mcpAskHydrateMessage.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskHydrateMessage.test.ts new file mode 100644 index 00000000..8ad94614 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskHydrateMessage.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { + hydrateMcpAskInteractionsFromExecutedComponents, + hydrateMcpAskInteractionsInMessageList, + prependAndHydrateMcpAskMessageList, +} from './mcpAskHydrateMessage'; + +const askInput = { + schemaVersion: 'nuwax.mcp_ask.v2', + requestId: 'ask-history-1', + revision: 1, + sessionId: 'session-1', + title: '历史表单', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'inline', + title: '历史表单', + fields: [{ name: 'topic', title: '主题', widget: 'text', required: true }], + }, +}; + +describe('hydrateMcpAskInteractionsFromExecutedComponents', () => { + it('hydrates successful persisted tool calls as submitted', () => { + const message = hydrateMcpAskInteractionsFromExecutedComponents({ + id: 'msg-1', + componentExecutedList: [ + { + status: 'SUCCESS', + result: { + success: true, + executeId: 'call-1', + }, + input: { + ...askInput, + toolName: 'nuwax_ask_question', + }, + }, + ], + } as any); + + expect(message.mcpAskInteractions).toHaveLength(1); + expect(message.mcpAskInteractions?.[0].responseStatus).toBe('submitted'); + }); + + it('does not hydrate failed timed-out ask/question calls', () => { + const message = hydrateMcpAskInteractionsFromExecutedComponents({ + id: 'msg-1', + componentExecutedList: [ + { + status: 'FAILED', + result: { + success: false, + executeId: 'call-1', + }, + input: { + ...askInput, + toolName: 'nuwax_ask_question', + }, + }, + ], + } as any); + + expect(message.mcpAskInteractions).toBeUndefined(); + }); +}); + +describe('hydrateMcpAskInteractionsInMessageList', () => { + it('resolves pending asks using resume messages from already loaded batches', () => { + const olderAskMessage = { + id: 'assistant-ask', + index: 1, + componentExecutedList: [ + { + status: 'EXECUTING', + result: { executeId: 'call-1' }, + input: { ...askInput, toolName: 'nuwax_ask_question' }, + }, + ], + }; + + const newerResumeMessage = { + id: 'user-resume', + index: 2, + text: '我已填写「历史表单」,表单内容如下:\n\n主题:AI', + }; + + const mergedContext = [olderAskMessage, newerResumeMessage] as any[]; + const hydratedOlderBatch = hydrateMcpAskInteractionsInMessageList( + [olderAskMessage as any], + mergedContext, + ); + + expect(hydratedOlderBatch[0].mcpAskInteractions?.[0].responseStatus).toBe( + 'submitted', + ); + }); +}); + +describe('prependAndHydrateMcpAskMessageList', () => { + it('hydrates prepended older messages against the merged context', () => { + const olderAskMessage = { + id: 'assistant-ask', + index: 1, + componentExecutedList: [ + { + status: 'EXECUTING', + result: { executeId: 'call-1' }, + input: { ...askInput, toolName: 'nuwax_ask_question' }, + }, + ], + }; + + const currentMessageList = [ + { + id: 'user-resume', + index: 2, + text: '我已填写「历史表单」,表单内容如下:\n\n主题:AI', + }, + ] as any[]; + + const result = prependAndHydrateMcpAskMessageList( + [olderAskMessage as any], + currentMessageList, + ); + + expect(result).toHaveLength(2); + expect(result[0].mcpAskInteractions?.[0].responseStatus).toBe('submitted'); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/mcpAskHydrateMessage.ts b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskHydrateMessage.ts new file mode 100644 index 00000000..a7cedd46 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskHydrateMessage.ts @@ -0,0 +1,82 @@ +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import type { McpAskInteraction } from '../types/mcpAskIntervention'; +import { createInterventionTriggeredAt } from './interventionTrigger'; +import { + getMcpAskComponentInput, + getMcpAskComponentToolCallId, + isMcpAskFailedComponent, + resolveMcpAskHydratedResponseStatus, + type McpAskExecutedComponent, +} from './mcpAskExecutedComponent'; +import { parseMcpAskToolInput } from './parseMcpAskToolInput'; +import { reconcileMcpAskHydratedMessageList } from './reconcileMcpAskHydratedStatus'; + +export function hydrateMcpAskInteractionsFromExecutedComponents( + message: MessageInfo, +): MessageInfo { + const componentExecutedList = (message.componentExecutedList || + []) as McpAskExecutedComponent[]; + if (!componentExecutedList.length) { + return message; + } + + const existing = message.mcpAskInteractions || []; + const existingRequestIds = new Set( + existing.map((interaction) => interaction.input.requestId), + ); + const hydrated: McpAskInteraction[] = []; + + componentExecutedList.forEach((component) => { + if (isMcpAskFailedComponent(component)) { + return; + } + + const input = parseMcpAskToolInput(getMcpAskComponentInput(component)); + const toolCallId = getMcpAskComponentToolCallId(component); + if (!input || !toolCallId || existingRequestIds.has(input.requestId)) { + return; + } + + existingRequestIds.add(input.requestId); + hydrated.push({ + input, + toolCallId, + responseStatus: resolveMcpAskHydratedResponseStatus(component), + triggeredAt: createInterventionTriggeredAt(), + }); + }); + + if (!hydrated.length) { + return message; + } + + return { + ...message, + mcpAskInteractions: [...existing, ...hydrated], + }; +} + +/** + * 从历史 componentExecutedList 重建 MCP Ask 交互,并结合完整会话上下文推断是否已回复。 + */ +export function hydrateMcpAskInteractionsInMessageList( + messageList: MessageInfo[] = [], + contextMessageList: MessageInfo[] = messageList, +): MessageInfo[] { + const hydratedList = messageList.map((message) => + hydrateMcpAskInteractionsFromExecutedComponents(message), + ); + + return reconcileMcpAskHydratedMessageList(hydratedList, contextMessageList); +} + +/** + * 加载更多历史消息:前置合并后在完整上下文中 hydrate。 + */ +export function prependAndHydrateMcpAskMessageList( + olderMessages: MessageInfo[], + currentMessageList: MessageInfo[], +): MessageInfo[] { + const merged = [...olderMessages, ...currentMessageList]; + return hydrateMcpAskInteractionsInMessageList(merged, merged); +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/mcpAskResumeMessage.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskResumeMessage.test.ts new file mode 100644 index 00000000..0e090698 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskResumeMessage.test.ts @@ -0,0 +1,678 @@ +import { AssistantRoleEnum } from '@/types/enums/agent'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { + buildMcpAskResumeMessage, + hasMcpAskResumeMessage, + isMcpAskResumeMessageForInteraction, + stripMcpAskResumeDisplayArtifacts, +} from './mcpAskResumeMessage'; + +import { describe, expect, it, vi } from 'vitest'; +import type { McpAskInteraction } from '../types/mcpAskIntervention'; + +/** 测试用中文 i18n 字典,与 zh-CN 本地包 resume 相关 key 对齐 */ +const zhCnResumeDict: Record = { + 'PC.Common.Global.yes': '是', + 'PC.Common.Global.no': '否', + 'PC.Components.McpAskQuestionCard.defaultTitle': '这次提问', + 'PC.Components.McpAskQuestionCard.notFilled': '未填写', + 'PC.Components.McpAskQuestionCard.unknownFile': '未知文件', + 'PC.Components.McpAskQuestionCard.file': '文件', + 'PC.Components.McpAskQuestionCard.emptyFormContent': '(无表单内容)', + 'PC.Components.McpAskQuestionCard.resumeCancelled': '我取消了「{0}」。', + 'PC.Components.McpAskQuestionCard.resumeSkipped': '我跳过了「{0}」。', + 'PC.Components.McpAskQuestionCard.resumeTimeout': + '「{0}」已超时,没有收到表单答案。', + 'PC.Components.McpAskQuestionCard.resumeSubmitted': + '我已填写「{0}」,表单内容如下:', +}; + +/** 测试用英文 i18n 字典 */ +const enUsResumeDict: Record = { + 'PC.Common.Global.yes': 'Yes', + 'PC.Common.Global.no': 'No', + 'PC.Components.McpAskQuestionCard.defaultTitle': 'This question', + 'PC.Components.McpAskQuestionCard.notFilled': 'Not provided', + 'PC.Components.McpAskQuestionCard.unknownFile': 'Unknown file', + 'PC.Components.McpAskQuestionCard.file': 'File', + 'PC.Components.McpAskQuestionCard.emptyFormContent': '(No form content)', + 'PC.Components.McpAskQuestionCard.resumeCancelled': 'I cancelled "{0}".', + 'PC.Components.McpAskQuestionCard.resumeSkipped': 'I skipped "{0}".', + 'PC.Components.McpAskQuestionCard.resumeTimeout': + '"{0}" timed out. No form answer was received.', + 'PC.Components.McpAskQuestionCard.resumeSubmitted': + 'I answered "{0}". Form details:', +}; + +let activeDict = zhCnResumeDict; +let activeLang = 'zh-CN'; + +vi.mock('@/services/i18nRuntime', () => ({ + dict: (key: string, ...args: (string | number)[]) => { + const template = activeDict[key] ?? key; + return args.reduce( + (text, item, index) => + text.replace(new RegExp(`\\{${index}\\}`, 'g'), String(item)), + template, + ); + }, + getCurrentLang: () => activeLang, +})); + +const baseInteraction: McpAskInteraction = { + toolCallId: 'tc-1', + input: { + toolName: 'nuwax_ask_question', + schemaVersion: 'nuwax.mcp_ask.v2', + requestId: 'ask-1', + revision: 1, + sessionId: 'session-1', + title: '请选择继续方式', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'inline', + title: '请选择继续方式', + fields: [ + { + name: 'choice', + title: '选项', + widget: 'radio', + required: true, + options: [ + { value: 'deploy', label: '直接部署' }, + { value: 'test', label: '先跑测试' }, + { value: 'cancel', label: '取消任务' }, + ], + }, + { name: 'notes', title: '补充说明', widget: 'textarea' }, + { + name: 'checks', + title: '检查项', + widget: 'checkboxes', + type: 'array', + options: [ + { value: 'lint', label: '代码检查' }, + { value: 'unit', label: '单元测试' }, + ], + }, + ], + }, + }, +}; + +describe('buildMcpAskResumeMessage', () => { + it('formats submitted answers as user-friendly label value lines', () => { + activeDict = zhCnResumeDict; + activeLang = 'zh-CN'; + const message = buildMcpAskResumeMessage(baseInteraction, { + interventionId: 'ask-1', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { + choice: 'test', + notes: '先跑关键链路', + checks: ['lint', 'unit'], + }, + }); + + expect(message).toBe( + [ + '我已填写「请选择继续方式」,表单内容如下:', + '选项:先跑测试', + '补充说明:先跑关键链路', + '检查项:代码检查、单元测试', + ].join('\n'), + ); + expect(message).not.toContain('nuwax-mcp-ask-request-id'); + expect(message).not.toContain('"choice"'); + expect(message).not.toContain('```json'); + }); + + it('keeps unknown fields readable without JSON blocks', () => { + activeDict = zhCnResumeDict; + activeLang = 'zh-CN'; + const message = buildMcpAskResumeMessage(baseInteraction, { + interventionId: 'ask-1', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { + confirmed: true, + extra: { owner: 'alice', retry: 2 }, + }, + }); + + expect(message).toContain('confirmed:是'); + expect(message).toContain('extra:owner:alice,retry:2'); + expect(message).not.toContain('nuwax-mcp-ask-request-id'); + }); + + it('uses custom radio input as the field value', () => { + activeDict = zhCnResumeDict; + activeLang = 'zh-CN'; + const message = buildMcpAskResumeMessage( + { + ...baseInteraction, + input: { + ...baseInteraction.input, + ui: { + ...baseInteraction.input.ui, + fields: [ + { + name: 'choice', + title: '选项', + widget: 'radio-with-custom', + options: [ + { value: 'deploy', label: '直接部署' }, + { value: 'test', label: '先跑测试' }, + ], + otherField: 'choiceOther', + }, + ], + }, + }, + }, + { + interventionId: 'ask-1', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { + choice: '__custom__', + choiceOther: '先发灰度环境', + }, + }, + ); + + expect(message).toContain('选项:先发灰度环境'); + expect(message).not.toContain('__custom__'); + expect(message).not.toContain('choiceOther'); + }); + + it('formats cancel, skip, and timeout as normal chat messages', () => { + activeDict = zhCnResumeDict; + activeLang = 'zh-CN'; + const commonPayload = { + interventionId: 'ask-1', + revision: 1, + source: 'mcp_ask' as const, + protocol: 'mcp' as const, + }; + + expect( + buildMcpAskResumeMessage(baseInteraction, { + ...commonPayload, + action: 'cancel', + }), + ).toContain('我取消了「请选择继续方式」。'); + expect( + buildMcpAskResumeMessage(baseInteraction, { + ...commonPayload, + action: 'skip', + }), + ).toContain('我跳过了「请选择继续方式」。'); + expect( + buildMcpAskResumeMessage(baseInteraction, { + ...commonPayload, + action: 'timeout', + }), + ).toContain('「请选择继续方式」已超时,没有收到表单答案。'); + expect( + buildMcpAskResumeMessage(baseInteraction, { + ...commonPayload, + action: 'cancel', + }), + ).not.toContain('nuwax-mcp-ask-request-id'); + }); + + it('uses English templates when locale dict is English', () => { + activeDict = enUsResumeDict; + activeLang = 'en-US'; + const message = buildMcpAskResumeMessage(baseInteraction, { + interventionId: 'ask-1', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { + confirmed: true, + }, + }); + + expect(message).toContain('I answered "请选择继续方式". Form details:'); + expect(message).toContain('confirmed: Yes'); + }); + + it('formats normalized file field URLs in submitted resume message', () => { + activeDict = zhCnResumeDict; + activeLang = 'zh-CN'; + const message = buildMcpAskResumeMessage( + { + ...baseInteraction, + input: { + ...baseInteraction.input, + title: '提交问题截图', + ui: { + ...baseInteraction.input.ui, + title: '提交问题截图', + fields: [ + { name: 'screenshot', title: '截图', widget: 'file' }, + { + name: 'attachments', + title: '相关附件', + widget: 'file', + multiple: true, + }, + ], + }, + }, + }, + { + interventionId: 'ask-1', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { + screenshot: 'https://cdn.example.com/shot.png', + attachments: [ + 'https://cdn.example.com/a.pdf', + 'https://cdn.example.com/b.pdf', + ], + }, + }, + ); + + expect(message).toContain('截图:shot.png'); + expect(message).toContain('相关附件:a.pdf、b.pdf'); + }); + + it('prefers file name over remote URL for UploadFileInfo objects', () => { + activeDict = zhCnResumeDict; + activeLang = 'zh-CN'; + const message = buildMcpAskResumeMessage( + { + ...baseInteraction, + input: { + ...baseInteraction.input, + ui: { + ...baseInteraction.input.ui, + fields: [{ name: 'screenshot', title: '截图', widget: 'file' }], + }, + }, + }, + { + interventionId: 'ask-1', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { + screenshot: [ + { + name: 'shot.png', + url: 'https://cdn.example.com/shot.png', + }, + ], + }, + }, + ); + + expect(message).toContain('截图:shot.png'); + }); + + it('does not append internal requestId marker to chat payload', () => { + activeDict = zhCnResumeDict; + activeLang = 'zh-CN'; + const sent = buildMcpAskResumeMessage( + { + ...baseInteraction, + input: { + ...baseInteraction.input, + ui: { + ...baseInteraction.input.ui, + fields: [{ name: 'screenshot', title: '文件上传', widget: 'file' }], + }, + }, + }, + { + interventionId: 'ask-1', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { + screenshot: 'https://cdn.example.com/a.png', + }, + }, + ); + + expect(sent).toContain('文件上传:a.png'); + expect(sent).not.toContain('nuwax-mcp-ask-request-id'); + }); +}); + +describe('stripMcpAskResumeDisplayArtifacts', () => { + it('removes HTML requestId marker from display text', () => { + const raw = + '我已填写「补充回复」,表单内容如下:\n文件上传:https://cdn.example.com/shot.png\n'; + expect(stripMcpAskResumeDisplayArtifacts(raw)).toBe( + '我已填写「补充回复」,表单内容如下:\n文件上传:https://cdn.example.com/shot.png', + ); + }); +}); + +describe('isMcpAskResumeMessageForInteraction', () => { + const createSharedTitleInteraction = ( + requestId: string, + responseStatus?: McpAskInteraction['responseStatus'], + ): McpAskInteraction => ({ + ...baseInteraction, + toolCallId: `tc-${requestId}`, + responseStatus, + input: { + ...baseInteraction.input, + requestId, + title: '补充回复', + ui: { + ...baseInteraction.input.ui, + title: '补充回复', + }, + }, + }); + + it('matches resume messages across supported locales and legacy Chinese text', () => { + activeDict = enUsResumeDict; + activeLang = 'en-US'; + const title = '请选择继续方式'; + + expect( + isMcpAskResumeMessageForInteraction( + '我已填写「请选择继续方式」,表单内容如下:\n选项:先跑测试', + baseInteraction, + ), + ).toBe(true); + + expect( + isMcpAskResumeMessageForInteraction( + `I answered "${title}". Form details:\nchoice: test`, + baseInteraction, + ), + ).toBe(true); + + expect( + isMcpAskResumeMessageForInteraction( + '我取消了「请选择继续方式」。', + baseInteraction, + ), + ).toBe(true); + + expect( + isMcpAskResumeMessageForInteraction( + 'unrelated user message', + baseInteraction, + ), + ).toBe(false); + }); + + it('matches resume by ordinal pairing when multiple asks share the same title', () => { + const sharedTitle = '补充回复'; + const firstInteraction: McpAskInteraction = { + ...baseInteraction, + input: { + ...baseInteraction.input, + requestId: 'ask-first', + title: sharedTitle, + ui: { + ...baseInteraction.input.ui, + title: sharedTitle, + }, + }, + }; + const secondInteraction: McpAskInteraction = { + ...firstInteraction, + input: { + ...firstInteraction.input, + requestId: 'ask-second', + }, + toolCallId: 'tc-2', + }; + + const firstResume = buildMcpAskResumeMessage(firstInteraction, { + interventionId: 'ask-first', + revision: 1, + source: 'mcp_ask', + protocol: 'mcp', + action: 'submit', + formData: { choice: 'test' }, + }); + + const messageList = [ + { + id: 'assistant-ask-1', + index: 0, + mcpAskInteractions: [firstInteraction], + } as MessageInfo, + { + id: 'user-resume', + index: 1, + role: AssistantRoleEnum.USER, + text: firstResume, + } as MessageInfo, + { + id: 'assistant-ask-2', + index: 2, + mcpAskInteractions: [secondInteraction], + } as MessageInfo, + ]; + + expect( + isMcpAskResumeMessageForInteraction(firstResume, firstInteraction), + ).toBe(true); + expect( + hasMcpAskResumeMessage(messageList, secondInteraction, { + containingMessageIndex: 2, + }), + ).toBe(false); + expect( + hasMcpAskResumeMessage(messageList, firstInteraction, { + containingMessageIndex: 0, + }), + ).toBe(true); + }); + + it('matches legacy resume before ask when message indexes are out of storage order', () => { + const messageList = [ + { + id: 'user-resume', + index: 1, + text: '我已填写「请选择继续方式」,表单内容如下:\n选项:先跑测试', + }, + { + id: 'assistant-ask', + index: 2, + mcpAskInteractions: [baseInteraction], + }, + ] as MessageInfo[]; + + expect( + hasMcpAskResumeMessage(messageList, baseInteraction, { + containingMessageIndex: 1, + }), + ).toBe(true); + }); + + it('still recognizes legacy fenced JSON requestId markers', () => { + const legacyResume = + '我已填写「请选择继续方式」,表单内容如下:\n\n```json\n{"nuwaxMcpAskRequestId":"ask-1"}\n```'; + expect( + isMcpAskResumeMessageForInteraction(legacyResume, baseInteraction), + ).toBe(true); + }); + + it('does not legacy-match a later ask when an earlier assistant message had the same title', () => { + const sharedTitle = '补充回复'; + const firstInteraction: McpAskInteraction = { + ...baseInteraction, + input: { + ...baseInteraction.input, + requestId: 'ask-first', + title: sharedTitle, + ui: { ...baseInteraction.input.ui, title: sharedTitle }, + }, + }; + const secondInteraction: McpAskInteraction = { + ...firstInteraction, + input: { ...firstInteraction.input, requestId: 'ask-second' }, + toolCallId: 'tc-2', + }; + const messageList = [ + { + id: 'assistant-ask-1', + index: 2, + mcpAskInteractions: [ + { ...firstInteraction, responseStatus: 'submitted' as const }, + ], + }, + { + id: 'user-resume', + index: 3, + text: `我已填写「${sharedTitle}」,表单内容如下:\n选项:test`, + }, + { + id: 'assistant-ask-2', + index: 4, + mcpAskInteractions: [ + { ...secondInteraction, responseStatus: 'pending' as const }, + ], + }, + ] as MessageInfo[]; + + expect( + hasMcpAskResumeMessage(messageList, secondInteraction, { + containingMessageIndex: 2, + }), + ).toBe(false); + expect( + hasMcpAskResumeMessage(messageList, firstInteraction, { + containingMessageIndex: 0, + }), + ).toBe(true); + }); + + it('pairs a lone resume with the nearest preceding unresolved same-title ask', () => { + const firstInteraction = createSharedTitleInteraction('ask-first'); + const secondInteraction = createSharedTitleInteraction('ask-second'); + const resumeText = '我已填写「补充回复」,表单内容如下:\n你的名字:alice'; + const messageList = [ + { + id: 'assistant-ask-1', + index: 0, + mcpAskInteractions: [firstInteraction], + }, + { + id: 'assistant-ask-2', + index: 2, + mcpAskInteractions: [secondInteraction], + }, + { + id: 'user-resume-2', + index: 3, + role: AssistantRoleEnum.USER, + text: resumeText, + }, + ] as MessageInfo[]; + + expect( + hasMcpAskResumeMessage(messageList, firstInteraction, { + containingMessageIndex: 0, + }), + ).toBe(false); + expect( + hasMcpAskResumeMessage(messageList, secondInteraction, { + containingMessageIndex: 1, + }), + ).toBe(true); + }); + + it('does not pair a foreign marker-era resume with a new same-title ask', () => { + const interaction = createSharedTitleInteraction('ask-new'); + const messageList = [ + { + id: 'user-old-resume', + index: 1, + role: AssistantRoleEnum.USER, + text: [ + '我已填写「补充回复」,表单内容如下:', + '你的名字:alice', + '', + ].join('\n'), + }, + { + id: 'assistant-new-ask', + index: 5, + mcpAskInteractions: [interaction], + }, + ] as MessageInfo[]; + + expect( + hasMcpAskResumeMessage(messageList, interaction, { + containingMessageIndex: 1, + }), + ).toBe(false); + }); + + it('pairs an ask and resume that share the same conversation index', () => { + const interaction = createSharedTitleInteraction('ask-tie'); + const messageList = [ + { + id: 'assistant-ask', + index: 7, + mcpAskInteractions: [interaction], + }, + { + id: 'user-resume', + index: 7, + role: AssistantRoleEnum.USER, + text: '我已填写「补充回复」,表单内容如下:\n你的名字:alice', + }, + ] as MessageInfo[]; + + expect( + hasMcpAskResumeMessage(messageList, interaction, { + containingMessageIndex: 0, + }), + ).toBe(true); + }); + + it('prefers a resolved ask when same-title asks share one message', () => { + const firstInteraction = createSharedTitleInteraction( + 'ask-first', + 'submitted', + ); + const secondInteraction = createSharedTitleInteraction( + 'ask-second', + 'pending', + ); + const messageList = [ + { + id: 'assistant-asks', + index: 1, + mcpAskInteractions: [firstInteraction, secondInteraction], + }, + { + id: 'user-resume', + index: 2, + role: AssistantRoleEnum.USER, + text: '我已填写「补充回复」,表单内容如下:\n你的名字:alice', + }, + ] as MessageInfo[]; + + expect(hasMcpAskResumeMessage(messageList, firstInteraction)).toBe(true); + expect(hasMcpAskResumeMessage(messageList, secondInteraction)).toBe(false); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/mcpAskResumeMessage.ts b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskResumeMessage.ts new file mode 100644 index 00000000..37151e40 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/mcpAskResumeMessage.ts @@ -0,0 +1,691 @@ +import { + MIN_EN_I18N_MAP, + MIN_JA_I18N_MAP, + MIN_ZH_HK_I18N_MAP, + MIN_ZH_I18N_MAP, + MIN_ZH_TW_I18N_MAP, +} from '@/constants/i18n.constants'; +import { dict, getCurrentLang } from '@/services/i18nRuntime'; +import { AssistantRoleEnum } from '@/types/enums/agent'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '../types/mcpAskIntervention'; +import { parseInteractionFields } from './parseMcpAskSchema'; + +/** MCP Ask resume 消息相关的 i18n key 前缀 */ +const I18N_PREFIX = 'PC.Components.McpAskQuestionCard'; + +type McpAskResumeAction = 'submit' | 'cancel' | 'skip' | 'timeout'; + +const RESUME_MESSAGE_KEY_BY_ACTION: Record< + Exclude, + string +> = { + cancel: `${I18N_PREFIX}.resumeCancelled`, + skip: `${I18N_PREFIX}.resumeSkipped`, + timeout: `${I18N_PREFIX}.resumeTimeout`, +}; + +const SUBMITTED_HEADER_KEY = `${I18N_PREFIX}.resumeSubmitted`; + +/** resume 消息 JSON 块中的 requestId 键(历史 fenced JSON 格式,仍识别) */ +export const MCP_ASK_REQUEST_ID_MARKER_KEY = 'nuwaxMcpAskRequestId'; + +/** HTML 注释标记前缀,对用户不可见 */ +const MCP_ASK_REQUEST_ID_HTML_PREFIX = 'nuwax-mcp-ask-request-id:'; + +/** 匹配 resume 消息末尾附带的 HTML 注释 requestId 标记 */ +const MCP_ASK_REQUEST_ID_HTML_COMMENT_RE = + /\n?/g; + +export interface McpAskResumeMatchOptions { + /** 承载该 interaction 的消息在已排序列表中的下标 */ + containingMessageIndex?: number; +} + +/** + * 按会话 message.index 升序排列,与 intervention 队列保持一致。 + */ +export function sortMessagesByConversationIndex( + messages: MessageInfo[], +): MessageInfo[] { + return [...(messages ?? [])].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); +} + +function readMessageOrdinal(message: MessageInfo, fallback = 0): number { + return message.index ?? fallback; +} + +/** + * 生成附在 resume 消息末尾的 HTML 注释 requestId 标记(聊天 UI 不展示)。 + */ +export function buildMcpAskRequestIdMarker(requestId: string): string { + return `\n`; +} + +/** + * 移除 resume 消息中仅供内部匹配的 requestId 标记,供聊天 UI 展示与复制使用。 + * 原始 message.text 仍保留标记,以便 hasMcpAskResumeMessage 等逻辑识别。 + */ +export function stripMcpAskResumeDisplayArtifacts( + text: string | undefined, +): string { + if (!text) { + return ''; + } + return text + .replace(MCP_ASK_REQUEST_ID_HTML_COMMENT_RE, '') + .replace(/\s+$/, ''); +} + +/** MCP Ask resume 表单展示用标点:英文 locale 用西式标点,其余语系用 CJK 标点 */ +function getMcpAskDisplaySeparators(lang = getCurrentLang()) { + const isEnglish = lang.toLowerCase().startsWith('en'); + if (isEnglish) { + return { + listSeparator: ', ', + objectEntrySeparator: '; ', + labelSeparator: ': ', + }; + } + return { + listSeparator: '、', + objectEntrySeparator: ',', + labelSeparator: ':', + }; +} + +function extractFileNameFromUrl(url: string, fallback: string): string { + try { + const pathname = new URL(url).pathname; + return pathname.split('/').filter(Boolean).pop() || fallback; + } catch { + return url.split('/').filter(Boolean).pop() || fallback; + } +} + +function messageHasForeignRequestIdMarker(text: string): boolean { + return ( + text.includes(MCP_ASK_REQUEST_ID_HTML_PREFIX) || + text.includes(MCP_ASK_REQUEST_ID_MARKER_KEY) + ); +} + +/** + * 判断文本是否包含指定 requestId 的 resume 标记(HTML 注释或历史 JSON 块)。 + */ +export function textContainsMcpAskRequestIdMarker( + text: string | undefined, + requestId: string, +): boolean { + if (!text || !requestId) { + return false; + } + if (text.includes(``)) { + return true; + } + const compact = `"${MCP_ASK_REQUEST_ID_MARKER_KEY}":"${requestId}"`; + const spaced = `"${MCP_ASK_REQUEST_ID_MARKER_KEY}": "${requestId}"`; + return text.includes(compact) || text.includes(spaced); +} + +/** 各语言本地包,用于跨语言匹配历史 resume 消息 */ +const LOCAL_RESUME_MESSAGE_MAPS = [ + MIN_ZH_I18N_MAP, + MIN_ZH_TW_I18N_MAP, + MIN_ZH_HK_I18N_MAP, + MIN_EN_I18N_MAP, + MIN_JA_I18N_MAP, +] as const; + +/** + * 迁移期兜底:旧版硬编码中文 resume 消息首行片段。 + * 用于识别切换语言前已发送的历史消息。 + */ +const LEGACY_RESUME_MESSAGE_SNIPPETS_BY_ACTION: Record< + McpAskResumeAction, + (title: string) => string +> = { + submit: (title) => `我已填写「${title}」`, + cancel: (title) => `我取消了「${title}」`, + skip: (title) => `我跳过了「${title}」`, + timeout: (title) => `「${title}」已超时`, +}; + +/** + * 按模板与占位符生成文案(不依赖当前运行时语言)。 + */ +function formatTemplate(template: string, values: (string | number)[]): string { + let text = template; + values.forEach((value, index) => { + text = text.replace(new RegExp(`\\{${index}\\}`, 'g'), String(value ?? '')); + }); + return text; +} + +/** + * 从指定语言包读取 resume 消息模板并格式化。 + */ +function formatResumeMessageFromMap( + map: Record, + key: string, + title: string, +): string | undefined { + const template = map[key]; + if (!template?.trim()) { + return undefined; + } + return formatTemplate(template, [title]); +} + +/** + * 收集某标题下所有语言的 resume 消息签名,供历史消息识别使用。 + */ +function collectResumeMessageSignatures( + title: string, + action: McpAskResumeAction, +): string[] { + const key = + action === 'submit' + ? SUBMITTED_HEADER_KEY + : RESUME_MESSAGE_KEY_BY_ACTION[action]; + const signatures = new Set(); + + LOCAL_RESUME_MESSAGE_MAPS.forEach((map) => { + const formatted = formatResumeMessageFromMap(map, key, title); + if (formatted) { + signatures.add(formatted); + // submit 消息可能有多行,仅用首行做 includes 匹配 + if (action === 'submit') { + signatures.add(formatted.split('\n')[0]); + } + } + }); + + signatures.add(LEGACY_RESUME_MESSAGE_SNIPPETS_BY_ACTION[action](title)); + + return [...signatures]; +} + +function tMcpAsk(key: string, ...values: (string | number)[]): string { + return dict(key, ...values); +} + +function formatFileFieldDisplayValue(value: unknown): string { + if (value === undefined || value === null || value === '') { + return tMcpAsk(`${I18N_PREFIX}.notFilled`); + } + + const formatSingle = (item: unknown): string => { + if (typeof item === 'string') { + return /^https?:\/\//i.test(item) + ? extractFileNameFromUrl(item, tMcpAsk(`${I18N_PREFIX}.unknownFile`)) + : item; + } + if (typeof item === 'object' && item !== null) { + const file = item as { + name?: string; + fileName?: string; + url?: string; + fileUrl?: string; + }; + return ( + file.name || + file.fileName || + (file.url || file.fileUrl + ? extractFileNameFromUrl( + file.url || file.fileUrl || '', + tMcpAsk(`${I18N_PREFIX}.unknownFile`), + ) + : tMcpAsk(`${I18N_PREFIX}.unknownFile`)) + ); + } + return String(item); + }; + + const { listSeparator } = getMcpAskDisplaySeparators(); + if (Array.isArray(value)) { + if (!value.length) { + return tMcpAsk(`${I18N_PREFIX}.notFilled`); + } + return value.map(formatSingle).join(listSeparator); + } + + return formatSingle(value); +} + +function stringifyDisplayValue(value: unknown): string { + if (value === undefined || value === null || value === '') { + return tMcpAsk(`${I18N_PREFIX}.notFilled`); + } + if (typeof value === 'boolean') { + return value + ? tMcpAsk('PC.Common.Global.yes') + : tMcpAsk('PC.Common.Global.no'); + } + if (typeof value === 'string' || typeof value === 'number') { + return String(value); + } + + const { listSeparator, objectEntrySeparator, labelSeparator } = + getMcpAskDisplaySeparators(); + + if (Array.isArray(value)) { + if (!value.length) { + return tMcpAsk(`${I18N_PREFIX}.notFilled`); + } + // 检查是否为文件上传数组 (UploadFileInfo[]) + if (value.length > 0 && typeof value[0] === 'object' && value[0] !== null) { + const first = value[0] as any; + if ( + 'name' in first || + 'url' in first || + 'fileName' in first || + 'fileUrl' in first + ) { + return formatFileFieldDisplayValue(value); + } + } + return value.map(stringifyDisplayValue).join(listSeparator); + } + if (typeof value === 'object') { + // 检查是否为单个文件对象 + const obj = value as any; + if (obj.name || obj.fileName || obj.url || obj.fileUrl) { + return formatFileFieldDisplayValue(value); + } + const entries = Object.entries(value as Record); + if (!entries.length) { + return tMcpAsk(`${I18N_PREFIX}.notFilled`); + } + return entries + .map( + ([key, item]) => + `${key}${labelSeparator}${stringifyDisplayValue(item)}`, + ) + .join(objectEntrySeparator); + } + return String(value); +} + +function formatFieldValue( + value: unknown, + enumValues: string[], + enumLabels: string[], +): string { + const labelByValue = new Map( + enumValues.map((item, index) => [item, enumLabels[index] ?? item]), + ); + + if (Array.isArray(value)) { + return stringifyDisplayValue( + value.map((item) => + typeof item === 'string' ? labelByValue.get(item) ?? item : item, + ), + ); + } + + if (typeof value === 'string') { + return labelByValue.get(value) ?? value; + } + + return stringifyDisplayValue(value); +} + +function formatAskFormData( + interaction: McpAskInteraction, + formData?: Record, +) { + const emptyFormContent = tMcpAsk(`${I18N_PREFIX}.emptyFormContent`); + if (!formData || Object.keys(formData).length === 0) { + return emptyFormContent; + } + + const { labelSeparator } = getMcpAskDisplaySeparators(); + const fields = parseInteractionFields(interaction.input.ui); + const consumedKeys = new Set(); + const lines = fields + .filter((field) => + Object.prototype.hasOwnProperty.call(formData, field.name), + ) + .map((field) => { + consumedKeys.add(field.name); + const label = field.property.title || field.name; + const otherValue = field.options.otherValue ?? '__custom__'; + const otherField = field.options.otherField ?? `${field.name}Custom`; + if ( + field.widget === 'radio-with-custom' && + formData[field.name] === otherValue && + Object.prototype.hasOwnProperty.call(formData, otherField) + ) { + consumedKeys.add(otherField); + return `${label}${labelSeparator}${stringifyDisplayValue( + formData[otherField], + )}`; + } + const rawValue = formData[field.name]; + const value = + field.widget === 'file' + ? formatFileFieldDisplayValue(rawValue) + : formatFieldValue(rawValue, field.enumValues, field.enumLabels); + return `${label}${labelSeparator}${value}`; + }); + + Object.entries(formData).forEach(([key, value]) => { + if (!consumedKeys.has(key)) { + lines.push(`${key}${labelSeparator}${stringifyDisplayValue(value)}`); + } + }); + + return lines.length ? lines.join('\n') : emptyFormContent; +} + +export function getMcpAskResumeTitle(interaction: McpAskInteraction): string { + return ( + interaction.input.title || + interaction.input.ui.title || + tMcpAsk(`${I18N_PREFIX}.defaultTitle`) + ); +} + +/** 存在其他同 title ask 时,标题兜底无法可靠判断 resume 归属。 */ +function shouldBlockLegacyTitleMatch( + sortedMessages: MessageInfo[], + interaction: McpAskInteraction, +): boolean { + const title = getMcpAskResumeTitle(interaction); + + return sortedMessages.some((message) => + (message.mcpAskInteractions ?? []).some( + (item) => + item.input.requestId !== interaction.input.requestId && + getMcpAskResumeTitle(item) === title, + ), + ); +} + +function resolveContainingMessageIndex( + sortedMessages: MessageInfo[], + interaction: McpAskInteraction, + explicitIndex?: number, +): number | undefined { + if (explicitIndex !== undefined && explicitIndex >= 0) { + return explicitIndex; + } + const autoIndex = sortedMessages.findIndex((message) => + message.mcpAskInteractions?.some( + (item) => item.input.requestId === interaction.input.requestId, + ), + ); + return autoIndex >= 0 ? autoIndex : undefined; +} + +export function buildMcpAskResumeMessage( + interaction: McpAskInteraction, + payload: McpAskRespondPayload, +) { + const title = getMcpAskResumeTitle(interaction); + + if (payload.action === 'cancel') { + return tMcpAsk(RESUME_MESSAGE_KEY_BY_ACTION.cancel, title); + } + if (payload.action === 'skip') { + return tMcpAsk(RESUME_MESSAGE_KEY_BY_ACTION.skip, title); + } + if (payload.action === 'timeout') { + return tMcpAsk(RESUME_MESSAGE_KEY_BY_ACTION.timeout, title); + } + + return [ + tMcpAsk(SUBMITTED_HEADER_KEY, title), + formatAskFormData(interaction, payload.formData), + ].join('\n'); +} + +function messageMatchesAnyResumeSignature( + text: string | undefined, + title: string, +): boolean { + if (!text?.trim()) { + return false; + } + const actions: McpAskResumeAction[] = ['submit', 'cancel', 'skip', 'timeout']; + return actions.some((action) => + collectResumeMessageSignatures(title, action).some((signature) => + text.includes(signature), + ), + ); +} + +function collectSameTitleAskOrdinals( + sortedMessages: MessageInfo[], + title: string, +): Array<{ + ordinal: number; + sequence: number; + requestId: string; + resolved: boolean; +}> { + const entries: Array<{ + ordinal: number; + sequence: number; + requestId: string; + resolved: boolean; + }> = []; + let sequence = 0; + + sortedMessages.forEach((message, messageIndex) => { + const ordinal = readMessageOrdinal(message, messageIndex); + message.mcpAskInteractions?.forEach((item) => { + if (getMcpAskResumeTitle(item) !== title) { + return; + } + const responseStatus = item.responseStatus ?? 'pending'; + entries.push({ + ordinal, + sequence: sequence++, + requestId: item.input.requestId, + resolved: + responseStatus !== 'pending' && responseStatus !== 'submitting', + }); + }); + }); + + return entries.sort( + (left, right) => + left.ordinal - right.ordinal || left.sequence - right.sequence, + ); +} + +function collectSameTitleResumeOrdinals( + sortedMessages: MessageInfo[], + title: string, +): Array<{ ordinal: number; sequence: number }> { + const entries: Array<{ ordinal: number; sequence: number }> = []; + + sortedMessages.forEach((message, messageIndex) => { + if (message.role !== undefined && message.role !== AssistantRoleEnum.USER) { + return; + } + const text = message.text; + if ( + !text || + messageHasForeignRequestIdMarker(text) || + !messageMatchesAnyResumeSignature(text, title) + ) { + return; + } + entries.push({ + ordinal: readMessageOrdinal(message, messageIndex), + sequence: messageIndex, + }); + }); + + return entries.sort( + (left, right) => + left.ordinal - right.ordinal || left.sequence - right.sequence, + ); +} + +/** + * 同 title 多次询问时,将无 marker 的 resume 配给它之前最可信的 ask: + * 已有终态的 ask 优先,否则取位置最近的未配对 ask。 + * 不依赖写入 chat 接口的 HTML 注释 marker。 + */ +function hasOrdinalPairedResumeMessage( + sortedMessages: MessageInfo[], + interaction: McpAskInteraction, +): boolean { + const title = getMcpAskResumeTitle(interaction); + const asks = collectSameTitleAskOrdinals(sortedMessages, title); + const resumes = collectSameTitleResumeOrdinals(sortedMessages, title); + const targetAsk = asks.find( + (entry) => entry.requestId === interaction.input.requestId, + ); + if (!targetAsk) { + return false; + } + + const pairedRequestIds = new Set(); + resumes.forEach((resume) => { + const candidates = asks.filter( + (ask) => + !pairedRequestIds.has(ask.requestId) && + (ask.ordinal < resume.ordinal || + (ask.ordinal === resume.ordinal && ask.sequence <= resume.sequence)), + ); + if (!candidates.length) { + return; + } + + const resolvedCandidate = candidates.find((ask) => ask.resolved); + const selectedAsk = resolvedCandidate ?? candidates[candidates.length - 1]; + pairedRequestIds.add(selectedAsk.requestId); + }); + + if (pairedRequestIds.has(targetAsk.requestId)) { + return true; + } + + // 历史 hydrate:单条无 marker resume 的 index 可能小于 ask(存储乱序) + return ( + asks.length === 1 && + resumes.length === 1 && + resumes[0].ordinal < targetAsk.ordinal + ); +} + +function matchesLegacyTitleResumeMessage( + text: string, + interaction: McpAskInteraction, +): boolean { + // 带其他 requestId 标记的 resume 不应被同 title 的后续 ask 误匹配 + if ( + messageHasForeignRequestIdMarker(text) && + !textContainsMcpAskRequestIdMarker(text, interaction.input.requestId) + ) { + return false; + } + + const title = getMcpAskResumeTitle(interaction); + const actions: McpAskResumeAction[] = ['submit', 'cancel', 'skip', 'timeout']; + + return actions.some((action) => + collectResumeMessageSignatures(title, action).some((signature) => + text.includes(signature), + ), + ); +} + +export function isMcpAskResumeMessageForInteraction( + text: string | undefined, + interaction: McpAskInteraction, + options?: { legacyTitleMatch?: boolean }, +): boolean { + if (!text) { + return false; + } + + if (textContainsMcpAskRequestIdMarker(text, interaction.input.requestId)) { + return true; + } + + if (options?.legacyTitleMatch === false) { + return false; + } + + return matchesLegacyTitleResumeMessage(text, interaction); +} + +export function hasMcpAskResumeMessage( + messages: MessageInfo[], + interaction: McpAskInteraction, + options?: McpAskResumeMatchOptions, +): boolean { + const sortedMessages = sortMessagesByConversationIndex(messages); + const containingMessageIndex = resolveContainingMessageIndex( + sortedMessages, + interaction, + options?.containingMessageIndex, + ); + const containingMessage = + containingMessageIndex !== undefined + ? sortedMessages[containingMessageIndex] + : undefined; + const containingOrdinal = containingMessage + ? readMessageOrdinal(containingMessage, containingMessageIndex ?? 0) + : undefined; + + if ( + sortedMessages.some((message) => + textContainsMcpAskRequestIdMarker( + message.text, + interaction.input.requestId, + ), + ) + ) { + return true; + } + + if (hasOrdinalPairedResumeMessage(sortedMessages, interaction)) { + return true; + } + + if (shouldBlockLegacyTitleMatch(sortedMessages, interaction)) { + return false; + } + + const matchesLegacy = (text: string | undefined) => + text ? matchesLegacyTitleResumeMessage(text, interaction) : false; + + if (containingOrdinal !== undefined) { + const afterMessages = sortedMessages.filter( + (message) => readMessageOrdinal(message, 0) > containingOrdinal, + ); + if (afterMessages.some((message) => matchesLegacy(message.text))) { + return true; + } + + // 历史 hydrate:resume 的 index 可能小于 ask(存储乱序),仅匹配无 marker 的旧消息 + const beforeMessages = sortedMessages.filter( + (message) => + readMessageOrdinal(message, 0) < containingOrdinal && + !messageHasForeignRequestIdMarker(message.text ?? ''), + ); + return beforeMessages.some((message) => matchesLegacy(message.text)); + } + + return sortedMessages.some((message) => matchesLegacy(message.text)); +} + +export function hasLaterMcpAskResumeMessage( + messages: MessageInfo[], + messageIndex: number, + interaction: McpAskInteraction, +): boolean { + return hasMcpAskResumeMessage(messages, interaction, { + containingMessageIndex: messageIndex, + }); +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/normalizeMcpAskFormData.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/normalizeMcpAskFormData.test.ts new file mode 100644 index 00000000..6526d8ac --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/normalizeMcpAskFormData.test.ts @@ -0,0 +1,310 @@ +import { UploadFileStatus } from '@/types/enums/common'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import { describe, expect, it } from 'vitest'; +import type { InteractionUiSchema } from '../types/mcpAskIntervention'; +import { + extractMcpAskFormAttachments, + extractUploadedFileUrls, + hydrateMcpAskFileFieldValue, + hydrateMcpAskFormValues, + limitMcpAskUploadFileList, + normalizeMcpAskFileFieldValue, + normalizeMcpAskFormData, + urlToUploadFileInfo, + validateMcpAskRequiredFileField, +} from './normalizeMcpAskFormData'; + +const fileUi = ( + fields: InteractionUiSchema['fields'], +): InteractionUiSchema => ({ + version: 'nuwax.interaction.v2', + presentation: 'inline', + title: '上传', + fields, +}); + +describe('normalizeMcpAskFormData', () => { + it('extracts remote URLs from UploadFileInfo list', () => { + const files: UploadFileInfo[] = [ + { + uid: '1', + name: 'a.png', + type: 'image/png', + size: 100, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/a.png', + }, + { + uid: '2', + name: 'b.png', + type: 'image/png', + size: 200, + status: UploadFileStatus.uploading, + url: '', + }, + ]; + + expect(extractUploadedFileUrls(files)).toEqual([ + 'https://cdn.example.com/a.png', + ]); + }); + + it('keeps existing URL strings unchanged', () => { + expect( + extractUploadedFileUrls('https://cdn.example.com/existing.png'), + ).toEqual(['https://cdn.example.com/existing.png']); + expect( + extractUploadedFileUrls([ + 'https://cdn.example.com/1.png', + 'https://cdn.example.com/2.png', + ]), + ).toEqual([ + 'https://cdn.example.com/1.png', + 'https://cdn.example.com/2.png', + ]); + }); + + it('normalizes single file field to one URL string', () => { + expect( + normalizeMcpAskFileFieldValue( + [ + { + uid: '1', + name: 'shot.png', + type: 'image/png', + size: 10, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/shot.png', + }, + ], + false, + ), + ).toBe('https://cdn.example.com/shot.png'); + }); + + it('normalizes multiple file field to URL array', () => { + expect( + normalizeMcpAskFileFieldValue( + [ + { + uid: '1', + name: 'a.png', + type: 'image/png', + size: 10, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/a.png', + }, + { + uid: '2', + name: 'b.png', + type: 'image/png', + size: 20, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/b.png', + }, + ], + true, + ), + ).toEqual([ + 'https://cdn.example.com/a.png', + 'https://cdn.example.com/b.png', + ]); + }); + + it('rejects required file field without remote URL', () => { + expect(() => + validateMcpAskRequiredFileField([ + { + uid: '1', + name: 'shot.png', + type: 'image/png', + size: 10, + status: UploadFileStatus.uploading, + url: '', + }, + ]), + ).toThrow('MCP_ASK_FILE_REQUIRED'); + }); + + it('accepts required file field with remote URL', () => { + expect(() => + validateMcpAskRequiredFileField([ + { + uid: '1', + name: 'shot.png', + type: 'image/png', + size: 10, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/shot.png', + }, + ]), + ).not.toThrow(); + }); + + it('normalizes only file widgets in form data', () => { + const formData = normalizeMcpAskFormData( + { + choice: 'deploy', + screenshot: [ + { + uid: '1', + name: 'shot.png', + type: 'image/png', + size: 10, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/shot.png', + }, + ], + attachments: [ + { + uid: '2', + name: 'doc.pdf', + type: 'application/pdf', + size: 20, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/doc.pdf', + }, + ], + }, + fileUi([ + { name: 'choice', title: '选项', widget: 'radio' }, + { name: 'screenshot', title: '截图', widget: 'file' }, + { + name: 'attachments', + title: '附件', + widget: 'file', + multiple: true, + }, + ]), + ); + + expect(formData).toEqual({ + choice: 'deploy', + screenshot: 'https://cdn.example.com/shot.png', + attachments: ['https://cdn.example.com/doc.pdf'], + }); + }); + + it('hydrates submitted URL strings back to Upload fileList', () => { + const hydrated = hydrateMcpAskFormValues( + { + screenshot: 'https://cdn.example.com/shot.png', + attachments: ['https://cdn.example.com/a.pdf'], + }, + fileUi([ + { name: 'screenshot', title: '截图', widget: 'file' }, + { + name: 'attachments', + title: '附件', + widget: 'file', + multiple: true, + }, + ]), + ); + + expect(hydrated.screenshot).toEqual([ + urlToUploadFileInfo('https://cdn.example.com/shot.png', 0), + ]); + expect(hydrated.attachments).toEqual([ + urlToUploadFileInfo('https://cdn.example.com/a.pdf', 0), + ]); + }); + + it('keeps existing Upload fileList when hydrating in-progress values', () => { + const uploading: UploadFileInfo[] = [ + { + uid: 'local-1', + name: 'local.png', + type: 'image/png', + size: 10, + status: UploadFileStatus.uploading, + url: '', + }, + ]; + + expect(hydrateMcpAskFileFieldValue(uploading, false)).toEqual(uploading); + }); + + it('limits single-file field hydration to one URL', () => { + expect( + hydrateMcpAskFileFieldValue( + ['https://cdn.example.com/a.png', 'https://cdn.example.com/b.png'], + false, + ), + ).toEqual([urlToUploadFileInfo('https://cdn.example.com/a.png', 0)]); + }); + + it('limits single-file upload list to the latest file', () => { + const files: UploadFileInfo[] = [ + { + uid: '1', + name: 'a.png', + type: 'image/png', + size: 10, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/a.png', + }, + { + uid: '2', + name: 'b.png', + type: 'image/png', + size: 20, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/b.png', + }, + ]; + + expect(limitMcpAskUploadFileList(files, false)).toEqual([files[1]]); + expect(limitMcpAskUploadFileList(files, true)).toEqual(files); + }); + + it('extractMcpAskFormAttachments collects file widgets as UploadFileInfo', () => { + const ui = fileUi([ + { name: 'screenshot', title: '截图', widget: 'file' }, + { + name: 'attachments', + title: '附件', + widget: 'file', + multiple: true, + }, + ]); + + const files = extractMcpAskFormAttachments( + { + screenshot: [ + { + uid: '1', + name: 'shot.png', + type: 'image/png', + size: 10, + status: UploadFileStatus.done, + url: 'https://cdn.example.com/shot.png', + key: 'k1', + }, + ], + attachments: [ + { + fileKey: 'k2', + fileUrl: 'https://cdn.example.com/doc.pdf', + fileName: 'doc.pdf', + mimeType: 'application/pdf', + fileSize: 20, + }, + ], + }, + ui, + ); + + expect(files).toHaveLength(2); + expect(files[0]).toMatchObject({ + name: 'shot.png', + url: 'https://cdn.example.com/shot.png', + key: 'k1', + }); + expect(files[1]).toMatchObject({ + name: 'doc.pdf', + url: 'https://cdn.example.com/doc.pdf', + key: 'k2', + }); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/normalizeMcpAskFormData.ts b/qiming/src/components/business-component/AgentIntervention/utils/normalizeMcpAskFormData.ts new file mode 100644 index 00000000..5b3d4178 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/normalizeMcpAskFormData.ts @@ -0,0 +1,305 @@ +import { UploadFileStatus } from '@/types/enums/common'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import type { InteractionUiSchema } from '../types/mcpAskIntervention'; +import { parseInteractionFields } from './parseMcpAskSchema'; + +/** 判断是否为统一文件上传服务返回的可访问远程 URL */ +export function isRemoteFileUrl(url: unknown): url is string { + return ( + typeof url === 'string' && + (url.startsWith('http://') || url.startsWith('https://')) + ); +} + +function isUploadFileInfoLike(item: unknown): item is UploadFileInfo { + return ( + !!item && + typeof item === 'object' && + ('url' in item || 'name' in item || 'uid' in item) + ); +} + +/** 判断是否为会话附件结构(Mobile AttachmentFile) */ +function isAttachmentFileLike(item: unknown): item is { + fileUrl?: string; + fileKey?: string; + fileName?: string; + mimeType?: string; + fileSize?: number; +} { + return ( + !!item && + typeof item === 'object' && + ('fileUrl' in item || 'fileName' in item || 'fileKey' in item) + ); +} + +function inferFileNameFromUrl(url: string): string { + try { + const segment = new URL(url).pathname.split('/').filter(Boolean).pop(); + return segment || 'file'; + } catch { + return 'file'; + } +} + +/** 将单个远程 URL 转为 Upload 组件可识别的已完成文件项 */ +export function urlToUploadFileInfo(url: string, index = 0): UploadFileInfo { + return { + uid: `mcp-ask-url-${index}-${url}`, + name: inferFileNameFromUrl(url), + type: '', + size: 0, + url, + status: UploadFileStatus.done, + }; +} + +/** 将 UploadFileInfo / AttachmentFile / URL 转为发送消息用的 UploadFileInfo */ +function toUploadFileInfo(item: unknown, index: number): UploadFileInfo | null { + if (typeof item === 'string' && isRemoteFileUrl(item)) { + return urlToUploadFileInfo(item, index); + } + + if (isUploadFileInfoLike(item)) { + if (item.status === UploadFileStatus.removed) { + return null; + } + const url = item.url; + if (!isRemoteFileUrl(url)) { + return null; + } + return { + uid: item.uid || `mcp-ask-file-${index}`, + name: item.name || inferFileNameFromUrl(url), + type: item.type || '', + size: item.size || 0, + url, + key: item.key, + status: item.status ?? UploadFileStatus.done, + }; + } + + if (isAttachmentFileLike(item)) { + const url = item.fileUrl; + if (!isRemoteFileUrl(url)) { + return null; + } + return { + uid: item.fileKey || `mcp-ask-attach-${index}`, + name: item.fileName || inferFileNameFromUrl(url), + type: item.mimeType || '', + size: item.fileSize || 0, + url, + key: item.fileKey, + status: UploadFileStatus.done, + }; + } + + return null; +} + +/** + * 从 MCP Ask 表单 file 字段收集已上传附件,供 resume 消息走 chat attachments 发送。 + * 兼容 UploadFileInfo[]、AttachmentFile[]、URL 字符串及其数组。 + */ +export function extractMcpAskFormAttachments( + formData: Record, + ui: InteractionUiSchema, +): UploadFileInfo[] { + const attachments: UploadFileInfo[] = []; + + parseInteractionFields(ui).forEach((field) => { + if (field.widget !== 'file') { + return; + } + if (!Object.prototype.hasOwnProperty.call(formData, field.name)) { + return; + } + + const rawValue = formData[field.name]; + const items: unknown[] = []; + + if (typeof rawValue === 'string') { + items.push(rawValue); + } else if (Array.isArray(rawValue)) { + items.push(...rawValue); + } else if (rawValue !== undefined && rawValue !== null && rawValue !== '') { + items.push(rawValue); + } + + items.forEach((item) => { + const fileInfo = toUploadFileInfo(item, attachments.length); + if (fileInfo) { + attachments.push(fileInfo); + } + }); + }); + + return attachments; +} + +/** + * 从表单原始值中提取已上传文件的远程 URL 列表。 + * 兼容:已是 URL 字符串、URL 数组、UploadFileInfo 对象或数组。 + */ +export function extractUploadedFileUrls(value: unknown): string[] { + if (value === undefined || value === null || value === '') { + return []; + } + + if (typeof value === 'string') { + return isRemoteFileUrl(value) ? [value] : []; + } + + if (Array.isArray(value)) { + if (value.every((item) => typeof item === 'string')) { + return value.filter(isRemoteFileUrl); + } + + if (value.length > 0 && isAttachmentFileLike(value[0])) { + return value + .filter(isAttachmentFileLike) + .map((item) => item.fileUrl) + .filter(isRemoteFileUrl); + } + + return value + .filter(isUploadFileInfoLike) + .filter((item) => item.status !== UploadFileStatus.removed) + .map((item) => item.url) + .filter(isRemoteFileUrl); + } + + if (isAttachmentFileLike(value)) { + return isRemoteFileUrl(value.fileUrl) ? [value.fileUrl] : []; + } + + if (isUploadFileInfoLike(value)) { + return isRemoteFileUrl(value.url) ? [value.url] : []; + } + + return []; +} + +/** + * 校验必填 file 字段是否已有统一上传服务返回的远程 URL。 + * 供 McpAskFormField 的 Form.Item validator 复用。 + */ +export function validateMcpAskRequiredFileField(value: unknown): void { + if (!extractUploadedFileUrls(value).length) { + throw new Error('MCP_ASK_FILE_REQUIRED'); + } +} + +/** + * 将单个 file 字段值归一化为提交用 URL。 + * - multiple: string[] + * - 单文件: string(无有效 URL 时为空字符串) + */ +export function normalizeMcpAskFileFieldValue( + value: unknown, + multiple?: boolean, +): string | string[] { + const urls = extractUploadedFileUrls(value); + if (multiple) { + return urls; + } + return urls[0] ?? ''; +} + +/** + * 提交前将 MCP Ask 表单中的 file 字段转为统一文件上传服务返回的 URL。 + */ +export function normalizeMcpAskFormData( + formData: Record, + ui: InteractionUiSchema, +): Record { + const normalized = { ...formData }; + + parseInteractionFields(ui).forEach((field) => { + if (field.widget !== 'file') { + return; + } + if (!Object.prototype.hasOwnProperty.call(normalized, field.name)) { + return; + } + normalized[field.name] = normalizeMcpAskFileFieldValue( + normalized[field.name], + field.options.multiple, + ); + }); + + return normalized; +} + +/** + * 将已提交的 URL 表单值还原为 Upload fileList,避免回填时破坏 Upload 控件。 + * 若值本身已是 UploadFileInfo[],则原样返回。 + */ +export function hydrateMcpAskFileFieldValue( + value: unknown, + multiple?: boolean, +): unknown { + if (value === undefined || value === null || value === '') { + return undefined; + } + + if ( + Array.isArray(value) && + value.length > 0 && + isUploadFileInfoLike(value[0]) + ) { + return value; + } + + if (isUploadFileInfoLike(value)) { + return [value]; + } + + const urls = extractUploadedFileUrls(value); + if (!urls.length) { + return undefined; + } + + const limitedUrls = multiple ? urls : urls.slice(0, 1); + return limitedUrls.map((url, index) => urlToUploadFileInfo(url, index)); +} + +/** + * 回填表单前,将 file 字段的 URL 值转为 Upload fileList。 + */ +export function hydrateMcpAskFormValues( + formData: Record, + ui: InteractionUiSchema, +): Record { + const hydrated = { ...formData }; + + parseInteractionFields(ui).forEach((field) => { + if (field.widget !== 'file') { + return; + } + if (!Object.prototype.hasOwnProperty.call(hydrated, field.name)) { + return; + } + hydrated[field.name] = hydrateMcpAskFileFieldValue( + hydrated[field.name], + field.options.multiple, + ); + }); + + return hydrated; +} + +/** + * 单文件字段仅保留最后一个已选文件,防止多选后静默丢文件。 + */ +export function limitMcpAskUploadFileList( + fileList: UploadFileInfo[], + multiple?: boolean, +): UploadFileInfo[] { + if (multiple || fileList.length <= 1) { + return fileList; + } + return fileList.slice(-1); +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskSchema.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskSchema.test.ts new file mode 100644 index 00000000..7bb76b95 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskSchema.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import type { InteractionUiSchema } from '../types/mcpAskIntervention'; +import { + getInteractionSteps, + isWizardPresentation, + parseInteractionFields, +} from './parseMcpAskSchema'; + +const v2 = ( + overrides: Partial = {}, +): InteractionUiSchema => + ({ + version: 'nuwax.interaction.v2', + presentation: 'inline', + title: 'Ask', + ...overrides, + } as InteractionUiSchema); + +describe('parseInteractionFields (v2)', () => { + it('maps fields[] in array order with merged options', () => { + const fields = parseInteractionFields( + v2({ + fields: [ + { name: 'remark', title: '备注', widget: 'textarea' }, + { + name: 'choice', + title: '继续方式', + widget: 'radio', + required: true, + options: [ + { value: 'a', label: '甲' }, + { value: 'b', label: '乙' }, + ], + }, + ], + }), + ); + + expect(fields).toMatchObject([ + { name: 'remark', widget: 'textarea', required: false }, + { + name: 'choice', + widget: 'radio', + required: true, + enumValues: ['a', 'b'], + enumLabels: ['甲', '乙'], + }, + ]); + }); + + it('rebuilds JsonSchema constraint view from v2 field', () => { + const [field] = parseInteractionFields( + v2({ + fields: [ + { + name: 'count', + title: '并发数', + widget: 'number', + type: 'integer', + minimum: 1, + maximum: 10, + multipleOf: 2, + }, + ], + }), + ); + expect(field.property).toMatchObject({ + title: '并发数', + type: 'integer', + minimum: 1, + maximum: 10, + multipleOf: 2, + }); + }); + + it('carries placeholder/accept/multiple into options', () => { + const [field] = parseInteractionFields( + v2({ + fields: [ + { + name: 'screenshot', + title: '截图', + widget: 'file', + accept: 'image/*', + multiple: true, + placeholder: '上传图片', + }, + ], + }), + ); + expect(field.options).toMatchObject({ + accept: 'image/*', + multiple: true, + placeholder: '上传图片', + }); + }); + + it('falls back unknown widget to text', () => { + const [field] = parseInteractionFields( + v2({ + fields: [{ name: 'x', title: 'X', widget: 'bogus' } as never], + }), + ); + expect(field.widget).toBe('text'); + }); + + it('filters by fieldNames (wizard step) in the given order', () => { + const fields = parseInteractionFields( + v2({ + presentation: 'wizard', + fields: [ + { name: 'a', title: 'A', widget: 'text' }, + { name: 'b', title: 'B', widget: 'text' }, + { name: 'c', title: 'C', widget: 'text' }, + ], + }), + ['c', 'a'], + ); + expect(fields.map((f) => f.name)).toEqual(['c', 'a']); + }); + + it('returns [] when no fields', () => { + expect(parseInteractionFields(v2())).toEqual([]); + }); +}); + +describe('getInteractionSteps / isWizardPresentation (v2)', () => { + it('returns ui.steps when provided', () => { + const ui = v2({ + presentation: 'wizard', + fields: [{ name: 'a', title: 'A', widget: 'text' }], + steps: [{ id: 's1', title: '步骤一', fields: ['a'] }], + }); + expect(getInteractionSteps(ui)).toMatchObject([ + { id: 's1', fields: ['a'] }, + ]); + expect(isWizardPresentation(ui)).toBe(true); + }); + + it('builds a default single step from fields[]', () => { + const ui = v2({ + fields: [ + { name: 'a', title: 'A', widget: 'text' }, + { name: 'b', title: 'B', widget: 'text' }, + ], + }); + expect(getInteractionSteps(ui)).toMatchObject([ + { id: 'default', fields: ['a', 'b'] }, + ]); + }); + + it('returns [] when no fields and no steps', () => { + expect(getInteractionSteps(v2())).toEqual([]); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskSchema.ts b/qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskSchema.ts new file mode 100644 index 00000000..80546abf --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskSchema.ts @@ -0,0 +1,145 @@ +import type { + FieldOption, + FormField, + InteractionUiOptions, + InteractionUiSchema, + InteractionUiStep, + JsonSchemaProperty, + McpAskFieldWidget, +} from '../types/mcpAskIntervention'; +import { MCP_ASK_WIDGET_TYPES } from '../types/mcpAskIntervention'; + +/** + * 解析后的字段:形状对齐渲染层(McpAskFormField)的读取路径,故渲染组件无需改动。 + * `property` 为从 v2 FormField 重建的 JsonSchema 约束视图。 + */ +export interface ParsedMcpAskField { + name: string; + property: JsonSchemaProperty; + widget: McpAskFieldWidget; + required: boolean; + options: InteractionUiOptions; + enumValues: string[]; + enumLabels: string[]; +} + +function isKnownWidget(widget: unknown): widget is McpAskFieldWidget { + return ( + typeof widget === 'string' && + (MCP_ASK_WIDGET_TYPES as readonly string[]).includes(widget) + ); +} + +function isFieldOption(opt: unknown): opt is FieldOption { + return ( + !!opt && + typeof opt === 'object' && + typeof (opt as FieldOption).value === 'string' && + typeof (opt as FieldOption).label === 'string' + ); +} + +/** 把 v2 FormField 重建为 JsonSchema 约束视图(FormField 组件读取路径)。 */ +function buildProperty(field: FormField): JsonSchemaProperty { + return { + type: field.type, + title: field.title, + description: field.description, + minimum: field.minimum, + maximum: field.maximum, + multipleOf: field.multipleOf, + minLength: field.minLength, + maxLength: field.maxLength, + }; +} + +function buildOptions(field: FormField): InteractionUiOptions { + return { + placeholder: field.placeholder, + allowCustom: field.allowCustom, + otherValue: field.otherValue, + otherField: field.otherField, + accept: field.accept, + multiple: field.multiple, + }; +} + +function parseField(field: FormField): ParsedMcpAskField { + const options = Array.isArray(field.options) + ? field.options.filter(isFieldOption) + : []; + return { + name: field.name, + property: buildProperty(field), + widget: isKnownWidget(field.widget) ? field.widget : 'text', + required: field.required === true, + options: buildOptions(field), + enumValues: options.map((opt) => opt.value), + enumLabels: options.map((opt) => opt.label), + }; +} + +/** + * 解析 v2 表单字段:遍历 `ui.fields[]`,数组顺序即展示顺序。 + * 传入 `fieldNames`(wizard 步骤)时,按其顺序与名称过滤对应字段。 + */ +export function parseInteractionFields( + ui: InteractionUiSchema, + fieldNames?: string[], +): ParsedMcpAskField[] { + const fields = ui.fields ?? []; + if (!fieldNames?.length) { + return fields.map(parseField); + } + const byName = new Map(fields.map((f) => [f.name, f])); + return fieldNames + .map((name) => byName.get(name)) + .filter((f): f is FormField => !!f) + .map(parseField); +} + +export function isWizardPresentation(ui: InteractionUiSchema): boolean { + return ( + ui.presentation === 'wizard' || + (Array.isArray(ui.steps) && ui.steps.length > 1) + ); +} + +export function getInteractionSteps( + ui: InteractionUiSchema, +): InteractionUiStep[] { + if (ui.steps?.length) { + return ui.steps; + } + const fields = ui.fields ?? []; + if (!fields.length) { + return []; + } + return [ + { + id: 'default', + title: ui.title, + description: ui.description, + fields: fields.map((f) => f.name), + }, + ]; +} + +/** 是否允许跳过:v2 由 ui.allowSkip 顶层字段控制(旧 v1 的 uiSchema.ui:options.allowSkip 已移除)。 */ +export function isSkipAllowed(ui: InteractionUiSchema): boolean { + return ui.allowSkip === true; +} + +export function getSkipLabel(ui: InteractionUiSchema): string | undefined { + return ui.skipLabel; +} + +/** JSON Schema 主类型(忽略 null 联合成员)。McpAskFormField 用于判断 integer。 */ +export function getJsonSchemaPrimaryType( + prop: Pick, +): string { + if (Array.isArray(prop.type)) { + return prop.type.find((t) => t !== 'null') || 'string'; + } + return prop.type || 'string'; +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskToolInput.ts b/qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskToolInput.ts new file mode 100644 index 00000000..bb60eeb6 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/parseMcpAskToolInput.ts @@ -0,0 +1,64 @@ +import type { McpAskUserToolInput } from '../types/mcpAskIntervention'; +import { + INTERACTION_UI_SCHEMA_VERSION, + MCP_ASK_SCHEMA_VERSION, +} from '../types/mcpAskIntervention'; + +/** + * agent / 平台 SSE 常漏写 schemaVersion;若 ui.version 合法则推断 ask schema 版本。 + * 仅支持最新 v2,不兼容旧版本(非 v2 输入返回 null,不渲染)。 + */ +function resolveSchemaVersion(record: Record): string | null { + if ( + typeof record.schemaVersion === 'string' && + record.schemaVersion === MCP_ASK_SCHEMA_VERSION + ) { + return record.schemaVersion; + } + + const ui = record.ui as Record | undefined; + if ( + typeof ui?.version === 'string' && + ui.version === INTERACTION_UI_SCHEMA_VERSION + ) { + return MCP_ASK_SCHEMA_VERSION; + } + + // 兜底:agent 按"服务端盖戳"约定省略 schemaVersion/ui.version 时,识别裸 v2。 + // v2 强信号 = 有 ui.fields 且无 v1 的 ui.schema(v1 用 schema,无 fields)。 + if (ui && Array.isArray(ui.fields) && !ui.schema) { + return MCP_ASK_SCHEMA_VERSION; + } + + return null; +} + +export function parseMcpAskToolInput(raw: unknown): McpAskUserToolInput | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const record = raw as Record; + const schemaVersion = resolveSchemaVersion(record); + if (!schemaVersion) { + return null; + } + if ((record.toolName ?? 'nuwax_ask_question') !== 'nuwax_ask_question') { + return null; + } + if (typeof record.requestId !== 'string' || !record.ui) { + return null; + } + const ui = record.ui as Record; + // 版本识别已在 resolveSchemaVersion 完成(含裸 v2 推断);此处只校验 v2 形状。 + // v2:表单定义为 fields[](inline/modal)或 steps[](wizard) + const hasFields = Array.isArray(ui.fields) && ui.fields.length > 0; + const hasSteps = Array.isArray(ui.steps) && ui.steps.length > 0; + if (!hasFields && !hasSteps) { + return null; + } + return { + ...record, + schemaVersion, + toolName: 'nuwax_ask_question', + } as unknown as McpAskUserToolInput; +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/parseSseEventEnvelope.ts b/qiming/src/components/business-component/AgentIntervention/utils/parseSseEventEnvelope.ts new file mode 100644 index 00000000..bf12a8bc --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/parseSseEventEnvelope.ts @@ -0,0 +1,55 @@ +import type { ConversationChatResponse } from '@/types/interfaces/conversationInfo'; + +interface SseEventEnvelope { + messageType?: string; + message_type?: string; + subType?: string; + sub_type?: string; + subEventType?: string; + data?: Record; + [key: string]: unknown; +} + +/** + * Extracts the event envelope from an SSE response. + * The backend sends events in different shapes: + * - { eventType, data: { messageType, subType, data: {...} } } + * - { eventType, messageType, subType, data: {...} } + * - { eventType, message_type, sub_type, data: {...} } + * This normalizes access to a single envelope object. + */ +export function parseSseEventEnvelope( + res: ConversationChatResponse, +): SseEventEnvelope { + const nested = res.data as SseEventEnvelope | undefined; + if ( + nested && + typeof nested === 'object' && + (nested.messageType || + nested.message_type || + nested.subType || + nested.sub_type || + nested.subEventType) + ) { + return nested; + } + return res as unknown as SseEventEnvelope; +} + +/** + * Extracts the inner event data from the envelope. + */ +export function extractEventData( + envelope: SseEventEnvelope, + fallback: ConversationChatResponse, +): Record { + const inner = envelope.data; + if (inner && typeof inner === 'object') { + return inner as Record; + } + const fallbackData = fallback.data; + if (fallbackData && typeof fallbackData === 'object') { + return fallbackData as Record; + } + return fallback as unknown as Record; +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/processInterventionSsePatch.ts b/qiming/src/components/business-component/AgentIntervention/utils/processInterventionSsePatch.ts new file mode 100644 index 00000000..f7a3138b --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/processInterventionSsePatch.ts @@ -0,0 +1,26 @@ +import type { + ConversationChatResponse, + MessageInfo, +} from '@/types/interfaces/conversationInfo'; +import { applyAcpPermissionSseEvent } from './applyAcpPermissionSseEvent'; +import { applyMcpAskToolCallSseEvent } from './applyMcpAskToolCallSseEvent'; +import { reconcileMessageAcpPermissionStatuses } from './reconcileAcpPermissionStatus'; + +export function processInterventionSsePatch( + res: ConversationChatResponse, + currentMessage: MessageInfo, + contextMessageList?: MessageInfo[], +): MessageInfo | null { + const patched = + applyAcpPermissionSseEvent(res, currentMessage) ?? + applyMcpAskToolCallSseEvent(res, currentMessage); + + if (!patched) { + return null; + } + + return reconcileMessageAcpPermissionStatuses( + patched, + contextMessageList ?? [patched], + ); +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/reconcileAcpPermissionStatus.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/reconcileAcpPermissionStatus.test.ts new file mode 100644 index 00000000..0bfb3c25 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/reconcileAcpPermissionStatus.test.ts @@ -0,0 +1,235 @@ +import { AgentComponentTypeEnum } from '@/types/enums/agent'; +import { ProcessingEnum } from '@/types/enums/common'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { describe, expect, it, vi } from 'vitest'; +import type { AcpPermissionInteraction } from '../types/acpIntervention'; + +vi.mock('./mcpAskResumeMessage', () => ({ + hasMcpAskResumeMessage: vi.fn(() => false), +})); + +import { hasMcpAskResumeMessage } from './mcpAskResumeMessage'; +import { + isIdempotentAcpPermissionResolveError, + reconcileAcpPermissionStatusesInMessageList, + reconcileMessageAcpPermissionStatuses, +} from './reconcileAcpPermissionStatus'; + +function createAcpInteraction( + overrides: Partial = {}, +): AcpPermissionInteraction { + return { + intervention: { + id: 'itv-1', + revision: 1, + kind: 'approval', + status: 'pending', + sessionId: 'sess-1', + source: 'acp_permission', + engine: 'nuwaxcode', + protocol: 'acp', + callbackTarget: { kind: 'electron', targetId: '' }, + schemaRef: '', + acp: { + method: 'session/request_permission', + request: { + sessionId: 'sess-1', + toolCall: { + toolCallId: 'call-1', + title: 'write', + kind: 'edit', + status: 'pending', + rawInput: { filePath: '/tmp/a.txt' }, + }, + options: [], + }, + }, + createdAt: 100, + }, + responseStatus: 'pending', + executeId: 'call-1', + ...overrides, + } as AcpPermissionInteraction; +} + +describe('reconcileAcpPermissionStatus', () => { + it('marks pending permission as submitted when matching tool finished in processingList', () => { + const message = { + id: 'assistant', + acpPermissionInteractions: [createAcpInteraction()], + processingList: [ + { + executeId: 'call-1', + name: 'write', + type: AgentComponentTypeEnum.ToolCall, + status: ProcessingEnum.FINISHED, + result: null, + targetId: -1, + cardBindConfig: null, + subEventType: null, + }, + ], + } as unknown as MessageInfo; + + const reconciled = reconcileMessageAcpPermissionStatuses(message); + expect(reconciled.acpPermissionInteractions?.[0].responseStatus).toBe( + 'submitted', + ); + }); + + it('marks permission submitted when MCP ask resume exists for linked requestId', () => { + vi.mocked(hasMcpAskResumeMessage).mockReturnValueOnce(true); + + const askPermission = createAcpInteraction({ + intervention: { + ...createAcpInteraction().intervention, + acp: { + method: 'session/request_permission', + request: { + sessionId: 'sess-1', + toolCall: { + toolCallId: 'call-ask', + title: 'ask', + kind: 'other', + status: 'pending', + rawInput: { + schemaVersion: 'nuwax.mcp_ask.v1', + requestId: 'ask-1', + title: '毕业论文 PPT 前置信息收集', + ui: { + version: 'nuwax.interaction.v1', + presentation: 'inline', + title: '毕业论文 PPT 前置信息收集', + fields: [], + }, + }, + }, + options: [], + }, + }, + }, + executeId: 'call-ask', + }); + + const messageList = [ + { + id: 'assistant', + acpPermissionInteractions: [askPermission], + }, + { + id: 'user-resume', + text: '我已填写「毕业论文 PPT 前置信息收集」', + }, + ] as unknown as MessageInfo[]; + + const reconciled = reconcileAcpPermissionStatusesInMessageList(messageList); + expect(reconciled[0].acpPermissionInteractions?.[0].responseStatus).toBe( + 'submitted', + ); + }); + + it('sub replay leaves only one pending after reconcile when one tool finished', () => { + const first = createAcpInteraction({ + intervention: { + ...createAcpInteraction().intervention, + id: 'itv-first', + createdAt: 100, + acp: { + method: 'session/request_permission', + request: { + sessionId: 'sess-1', + toolCall: { + toolCallId: 'call-first', + title: 'a', + kind: 'edit', + status: 'pending', + rawInput: {}, + }, + options: [], + }, + }, + }, + executeId: 'call-first', + }); + const second = createAcpInteraction({ + intervention: { + ...createAcpInteraction().intervention, + id: 'itv-second', + createdAt: 200, + acp: { + method: 'session/request_permission', + request: { + sessionId: 'sess-1', + toolCall: { + toolCallId: 'call-second', + title: 'b', + kind: 'edit', + status: 'pending', + rawInput: {}, + }, + options: [], + }, + }, + }, + executeId: 'call-second', + }); + + const messageList = [ + { + id: 'assistant', + acpPermissionInteractions: [first, second], + processingList: [ + { + executeId: 'call-first', + status: ProcessingEnum.FINISHED, + }, + ], + }, + ] as unknown as MessageInfo[]; + + const reconciled = reconcileAcpPermissionStatusesInMessageList(messageList); + const pending = reconciled[0].acpPermissionInteractions?.filter( + (item) => item.responseStatus === 'pending', + ); + expect(pending).toHaveLength(1); + expect(pending?.[0].intervention.id).toBe('itv-second'); + }); + + it('keeps still-pending permission when tool has not finished', () => { + const message = { + id: 'assistant', + acpPermissionInteractions: [createAcpInteraction()], + processingList: [ + { + executeId: 'call-1', + name: 'write', + type: AgentComponentTypeEnum.ToolCall, + status: ProcessingEnum.EXECUTING, + result: null, + targetId: -1, + cardBindConfig: null, + subEventType: null, + }, + ], + } as unknown as MessageInfo; + + const reconciled = reconcileMessageAcpPermissionStatuses(message); + expect(reconciled.acpPermissionInteractions?.[0].responseStatus).toBe( + 'pending', + ); + }); + + it('detects idempotent resolve errors', () => { + expect( + isIdempotentAcpPermissionResolveError( + new Error('pending permission not found'), + ), + ).toBe(true); + expect(isIdempotentAcpPermissionResolveError('already_resolved')).toBe( + true, + ); + expect(isIdempotentAcpPermissionResolveError('network timeout')).toBe( + false, + ); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/reconcileAcpPermissionStatus.ts b/qiming/src/components/business-component/AgentIntervention/utils/reconcileAcpPermissionStatus.ts new file mode 100644 index 00000000..550bd053 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/reconcileAcpPermissionStatus.ts @@ -0,0 +1,206 @@ +import { ProcessingEnum } from '@/types/enums/common'; +import type { + MessageInfo, + ProcessingInfo, +} from '@/types/interfaces/conversationInfo'; +import type { AcpPermissionInteraction } from '../types/acpIntervention'; +import type { McpAskInteraction } from '../types/mcpAskIntervention'; +import { + hasMcpAskResumeMessage, + sortMessagesByConversationIndex, +} from './mcpAskResumeMessage'; + +function readMcpAskRequestId(rawInput: unknown): string | undefined { + if (!rawInput || typeof rawInput !== 'object') { + return undefined; + } + const input = rawInput as Record; + return typeof input.requestId === 'string' && input.requestId + ? input.requestId + : undefined; +} + +function getToolCallId( + interaction: AcpPermissionInteraction, +): string | undefined { + return interaction.intervention.acp?.request?.toolCall?.toolCallId; +} + +function isProcessingFinishedForTool( + processingList: ProcessingInfo[] | undefined, + toolCallId: string, + permissionExecuteId?: string, +): boolean { + if (!processingList?.length) { + return false; + } + + for (const item of processingList) { + if (item.status !== ProcessingEnum.FINISHED) { + continue; + } + + if (permissionExecuteId && item.executeId === permissionExecuteId) { + return true; + } + if (item.executeId === toolCallId) { + return true; + } + + const result = item.result as Record | null | undefined; + const input = (result?.input ?? undefined) as + | Record + | undefined; + if (input) { + const matchedId = input.toolCallId ?? input.tool_call_id; + if (matchedId === toolCallId) { + return true; + } + } + } + + return false; +} + +function buildMcpAskInteractionFromRawInput( + rawInput: unknown, + toolCallId: string, +): McpAskInteraction | null { + if (!rawInput || typeof rawInput !== 'object') { + return null; + } + const input = rawInput as Record; + const requestId = readMcpAskRequestId(rawInput); + const title = typeof input.title === 'string' ? input.title : undefined; + const ui = input.ui; + if (!requestId || !title || !ui || typeof ui !== 'object') { + return null; + } + + return { + input: { + toolName: 'nuwax_ask_question', + schemaVersion: 'nuwax.mcp_ask.v2', + requestId, + revision: 1, + sessionId: '', + title, + ui: ui as McpAskInteraction['input']['ui'], + }, + toolCallId, + responseStatus: 'pending', + }; +} + +function shouldMarkAcpPermissionSubmitted( + interaction: AcpPermissionInteraction, + message: MessageInfo, + contextMessageList: MessageInfo[], + containingMessageIndex?: number, +): boolean { + const status = interaction.responseStatus ?? 'pending'; + if (status === 'submitted' || status === 'failed') { + return false; + } + + const toolCallId = getToolCallId(interaction); + if ( + toolCallId && + isProcessingFinishedForTool( + message.processingList, + toolCallId, + interaction.executeId, + ) + ) { + return true; + } + + const rawInput = interaction.intervention.acp?.request?.toolCall?.rawInput; + if (toolCallId && readMcpAskRequestId(rawInput)) { + const syntheticAsk = buildMcpAskInteractionFromRawInput( + rawInput, + toolCallId, + ); + if ( + syntheticAsk && + hasMcpAskResumeMessage(contextMessageList, syntheticAsk, { + containingMessageIndex, + }) + ) { + return true; + } + } + + return false; +} + +/** + * 根据 processingList 完成态与 MCP Ask resume 消息,将已解决的 ACP 审批标为 submitted。 + * 用于 sub 流重放、跨页签审批后自动关闭对应卡片。 + */ +export function reconcileMessageAcpPermissionStatuses( + message: MessageInfo, + contextMessageList?: MessageInfo[], +): MessageInfo { + const interactions = message.acpPermissionInteractions; + if (!interactions?.length) { + return message; + } + + const context = sortMessagesByConversationIndex( + contextMessageList ?? [message], + ); + const containingMessageIndex = context.findIndex( + (item) => item.id === message.id || item.index === message.index, + ); + let changed = false; + const nextInteractions = interactions.map((interaction) => { + if ( + !shouldMarkAcpPermissionSubmitted( + interaction as AcpPermissionInteraction, + message, + context, + containingMessageIndex >= 0 ? containingMessageIndex : undefined, + ) + ) { + return interaction; + } + changed = true; + return { + ...interaction, + responseStatus: 'submitted' as const, + }; + }); + + if (!changed) { + return message; + } + + return { + ...message, + acpPermissionInteractions: nextInteractions, + }; +} + +export function reconcileAcpPermissionStatusesInMessageList( + messageList: MessageInfo[], +): MessageInfo[] { + return messageList.map((message) => + reconcileMessageAcpPermissionStatuses(message, messageList), + ); +} + +/** notify-resolved 幂等:Host 侧 pending 已不存在时视为已审批成功 */ +export function isIdempotentAcpPermissionResolveError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ''); + const normalized = message.toLowerCase(); + return ( + normalized.includes('not_found') || + normalized.includes('not found') || + normalized.includes('already_resolved') || + normalized.includes('already resolved') || + normalized.includes('permission not found') || + normalized.includes('err_permission_not_found') || + normalized.includes('gone') + ); +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/reconcileFinalMessageState.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/reconcileFinalMessageState.test.ts new file mode 100644 index 00000000..bd4e5145 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/reconcileFinalMessageState.test.ts @@ -0,0 +1,122 @@ +import { AgentComponentTypeEnum } from '@/types/enums/agent'; +import { MessageStatusEnum, ProcessingEnum } from '@/types/enums/common'; +import { describe, expect, it } from 'vitest'; +import { reconcileFinalMessageState } from './reconcileFinalMessageState'; + +describe('reconcileFinalMessageState', () => { + it('hydrates missed ACP permission events from final component results', () => { + const patched = reconcileFinalMessageState( + { + id: 'msg-1', + status: MessageStatusEnum.Complete, + } as any, + { + componentExecuteResults: [ + { + id: -1, + name: 'Backend.Sandbox.Event.RequestPermission', + type: AgentComponentTypeEnum.Event, + startTime: 1, + endTime: 1, + success: null, + error: null, + data: null, + innerExecuteInfo: null, + executeId: 'call-write-1', + kind: null, + locations: null, + input: { + request_permission_request: { + sessionId: 'sess-final', + toolCall: { + toolCallId: 'call-write-1', + kind: 'edit', + status: 'pending', + title: '写入文件', + rawInput: { + filepath: '/tmp/demo.txt', + diff: '+++ /tmp/demo.txt', + }, + }, + options: [ + { + optionId: 'once', + kind: 'allow_once', + name: 'Allow once', + }, + ], + }, + tool_call_id: 'call-write-1', + }, + }, + ], + } as any, + ); + + const interaction = patched.acpPermissionInteractions?.[0]; + expect(interaction?.intervention.acp.request.sessionId).toBe('sess-final'); + expect(interaction?.intervention.acp.request.toolCall.toolCallId).toBe( + 'call-write-1', + ); + expect(interaction?.responseStatus).toBe('pending'); + }); + + it('reconciles final execute results and clears orphan executing processing items', () => { + const patched = reconcileFinalMessageState( + { + id: 'msg-2', + processingList: [ + { + executeId: 'call-write-1', + name: '写入文件', + type: AgentComponentTypeEnum.ToolCall, + status: ProcessingEnum.EXECUTING, + result: null, + targetId: -1, + cardBindConfig: null, + subEventType: null, + }, + { + executeId: 'call-stuck-2', + name: '编辑文件', + type: AgentComponentTypeEnum.ToolCall, + status: ProcessingEnum.EXECUTING, + result: null, + targetId: -1, + cardBindConfig: null, + subEventType: null, + }, + ], + } as any, + { + componentExecuteResults: [ + { + id: -1, + name: '写入文件', + type: AgentComponentTypeEnum.ToolCall, + startTime: 1, + endTime: 2, + success: true, + error: null, + data: null, + innerExecuteInfo: null, + executeId: 'call-write-1', + kind: 'edit', + locations: [{ path: '/tmp/demo.txt' }], + input: { + filePath: '/tmp/demo.txt', + content: 'hello', + }, + }, + ], + } as any, + ); + + expect(patched.processingList?.[0].status).toBe(ProcessingEnum.FINISHED); + expect(patched.processingList?.[0].result?.input).toEqual({ + filePath: '/tmp/demo.txt', + content: 'hello', + }); + expect(patched.processingList?.[1].status).toBe(ProcessingEnum.FAILED); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/reconcileFinalMessageState.ts b/qiming/src/components/business-component/AgentIntervention/utils/reconcileFinalMessageState.ts new file mode 100644 index 00000000..1d804407 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/reconcileFinalMessageState.ts @@ -0,0 +1,137 @@ +import { + AgentComponentTypeEnum, + ConversationEventTypeEnum, +} from '@/types/enums/agent'; +import { ProcessingEnum } from '@/types/enums/common'; +import type { + ConversationChatResponse, + ConversationFinalResult, + ExecuteResultInfo, + MessageInfo, + ProcessingInfo, +} from '@/types/interfaces/conversationInfo'; +import { processInterventionSsePatch } from './processInterventionSsePatch'; + +function inferProcessingStatus(result: ExecuteResultInfo): ProcessingEnum { + return result.success === false + ? ProcessingEnum.FAILED + : ProcessingEnum.FINISHED; +} + +function inferSubEventType( + result: ExecuteResultInfo, +): ProcessingInfo['subEventType'] | 'ASK_QUESTION' | 'REQUEST_PERMISSION' { + if (result.name === 'Backend.Sandbox.Event.AskQuestion') { + return 'ASK_QUESTION'; + } + if (result.name === 'Backend.Sandbox.Event.RequestPermission') { + return 'REQUEST_PERMISSION'; + } + return null; +} + +function toProcessingData(result: ExecuteResultInfo) { + return { + targetId: -1, + name: result.name, + originalTitle: null, + type: result.type, + status: inferProcessingStatus(result), + executingMessage: null, + result, + cardBindConfig: null, + cardData: null, + pageArgConfig: null, + subEventType: inferSubEventType(result), + }; +} + +function patchInterventionsFromExecuteResults( + currentMessage: MessageInfo, + finalResult: ConversationFinalResult, +): MessageInfo { + return (finalResult.componentExecuteResults || []).reduce((message, item) => { + const patch = processInterventionSsePatch( + { + completed: false, + data: toProcessingData(item), + error: '', + eventType: ConversationEventTypeEnum.PROCESSING, + requestId: '', + } as ConversationChatResponse, + message, + ); + return patch || message; + }, currentMessage); +} + +function reconcileProcessingList( + currentList: ProcessingInfo[] | undefined, + finalResult: ConversationFinalResult, +): ProcessingInfo[] { + const nextList = [...(currentList || [])]; + const resultMap = new Map( + (finalResult.componentExecuteResults || []) + .filter((item) => item.executeId) + .map((item) => [item.executeId as string, item]), + ); + + const seenExecuteIds = new Set(); + + for (let i = 0; i < nextList.length; i += 1) { + const item = nextList[i]; + const executeId = item.executeId; + if (executeId && resultMap.has(executeId)) { + const executeResult = resultMap.get(executeId)!; + seenExecuteIds.add(executeId); + nextList[i] = { + ...item, + name: executeResult.name, + type: executeResult.type as AgentComponentTypeEnum, + status: inferProcessingStatus(executeResult), + result: executeResult, + subEventType: inferSubEventType( + executeResult, + ) as ProcessingInfo['subEventType'], + }; + continue; + } + + if (item.status === ProcessingEnum.EXECUTING) { + nextList[i] = { + ...item, + status: ProcessingEnum.FAILED, + }; + } + } + + resultMap.forEach((executeResult, executeId) => { + if (seenExecuteIds.has(executeId)) { + return; + } + nextList.push({ + ...toProcessingData(executeResult), + executeId, + } as ProcessingInfo); + }); + + return nextList; +} + +export function reconcileFinalMessageState( + currentMessage: MessageInfo, + finalResult: ConversationFinalResult, +): MessageInfo { + const messageWithInterventions = patchInterventionsFromExecuteResults( + currentMessage, + finalResult, + ); + + return { + ...messageWithInterventions, + processingList: reconcileProcessingList( + messageWithInterventions.processingList, + finalResult, + ), + }; +} diff --git a/qiming/src/components/business-component/AgentIntervention/utils/reconcileMcpAskHydratedStatus.test.ts b/qiming/src/components/business-component/AgentIntervention/utils/reconcileMcpAskHydratedStatus.test.ts new file mode 100644 index 00000000..a9a80420 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/reconcileMcpAskHydratedStatus.test.ts @@ -0,0 +1,104 @@ +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { describe, expect, it } from 'vitest'; +import { reconcileMcpAskHydratedMessageList } from './reconcileMcpAskHydratedStatus'; + +const askInteraction = { + input: { + toolName: 'nuwax_ask_question' as const, + schemaVersion: 'nuwax.mcp_ask.v2', + requestId: 'ask-1', + revision: 1, + sessionId: 'session-1', + title: '历史表单', + ui: { + version: 'nuwax.interaction.v2', + presentation: 'inline' as const, + title: '历史表单', + fields: [], + }, + }, + toolCallId: 'call-1', + responseStatus: 'pending' as const, +}; + +describe('reconcileMcpAskHydratedMessageList', () => { + it('marks pending interactions as submitted when resume message exists', () => { + const askMessage = { + id: 'assistant-ask', + mcpAskInteractions: [askInteraction], + } as MessageInfo; + + const resumeMessage = { + id: 'user-resume', + text: '我已填写「历史表单」,表单内容如下:\n\n主题:AI', + } as MessageInfo; + + const [reconciled] = reconcileMcpAskHydratedMessageList( + [askMessage], + [askMessage, resumeMessage], + ); + + expect(reconciled.mcpAskInteractions?.[0].responseStatus).toBe('submitted'); + }); + + it('keeps pending interactions when no resume message exists', () => { + const askMessage = { + id: 'assistant-ask', + mcpAskInteractions: [askInteraction], + } as MessageInfo; + + const [reconciled] = reconcileMcpAskHydratedMessageList([askMessage]); + + expect(reconciled.mcpAskInteractions?.[0].responseStatus).toBe('pending'); + }); + + it('does not mark a second same-title ask as submitted by the first resume', () => { + const sharedTitle = '补充回复'; + const firstAsk = { + ...askInteraction, + input: { + ...askInteraction.input, + requestId: 'ask-first', + title: sharedTitle, + ui: { + ...askInteraction.input.ui, + title: sharedTitle, + }, + }, + responseStatus: 'pending' as const, + }; + const secondAsk = { + ...askInteraction, + input: { + ...askInteraction.input, + requestId: 'ask-second', + title: sharedTitle, + ui: { + ...askInteraction.input.ui, + title: sharedTitle, + }, + }, + toolCallId: 'call-2', + responseStatus: 'pending' as const, + }; + const askMessage = { + id: 'assistant-ask', + index: 1, + mcpAskInteractions: [firstAsk, secondAsk], + } as MessageInfo; + + const resumeMessage = { + id: 'user-resume', + index: 2, + text: `我已填写「${sharedTitle}」,表单内容如下:\n\n主题:AI\n`, + } as MessageInfo; + + const [reconciled] = reconcileMcpAskHydratedMessageList( + [askMessage], + [askMessage, resumeMessage], + ); + + expect(reconciled.mcpAskInteractions?.[0].responseStatus).toBe('submitted'); + expect(reconciled.mcpAskInteractions?.[1].responseStatus).toBe('pending'); + }); +}); diff --git a/qiming/src/components/business-component/AgentIntervention/utils/reconcileMcpAskHydratedStatus.ts b/qiming/src/components/business-component/AgentIntervention/utils/reconcileMcpAskHydratedStatus.ts new file mode 100644 index 00000000..8609ceb2 --- /dev/null +++ b/qiming/src/components/business-component/AgentIntervention/utils/reconcileMcpAskHydratedStatus.ts @@ -0,0 +1,82 @@ +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import type { McpAskInteraction } from '../types/mcpAskIntervention'; +import { + hasMcpAskResumeMessage, + sortMessagesByConversationIndex, +} from './mcpAskResumeMessage'; + +function isAwaitingUserResponse( + status: McpAskInteraction['responseStatus'] | undefined, +): boolean { + const value = status ?? 'pending'; + return value === 'pending' || value === 'submitting'; +} + +/** + * 将仍为 pending 的 hydrated 交互与完整会话上下文对齐: + * 若已存在 resume 用户消息,则标记为 submitted,避免 dock 误弹。 + */ +export function reconcileMessageMcpAskHydratedStatus( + message: MessageInfo, + contextMessageList: MessageInfo[], + containingMessageIndex?: number, +): MessageInfo { + const interactions = message.mcpAskInteractions; + if (!interactions?.length) { + return message; + } + + const resolvedContainingMessageIndex = + containingMessageIndex ?? + contextMessageList.findIndex( + (item) => item.id === message.id || item.index === message.index, + ); + + let changed = false; + const nextInteractions = interactions.map((interaction) => { + if (!isAwaitingUserResponse(interaction.responseStatus)) { + return interaction; + } + if ( + !hasMcpAskResumeMessage(contextMessageList, interaction, { + containingMessageIndex: + resolvedContainingMessageIndex >= 0 + ? resolvedContainingMessageIndex + : undefined, + }) + ) { + return interaction; + } + changed = true; + return { + ...interaction, + responseStatus: 'submitted' as const, + }; + }); + + if (!changed) { + return message; + } + + return { + ...message, + mcpAskInteractions: nextInteractions, + }; +} + +export function reconcileMcpAskHydratedMessageList( + messageList: MessageInfo[], + contextMessageList: MessageInfo[] = messageList, +): MessageInfo[] { + const sortedContext = sortMessagesByConversationIndex(contextMessageList); + return messageList.map((message) => { + const containingMessageIndex = sortedContext.findIndex( + (item) => item.id === message.id || item.index === message.index, + ); + return reconcileMessageMcpAskHydratedStatus( + message, + sortedContext, + containingMessageIndex >= 0 ? containingMessageIndex : undefined, + ); + }); +} diff --git a/qiming/src/components/business-component/ChangeFileGitDiffView/index.less b/qiming/src/components/business-component/ChangeFileGitDiffView/index.less new file mode 100644 index 00000000..2904b75a --- /dev/null +++ b/qiming/src/components/business-component/ChangeFileGitDiffView/index.less @@ -0,0 +1,45 @@ +@import '@/styles/token'; + +.diff-view-root { + flex: 1; + min-height: 0; + height: 100%; + overflow: auto; + background-color: @colorBgContainer; + -webkit-overflow-scrolling: touch; + + // 与 AppDev DiffViewItem 保持一致,避免表格边框异常 + table, + tr, + td, + th { + border: none !important; + box-shadow: none !important; + } + + pre { + margin: 0 !important; + padding: 0 12px !important; + background: transparent !important; + border: none !important; + font-family: @fontFamilyCode !important; + font-size: @fontSizeSm !important; + white-space: pre !important; + } + + td { + padding: 1px 0 !important; + line-height: 1.35 !important; + vertical-align: middle !important; + padding-right: 6px !important; + } + + /* 限制行号列宽度,防止在某些情况下过宽 */ + td[class*='diff-line-num'], + td[class*='diff-line-old-num'], + td[class*='diff-line-new-num'] { + width: 40px !important; + min-width: 40px !important; + max-width: 50px !important; + } +} diff --git a/qiming/src/components/business-component/ChangeFileGitDiffView/index.tsx b/qiming/src/components/business-component/ChangeFileGitDiffView/index.tsx new file mode 100644 index 00000000..e8c74283 --- /dev/null +++ b/qiming/src/components/business-component/ChangeFileGitDiffView/index.tsx @@ -0,0 +1,120 @@ +import { getLanguageFromFile } from '@/utils/monacoConfig'; +import { generateDiffFile } from '@git-diff-view/file'; +import { DiffModeEnum, DiffView } from '@git-diff-view/react'; +import '@git-diff-view/react/styles/diff-view.css'; +import classNames from 'classnames'; +import React, { useEffect, useMemo, useState } from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +/** diff 主题 */ +export type GitDiffViewTheme = 'light' | 'dark'; + +export { DiffModeEnum }; + +export interface ChangeFileGitDiffViewProps { + /** 文件 ID(用于 diff 实例刷新) */ + fileId: string; + /** 文件名(用于语法高亮) */ + fileName: string; + /** 原始文件内容 */ + originalContent: string; + /** 修改后的文件内容 */ + modifiedContent: string; + /** + * diff 主题;不传时跟随页面 `data-theme` 自动切换 + * @default 跟随 document.documentElement[data-theme] + */ + diffViewTheme?: GitDiffViewTheme; + /** + * diff 展示模式:并排 / 统一 + * @default DiffModeEnum.Split + */ + diffViewMode?: DiffModeEnum; + className?: string; +} + +const DEFAULT_DIFF_VIEW_MODE = DiffModeEnum.Split; + +/** + * 基于 @git-diff-view/react 的 Git 风格 diff 展示 + * 用于源代码管理中的文件变更对比(只读) + */ +const ChangeFileGitDiffView: React.FC = ({ + fileId, + fileName, + originalContent, + modifiedContent, + diffViewTheme: diffViewThemeProp, + diffViewMode = DEFAULT_DIFF_VIEW_MODE, + className, +}) => { + const isThemeControlled = diffViewThemeProp !== undefined; + const [autoTheme, setAutoTheme] = useState('light'); + + /** 未传入 diffViewTheme 时,跟随页面 data-theme */ + useEffect(() => { + if (isThemeControlled) { + return; + } + const root = document.documentElement; + const syncTheme = () => { + setAutoTheme( + root.getAttribute('data-theme') === 'dark' ? 'dark' : 'light', + ); + }; + syncTheme(); + const observer = new MutationObserver(syncTheme); + observer.observe(root, { + attributes: true, + attributeFilter: ['data-theme'], + }); + return () => observer.disconnect(); + }, [isThemeControlled]); + + const resolvedTheme = diffViewThemeProp ?? autoTheme; + + const diffFile = useMemo(() => { + const language = getLanguageFromFile(fileName); + const file = generateDiffFile( + fileName, + originalContent ?? '', + fileName, + modifiedContent ?? '', + language, + language, + ); + file.initTheme(resolvedTheme); + file.init(); + if (diffViewMode === DiffModeEnum.Unified) { + file.buildUnifiedDiffLines(); + } else { + file.buildSplitDiffLines(); + } + return file; + }, [ + fileId, + fileName, + originalContent, + modifiedContent, + resolvedTheme, + diffViewMode, + ]); + + return ( +
+ +
+ ); +}; + +export default ChangeFileGitDiffView; diff --git a/qiming/src/components/business-component/ConversationBottomConsole/DevLogActions.tsx b/qiming/src/components/business-component/ConversationBottomConsole/DevLogActions.tsx new file mode 100644 index 00000000..fbed8948 --- /dev/null +++ b/qiming/src/components/business-component/ConversationBottomConsole/DevLogActions.tsx @@ -0,0 +1,94 @@ +import { SvgIcon } from '@/components/base'; +import TooltipIcon from '@/components/custom/TooltipIcon'; +import { dict } from '@/services/i18nRuntime'; +import { ClearOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Badge, Button } from 'antd'; +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +export interface DevLogActionsProps { + /** 最新日志块中是否包含错误(控制错误徽标与一键修复按钮) */ + hasErrorInLatestBlock?: boolean; + /** 最新错误日志内容(一键修复时发送) */ + latestErrorLogs?: string; + /** 对话是否进行中(禁用一键修复) */ + isChatLoading?: boolean; + /** 将日志内容加入对话 */ + onAddToChat?: (content: string, isAuto?: boolean) => void; + /** 一键修复前重置自动重试状态 */ + onResetAutoRetry?: () => void; + /** 刷新日志 */ + onRefresh?: () => void; + /** 清空日志 */ + onClear?: () => void; +} + +/** + * 开发服务器日志操作按钮组(一键修复 / 刷新 / 清空) + * 作为 ConversationBottomConsole 的 extra 内容嵌入头部 + */ +const DevLogActions: React.FC = ({ + hasErrorInLatestBlock = false, + latestErrorLogs, + isChatLoading = false, + onAddToChat, + onResetAutoRetry, + onRefresh, + onClear, +}) => { + const handleFindLatestErrorLogs = useCallback(() => { + if (!onAddToChat || isChatLoading || !onResetAutoRetry) { + return; + } + onResetAutoRetry(); + if (latestErrorLogs) { + onAddToChat(latestErrorLogs, true); + } + }, [onAddToChat, isChatLoading, onResetAutoRetry, latestErrorLogs]); + + return ( +
+ {hasErrorInLatestBlock && onAddToChat && ( + <> + + + + + + )} + } + onClick={onRefresh} + /> + } + onClick={onClear} + /> +
+ ); +}; + +export default DevLogActions; diff --git a/qiming/src/components/business-component/ConversationBottomConsole/DevLogPanel/index.less b/qiming/src/components/business-component/ConversationBottomConsole/DevLogPanel/index.less new file mode 100644 index 00000000..d1fb29e1 --- /dev/null +++ b/qiming/src/components/business-component/ConversationBottomConsole/DevLogPanel/index.less @@ -0,0 +1,138 @@ +@import '@/styles/color.less'; +@import '@/styles/token.less'; + +.dev-log-panel { + height: 100%; + overflow: hidden; + background: #f5f5dc; + + .log-list { + height: 100%; + overflow-y: auto; + padding: 8px; + + .log-group { + display: flex; + flex-direction: row; + margin-bottom: 4px; + background: transparent; + cursor: pointer; + border-radius: 4px; + + &:hover { + background: rgba(139, 69, 19, 10%); + } + + .log-group-header { + padding: 4px 8px; + transition: all 0.2s ease; + + .group-header-left { + display: flex; + align-items: center; + + .group-timestamp { + display: inline-block; + min-width: 132px; + font-size: @fontSizeSm; + color: #8b4513; + font-family: @fontFamilyCode; + } + } + } + + .log-group-content { + flex: 1; + padding: 0; + background: transparent; + + .log-content-block { + background: transparent; + border: none; + border-radius: 0; + + .log-text { + margin: 0; + padding: 4px 8px; + font-family: @fontFamilyCode; + font-size: @fontSizeSm; + white-space: pre-wrap; + word-break: break-all; + color: #8b4513; + background: transparent; + + .log-line { + &.log-line-error { + color: #d32f2f; + } + + &.log-line-warn { + color: #d46b08; + } + + &.log-line-info { + color: #1890ff; + } + + &.log-line-normal { + color: #8b4513; + } + } + } + } + } + } + } + + .empty-logs { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: @colorTextTertiary; + + .empty-icon { + font-size: 48px; + margin-bottom: 16px; + color: @colorTextTertiary; + } + + p { + margin: 0 0 16px; + font-size: @fontSize; + } + } + + .loading-logs { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: @colorTextTertiary; + + .loading-spinner { + font-size: 32px; + margin-bottom: 16px; + color: @colorPrimary; + animation: dev-log-spin 1s linear infinite; + } + + p { + margin: 0; + font-size: @fontSize; + color: @colorTextSecondary; + } + } +} + +@keyframes dev-log-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/qiming/src/components/business-component/ConversationBottomConsole/DevLogPanel/index.tsx b/qiming/src/components/business-component/ConversationBottomConsole/DevLogPanel/index.tsx new file mode 100644 index 00000000..7ba4066b --- /dev/null +++ b/qiming/src/components/business-component/ConversationBottomConsole/DevLogPanel/index.tsx @@ -0,0 +1,254 @@ +/** + * 开发日志面板(仅内容区,供底部合集控制台嵌入使用) + */ + +import { + formatTimestampDisplay, + groupLogsByTimestamp, + type LogGroup, +} from '@/pages/AppDev/utils/devLogParser'; +import { t } from '@/services/i18nRuntime'; +import type { DevLogEntry } from '@/types/interfaces/appDev'; +import { LogLevel } from '@/types/interfaces/appDev'; +import { BugOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Tooltip } from 'antd'; +import classNames from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +export interface DevLogPanelProps { + /** 日志数据 */ + logs: DevLogEntry[]; + /** 是否正在加载 */ + isLoading?: boolean; + /** 最后加载的行号 */ + lastLine?: number; + /** 添加日志到聊天框回调 */ + onAddToChat?: (content: string, isAuto?: boolean) => void; + /** 是否正在发送消息 */ + isChatLoading?: boolean; + /** 获取最新错误日志(自动处理问题) */ + latestErrorLogs?: string; + /** 自定义根节点 className */ + className?: string; +} + +/** + * 单组日志内容块 + * 按日志级别着色,组级别取组内最高级别(error > warn > info > normal) + */ +const LogContentBlock: React.FC<{ logs: DevLogEntry[] }> = ({ logs }) => { + /** 组内最高日志级别(决定整块底色) */ + const getGroupLevel = () => { + if (logs.some((log) => log.level === LogLevel.ERROR)) return 'error'; + if (logs.some((log) => log.level === LogLevel.WARN)) return 'warn'; + if (logs.some((log) => log.level === LogLevel.INFO)) return 'info'; + return 'normal'; + }; + + /** 渲染日志行:去掉时间戳前缀与行号前缀后按级别着色 */ + const renderHighlightedContent = () => + logs.map((log, index) => { + let content = log.content.replace( + /\[\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\]\s*/, + '', + ); + content = content.replace( + /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?\s*/, + '', + ); + content = content.replace(/^\d+\|\s*/, ''); + + return ( +
+ {content} +
+ ); + }); + + return ( +
+
{renderHighlightedContent()}
+
+ ); +}; + +/** + * 单个日志分组项(时间戳 + 内容块) + * 点击整组可将组内日志内容加入对话 + */ +const LogGroupItem: React.FC<{ + group: LogGroup; + onAddToChat?: (content: string, isAuto?: boolean) => void; +}> = ({ group, onAddToChat }) => { + /** 组内日志拼接为一段文本(用于加入对话) */ + const groupLogs = group.logs + .map((log) => log.content) + .join('\n') + .trim(); + + /** 点击日志组:将整组内容加入对话(手动触发,isAuto=false) */ + const handleClick = () => { + if (onAddToChat && groupLogs) { + onAddToChat(groupLogs, false); + } + }; + + return ( +
+
+ +
+ + {formatTimestampDisplay(group.timestamp) || '\u00a0'} + +
+
+
+
+ +
+
+ ); +}; + +/** + * 开发日志列表面板 + */ +const DevLogPanel: React.FC = ({ + logs, + isLoading = false, + lastLine = 0, + onAddToChat, + isChatLoading = false, + latestErrorLogs, + className, +}) => { + /** 日志列表容器引用(用于自动滚动到底部) */ + const logListRef = useRef(null); + /** 按时间戳分组后的日志 */ + const [logGroups, setLogGroups] = useState([]); + /** 是否显示加载态(带最短展示时长,避免闪烁) */ + const [showLoading, setShowLoading] = useState(false); + /** 延迟隐藏加载态的定时器 */ + const loadingTimerRef = useRef(null); + /** 加载态开始展示的时间戳(用于计算最短展示时长) */ + const loadingStartTimeRef = useRef(null); + + /** 日志变化时重新按时间戳分组 */ + useEffect(() => { + setLogGroups(groupLogsByTimestamp(logs)); + }, [logs]); + + /** + * 加载态控制:仅首次加载(lastLine <= 1)时显示, + * 且保证至少展示 500ms,避免快速加载时一闪而过 + */ + useEffect(() => { + if (loadingTimerRef.current) { + clearTimeout(loadingTimerRef.current); + loadingTimerRef.current = null; + } + + if (isLoading && lastLine <= 1) { + // 首次加载开始:记录起始时间并显示加载态 + if (!showLoading) { + loadingStartTimeRef.current = Date.now(); + setShowLoading(true); + } + } else if (!isLoading && showLoading) { + // 加载结束:不足 500ms 时延迟隐藏,补足最短展示时长 + const elapsed = loadingStartTimeRef.current + ? Date.now() - loadingStartTimeRef.current + : 0; + const remainingTime = Math.max(0, 500 - elapsed); + + loadingTimerRef.current = setTimeout(() => { + setShowLoading(false); + loadingStartTimeRef.current = null; + }, remainingTime); + } + + return () => { + if (loadingTimerRef.current) { + clearTimeout(loadingTimerRef.current); + loadingTimerRef.current = null; + } + }; + }, [isLoading, lastLine, showLoading]); + + /** 日志更新后自动滚动到底部(setTimeout 等待 DOM 渲染完成) */ + useEffect(() => { + if (logGroups.length > 0 && logListRef.current) { + setTimeout(() => { + if (logListRef.current) { + logListRef.current.scrollTop = logListRef.current.scrollHeight; + } + }, 0); + } + }, [logGroups.length, logGroups]); + + /** 检测到最新错误日志时自动加入对话(对话进行中时跳过) */ + useEffect(() => { + if (isChatLoading) return; + if (latestErrorLogs) { + onAddToChat?.(latestErrorLogs, true); + } + }, [logs, isChatLoading, latestErrorLogs, onAddToChat]); + + return ( +
+ {showLoading ? ( + /* 加载态 */ +
+
+ +
+

{t('PC.Pages.AppDevDevLogConsole.loadingLogs')}

+
+ ) : logGroups.length > 0 ? ( + /* 日志分组列表 */ +
+ {logGroups.map((group, index) => ( + + ))} +
+ ) : ( + /* 空态 */ +
+ +

{t('PC.Pages.AppDevDevLogConsole.noLogData')}

+
+ )} +
+ ); +}; + +export default DevLogPanel; diff --git a/qiming/src/components/business-component/ConversationBottomConsole/index.less b/qiming/src/components/business-component/ConversationBottomConsole/index.less new file mode 100644 index 00000000..fe3b6e39 --- /dev/null +++ b/qiming/src/components/business-component/ConversationBottomConsole/index.less @@ -0,0 +1,302 @@ +@import '@/styles/token'; + +@console-content-height: 240px; + +@terminal-console-bg-light: #ffffff; +@terminal-console-fg-light: #1a1a1a; +@terminal-console-bg-dark: #000000; +@terminal-console-fg-dark: #d4d4d4; + +.console { + display: flex; + flex-direction: column; + border-top: @lineWidth solid @colorSplit; + background-color: @colorBgContainer; + flex-shrink: 0; + + .console-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: @paddingXs @paddingSm; + border-bottom: @lineWidth solid @colorSplit; + flex-shrink: 0; + position: relative; + z-index: 2; + } + + .console-tabs { + display: flex; + gap: @margin; + flex: 1; + min-width: 0; + } + + .console-tab { + font-size: @fontSize; + color: @colorTextSecondary; + cursor: pointer; + padding: 2px 0; + transition: color 0.2s ease; + + &:hover { + color: @colorPrimary; + } + + &.active { + color: @colorPrimary; + } + } + + .console-actions { + display: flex; + align-items: center; + gap: @marginXxs; + flex-shrink: 0; + margin-left: @marginSm; + } + + .console-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + color: @colorTextSecondary; + border-radius: @borderRadius; + + &.console-action-restart-btn { + width: auto; + height: auto; + } + + &:hover { + color: @colorPrimary; + background-color: @colorPrimaryBg; + } + } + + .console-log-actions { + display: flex; + align-items: center; + gap: @marginXxs; + margin-right: @marginXxs; + padding-right: @marginXxs; + border-right: @lineWidth solid @colorSplit; + } + + .console-quick-fix-btn { + height: 24px; + padding: 0 8px; + font-size: @fontSizeSm; + } + + &.console-expanded { + position: absolute; + inset: 0; + z-index: 10; + flex-shrink: 0; + min-height: 0; + + .console-content { + flex: 1; + height: auto; + min-height: 0; + } + } + + &.console-collapsed { + .console-content { + display: none; + } + } + + &.console-hidden { + display: none; + } + + .console-content { + position: relative; + height: @console-content-height; + flex-shrink: 0; + overflow: hidden; + background-color: transparent; + transition: background-color 0.2s ease; + } + + &.console-terminal-dark .console-content { + background-color: @terminal-console-bg-dark; + } + + .tab-pane { + position: absolute; + inset: 0; + overflow: hidden; + visibility: hidden; + pointer-events: none; + z-index: 0; + + &.tab-pane-active { + visibility: visible; + pointer-events: auto; + z-index: 1; + } + } + + .console-body { + height: 100%; + overflow: auto; + padding: @paddingSm; + color: @terminal-console-fg-light; + font-family: Menlo, Monaco, Consolas, monospace; + font-size: @fontSizeSm; + line-height: @lineHeightLg; + white-space: pre-wrap; + word-break: break-all; + background-color: transparent; + transition: background-color 0.2s ease, color 0.2s ease; + } + + &.console-terminal-dark .console-body { + color: @terminal-console-fg-dark; + background-color: transparent; + } + + .console-empty { + color: @colorTextTertiary; + font-size: @fontSizeSm; + text-align: center; + padding: @paddingLg 0; + } + + .container-retry-btn { + margin-top: @marginXxs; + } + + .terminal-tab-stage { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + } + + .terminal-tab-stage-light { + background-color: @terminal-console-bg-light; + } + + .terminal-tab-stage-dark { + background-color: @terminal-console-bg-dark; + } + + .terminal-tab-overlay { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.24s ease, visibility 0.24s ease; + + &.terminal-tab-overlay-visible { + opacity: 1; + visibility: visible; + pointer-events: auto; + } + + &.terminal-tab-overlay-instant { + transition: none; + } + } + + .terminal-tab-stage-light .terminal-tab-overlay { + background-color: @terminal-console-bg-light; + } + + .terminal-tab-stage-dark .terminal-tab-overlay { + background-color: @terminal-console-bg-dark; + } + + .xterm-container { + width: 100%; + height: 100%; + overflow: hidden; + } + + .terminal-embedded { + width: 100%; + height: 100%; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + outline: none !important; + overflow: hidden; + + :global(.xterm) { + height: 100%; + padding: @marginXxs @paddingSm @marginXxs; + box-sizing: border-box; + } + + :global(.xterm-decoration-overview-ruler) { + opacity: 0.4; + } + } + + .terminal-embedded-light { + background-color: @terminal-console-bg-light; + + :global(.xterm .xterm-viewport) { + background-color: @terminal-console-bg-light; + + &::-webkit-scrollbar { + width: @marginXs; + } + + &::-webkit-scrollbar-thumb { + background: @colorFill; + border-radius: @borderRadiusSm; + + &:hover { + background: @colorPrimary; + } + } + + &::-webkit-scrollbar-track { + background: @colorFillTertiary; + } + } + } + + .terminal-embedded-dark { + background-color: @terminal-console-bg-dark; + + :global(.xterm .xterm-viewport) { + background-color: @terminal-console-bg-dark; + + &::-webkit-scrollbar { + width: @marginXs; + } + + &::-webkit-scrollbar-thumb { + background: @colorFillSecondary; + border-radius: @borderRadiusSm; + + &:hover { + background: @cyan; + } + } + + &::-webkit-scrollbar-track { + background: @colorFillQuaternary; + } + } + } + + .dev-log-pane { + height: 100%; + overflow: hidden; + } +} diff --git a/qiming/src/components/business-component/ConversationBottomConsole/index.tsx b/qiming/src/components/business-component/ConversationBottomConsole/index.tsx new file mode 100644 index 00000000..7632d77d --- /dev/null +++ b/qiming/src/components/business-component/ConversationBottomConsole/index.tsx @@ -0,0 +1,944 @@ +import { SvgIcon } from '@/components/base'; +import TooltipIcon from '@/components/custom/TooltipIcon'; +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { dict } from '@/services/i18nRuntime'; +import { + apiEnsurePod, + apiKeepalivePod, + isEnsurePodThrottledError, +} from '@/services/vncDesktop'; +import type { DevLogEntry } from '@/types/interfaces/appDev'; +import { + DownOutlined, + FullscreenExitOutlined, + MoonOutlined, + ReloadOutlined, + SunOutlined, + UpOutlined, +} from '@ant-design/icons'; +import { useRequest } from 'ahooks'; +import { Button } from 'antd'; +import classNames from 'classnames'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import type { EmbeddedConsoleTerminalRef } from '../Terminal/EmbeddedConsoleTerminal'; +import EmbeddedConsoleTerminal from '../Terminal/EmbeddedConsoleTerminal'; +import { DEFAULT_TERMINAL_RECONNECT } from '../Terminal/terminalReconnect'; +import type { TerminalWireProtocol } from '../Terminal/type'; +import DevLogPanel from './DevLogPanel'; +import styles from './index.less'; +import { + CONSOLE_TERMINAL_FONT_FAMILY, + DEFAULT_TERMINAL_APPEARANCE, + getConsoleTerminalTheme, + type TerminalAppearanceMode, +} from './terminalTheme'; + +export type { TerminalAppearanceMode } from './terminalTheme'; + +const cx = classNames.bind(styles); + +/** 底部控制台布局模式 */ +export type ConsoleLayoutMode = 'default' | 'expanded' | 'collapsed'; + +/** 开发服务器日志相关配置(网页应用) */ +export interface ConversationBottomConsoleDevLogProps { + logs: DevLogEntry[]; + latestErrorLogs?: string; + isLoading?: boolean; + lastLine?: number; + onAddToChat?: (content: string, isAuto?: boolean) => void; + isChatLoading?: boolean; +} + +export interface ConversationBottomConsoleProps { + /** 是否显示面板 @default true */ + visible?: boolean; + /** 运行日志纯文本(会话智能体) */ + runtimeLogs?: string; + /** 开发服务器结构化日志;传入后日志 Tab 使用 DevLogPanel */ + devLog?: ConversationBottomConsoleDevLogProps; + /** 终端 WebSocket 地址;传入后终端 Tab 渲染 XtermTerminal */ + wsUrl?: string; + /** + * 会话 ID;传入后组件挂载时自动调用 apiEnsurePod 启动容器, + * 容器启动成功后才允许终端发起 WebSocket 连接。 + * 若不传,则不启动容器,终端在有 wsUrl 时直接连接。 + */ + conversationId?: number; + /** + * 容器启动成功后是否开启保活轮询 @default true + * 网页应用开发只需要确保/重启服务,不需要轮询保活接口。 + */ + enableKeepalivePolling?: boolean; + /** WebSocket 子协议(ttyd 需传 ['tty']) */ + wsSubprotocols?: string | string[]; + /** 终端消息格式('plain' | 'ttyd') */ + wireProtocol?: TerminalWireProtocol; + /** 终端主题(受控);不传时由组件内部维护 */ + terminalAppearance?: TerminalAppearanceMode; + /** 终端主题默认值(非受控) */ + defaultTerminalAppearance?: TerminalAppearanceMode; + /** 终端主题切换回调 */ + onTerminalAppearanceChange?: (mode: TerminalAppearanceMode) => void; + /** 是否显示终端主题切换按钮 @default true */ + showTerminalAppearanceToggle?: boolean; + /** 信号值变化时将 expanded 布局重置为 default(如外部布局变动) */ + layoutResetSignal?: number; + /** 信号值变化时切到终端 Tab 并全屏展开(如点击「终端」入口) */ + expandSignal?: number; + /** 信号值变化时切到终端 Tab(折叠时恢复默认高度,如点击「终端」图标) */ + terminalSignal?: number; + /** 信号值变化时将面板折叠为仅保留头部 */ + collapseSignal?: number; + /** 信号值变化时切到日志 Tab(折叠时恢复默认高度,如点击「日志」图标) */ + logsSignal?: number; + /** 布局模式变化回调(供外部感知折叠/展开状态) */ + onLayoutModeChange?: (mode: ConsoleLayoutMode) => void; + /** 激活 Tab 变化回调(供外部感知当前显示终端还是日志) */ + onActiveTabChange?: (tab: 'terminal' | 'logs') => void; + /** 打开控制台时的默认 Tab @default 'terminal' */ + defaultActiveTab?: 'terminal' | 'logs'; + /** 打开控制台时的默认布局模式 @default 'collapsed' */ + defaultLayoutMode?: ConsoleLayoutMode; + /** 是否显示日志 Tab @default true */ + showLogsTab?: boolean; + /** 头部操作区额外内容(渲染在内置按钮之前),仅日志 Tab 激活时显示 */ + logsExtra?: React.ReactNode; + className?: string; +} + +/** + * 底部终端 + 日志合集面板 + * - 终端 Tab:XtermTerminal(wsUrl)或空态 + * - 日志 Tab:DevLogPanel(devLog)或 runtimeLogs 纯文本 + */ +const ConversationBottomConsole: React.FC = ({ + visible = true, + runtimeLogs = '', + devLog, + wsUrl, + conversationId, + enableKeepalivePolling = true, + wsSubprotocols, + wireProtocol, + terminalAppearance: terminalAppearanceProp, + defaultTerminalAppearance = DEFAULT_TERMINAL_APPEARANCE, + onTerminalAppearanceChange, + showTerminalAppearanceToggle = true, + layoutResetSignal, + expandSignal, + terminalSignal, + collapseSignal, + logsSignal, + onLayoutModeChange, + onActiveTabChange, + defaultActiveTab = 'terminal', + defaultLayoutMode = 'collapsed', + showLogsTab = true, + logsExtra, + className, +}) => { + /** 当前激活的 Tab(终端 / 日志) */ + const [activeTab, setActiveTab] = useState<'terminal' | 'logs'>( + showLogsTab ? defaultActiveTab : 'terminal', + ); + /** 面板布局模式:default 默认高度 / expanded 全屏 / collapsed 仅保留头部 */ + const [layoutMode, setLayoutMode] = + useState(defaultLayoutMode); + /** 终端主题(非受控时的内部状态) */ + const [internalAppearance, setInternalAppearance] = + useState(defaultTerminalAppearance); + /** 终端实例引用(用于主动 refresh 修复切换 Tab 后的渲染残影) */ + const terminalRef = useRef(null); + /** 终端当前连接状态(用于控制断连后再触发容器兜底重启) */ + const terminalConnectedRef = useRef(false); + /** 终端是否曾成功连接过(避免首次连接前误判为”断开”) */ + const terminalConnectedOnceRef = useRef(false); + /** 用户是否曾展开过终端面板(首次展开后才触发容器启动/WS连接) */ + const terminalActivatedRef = useRef(false); + /** 上一次 layoutMode 值,用于检测首次从 collapsed 展开 */ + const prevLayoutModeRef = useRef(defaultLayoutMode); + /** 终端自动重连耗尽后显示手动重连按钮 */ + const [showTerminalReconnect, setShowTerminalReconnect] = + useState(false); + /** 手动重连 loading */ + const [isTerminalReconnecting, setIsTerminalReconnecting] = + useState(false); + /** 断连后预热容器的防抖定时器(自动重连前尽量 ensurePod) */ + const ensurePodOnDisconnectTimerRef = useRef(null); + /** ensure 进行中,避免 handleFirstExpand 重复触发 */ + const ensureInFlightRef = useRef(false); + + /** 是否需要先启动服务再连接终端(由 conversationId 决定) */ + const requiresServiceStart = Boolean(conversationId); + + /** + * 容器启动状态机: + * - idle:未启动 / 未传 conversationId(后者表示无需容器,终端可直接连 WS) + * - starting:apiEnsurePod 调用中,终端等待 + * - running:容器已就绪,终端可连接,保活轮询中 + * - error:容器启动或保活失败,显示重试按钮,终端不可连接 + */ + const [containerStatus, setContainerStatus] = useState< + 'idle' | 'starting' | 'running' | 'error' + >('idle'); + const containerStatusRef = useRef(containerStatus); + containerStatusRef.current = containerStatus; + + /** + * 保活轮询(useRequest 自动管理定时器、页面可见性暂停/恢复) + * 使用 ref 持有 run/cancel,避免闭包陷阱并保证 startContainer 引用稳定 + */ + const runKeepaliveRef = useRef<(cId: number) => void>(() => {}); + const stopKeepaliveRef = useRef<() => void>(() => {}); + + const { run: runKeepalivePodPolling, cancel: stopKeepalivePodPolling } = + useRequest(apiKeepalivePod, { + manual: true, + loadingDelay: 30000, + debounceWait: 5000, + pollingInterval: 60000, // 60 秒保活一次 + pollingWhenHidden: false, // 屏幕不可见时暂停 + pollingErrorRetryCount: -1, // 网络错误无限重试 + // 页面重新可见时,调用 apiEnsurePod 确保容器运行 + onBefore: async (params) => { + const shouldEnsureContainer = + document.visibilityState === 'visible' && + params[0] && + terminalConnectedOnceRef.current && + !terminalConnectedRef.current; + + if (shouldEnsureContainer) { + try { + await apiEnsurePod(params[0]); + } catch (error) { + console.error('[keepalive] apiEnsurePod failed:', error); + } + } + }, + onSuccess: (result) => { + // HTTP 成功但业务码失败 → 视为保活失败,停止轮询并显示错误 + if (result.code !== SUCCESS_CODE) { + setContainerStatus('error'); + stopKeepaliveRef.current(); + } + }, + onError: (error) => { + console.error('[keepalive] Pod keepalive error:', error); + setContainerStatus('error'); + stopKeepaliveRef.current(); + }, + }); + + // 同步 ref,保证 startContainer / useLayoutEffect 中始终拿到最新引用 + runKeepaliveRef.current = runKeepalivePodPolling; + stopKeepaliveRef.current = stopKeepalivePodPolling; + + /** 启动容器并开启保活轮询 */ + const startContainer = useCallback( + async (cId: number): Promise => { + setContainerStatus('starting'); + try { + const { code } = await apiEnsurePod(cId); + if (code === SUCCESS_CODE) { + setContainerStatus('running'); + if (enableKeepalivePolling) { + runKeepaliveRef.current(cId); + } + return true; + } else { + setContainerStatus('error'); + return false; + } + } catch (error: unknown) { + // 智能体电脑等外部刚调过 ensure 时会被 5s 限流,容器已在运行,终端可直接连接 + if (isEnsurePodThrottledError(error)) { + setContainerStatus('running'); + if (enableKeepalivePolling) { + runKeepaliveRef.current(cId); + } + return true; + } + setContainerStatus('error'); + return false; + } + }, + [enableKeepalivePolling], + ); + + /** 挂载时仅清理状态,不自动启动容器;等用户首次展开时触发 */ + useLayoutEffect(() => { + ensureInFlightRef.current = false; + + if (!conversationId) { + setContainerStatus('idle'); + terminalConnectedRef.current = false; + terminalConnectedOnceRef.current = false; + return; + } + + // 清理旧的保活轮询,重置状态,但不启动容器 + if (enableKeepalivePolling) { + stopKeepaliveRef.current(); + } + setContainerStatus('idle'); + terminalConnectedRef.current = false; + terminalConnectedOnceRef.current = false; + + return () => { + if (enableKeepalivePolling) { + stopKeepaliveRef.current(); + } + }; + }, [conversationId, enableKeepalivePolling]); + + useEffect(() => { + return () => { + if (ensurePodOnDisconnectTimerRef.current) { + window.clearTimeout(ensurePodOnDisconnectTimerRef.current); + ensurePodOnDisconnectTimerRef.current = null; + } + }; + }, [conversationId]); + + /** 用户首次展开终端面板时,触发容器启动 / 终端直接连接 */ + const handleFirstExpand = useCallback(() => { + terminalActivatedRef.current = true; + + if (!conversationId || ensureInFlightRef.current) { + return; + } + + const status = containerStatusRef.current; + if (status === 'starting' || status === 'running') { + return; + } + + ensureInFlightRef.current = true; + void startContainer(conversationId).finally(() => { + ensureInFlightRef.current = false; + }); + }, [conversationId, startContainer]); + + /** 终端可见后 fit / sync / focus(委托给 EmbeddedConsoleTerminal) */ + const syncTerminalLayoutAndFocus = useCallback(() => { + terminalRef.current?.restoreAfterVisibilityChange(); + }, []); + + /** 折叠恢复可见后多轮 restore,覆盖布局动画与浏览器重排延迟 */ + const scheduleTerminalRestoreAfterExpand = useCallback(() => { + const restore = () => terminalRef.current?.restoreAfterVisibilityChange(); + window.requestAnimationFrame(() => { + restore(); + window.setTimeout(restore, 50); + window.setTimeout(restore, 150); + window.setTimeout(restore, 350); + }); + }, []); + + /** 监听 layoutMode 变化,检测首次从 collapsed 展开 */ + useEffect(() => { + const prevMode = prevLayoutModeRef.current; + // 两种情况触发首次启动: + // 1. 首次渲染时已是非折叠状态(如 defaultLayoutMode="default") + // 2. 从折叠展开为非折叠状态 + const isFirstExpand = + (!terminalActivatedRef.current && layoutMode !== 'collapsed') || + (layoutMode !== 'collapsed' && prevMode === 'collapsed'); + + if (isFirstExpand) { + handleFirstExpand(); + } + + // 从折叠恢复:终端仍连着 WS 但 xterm 在 display:none 内尺寸/焦点会失效 + if ( + layoutMode !== 'collapsed' && + prevMode === 'collapsed' && + activeTab === 'terminal' && + wsUrl && + visible + ) { + scheduleTerminalRestoreAfterExpand(); + } + + prevLayoutModeRef.current = layoutMode; + }, [ + activeTab, + handleFirstExpand, + layoutMode, + scheduleTerminalRestoreAfterExpand, + visible, + wsUrl, + ]); + + /** + * 终端连接开关(完全由内部容器状态控制): + * - 未传 conversationId:无需启动服务,有 wsUrl 且面板可见时直接连接 + * - 传入 conversationId:仅 containerStatus === 'running' 后连接 + * - starting / error:服务未就绪,终端不可连接 + * 面板折叠或整体隐藏时不建立连接,避免在 display:none 容器内 init ttyd 导致无法输入 + * 例外:用户已首次展开过终端面板后,即使折叠也保持连接不断 + * 终端重连失败展示重试面板时暂停自动连接 + */ + const isServiceReadyForTerminal = requiresServiceStart + ? containerStatus === 'running' + : true; + const terminalAutoConnect = + isServiceReadyForTerminal && + visible && + !showTerminalReconnect && + (layoutMode !== 'collapsed' || terminalActivatedRef.current); + /** 上一次 visible 值(用于识别「重新打开」时机) */ + const prevVisibleRef = useRef(visible); + /** 外部信号上一次值(undefined 表示未消费,避免 lazy mount 时与当前 signal 相同而跳过) */ + const prevLayoutResetSignalRef = useRef(undefined); + const prevExpandSignalRef = useRef(undefined); + const prevTerminalSignalRef = useRef(undefined); + const prevCollapseSignalRef = useRef(undefined); + const prevLogsSignalRef = useRef(undefined); + + /** 日志 Tab 是否使用结构化日志面板(DevLogPanel) */ + const useDevLogPanel = Boolean(devLog); + /** 终端主题是否为受控模式 */ + const isControlled = terminalAppearanceProp !== undefined; + const terminalAppearance = isControlled + ? terminalAppearanceProp + : internalAppearance; + const isLightTerminal = terminalAppearance === 'light'; + + /** 面板重新打开时恢复默认 Tab;若上次处于折叠状态则恢复默认高度 */ + useEffect(() => { + if (visible && !prevVisibleRef.current) { + setActiveTab(defaultActiveTab); + setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : prev)); + } + prevVisibleRef.current = visible; + }, [visible, defaultActiveTab]); + + /** 切换终端深浅色主题(受控模式下仅触发回调) */ + const handleToggleTerminalAppearance = useCallback(() => { + const next: TerminalAppearanceMode = + terminalAppearance === 'dark' ? 'light' : 'dark'; + if (!isControlled) { + setInternalAppearance(next); + } + onTerminalAppearanceChange?.(next); + }, [isControlled, onTerminalAppearanceChange, terminalAppearance]); + + useEffect(() => { + if ( + activeTab !== 'terminal' || + !wsUrl || + !visible || + layoutMode === 'collapsed' || + (requiresServiceStart && containerStatus !== 'running') + ) { + return; + } + const timer = window.setTimeout(syncTerminalLayoutAndFocus, 100); + const retryTimer = window.setTimeout(syncTerminalLayoutAndFocus, 300); + return () => { + window.clearTimeout(timer); + window.clearTimeout(retryTimer); + }; + }, [ + activeTab, + containerStatus, + requiresServiceStart, + layoutMode, + syncTerminalLayoutAndFocus, + wsUrl, + terminalAppearance, + visible, + ]); + + /** 外部信号:将全屏布局重置回默认高度(collapsed 状态保持不变) */ + useEffect(() => { + if ( + layoutResetSignal === undefined || + layoutResetSignal === prevLayoutResetSignalRef.current + ) { + return; + } + prevLayoutResetSignalRef.current = layoutResetSignal; + if (!layoutResetSignal) return; + setLayoutMode((prev) => (prev === 'expanded' ? 'default' : prev)); + }, [layoutResetSignal]); + + /** 外部信号:切到终端 Tab 并全屏展开 */ + useEffect(() => { + if ( + expandSignal === undefined || + expandSignal === prevExpandSignalRef.current + ) { + return; + } + prevExpandSignalRef.current = expandSignal; + if (!expandSignal) return; + setActiveTab('terminal'); + setLayoutMode('expanded'); + }, [expandSignal]); + + /** 外部信号:切到终端 Tab(折叠时恢复默认高度) */ + useEffect(() => { + if ( + terminalSignal === undefined || + terminalSignal === prevTerminalSignalRef.current + ) { + return; + } + prevTerminalSignalRef.current = terminalSignal; + if (!terminalSignal) return; + setActiveTab('terminal'); + setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : prev)); + }, [terminalSignal]); + + /** 外部信号:折叠面板(仅保留头部) */ + useEffect(() => { + if ( + collapseSignal === undefined || + collapseSignal === prevCollapseSignalRef.current + ) { + return; + } + prevCollapseSignalRef.current = collapseSignal; + if (!collapseSignal) return; + setLayoutMode('collapsed'); + }, [collapseSignal]); + + /** 外部信号:切到日志 Tab(折叠时恢复默认高度) */ + useEffect(() => { + if ( + logsSignal === undefined || + !showLogsTab || + logsSignal === prevLogsSignalRef.current + ) { + return; + } + prevLogsSignalRef.current = logsSignal; + if (!logsSignal) return; + setActiveTab('logs'); + setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : prev)); + }, [logsSignal, showLogsTab]); + + /** 布局模式变化时通知外部 */ + useEffect(() => { + onLayoutModeChange?.(layoutMode); + }, [layoutMode, onLayoutModeChange]); + + /** 激活 Tab 变化时通知外部 */ + useEffect(() => { + onActiveTabChange?.(activeTab); + }, [activeTab, onActiveTabChange]); + + /** 全屏展开 / 恢复默认高度 */ + const handleToggleExpand = () => { + setLayoutMode((prev) => (prev === 'expanded' ? 'default' : 'expanded')); + }; + + /** 折叠到仅头部 / 恢复默认高度 */ + const handleToggleCollapse = () => { + setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : 'collapsed')); + }; + + /** + * 手动重连:有 conversationId 时始终 ensurePod 再连终端。 + * 不能仅依赖 containerStatus === 'running' 跳过启动——未开保活或保活间隔内 + * 容器已停而前端状态未更新时,必须先拉起服务才能连终端。 + * apiEnsurePod 对已在运行的容器是幂等的,重复调用开销可接受。 + */ + const handleReconnectTerminal = useCallback(async () => { + if (!wsUrl || isTerminalReconnecting) { + return; + } + + setIsTerminalReconnecting(true); + try { + if (conversationId) { + const isReady = await startContainer(conversationId); + if (!isReady) { + return; + } + } + + setShowTerminalReconnect(false); + setActiveTab('terminal'); + setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : prev)); + + window.requestAnimationFrame(() => { + terminalRef.current?.reconnect(wsUrl); + }); + } finally { + setIsTerminalReconnecting(false); + } + }, [conversationId, isTerminalReconnecting, startContainer, wsUrl]); + + /** 容器启动失败或终端连接失败时,展示统一的重启服务面板(叠加层内容) */ + const renderTerminalRetryOverlay = () => ( +
+ +
+ ); + + /** 切换 Tab;折叠状态下点击 Tab 自动恢复默认高度 */ + const handleTabClick = (tab: 'terminal' | 'logs') => { + const wasCollapsed = layoutMode === 'collapsed'; + setActiveTab(tab); + if (wasCollapsed) { + setLayoutMode('default'); + } + if (tab === 'terminal') { + if (wasCollapsed) { + scheduleTerminalRestoreAfterExpand(); + } else { + window.setTimeout(syncTerminalLayoutAndFocus, 100); + } + } + }; + + /** 终端 Tab:终端始终挂载,加载/错误态以叠加层淡入淡出展示 */ + const renderTerminalTab = () => { + const showStartingOverlay = containerStatus === 'starting'; + const showRetryOverlay = + containerStatus === 'error' || showTerminalReconnect; + + if (!wsUrl) { + return ( +
+
+ {dict('PC.Components.ConversationBottomConsole.terminalEmpty')} +
+
+ ); + } + + return ( +
+
{ + if (event.button !== 0 || showStartingOverlay || showRetryOverlay) { + return; + } + terminalRef.current?.focus(); + }} + > + { + const isReconnect = terminalConnectedOnceRef.current; + terminalConnectedRef.current = true; + terminalConnectedOnceRef.current = true; + setShowTerminalReconnect(false); + if (isReconnect) { + terminalRef.current?.getTerminal()?.write('\r\n'); + } + terminalRef.current?.writeln( + '\x1b[1;38;2;22;163;74m[Terminal connected]\x1b[0m', + ); + // 重连后 fit + focus;shell 提示符由 EmbeddedConsoleTerminal 内 requestShellPrompt 补发 + terminalRef.current?.restoreAfterVisibilityChange(); + window.setTimeout( + () => terminalRef.current?.restoreAfterVisibilityChange(), + 50, + ); + window.setTimeout( + () => terminalRef.current?.restoreAfterVisibilityChange(), + 250, + ); + }} + onDisconnect={() => { + terminalConnectedRef.current = false; + terminalRef.current?.getTerminal()?.write('\r\n'); + terminalRef.current?.writeln( + '\x1b[1;38;2;220;38;38m[Terminal disconnected]\x1b[0m', + ); + // 仅「需先启动容器」模式(传入 conversationId)才在断连后预热服务; + // 未传 conversationId 时终端直连 wsUrl,由 EmbeddedConsoleTerminal 自动重连即可 + if (requiresServiceStart && containerStatus === 'running') { + if (ensurePodOnDisconnectTimerRef.current) { + window.clearTimeout(ensurePodOnDisconnectTimerRef.current); + } + ensurePodOnDisconnectTimerRef.current = window.setTimeout( + () => { + ensurePodOnDisconnectTimerRef.current = null; + if (!conversationId) { + return; + } + void apiEnsurePod(conversationId).catch((error) => { + console.error( + '[ConversationBottomConsole] ensurePod on disconnect failed:', + error, + ); + }); + }, + 300, + ); + } + }} + onReconnectFailed={() => { + terminalConnectedRef.current = false; + setShowTerminalReconnect(true); + }} + /> +
+ +
+
+ + {dict( + 'PC.Components.ConversationBottomConsole.containerStarting', + )} + +
+
+ +
+ {renderTerminalRetryOverlay()} +
+
+ ); + }; + + /** 日志 Tab 内容:有 devLog 时渲染结构化日志面板,否则展示纯文本/空态 */ + const renderLogsTab = () => { + if (useDevLogPanel && devLog) { + return ( +
+ +
+ ); + } + + return ( +
+ {runtimeLogs ? ( + runtimeLogs + ) : ( +
+ {dict('PC.Components.ConversationBottomConsole.logsEmpty')} +
+ )} +
+ ); + }; + + return ( +
+ {/* 头部:左侧 Tab 切换 + 右侧操作按钮 */} +
+
+ handleTabClick('terminal')} + > + {dict('PC.Components.ConversationBottomConsole.tabTerminal')} + + {showLogsTab && ( + handleTabClick('logs')} + > + {dict('PC.Components.ConversationBottomConsole.tabLogs')} + + )} +
+
+ {/* 页面注入的额外操作(如开发日志操作按钮组),仅日志 Tab 显示 */} + {showLogsTab && activeTab === 'logs' && logsExtra} + + {/* 终端主题切换按钮 */} + {showTerminalAppearanceToggle && ( + + ) : ( + + ) + } + onClick={handleToggleTerminalAppearance} + /> + )} + + {/* 全屏展开 / 恢复默认高度按钮 */} + + ) : ( + + ) + } + onClick={handleToggleExpand} + /> + + {/* 折叠到底部 / 恢复默认高度按钮 */} + : + } + onClick={handleToggleCollapse} + /> +
+
+ {/* 内容区:两个 Tab 面板常驻渲染,通过样式切换显隐(保持终端连接不断开) */} +
+
+ {renderTerminalTab()} +
+
+ {renderLogsTab()} +
+
+
+ ); +}; + +export default ConversationBottomConsole; diff --git a/qiming/src/components/business-component/ConversationBottomConsole/terminalTheme.ts b/qiming/src/components/business-component/ConversationBottomConsole/terminalTheme.ts new file mode 100644 index 00000000..39f89fb0 --- /dev/null +++ b/qiming/src/components/business-component/ConversationBottomConsole/terminalTheme.ts @@ -0,0 +1,75 @@ +import type { ITheme } from '@xterm/xterm'; + +/** 底部控制台终端外观:深色 / 浅色 */ +export type TerminalAppearanceMode = 'dark' | 'light'; + +/** 默认终端外观:浅色(白底) */ +export const DEFAULT_TERMINAL_APPEARANCE: TerminalAppearanceMode = 'light'; + +/** + * 深色终端(VS Code Dark+) + * 黑底 + 高对比 ANSI,对应图一 + */ +export const CONSOLE_TERMINAL_THEME_DARK: ITheme = { + background: '#000000', + foreground: '#d4d4d4', + cursor: '#4ec9b0', + cursorAccent: '#000000', + selectionBackground: '#264f78', + selectionForeground: '#ffffff', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#ffffff', +}; + +/** + * 浅色终端(IDE 白底风格) + * 白底 + 深色正文,青绿提示符 / 蓝色路径,对应图二 + */ +export const CONSOLE_TERMINAL_THEME_LIGHT: ITheme = { + background: '#ffffff', + foreground: '#1a1a1a', + cursor: '#4078f2', + cursorAccent: '#ffffff', + selectionBackground: '#b4d7ff', + selectionForeground: '#1a1a1a', + black: '#1a1a1a', + red: '#cd3131', + green: '#098658', + yellow: '#9a6f00', + blue: '#0451a5', + magenta: '#a626a4', + cyan: '#0598bc', + white: '#fafafa', + brightBlack: '#6e7681', + brightRed: '#e51400', + brightGreen: '#0d9c6c', + brightYellow: '#bf8803', + brightBlue: '#0078d4', + brightMagenta: '#a626a4', + brightCyan: '#0598bc', + brightWhite: '#ffffff', +}; + +/** @deprecated 使用 CONSOLE_TERMINAL_THEME_DARK */ +export const CONSOLE_TERMINAL_THEME = CONSOLE_TERMINAL_THEME_DARK; + +/** 等宽字体栈,与 IDE 终端保持一致 */ +export const CONSOLE_TERMINAL_FONT_FAMILY = + '"SF Mono", Menlo, Monaco, Consolas, "DejaVu Sans Mono", "Microsoft YaHei", monospace'; + +export const getConsoleTerminalTheme = (mode: TerminalAppearanceMode): ITheme => + mode === 'light' ? CONSOLE_TERMINAL_THEME_LIGHT : CONSOLE_TERMINAL_THEME_DARK; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/index.less b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/index.less new file mode 100644 index 00000000..947edc56 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/index.less @@ -0,0 +1,92 @@ +@import '@/styles/color.less'; +@import '@/styles/token.less'; + +// 右键菜单样式 +.contextMenu { + background: @colorBgElevated; + border: @lineWidth solid @colorBorder; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 15%); + padding: 4px 0; + min-width: 120px; + z-index: 1000; +} + +.contextMenuItem { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + // color: @colorTextBase; + font-size: @fontSizeSm; + transition: all 0.2s ease; + color: #5e5e5b; + + &:hover { + background: @colorPrimaryBg; + color: @colorPrimary; + } + + &.dangerMenuItem { + color: @colorError; + + &:hover { + background: @colorErrorBg; + color: @colorError; + } + } + + &.disabledMenuItem { + color: @colorTextQuaternary; + cursor: not-allowed; + + &:hover { + background: transparent; + color: @colorTextQuaternary; + } + } +} + +.contextMenuIcon { + margin-right: 8px; + font-size: @fontSizeSm; + display: flex; + align-items: center; +} + +.contextMenuDivider { + height: 1px; + background: @colorBorder; + opacity: 0.6; + margin: 4px 0; +} + +// 右键菜单暗色主题支持 +[data-theme='dark'] { + .contextMenu { + background: @colorBgElevated; + border-color: @colorBorder; + } + + .contextMenuItem { + color: @colorTextBase; + + &:hover { + background: @colorPrimaryBg; + color: @colorPrimary; + } + + &.dangerMenuItem { + color: @colorError; + + &:hover { + background: @colorErrorBg; + color: @colorError; + } + } + } + + .contextMenuDivider { + background: @colorBorder; + } +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/index.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/index.tsx new file mode 100644 index 00000000..8952e390 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/index.tsx @@ -0,0 +1,467 @@ +import { dict } from '@/services/i18nRuntime'; +import { isMarkdownFile } from '@/utils/common'; +import { + DeleteOutlined, + DownloadOutlined, + EditOutlined, + FileAddOutlined, + FilePdfOutlined, + FolderAddOutlined, + FolderOpenOutlined, + ImportOutlined, + UploadOutlined, +} from '@ant-design/icons'; +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import styles from './index.less'; +import type { FileContextMenuProps } from './types'; + +/** + * FileContextMenu 组件 + * 提供文件树右键上下文菜单功能 + */ +const FileContextMenu: React.FC = ({ + visible, + position, + targetNode, + onClose, + onDelete, + onRename, + onUploadFiles, + onUploadFolder, + onCreateFile, + onCreateFolder, + // 导入项目 + onImportProject, + importProjectLabel, + onDownloadFileByUrl, + disabledDelete = false, + useRelativePosition = false, +}) => { + /** + * 处理菜单项点击 + */ + const handleMenuItemClick = useCallback( + (action: () => void) => { + action(); + onClose(); + }, + [onClose], + ); + + /** + * 处理删除操作 + */ + const handleDelete = useCallback(() => { + if (!targetNode) return; + handleMenuItemClick(() => { + // 创建一个模拟的 MouseEvent 对象,包含必要的方法 + const mockEvent = { + stopPropagation: () => {}, + preventDefault: () => {}, + currentTarget: null, + target: null, + bubbles: false, + cancelable: false, + defaultPrevented: false, + eventPhase: 0, + isTrusted: false, + nativeEvent: {} as Event, + timeStamp: Date.now(), + type: 'click', + } as unknown as React.MouseEvent; + + onDelete(targetNode, mockEvent); + }); + }, [targetNode, onDelete, handleMenuItemClick]); + + /** + * 处理重命名操作 + */ + const handleRename = useCallback(() => { + if (!targetNode || !onRename) return; + handleMenuItemClick(() => { + onRename(targetNode); + }); + }, [targetNode, onRename, handleMenuItemClick]); + + /** + * 处理上传操作 + */ + const handleUpload = useCallback(() => { + if (!onUploadFiles) return; + handleMenuItemClick(() => { + onUploadFiles(targetNode); + }); + }, [targetNode, onUploadFiles, handleMenuItemClick]); + + /** + * 处理上传文件夹操作 + */ + const handleUploadFolder = useCallback(() => { + if (!onUploadFolder) return; + handleMenuItemClick(() => { + onUploadFolder(targetNode); + }); + }, [targetNode, onUploadFolder, handleMenuItemClick]); + + /** + * 处理新建文件操作 + */ + const handleCreateFile = useCallback(() => { + if (!onCreateFile) return; + handleMenuItemClick(() => { + // 如果目标节点是文件夹,则在该文件夹下创建;否则在根目录创建 + const parentNode = + targetNode && targetNode.type === 'folder' ? targetNode : null; + onCreateFile(parentNode); + }); + }, [targetNode, onCreateFile, handleMenuItemClick]); + + /** + * 处理新建文件夹操作 + */ + const handleCreateFolder = useCallback(() => { + if (!onCreateFolder) return; + handleMenuItemClick(() => { + // 如果目标节点是文件夹,则在该文件夹下创建;否则在根目录创建 + const parentNode = + targetNode && targetNode.type === 'folder' ? targetNode : null; + onCreateFolder(parentNode); + }); + }, [targetNode, onCreateFolder, handleMenuItemClick]); + + /** + * 处理下载文件操作 + */ + const handleDownload = useCallback(() => { + if (!targetNode || !targetNode.fileProxyUrl) return; + + handleMenuItemClick(() => { + onDownloadFileByUrl?.(targetNode); + }); + }, [targetNode, handleMenuItemClick]); + + /** + * 处理导出为 PDF 操作(仅 Markdown 文件) + */ + const handleExportPdf = useCallback(() => { + if (!targetNode || !targetNode.fileProxyUrl) return; + + handleMenuItemClick(() => { + onDownloadFileByUrl?.(targetNode, true); + }); + }, [targetNode, handleMenuItemClick, onDownloadFileByUrl]); + + // 菜单 DOM 引用(必须在条件返回之前) + const menuRef = useRef(null); + // 调整后的菜单位置(必须在条件返回之前) + const [adjustedPosition, setAdjustedPosition] = useState(position); + + // 计算并调整菜单位置,避免超出视口或容器(必须在条件返回之前) + useLayoutEffect(() => { + if (!visible || !menuRef.current) { + // 菜单不可见时,重置为原始位置 + if (!visible) { + setAdjustedPosition(position); + } + return; + } + + const menuElement = menuRef.current; + const menuHeight = menuElement.offsetHeight; + const menuWidth = menuElement.offsetWidth; + + // 如果菜单还未渲染完成(高度为 0),使用原始位置 + if (menuHeight === 0) { + setAdjustedPosition(position); + return; + } + + if (useRelativePosition) { + // 使用相对定位时,相对于父容器调整位置 + const containerElement = menuElement.offsetParent as HTMLElement; + if (containerElement) { + const containerHeight = containerElement.clientHeight; + const containerWidth = containerElement.clientWidth; + + let adjustedTop = position.y; + let adjustedLeft = position.x; + + // 如果菜单底部超出容器,向上调整位置 + if (position.y + menuHeight > containerHeight) { + adjustedTop = position.y - menuHeight; + // 确保调整后的菜单顶部不会超出容器顶部,且至少距离顶部 8px + adjustedTop = Math.max(8, adjustedTop); + // 如果菜单高度超过容器高度,确保菜单顶部从容器顶部开始(保留 8px 边距) + if (menuHeight > containerHeight - 16) { + adjustedTop = 8; + } + } + + // 如果菜单右侧超出容器,向左调整位置 + if (position.x + menuWidth > containerWidth) { + adjustedLeft = Math.max(8, containerWidth - menuWidth - 8); // 至少距离右侧 8px + } + + setAdjustedPosition({ x: adjustedLeft, y: adjustedTop }); + } else { + // 如果找不到容器,使用原始位置 + setAdjustedPosition(position); + } + } else { + // 使用固定定位时,相对于视口调整位置 + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + + let adjustedTop = position.y; + let adjustedLeft = position.x; + + // 如果菜单底部超出视口,向上调整位置 + if (position.y + menuHeight > viewportHeight) { + adjustedTop = position.y - menuHeight; + // 确保调整后的菜单顶部不会超出视口顶部,且至少距离顶部 8px + adjustedTop = Math.max(8, adjustedTop); + // 如果菜单高度超过视口高度,确保菜单顶部从视口顶部开始(保留 8px 边距) + if (menuHeight > viewportHeight - 16) { + adjustedTop = 8; + } + } + + // 如果菜单右侧超出视口,向左调整位置 + if (position.x + menuWidth > viewportWidth) { + adjustedLeft = Math.max(8, viewportWidth - menuWidth - 8); // 至少距离右侧 8px + } + + setAdjustedPosition({ x: adjustedLeft, y: adjustedTop }); + } + }, [visible, position, useRelativePosition]); + + // 如果不显示,返回 null + if (!visible) { + return null; + } + + // 构建菜单项 - 根据是否有目标节点显示不同菜单 + const allMenuItems = targetNode + ? targetNode.type === 'folder' + ? [ + // 文件夹菜单项 + { + key: 'createFile', + label: dict('PC.Components.FileContextMenu.newFile'), + icon: , + onClick: handleCreateFile, + disabled: !onCreateFile, + }, + { + key: 'createFolder', + label: dict('PC.Components.FileContextMenu.newFolder'), + icon: , + onClick: handleCreateFolder, + disabled: !onCreateFolder, + }, + { + key: 'divider1', + type: 'divider' as const, + }, + { + key: 'rename', + label: dict('PC.Components.FileContextMenu.rename'), + icon: , + onClick: handleRename, + disabled: !onRename, + }, + { + key: 'upload', + label: dict('PC.Components.FileContextMenu.uploadFile'), + icon: , + onClick: handleUpload, + disabled: !onUploadFiles || targetNode?.name?.startsWith('.'), + }, + ...(onUploadFolder + ? [ + { + key: 'uploadFolder', + label: dict('PC.Components.FileContextMenu.uploadFolder'), + icon: , + onClick: handleUploadFolder, + disabled: + !onUploadFolder || targetNode?.name?.startsWith('.'), + }, + ] + : []), + { + key: 'divider2', + type: 'divider' as const, + }, + { + key: 'delete', + label: dict('PC.Components.FileContextMenu.delete'), + icon: , + onClick: handleDelete, + danger: true, + }, + ] + : [ + // 文件菜单项(不包含新建选项) + { + key: 'rename', + label: dict('PC.Components.FileContextMenu.rename'), + icon: , + onClick: handleRename, + disabled: !onRename, + }, + // 只有当 fileProxyUrl 存在且不为空时才显示下载选项 + ...(targetNode?.fileProxyUrl + ? [ + { + key: 'download', + label: dict('PC.Components.FileContextMenu.download'), + icon: , + onClick: handleDownload, + }, + // 如果是 Markdown 或 HTML 文件,显示导出为 PDF 选项 + ...(isMarkdownFile(targetNode?.name || '') || + targetNode?.name?.endsWith('.html') || + targetNode?.name?.endsWith('.htm') + ? [ + { + key: 'exportPdf', + label: dict('PC.Components.FileContextMenu.exportPdf'), + icon: , + onClick: handleExportPdf, + }, + ] + : []), + ] + : []), + { + key: 'upload', + label: dict('PC.Components.FileContextMenu.uploadFile'), + icon: , + onClick: handleUpload, + disabled: !onUploadFiles || targetNode?.name?.startsWith('.'), + }, + ...(onUploadFolder + ? [ + { + key: 'uploadFolder', + label: dict('PC.Components.FileContextMenu.uploadFolder'), + icon: , + onClick: handleUploadFolder, + disabled: + !onUploadFolder || targetNode?.name?.startsWith('.'), + }, + ] + : []), + { + key: 'divider', + type: 'divider' as const, + }, + { + key: 'delete', + label: dict('PC.Components.FileContextMenu.delete'), + icon: , + onClick: handleDelete, + danger: true, + }, + ] + : [ + // 空白区域菜单项 + { + key: 'createFile', + label: dict('PC.Components.FileContextMenu.newFile'), + icon: , + onClick: handleCreateFile, + disabled: !onCreateFile, + }, + { + key: 'createFolder', + label: dict('PC.Components.FileContextMenu.newFolder'), + icon: , + onClick: handleCreateFolder, + disabled: !onCreateFolder, + }, + // 只有当 onImportProject 存在时才显示导入选项(技能详情页导入技能 / AppDev 导入项目) + ...(onImportProject + ? [ + { + key: 'importProject', + label: + importProjectLabel ?? + dict('PC.Components.FileContextMenu.importSkill'), + icon: , + onClick: onImportProject, + }, + ] + : []), + { + key: 'divider1', + type: 'divider' as const, + }, + { + key: 'upload', + label: dict('PC.Components.FileContextMenu.uploadFile'), + icon: , + onClick: handleUpload, + disabled: !onUploadFiles, + }, + ...(onUploadFolder + ? [ + { + key: 'uploadFolder', + label: dict('PC.Components.FileContextMenu.uploadFolder'), + icon: , + onClick: handleUploadFolder, + disabled: !onUploadFolder, + }, + ] + : []), + ]; + + // 如果对于SKILL.md文件禁用删除功能和重命名功能,过滤掉删除菜单项、重命名菜单项和相关 divider + const menuItems = disabledDelete + ? allMenuItems.filter( + (item) => + item.key !== 'delete' && + item.key !== 'rename' && + item.key !== 'divider' && + item.key !== 'divider2', + ) + : allMenuItems; + + return ( +
e.stopPropagation()} + > + {menuItems.map((item, index) => { + if (item.type === 'divider') { + return
; + } + + return ( +
+ {item.icon} + {item.label} +
+ ); + })} +
+ ); +}; + +export default FileContextMenu; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/types.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/types.ts new file mode 100644 index 00000000..057c8bd9 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileContextMenu/types.ts @@ -0,0 +1,55 @@ +import type { FileNode } from '@/types/interfaces/appDev'; + +/** + * 右键菜单项类型 + */ +export interface ContextMenuItem { + /** 菜单项唯一标识 */ + key: string; + /** 菜单项显示文本 */ + label: string; + /** 菜单项图标 */ + icon: React.ReactNode; + /** 点击回调函数 */ + onClick: () => void; + /** 是否为危险操作(如删除) */ + danger?: boolean; + /** 是否禁用 */ + disabled?: boolean; +} + +/** + * FileContextMenu 组件属性接口 + */ +export interface FileContextMenuProps { + /** 是否显示菜单 */ + visible: boolean; + /** 菜单位置 */ + position: { x: number; y: number }; + /** 目标文件/文件夹节点 */ + targetNode: FileNode | null; + /** 是否禁用删除功能(为 true 时隐藏删除菜单项和 divider) */ + disabledDelete?: boolean; + /** 关闭菜单回调 */ + onClose: () => void; + /** 删除文件回调 */ + onDelete: (node: FileNode, event: React.MouseEvent) => void; + /** 重命名文件回调 */ + onRename?: (node: FileNode) => void; + /** 上传多个文件回调(直接调用,不弹窗) */ + onUploadFiles?: (node: FileNode | null) => void; + /** 上传文件夹回调(保留子目录结构) */ + onUploadFolder?: (node: FileNode | null) => void; + /** 新建文件回调 */ + onCreateFile?: (parentNode: FileNode | null) => void; + /** 新建文件夹回调 */ + onCreateFolder?: (parentNode: FileNode | null) => void; + /** 导入项目回调 */ + onImportProject?: () => void; + /** 导入项目菜单项文案(不传时使用默认的「导入技能」文案) */ + importProjectLabel?: string; + /** 通过URL下载文件回调 */ + onDownloadFileByUrl?: (node: FileNode, exportAsPdf?: boolean) => void; + /** 是否使用相对定位(相对于父容器),默认为 false(使用 fixed 定位) */ + useRelativePosition?: boolean; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/index.less b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/index.less new file mode 100644 index 00000000..5a8ec7db --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/index.less @@ -0,0 +1,135 @@ +@import '@/styles/color.less'; +@import '@/styles/token.less'; + +// 文件树样式 +.fileTree { + max-width: 100%; + flex: 1; + width: 100%; + padding: 8px; + box-sizing: border-box; + overflow: auto; + user-select: none; + + // 内联重命名样式 + .inlineRenameInput { + flex: 1; + min-width: 0; // 允许输入框收缩 + max-width: 100%; // 限制最大宽度 + font-size: @fontSize; + height: 22px; + user-select: text; + } +} + +.folderItem { + margin-bottom: 0; + min-width: fit-content; // 允许内容撑开宽度以支持横向滚动 + white-space: nowrap; +} + +.folderHeader { + display: flex; + align-items: center; + padding: 4px; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s ease; + min-width: fit-content; // 允许内容撑开宽度以支持横向滚动 + + &:hover { + background: rgba(0, 0, 0, 5%); + } + + &.activeFolder { + background: @colorPrimaryBg; + } + + .folderIcon { + margin-right: 4px; + flex-shrink: 0; // 图标不收缩 + // color: @colorTextBase; + color: #5e5e5b; + transition: transform 0.2s ease; + + &.expanded { + transform: rotate(90deg); + } + } + + .folderName { + height: 22px; + // color: @colorTextBase; + color: #5e5e5b; + font-weight: @fontWeightStrong; + font-size: @fontSize; + font-family: @fontFamilyCode; + flex: 1; + min-width: 0; // 允许文本收缩 + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.fileList { + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + animation: slide-down 0.3s ease-out; +} + +@keyframes slide-down { + from { + opacity: 0; + max-height: 0; + } + + to { + opacity: 1; + max-height: 1000px; + } +} + +.fileItem { + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + cursor: pointer; + border-radius: 4px; + transition: all 0.2s ease; + font-size: 13px; + color: #5e5e5b; + min-width: fit-content; // 允许内容撑开宽度以支持横向滚动 + white-space: nowrap; + + svg { + font-size: 16px; + flex-shrink: 0; // 图标不收缩 + } + + &:hover { + background: rgba(0, 0, 0, 5%); + } + + &.activeFile { + background: @colorPrimaryBg; + } +} + +.fileName { + height: 22px; + color: #5e5e5b; + font-size: @fontSize; + font-family: @fontFamilyCode; + white-space: nowrap; + + &.hiddenFile { + color: @colorTextQuaternary; + font-style: italic; + } +} + +.no-files { + color: @colorTextQuaternary; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/index.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/index.tsx new file mode 100644 index 00000000..616d3394 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/index.tsx @@ -0,0 +1,471 @@ +import SvgIcon from '@/components/base/SvgIcon'; +import Loading from '@/components/custom/Loading'; +import { dict } from '@/services/i18nRuntime'; +import { FileNode } from '@/types/interfaces/appDev'; +import { findFileNode } from '@/utils/appDevUtils'; +import { getFileIcon } from '@/utils/fileTree'; +import type { InputRef } from 'antd'; +import { Input } from 'antd'; +import classNames from 'classnames'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import styles from './index.less'; +import type { FileTreeProps, FileTreeRef } from './types'; + +const cx = classNames.bind(styles); + +/** + * 文件树组件 + * 提供文件树展示、数据资源管理和折叠/展开功能 + */ +const FileTree = forwardRef( + ( + { + files, + fileTreeDataLoading, + taskAgentSelectedFileId, + selectedFileId, + selectedFolderId = '', + // 正在重命名的节点 + renamingNode, + // 取消重命名回调 + onCancelRename, + // 文件选择回调 + onFileSelect, + // 重命名文件回调 + onConfirmRenameFile, + // 右键菜单回调 + onContextMenu, + }, + ref, + ) => { + // 重命名值 + const [renameValue, setRenameValue] = useState(''); + const renameInputRef = useRef(null); + // 已展开的文件夹ID集合 + const [expandedFolders, setExpandedFolders] = useState>( + () => + // 初次渲染时自动展开第一层文件夹,后续文件列表变更时不重置,避免已展开的节点被折叠 + new Set( + (files || []) + .filter((node) => node.type === 'folder') + .map((node) => node.id), + ), + ); + + useImperativeHandle( + ref, + () => ({ + collapseAll: () => setExpandedFolders(new Set()), + }), + [], + ); + + /** + * 切换文件夹展开状态,用于展开/折叠回调 + * 当展开文件夹时,如果文件夹下有文件且当前没有选中任何文件,则自动选中第一个文件 + */ + const onToggleFolder = useCallback( + (folderId: string) => { + setExpandedFolders((prev) => { + const newExpanded = new Set(prev); + const wasExpanded = newExpanded.has(folderId); + // 如果已展开则删除,否则添加,实现切换效果 + if (wasExpanded) { + newExpanded.delete(folderId); + } else { + newExpanded.add(folderId); + // 当文件夹展开时,检查是否需要自动选中第一个文件 + // 只有当当前没有选中任何文件时,才自动选中 + if (!selectedFileId) { + // 查找该文件夹节点 + const folderNode = findFileNode(folderId, files || []); + if ( + folderNode && + folderNode.children && + folderNode.children.length > 0 + ) { + // 查找第一个文件(非隐藏文件,跳过以 . 开头的文件) + const firstFile = folderNode.children.find( + (child) => + child.type === 'file' && !child.name.startsWith('.'), + ); + if (firstFile) { + // 使用 setTimeout 确保状态更新后再触发文件选择 + setTimeout(() => { + onFileSelect(firstFile.id); + }, 0); + } + } + } + } + return newExpanded; + }); + }, + [files, selectedFileId, onFileSelect], + ); + + /** + * 取消重命名 + */ + const cancelRename = () => { + const trimmedValue = renameValue.trim(); + const shouldRemove = renamingNode?.status === 'create' && !trimmedValue; + + onCancelRename({ + removeIfNew: shouldRemove, + node: renamingNode || null, + }); + setRenameValue(''); + }; + + /** + * 确认重命名 + */ + const confirmRename = () => { + if (!renamingNode) return; + + const trimmedValue = renameValue.trim(); + if (!trimmedValue || trimmedValue === renamingNode.name) { + cancelRename(); + return; + } + + // 验证文件名 + const invalidChars = /[/\\:*?"<>|]/; + if (invalidChars.test(trimmedValue)) { + // 这里可以显示错误提示 + return; + } + + // 恢复数据状态 + cancelRename(); + + // 异步执行重命名操作 + try { + onConfirmRenameFile(renamingNode, trimmedValue); + } catch (error) { + // 如果重命名失败,可以考虑恢复原名字或显示错误提示 + console.error('Rename failed:', error); + } + }; + + /** + * 处理重命名输入框键盘事件 + */ + const handleRenameKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + confirmRename(); + } else if (e.key === 'Escape') { + cancelRename(); + } + }, + [confirmRename, cancelRename], + ); + + /** + * 处理重命名输入框失焦 + */ + const handleRenameBlur = useCallback(() => { + // 延迟执行,避免与点击事件冲突 + setTimeout(() => { + if (renamingNode) { + // 对于新建节点(status === 'create'),根据输入值决定是创建还是取消 + if (renamingNode.status === 'create') { + const trimmedValue = renameValue.trim(); + // 如果输入了有效名称,则确认创建;否则取消并移除临时节点 + if (trimmedValue) { + confirmRename(); + } else { + cancelRename(); + } + } else { + // 其它场景(普通重命名)失焦仍然走确认逻辑 + confirmRename(); + } + } + }, 100); + }, [renamingNode, renameValue, confirmRename, cancelRename]); + + // 重命名输入框自动聚焦 + useEffect(() => { + // 当进入重命名 / 新建状态,且输入框已经渲染到 DOM 中时,自动聚焦并选中 + if (renamingNode && renameInputRef.current) { + renameInputRef.current.focus(); + renameInputRef.current.select(); + } + + if (renamingNode) { + setRenameValue(renamingNode.name); + } + }, [renamingNode, expandedFolders]); + + /** + * 当进入新建状态时,自动展开其父级及祖先文件夹 + * 确保在折叠状态下新建文件/文件夹也能立刻可见 + */ + useEffect(() => { + if (!renamingNode || renamingNode.status !== 'create') { + return; + } + + const parentPath = renamingNode.parentPath; + if (!parentPath) return; + + const parts = parentPath.split('/').filter(Boolean); + + setExpandedFolders((prev) => { + const next = new Set(prev); + let currentPath = ''; + parts.forEach((part) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + next.add(currentPath); + }); + return next; + }); + }, [renamingNode]); + + /** + * 根据 taskAgentSelectedFileId 自动展开包含该文件的文件夹路径 + * 当 taskAgentSelectedFileId 和 files 都不为空时,展开所有父级文件夹 + */ + useEffect(() => { + // 如果 taskAgentSelectedFileId 或 files 为空,则不处理 + if (!taskAgentSelectedFileId || !files || files.length === 0) { + return; + } + + // 查找选中的文件节点 + const selectedFileNode = findFileNode(taskAgentSelectedFileId, files); + if (!selectedFileNode) { + return; + } + + // 如果选中的文件节点是文件夹 + if (selectedFileNode.type === 'folder') { + // 如果文件夹有子节点,则展开文件夹,并选中第一个子节点 + if (selectedFileNode?.children?.length) { + setExpandedFolders((prev) => { + const next = new Set(prev); + next.add(selectedFileNode?.id); + return next; + }); + } + return; + } + + // 获取所有父级文件夹ID + // 通过路径分割直接获取所有父级路径,避免重复查找节点 + const getParentFolderIds = (filePath: string): string[] => { + const parentIds: string[] = []; + const pathParts = filePath.split('/').filter(Boolean); + + // 如果是根目录文件,则没有父级文件夹 + if (pathParts.length <= 1) { + return parentIds; + } + + // 从根目录开始,逐步构建所有父级路径 + // 例如:folder1/folder2/file.txt -> ['folder1', 'folder1/folder2'] + let currentPath = ''; + for (let i = 0; i < pathParts.length - 1; i++) { + currentPath = currentPath + ? `${currentPath}/${pathParts[i]}` + : pathParts[i]; + parentIds.push(currentPath); + } + + return parentIds; + }; + + // 获取所有需要展开的文件夹ID + const parentFolderIds = getParentFolderIds( + selectedFileNode.path || selectedFileNode.id, + ); + + // 如果有父级文件夹,则展开它们 + if (parentFolderIds.length > 0) { + setExpandedFolders((prev) => { + const next = new Set(prev); + parentFolderIds.forEach((folderId) => { + next.add(folderId); + }); + return next; + }); + } + }, [taskAgentSelectedFileId, files]); + + /** + * 渲染文件树节点 + */ + const renderFileTreeNode = useCallback( + (node: FileNode, level: number = 0) => { + const isExpanded = expandedFolders.has(node.id); + // 文件夹与文件选中互斥:选中文件夹时仅高亮文件夹,预览仍由 selectedFileId 驱动 + const isSelected = + node.type === 'folder' + ? selectedFolderId === node.id + : selectedFileId === node.id && !selectedFolderId; + const isRenaming = renamingNode?.id === node.id; + + const nodeKey = node.id; + + // 文件夹节点 + if (node.type === 'folder') { + return ( +
+
{ + if (isRenaming) { + return; + } + onToggleFolder(node.id); + onFileSelect(node.id, { selectFolder: true }); + }} + onContextMenu={(e) => onContextMenu(e, node)} + > + + + {isRenaming ? ( + setRenameValue(e.target.value)} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + className={styles.inlineRenameInput} + size="small" + /> + ) : ( + + {node.name} + + )} +
+ {isExpanded && node.children && ( +
+ {node.children.map((child: any) => + renderFileTreeNode(child, level + 1), + )} +
+ )} +
+ ); + } else { + return ( +
{ + // 跳过以"."为前缀的隐藏文件和重命名模式 + // if (node.name.startsWith('.') || isRenaming) { + // return; + // } + // 重命名模式下,不进行文件选择 + if (isRenaming) { + return; + } + onFileSelect(node.id); + }} + onContextMenu={(e) => onContextMenu(e, node)} + style={{ marginLeft: level * 8 }} + > + {/* 文件图标 */} + {getFileIcon(node.name)} + + {/* 重命名输入框 */} + {isRenaming ? ( + setRenameValue(e.target.value)} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + className={styles.inlineRenameInput} + size="small" + /> + ) : ( + // 文件名 + + {node.name} + + )} +
+ ); + } + }, + [ + expandedFolders, + selectedFileId, + selectedFolderId, + renamingNode, + renameValue, + onToggleFolder, + onFileSelect, + onContextMenu, + handleRenameKeyDown, + handleRenameBlur, + ], + ); + + return ( +
onContextMenu(e, null)} + > + {/* 文件树数据加载状态 */} + {fileTreeDataLoading && !files?.length ? ( +
+ +
+ ) : files?.length > 0 ? ( + files?.map((node: FileNode) => renderFileTreeNode(node)) + ) : ( +
+ {dict('PC.Components.FileTree.noFiles')} +
+ )} +
+ ); + }, +); + +FileTree.displayName = 'FileTree'; + +export default FileTree; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/types.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/types.ts new file mode 100644 index 00000000..840b07c3 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/types.ts @@ -0,0 +1,47 @@ +import type { FileNode } from '@/types/interfaces/appDev'; + +/** + * 页面应用开发文件树组件属性接口 + */ +export interface FileTreeProps { + /** 文件树数据 */ + files: FileNode[]; + + /** 文件树数据加载状态 */ + fileTreeDataLoading?: boolean; + + /** 通用型智能体会话中点击选中的文件ID */ + taskAgentSelectedFileId?: string; + + /** 当前选中的文件ID */ + selectedFileId: string; + + /** 当前选中的文件夹 ID(仅用于树高亮) */ + selectedFolderId?: string; + + /** 正在重命名的节点 */ + renamingNode?: FileNode | null; + + /** 取消重命名回调 + * 当 removeIfNew 为 true 且 node.status === 'create' 时,父组件应删除该临时节点 + */ + onCancelRename: (options?: { + removeIfNew?: boolean; + node?: FileNode | null; + }) => void; + + /** 右键菜单回调 */ + onContextMenu: (e: React.MouseEvent, node: FileNode | null) => void; + + /** 文件选择回调;selectFolder 为 true 时仅选中文件夹(不切换预览) */ + onFileSelect: (fileId: string, options?: { selectFolder?: boolean }) => void; + + /** 重命名文件回调 */ + onConfirmRenameFile: (node: FileNode, newName: string) => void; +} + +/** FileTree 组件对外暴露的方法 */ +export interface FileTreeRef { + /** 折叠全部文件夹 */ + collapseAll: () => void; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/SearchView/index.less b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/SearchView/index.less new file mode 100644 index 00000000..c3e3348a --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/SearchView/index.less @@ -0,0 +1,148 @@ +@import '@/styles/token.less'; + +.search-view { + width: 100%; + display: flex; + align-items: center; + padding: 0 8px; + + // 搜索栏样式 + .search-bar { + width: 100%; + position: relative; + z-index: 10; + + .search-input { + box-sizing: border-box; + border: none; + padding: 8px; + border-radius: 8px; + background: rgba(12, 20, 102, 4%); + } + + .search-icon { + color: @colorTextPlaceholder; + font-size: 16px; + } + + // 输入框聚焦态样式(不改变整体尺寸) + :global(.ant-input-outlined:focus-within) { + box-shadow: none; + outline: 0; + } + } + + // 搜索结果下拉列表样式 + .search-dropdown { + position: absolute; + left: 0; + right: 0; + top: 32px; + margin: 0 8px; + max-height: 400px; + overflow-y: auto; + background: #2d2d2d; + border-radius: @borderRadius; + box-shadow: 0 4px 12px rgba(0, 0, 0, 30%); + z-index: 1000; + + // 自定义滚动条样式 + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 20%); + border-radius: 4px; + + &:hover { + background: rgba(255, 255, 255, 30%); + } + } + } + + // 搜索结果项样式 + .search-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + user-select: none; + transition: background-color 0.15s ease; + border-bottom: 1px solid rgba(255, 255, 255, 5%); + + &:last-child { + border-bottom: none; + border-radius: 0 0 @borderRadius @borderRadius; + } + + &:hover { + background: rgba(255, 255, 255, 8%); + } + + &.search-item-selected { + background: rgba(255, 255, 255, 15%); + } + + .file-icon { + display: flex; + align-items: center; + margin-right: 12px; + flex-shrink: 0; + + :global(svg) { + width: 16px; + height: 16px; + } + } + + .file-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .file-name { + color: #e5e5e5; + font-size: @fontSize; + font-family: @fontFamilyCode; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.5; + } + + .file-path { + color: #8c8c8c; + font-size: @fontSizeSm; + font-family: @fontFamilyCode; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; + } + } + + // 暂无数据提示样式 + .search-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 24px 12px; + min-height: 60px; + + .empty-text { + color: #8c8c8c; + font-size: @fontSize; + font-family: @fontFamily; + text-align: center; + } + } +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/SearchView/index.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/SearchView/index.tsx new file mode 100644 index 00000000..bcf7d3c8 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/SearchView/index.tsx @@ -0,0 +1,211 @@ +import { flattenFiles } from '@/pages/AppDev/components/ChatArea/components/MentionSelector/utils'; +import { dict } from '@/services/i18nRuntime'; +import { FileNode } from '@/types/interfaces/appDev'; +import { getFileIcon } from '@/utils/fileTree'; +import { SearchOutlined } from '@ant-design/icons'; +import { Input } from 'antd'; +import classNames from 'classnames'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +interface SearchViewProps { + className?: string; + files: FileNode[]; + onFileSelect?: (fileId: string) => void; +} + +/** + * 搜索视图组件 + * 提供文件搜索功能和项目根目录显示 + */ +const SearchView: React.FC = ({ + className, + files, + onFileSelect, +}) => { + const [searchValue, setSearchValue] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const searchContainerRef = useRef(null); + const dropdownRef = useRef(null); + + /** + * 扁平化并过滤文件列表 + */ + const filteredFiles = useMemo(() => { + if (!searchValue.trim()) { + return []; + } + return flattenFiles(files, searchValue); + }, [files, searchValue]); + + /** + * 处理搜索输入变化 + */ + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchValue(value); + setIsDropdownVisible(value.trim().length > 0); + setSelectedIndex(0); + }; + + /** + * 处理文件选择 + */ + const handleFileClick = (file: FileNode) => { + if (onFileSelect) { + onFileSelect(file.id); + } + setSearchValue(''); + setIsDropdownVisible(false); + }; + + /** + * 处理键盘导航 + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isDropdownVisible || filteredFiles.length === 0) { + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex((prev) => + prev < filteredFiles.length - 1 ? prev + 1 : prev, + ); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + break; + case 'Enter': + e.preventDefault(); + if (filteredFiles[selectedIndex]) { + handleFileClick(filteredFiles[selectedIndex]); + } + break; + case 'Escape': + setSearchValue(''); + setIsDropdownVisible(false); + break; + } + }; + + /** + * 获取文件路径(去掉文件名) + */ + const getFileDirPath = (filePath: string): string => { + const parts = filePath.split('/'); + return parts.slice(0, -1).join('/'); + }; + + /** + * 点击外部关闭下拉列表 + */ + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + searchContainerRef.current && + !searchContainerRef.current.contains(event.target as Node) + ) { + setIsDropdownVisible(false); + } + }; + + if (isDropdownVisible) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [isDropdownVisible]); + + /** + * 滚动到选中项 + */ + useEffect(() => { + if (dropdownRef.current && selectedIndex >= 0) { + const selectedElement = dropdownRef.current.children[ + selectedIndex + ] as HTMLElement; + if (selectedElement) { + selectedElement.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); + } + } + }, [selectedIndex]); + + return ( +
+ {/* 搜索栏 */} +
+ } + value={searchValue} + allowClear + onChange={handleSearchChange} + onKeyDown={handleKeyDown} + onFocus={() => { + if (searchValue.trim().length > 0) { + setIsDropdownVisible(true); + } + }} + /> +
+ + {/* 搜索结果下拉列表 */} + {isDropdownVisible && ( +
+ {filteredFiles.length > 0 ? ( + // 有搜索结果时显示文件列表 + filteredFiles.map((file: FileNode, index: number) => { + const isSelected = index === selectedIndex; + const fileDirPath = getFileDirPath(file.path || file.id); + + return ( +
handleFileClick(file)} + onMouseEnter={() => setSelectedIndex(index)} + > + {/* 文件图标 */} +
+ {getFileIcon(file.name)} +
+ + {/* 文件信息 */} +
+
{file.name}
+
{fileDirPath}
+
+
+ ); + }) + ) : ( + // 没有搜索结果时显示提示 +
+
+ {dict('PC.Components.SearchView.noMatchingFiles')} +
+
+ )} +
+ )} +
+ ); +}; + +export default SearchView; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/index.less b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/index.less new file mode 100644 index 00000000..dbbea4c7 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/index.less @@ -0,0 +1,53 @@ +@import '@/styles/token'; + +.tree-panel { + position: relative; + width: 100% !important; + height: 100%; + min-height: 0; + opacity: 1 !important; + pointer-events: auto !important; + border-right: none !important; +} + +.file-tree-view { + position: relative; + width: 100%; + min-height: 0; + border-right: @lineWidth solid @colorSplit; + overflow: auto; + transition: width @motionDurationMid ease-in-out, + opacity @motionDurationMid ease-in-out, + border-right @motionDurationMid ease-in-out; + flex-shrink: 0; +} + +// 与 .file-tree-view 组合使用(CSS Modules 会生成复合选择器) +.file-tree-view-hidden { + width: 0; + opacity: 0; + pointer-events: none; + border-right: none; +} + +.file-tree-view-visible { + width: 100%; + opacity: 1; + pointer-events: auto; + border-right: @lineWidth solid @colorSplit; +} + +.file-tree-header { + height: 44px; + padding: @paddingXs @padding; + gap: @marginXxl; + color: @colorText; + font-size: @fontSize; + font-weight: @fontWeightStrong; + line-height: @lineHeight; + flex-shrink: 0; +} + +.file-tree-header-action { + flex-shrink: 0; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/index.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/index.tsx new file mode 100644 index 00000000..f0b90b16 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreePanel/index.tsx @@ -0,0 +1,232 @@ +import TipsBox from '@/components/TipsBox'; +import FileTreeToolbar from '@/components/business-component/FileTreeGitSourcePanel/FileTreeToolbar'; +import { dict } from '@/services/i18nRuntime'; +import type { FileNode } from '@/types/interfaces/appDev'; +import { findFileNode } from '@/utils/appDevUtils'; +import classNames from 'classnames'; +import React, { useRef } from 'react'; +import type { FileTreeContainerProps } from '../types/file-tree-git-source'; +import FileContextMenu from './FileContextMenu'; +import FileTree from './FileTree'; +import type { FileTreeRef } from './FileTree/types'; +import SearchView from './SearchView'; +import styles from './index.less'; + +export interface FileTreePanelProps { + /** 文件树状态与交互处理器 */ + tree: FileTreeContainerProps; + className?: string; + headerClassName?: string; + /** 文件列表为空(且非加载中)时的自定义空态内容,不传则使用 FileTree 默认空态 */ + emptyState?: React.ReactNode; +} + +/** + * 任务智能体文件树组件 + * 负责文件树渲染、搜索、右键菜单、选中与重命名等交互 + */ +const FileTreePanel: React.FC = ({ + tree, + className, + headerClassName, + emptyState, +}) => { + const { + readOnly = false, + files, + selectedFileId, + selectedFolderId = '', + renamingNode, + contextMenuTarget, + contextMenuPosition, + contextMenuVisible, + isFileTreeVisible, + fileTreeContainerRef, + fileTreeDataLoading, + taskAgentSelectedFileId, + isCanDeleteSkillFile, + isRefreshingFileTree, + isUploadingFiles, + isDownloadingFile, + hideFileTree, + showRefreshButton, + handleFileSelect, + handleContextMenu, + closeContextMenu, + handleRenameFile, + handleCancelRename, + handleRefreshFileList, + handleDelete, + handleRenameFromMenu, + handleUploadMultipleFiles, + handleUploadFolder, + handleCreateFile, + handleCreateFolder, + handleDownloadFileByUrl, + handleImportProject, + importProjectLabel, + handleExportProject, + isExportingProject = false, + isImportingProject = false, + toolbarDisabled = false, + } = tree; + + const fileTreeRef = useRef(null); + + /** + * 计算工具栏新建文件/文件夹的目标父级节点 + * - 选中文件夹:在该文件夹下创建 + * - 选中文件:在该文件所在层级(其父文件夹)下创建 + * - 未选中或找不到节点:在根目录创建 + */ + const resolveCreateParentNode = (): FileNode | null => { + if (selectedFolderId) { + const folderNode = findFileNode(selectedFolderId, files); + if (folderNode?.type === 'folder') { + return folderNode; + } + } + if (!selectedFileId) { + return null; + } + const selectedNode = findFileNode(selectedFileId, files); + if (!selectedNode) { + return null; + } + if (selectedNode.type === 'folder') { + return selectedNode; + } + // 文件节点:在其父文件夹下创建(与选中文件同级);无父级则为根目录 + return selectedNode.parentPath + ? findFileNode(selectedNode.parentPath, files) + : null; + }; + + if (hideFileTree) { + return null; + } + + return ( +
+ {/* 右键菜单 */} + + + {/* 提示框 */} + + + + + + {/* 搜索框 */} + + + {/* 文件树工具栏 */} + void handleExportProject() : undefined + } + onImportProject={ + handleImportProject ? () => void handleImportProject() : undefined + } + importLoading={isImportingProject} + onCreateFile={ + readOnly + ? undefined + : () => handleCreateFile(resolveCreateParentNode()) + } + onCreateFolder={ + readOnly + ? undefined + : () => handleCreateFolder(resolveCreateParentNode()) + } + onUpload={ + readOnly + ? undefined + : () => void handleUploadMultipleFiles(resolveCreateParentNode()) + } + onUploadFolder={ + readOnly || !handleUploadFolder + ? undefined + : () => void handleUploadFolder(resolveCreateParentNode()) + } + onCollapseAll={() => fileTreeRef.current?.collapseAll()} + onRefresh={ + showRefreshButton ? () => void handleRefreshFileList() : undefined + } + refreshLoading={isRefreshingFileTree} + /> + + {/* 文件树;列表为空且非加载中时优先展示自定义空态 */} + {!fileTreeDataLoading && files.length === 0 && emptyState ? ( + emptyState + ) : ( + + )} +
+ ); +}; + +export default FileTreePanel; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreeToolbar/index.less b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreeToolbar/index.less new file mode 100644 index 00000000..e143fcf3 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreeToolbar/index.less @@ -0,0 +1,47 @@ +@import '@/styles/token'; + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: @marginSm; + padding: @paddingXs; + flex-shrink: 0; +} + +.title { + font-size: @fontSize; + font-weight: @fontWeightStrong; + color: @colorText; + line-height: @lineHeight; + white-space: nowrap; +} + +.actions { + display: flex; + align-items: center; + gap: @marginXxs; + flex-shrink: 0; +} + +.action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border-radius: @borderRadius; + color: @colorTextSecondary; + transition: color 0.2s ease, background-color 0.2s ease; + + &:hover:not(:disabled) { + color: @colorText; + background-color: @colorBgTextHover; + } + + &:disabled { + color: @colorTextQuaternary; + cursor: not-allowed; + } +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreeToolbar/index.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreeToolbar/index.tsx new file mode 100644 index 00000000..61211ec1 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/FileTreeToolbar/index.tsx @@ -0,0 +1,205 @@ +import SvgIcon from '@/components/base/SvgIcon'; +import { dict } from '@/services/i18nRuntime'; +import { + FileAddOutlined, + FolderAddOutlined, + FolderOpenOutlined, + MenuFoldOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { Button, Tooltip } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +export interface FileTreeToolbarProps { + /** 左侧标题 */ + title?: string; + /** 是否禁用全部操作 */ + disabled?: boolean; + /** 项目导出 loading */ + exportLoading?: boolean; + /** 项目导出 */ + onExportProject?: () => void; + /** 项目导入 loading */ + importLoading?: boolean; + /** 项目导入 */ + onImportProject?: () => void; + /** 新建文件 */ + onCreateFile?: () => void; + /** 新建文件夹 */ + onCreateFolder?: () => void; + /** 上传文件 */ + onUpload?: () => void; + /** 上传文件夹(保留子目录结构) */ + onUploadFolder?: () => void; + /** 折叠全部文件夹 */ + onCollapseAll?: () => void; + /** 刷新文件树 */ + onRefresh?: () => void; + /** 刷新文件树 loading */ + refreshLoading?: boolean; + className?: string; +} + +/** + * 文件树操作工具栏(搜索框下方) + * 提供导出、新建、上传、折叠等快捷操作 + */ +const FileTreeToolbar: React.FC = ({ + title = dict('PC.Components.FileTreePanel.FileTreeToolbar.projects'), + disabled = false, + exportLoading = false, + onExportProject, + importLoading = false, + onImportProject, + onCreateFile, + onCreateFolder, + onUpload, + onUploadFolder, + onCollapseAll, + onRefresh, + refreshLoading = false, + className, +}) => { + const actionDisabled = (handler?: () => void) => disabled || !handler; + /** 新建文件/文件夹:仅在没有回调时禁用,不受全局 disabled 影响 */ + const createActionDisabled = (handler?: () => void) => !handler; + + return ( +
+ {title} +
+ +
+
+ ); +}; + +export default FileTreeToolbar; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/PanelCore.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/PanelCore.tsx new file mode 100644 index 00000000..3ba87ae1 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/PanelCore.tsx @@ -0,0 +1,186 @@ +import TooltipIcon from '@/components/custom/TooltipIcon'; +import { isAgentVersionControlEnabled } from '@/constants/agent.constants'; +import { dict } from '@/services/i18nRuntime'; +import { BranchesOutlined, FolderOutlined } from '@ant-design/icons'; +import classNames from 'classnames'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import FileTreePanel from './FileTreePanel'; +import styles from './index.less'; +import SourceControlPanel from './SourceControlPanel'; +import type { FileTreeGitSourcePanelProps } from './types/file-tree-git-source'; + +const cx = classNames.bind(styles); + +/** 中间面板视图类型 */ +type PanelView = 'files' | 'sourceControl'; + +/** + * FileTreeGitSourcePanel 公共组件 + * ConversationAgent 中间面板 / AppDev 文件树侧栏的统一壳层 + * 顶部切换文件树 / 源代码管理,统一样式与 Git 面板交互 + */ +const FileTreeGitSourcePanel: React.FC = ({ + className, + isCollapsed, + showSourceControl, + enableVersionControl, + tree, + treeClassName, + treeHeaderClassName, + treeEmptyState, + onImportProject, + importProjectLabel, + isImportingProject, + sourceControl, +}) => { + const [activeView, setActiveView] = useState('files'); + + const mergedTree = useMemo( + () => ({ + ...tree, + ...(onImportProject ? { handleImportProject: onImportProject } : {}), + ...(importProjectLabel ? { importProjectLabel } : {}), + ...(isImportingProject !== undefined ? { isImportingProject } : {}), + }), + [tree, onImportProject, importProjectLabel, isImportingProject], + ); + + const { + changeFiles, + selectedChangeFile = null, + isCommitting = false, + isRefreshingGitList = false, + refreshDisabled = false, + onRefreshGitList, + onCommit, + onDiffFileSelect, + onOpenChangeFile, + onDiscardChanges, + onStageChanges, + onUnstageChanges, + onAddToGitignore, + } = sourceControl; + + const modifiedCount = changeFiles.length; + const enableSourceControl = useMemo(() => { + const baseEnabled = showSourceControl ?? Boolean(onCommit); + if (enableVersionControl === undefined) { + return baseEnabled; + } + return baseEnabled && isAgentVersionControlEnabled(enableVersionControl); + }, [showSourceControl, onCommit, enableVersionControl]); + + useEffect(() => { + if (!enableSourceControl && activeView === 'sourceControl') { + setActiveView('files'); + } + }, [enableSourceControl, activeView]); + + /** 点击修改文件:仅触发 diff 预览,不走文件树选中逻辑 */ + const handleModifiedFileClick = useCallback( + (fileId: string, section: 'staged' | 'unstaged') => { + onDiffFileSelect?.(fileId, section); + }, + [onDiffFileSelect], + ); + + const panelBody = ( + <> + {enableSourceControl && ( +
+ {/* 文件 */} +
+ } + onClick={() => setActiveView('files')} + /> +
+ {/* 源代码管理 */} +
+ + + {modifiedCount > 0 && ( + + {modifiedCount > 99 ? '99+' : modifiedCount} + + )} + + } + onClick={() => setActiveView('sourceControl')} + /> +
+
+ )} + +
+ {activeView === 'sourceControl' && enableSourceControl ? ( +
+ {/* 源代码管理面板 */} + +
+ ) : ( + // 文件树容器(内部统一渲染 FileTreePanel) +
+ +
+ )} +
+ + ); + + return ( +
+ {panelBody} +
+ ); +}; + +export default FileTreeGitSourcePanel; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileContextMenu/index.less b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileContextMenu/index.less new file mode 100644 index 00000000..da960ee9 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileContextMenu/index.less @@ -0,0 +1,27 @@ +@import '@/styles/token'; + +@context-menu-min-width: 160px; +@context-menu-item-padding-block: 6px; + +.context-menu { + background: @colorBgElevated; + border: @lineWidth solid @colorBorder; + border-radius: @borderRadiusOuter; + box-shadow: @boxShadowSecondary; + padding: @marginXxs 0; + min-width: @context-menu-min-width; + z-index: @zIndexPopupBase; +} + +.context-menu-item { + padding: @context-menu-item-padding-block @paddingSm; + cursor: pointer; + font-size: @fontSizeSm; + color: @colorText; + transition: background-color 0.2s ease; + + &:hover { + background: @colorPrimaryBg; + color: @colorPrimary; + } +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileContextMenu/index.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileContextMenu/index.tsx new file mode 100644 index 00000000..7591c807 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileContextMenu/index.tsx @@ -0,0 +1,213 @@ +import { dict } from '@/services/i18nRuntime'; +import classNames from 'classnames'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +export interface ChangeFileContextMenuProps { + /** 是否可见 */ + visible: boolean; + /** 菜单坐标(相对容器) */ + position: { x: number; y: number }; + /** 右键目标类型:文件 / 文件夹 */ + targetType?: 'file' | 'folder'; + /** 目标文件是否已暂存(文件)或是否来自暂存区块(文件夹) */ + isStaged?: boolean; + /** 关闭菜单 */ + onClose: () => void; + /** 打开更改(diff) */ + onOpenChanges?: () => void; + /** 打开文件 */ + onOpenFile?: () => void; + /** 放弃更改 */ + onDiscardChange?: () => void; + /** 暂存更改 */ + onStageChange?: () => void; + /** 取消暂存 */ + onUnstageChange?: () => void; + /** 添加到 .gitignore */ + onAddToGitignore?: () => void; +} + +/** + * 源代码管理 - 更改文件右键菜单 + * 未暂存与已暂存文件展示不同菜单项 + */ +const ChangeFileContextMenu: React.FC = ({ + visible, + position, + targetType = 'file', + isStaged = false, + onClose, + onOpenChanges, + onOpenFile, + onDiscardChange, + onStageChange, + onUnstageChange, + onAddToGitignore, +}) => { + const menuRef = useRef(null); + const [adjustedPosition, setAdjustedPosition] = useState(position); + + const handleItemClick = useCallback( + (action?: () => void) => { + action?.(); + onClose(); + }, + [onClose], + ); + + useLayoutEffect(() => { + if (!visible || !menuRef.current) { + setAdjustedPosition(position); + return; + } + + const menuRect = menuRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let x = position.x; + let y = position.y; + + if (x + menuRect.width > viewportWidth) { + x = Math.max(0, viewportWidth - menuRect.width - 8); + } + if (y + menuRect.height > viewportHeight) { + y = Math.max(0, viewportHeight - menuRect.height - 8); + } + + setAdjustedPosition({ x, y }); + }, [visible, position]); + + useEffect(() => { + if (!visible) { + return; + } + + const handleDocumentClick = () => { + onClose(); + }; + + document.addEventListener('click', handleDocumentClick); + return () => document.removeEventListener('click', handleDocumentClick); + }, [visible, onClose]); + + if (!visible) { + return null; + } + + const unstagedItems = [ + { + key: 'openChanges', + label: dict('PC.Pages.ConversationAgentSourceControl.openChanges'), + onClick: onOpenChanges, + }, + { + key: 'openFile', + label: dict('PC.Pages.ConversationAgentSourceControl.openFile'), + onClick: onOpenFile, + }, + { + key: 'discard', + label: dict('PC.Pages.ConversationAgentSourceControl.discardChanges'), + onClick: onDiscardChange, + }, + { + key: 'stage', + label: dict('PC.Pages.ConversationAgentSourceControl.stageChanges'), + onClick: onStageChange, + }, + { + key: 'gitignore', + label: dict('PC.Pages.ConversationAgentSourceControl.addToGitignore'), + onClick: onAddToGitignore, + }, + ]; + + const stagedItems = [ + { + key: 'openChanges', + label: dict('PC.Pages.ConversationAgentSourceControl.openChanges'), + onClick: onOpenChanges, + }, + { + key: 'openFile', + label: dict('PC.Pages.ConversationAgentSourceControl.openFile'), + onClick: onOpenFile, + }, + { + key: 'unstage', + label: dict('PC.Pages.ConversationAgentSourceControl.unstageChanges'), + onClick: onUnstageChange, + }, + ]; + + const unstagedFolderItems = [ + { + key: 'discard', + label: dict('PC.Pages.ConversationAgentSourceControl.discardChanges'), + onClick: onDiscardChange, + }, + { + key: 'stage', + label: dict('PC.Pages.ConversationAgentSourceControl.stageChanges'), + onClick: onStageChange, + }, + { + key: 'gitignore', + label: dict('PC.Pages.ConversationAgentSourceControl.addToGitignore'), + onClick: onAddToGitignore, + }, + ]; + + const stagedFolderItems = [ + { + key: 'unstage', + label: dict('PC.Pages.ConversationAgentSourceControl.unstageChanges'), + onClick: onUnstageChange, + }, + ]; + + const menuItems = + targetType === 'folder' + ? isStaged + ? stagedFolderItems + : unstagedFolderItems + : isStaged + ? stagedItems + : unstagedItems; + + return ( +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + {menuItems.map((item) => ( +
handleItemClick(item.onClick)} + > + {item.label} +
+ ))} +
+ ); +}; + +export default ChangeFileContextMenu; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileListSection/index.less b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileListSection/index.less new file mode 100644 index 00000000..3bffd4be --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileListSection/index.less @@ -0,0 +1,321 @@ +@import '@/styles/token'; + +@change-list-compact-gap: 6px; +@change-list-count-badge-size: 18px; +@change-list-count-font-size: 11px; +@change-list-status-font-size: 11px; +// 已删除文件:文件名、删除横线、D 角标统一颜色 +@change-list-deleted-color: @red3; +// 合并冲突文件:文件名、C 角标统一大红色 +@change-list-conflict-color: @red6; + +.changes-section { + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow: hidden; + + &.changes-section-nested { + flex: none; + } +} + +.changes-list-nested { + flex: none; + min-height: auto; + overflow: visible; +} + +.change-file-tree { + padding: 0 @paddingSm; + width: 100%; + max-width: 100%; + box-sizing: border-box; + overflow-x: hidden; +} + +.tree-folder-item { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.tree-file-item { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + overflow: hidden; +} + +.tree-file-name-wrap { + flex: 1; + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +.tree-file-name { + width: 100%; + max-width: 100%; +} + +.tree-file-trailing { + flex-shrink: 0; + margin-left: auto; + padding-left: @marginXxs; +} + +.changes-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 34px; + padding: 0 @paddingSm; + flex-shrink: 0; + cursor: pointer; + user-select: none; + + .changes-title { + display: flex; + align-items: center; + gap: @change-list-compact-gap; + font-size: @fontSizeSm; + font-weight: @fontWeightStrong; + color: @colorText; + + .changes-expand-icon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: @colorTextSecondary; + transform: rotate(0deg); + transition: transform 0.2s ease; + + &.changes-expand-icon-expanded { + transform: rotate(90deg); + } + } + } + + .changes-header-trailing { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + } + + .changes-count { + min-width: @change-list-count-badge-size; + height: @change-list-count-badge-size; + padding: 0 @change-list-compact-gap; + border-radius: (@change-list-count-badge-size / 2); + background-color: @colorBgTextActive; + font-size: @change-list-count-font-size; + line-height: @change-list-count-badge-size; + text-align: center; + color: @colorTextSecondary; + } + + &.changes-header-has-actions:hover { + .changes-count { + display: none; + } + + .changes-header-actions { + display: flex; + } + } +} + +.changes-header-actions { + display: none; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.changes-list { + flex: 1; + min-height: 0; + overflow: auto; +} + +.change-item { + display: flex; + align-items: center; + gap: 4px; + padding: 4px @paddingSm; + font-size: 13px; + color: #5e5e5b; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: @colorBgTextHover; + } + + &.change-item-has-actions:hover { + .status-badge { + display: none; + } + + .row-actions { + display: flex; + } + } + + &.change-item-active { + background-color: @colorFillTertiary; + + &:hover { + background-color: @colorFillSecondary; + } + } + + .file-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + font-size: @fontSizeLg; + } + + .file-info { + flex: 1; + min-width: 0; + display: flex; + align-items: baseline; + gap: @marginXxs; + } + + .file-name { + flex-shrink: 0; + height: 22px; + font-size: @fontSize; + font-family: @fontFamilyCode; + color: #5e5e5b; + max-width: 100%; + } + + .file-path-wrap { + flex: 1; + min-width: 0; + } + + .file-path { + width: 100%; + height: 22px; + font-size: @fontSize; + font-family: @fontFamilyCode; + color: @colorTextPlaceholder; + } + + // 已删除文件:文件名、路径删除横线与 D 角标同色 + &-deleted { + .file-name, + .file-path { + text-decoration: line-through; + color: @change-list-deleted-color; + } + } + + // 合并冲突文件:大红加粗 + &-conflict { + .file-name { + color: @change-list-conflict-color; + font-weight: @fontWeightStrong; + } + } +} + +.change-item-trailing { + flex-shrink: 0; + margin-left: auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; +} + +.row-actions { + display: none; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.row-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: @borderRadius; + color: @colorTextSecondary; + cursor: pointer; + transition: color 0.2s ease; + + &:hover { + color: @colorText; + } +} + +.file-name-deleted { + text-decoration: line-through; + color: @change-list-deleted-color; +} + +.file-name-conflict { + color: @change-list-conflict-color; + font-weight: @fontWeightStrong; +} + +.status-badge { + flex-shrink: 0; + min-width: 14px; + font-size: @change-list-status-font-size; + font-weight: @fontWeightStrong; + text-align: center; + line-height: 1.2; + + // 未暂存:修改(VS Code 黄) + &--modified { + color: @gold7; + } + + // 新增 / 未跟踪(VS Code 绿) + &--added, + &--untracked { + color: @green7; + } + + // 删除(与已删除文件名、删除横线同色) + &--deleted { + color: @change-list-deleted-color; + } + + // 冲突:大红色加粗 + &--conflict { + color: @change-list-conflict-color; + font-weight: @fontWeightStrong; + } + + // 重命名 + &--renamed { + color: @blue7; + } + + // 已暂存:修改态在 VS Code 中为绿色 M + &--staged&--modified { + color: @green7; + } +} + +.empty-state { + padding: @paddingLg @paddingSm; + text-align: center; + font-size: @fontSizeSm; + color: @colorTextTertiary; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileListSection/index.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileListSection/index.tsx new file mode 100644 index 00000000..46a5374a --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/ChangeFileListSection/index.tsx @@ -0,0 +1,516 @@ +import SvgIcon from '@/components/base/SvgIcon'; +import fileTreeStyles from '@/components/business-component/FileTreeGitSourcePanel/FileTreePanel/FileTree/index.less'; +import { EllipsisTooltip } from '@/components/custom/EllipsisTooltip'; +import TooltipIcon from '@/components/custom/TooltipIcon'; +import { dict } from '@/services/i18nRuntime'; +import { getFileIcon } from '@/utils/fileTree'; +import { + FileTextOutlined, + MinusOutlined, + PlusOutlined, + RightOutlined, + UndoOutlined, +} from '@ant-design/icons'; +import classNames from 'classnames'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ChangeListItem } from '../../utils/buildChangeFileTree'; +import { + buildChangeFileTree, + type ChangeTreeNode, +} from '../../utils/buildChangeFileTree'; +import { + type ChangeListSection, + isChangeFileSelected, + type SelectedChangeFile, +} from '../../utils/changeFileStatus'; +import styles from './index.less'; + +const cx = classNames.bind(styles); +const fileTreeCx = classNames.bind(fileTreeStyles); + +export type ChangeListViewMode = 'list' | 'tree'; + +export interface ChangeFileListSectionProps { + /** 区块标题 */ + title: string; + /** 变更项列表 */ + items: ChangeListItem[]; + /** 展示模式:平铺列表 / 目录树 */ + viewMode: ChangeListViewMode; + /** 当前列表所属区块 */ + section: ChangeListSection; + /** 当前选中的变更文件(含区块,避免跨区块重复高亮) */ + selectedChangeFile?: SelectedChangeFile | null; + /** 空状态文案 */ + emptyText?: string; + /** 点击文件项 */ + onFileClick?: (fileId: string, section: ChangeListSection) => void; + /** 文件右键菜单 */ + onContextMenu?: (e: React.MouseEvent, fileId: string) => void; + /** 树形视图文件夹右键菜单 */ + onFolderContextMenu?: (e: React.MouseEvent, folderId: string) => void; + /** 打开文件 */ + onOpenFile?: (fileId: string) => void; + /** 放弃更改 */ + onDiscardChange?: (fileId: string, fileName: string) => void; + /** 暂存更改 */ + onStageChange?: (fileId: string) => void; + /** 取消暂存 */ + onUnstageChange?: (fileId: string) => void; + /** 放弃区块内所有更改 */ + onDiscardAllChanges?: () => void; + /** 暂存区块内所有更改 */ + onStageAllChanges?: () => void; + /** 取消区块内所有暂存 */ + onUnstageAllChanges?: () => void; +} + +/** + * 源代码管理变更列表区块(支持列表 / 树形两种视图) + * 树形视图对齐 FileTree 组件的图标与展示方式 + */ +const ChangeFileListSection: React.FC = ({ + title, + items, + viewMode, + section, + selectedChangeFile, + emptyText, + onFileClick, + onContextMenu, + onFolderContextMenu, + onOpenFile, + onDiscardChange, + onStageChange, + onUnstageChange, + onDiscardAllChanges, + onStageAllChanges, + onUnstageAllChanges, +}) => { + const [expanded, setExpanded] = useState(true); + const [expandedFolders, setExpandedFolders] = useState>( + () => new Set(), + ); + + const treeNodes = useMemo( + () => (viewMode === 'tree' ? buildChangeFileTree(items) : []), + [items, viewMode], + ); + + /** 树形视图下默认展开所有文件夹 */ + useEffect(() => { + if (viewMode !== 'tree' || !treeNodes.length) { + return; + } + + const collectFolderIds = ( + nodes: ChangeTreeNode[], + ids: Set, + ): void => { + nodes.forEach((node) => { + if (node.type === 'folder') { + ids.add(node.id); + if (node.children?.length) { + collectFolderIds(node.children, ids); + } + } + }); + }; + + const ids = new Set(); + collectFolderIds(treeNodes, ids); + setExpandedFolders(ids); + }, [treeNodes, viewMode]); + + const toggleFolder = useCallback((folderId: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(folderId)) { + next.delete(folderId); + } else { + next.add(folderId); + } + return next; + }); + }, []); + + /** 是否为已删除文件(展示删除横线) */ + const isDeletedFile = (item: ChangeListItem) => + item.statusMeta.kind === 'deleted'; + + /** 是否为合并冲突文件(大红加粗展示) */ + const isConflictFile = (item: ChangeListItem) => + item.statusMeta.kind === 'conflict'; + + const renderStatusBadge = (item: ChangeListItem) => ( + + {item.statusMeta.label} + + ); + + /** 列表视图:悬停时展示快捷操作(与右键菜单对应能力一致) */ + const renderListRowActions = (item: ChangeListItem) => { + const stopRowClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + const openFileLabel = dict( + 'PC.Pages.ConversationAgentSourceControl.openFile', + ); + + const actions = ( + section === 'staged' + ? [ + { + key: 'openFile', + title: openFileLabel, + icon: , + enabled: Boolean(onOpenFile), + onClick: () => onOpenFile?.(item.fileId), + }, + { + key: 'unstage', + title: dict( + 'PC.Pages.ConversationAgentSourceControl.unstageChanges', + ), + icon: , + enabled: Boolean(onUnstageChange), + onClick: () => onUnstageChange?.(item.fileId), + }, + ] + : [ + { + key: 'openFile', + title: openFileLabel, + icon: , + enabled: Boolean(onOpenFile), + onClick: () => onOpenFile?.(item.fileId), + }, + { + key: 'discard', + title: dict( + 'PC.Pages.ConversationAgentSourceControl.discardChanges', + ), + icon: , + enabled: Boolean(onDiscardChange), + onClick: () => onDiscardChange?.(item.fileId, item.fileName), + }, + { + key: 'stage', + title: dict( + 'PC.Pages.ConversationAgentSourceControl.stageChanges', + ), + icon: , + enabled: Boolean(onStageChange), + onClick: () => onStageChange?.(item.fileId), + }, + ] + ).filter((action) => action.enabled); + + if (!actions.length) { + return null; + } + + return ( +
+ {actions.map((action) => ( + + ))} +
+ ); + }; + + /** 区块标题悬停:批量操作(放弃/暂存/取消暂存全部) */ + const renderSectionHeaderActions = () => { + const stopHeaderClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + const actions = ( + section === 'staged' + ? [ + { + key: 'unstageAll', + title: dict( + 'PC.Pages.ConversationAgentSourceControl.unstageAllChanges', + ), + icon: , + enabled: Boolean(onUnstageAllChanges), + onClick: () => onUnstageAllChanges?.(), + }, + ] + : [ + { + key: 'discardAll', + title: dict( + 'PC.Pages.ConversationAgentSourceControl.discardAllChanges', + ), + icon: , + enabled: Boolean(onDiscardAllChanges), + onClick: () => onDiscardAllChanges?.(), + }, + { + key: 'stageAll', + title: dict( + 'PC.Pages.ConversationAgentSourceControl.stageAllChanges', + ), + icon: , + enabled: Boolean(onStageAllChanges), + onClick: () => onStageAllChanges?.(), + }, + ] + ).filter((action) => action.enabled); + + if (!actions.length) { + return null; + } + + return ( +
+ {actions.map((action) => ( + + ))} +
+ ); + }; + + const sectionHeaderActions = renderSectionHeaderActions(); + + /** 列表视图:平铺展示文件名与路径 */ + const renderListFileRow = (item: ChangeListItem) => { + const listRowActions = renderListRowActions(item); + + return ( +
onFileClick?.(item.fileId, section)} + onContextMenu={(e) => onContextMenu?.(e, item.fileId)} + title={item.fileId} + > + + {getFileIcon(item.fileName)} + + + {/* 文件信息 */} +
+ {/* 文件名 */} + + {item.fileName} + + + {/* 文件路径 */} + {item.parentPath && ( +
+ +
+ )} +
+ + {/* 文件状态与操作按钮 */} +
+ {renderStatusBadge(item)} + {listRowActions} +
+
+ ); + }; + + /** + * 树形视图:对齐 FileTree 的文件夹/文件行样式与 caret 展开交互 + */ + const renderTreeFileRow = (item: ChangeListItem, level: number) => { + const isSelected = isChangeFileSelected( + item.fileId, + section, + selectedChangeFile, + ); + + return ( +
onFileClick?.(item.fileId, section)} + onContextMenu={(e) => onContextMenu?.(e, item.fileId)} + > + {getFileIcon(item.fileName)} +
+ +
+ + {renderStatusBadge(item)} + +
+ ); + }; + + /** 树形视图:渲染文件夹与文件 */ + const renderTreeNodes = (nodes: ChangeTreeNode[], level = 0) => + nodes.map((node) => { + if (node.type === 'file' && node.fileItem) { + return renderTreeFileRow(node.fileItem, level); + } + + const isFolderExpanded = expandedFolders.has(node.id); + return ( +
+
toggleFolder(node.id)} + onContextMenu={(e) => { + if (viewMode !== 'tree') { + return; + } + e.preventDefault(); + e.stopPropagation(); + onFolderContextMenu?.(e, node.id); + }} + > + + + {node.name} + +
+ {isFolderExpanded && node.children?.length ? ( +
+ {renderTreeNodes(node.children, level + 1)} +
+ ) : null} +
+ ); + }); + + return ( +
+ {/* 区块标题 */} +
0, + })} + onClick={() => setExpanded((prev) => !prev)} + > +
+ + + + {title} +
+
+ {items.length} + {items.length > 0 && sectionHeaderActions} +
+
+ + {expanded && ( +
+ {items.length ? ( + // 树形视图 + viewMode === 'tree' ? ( +
+ {renderTreeNodes(treeNodes)} +
+ ) : ( + // 列表视图 + items.map((item) => renderListFileRow(item)) + ) + ) : ( + emptyText && ( +
{emptyText}
+ ) + )} +
+ )} +
+ ); +}; + +export default ChangeFileListSection; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/index.less b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/index.less new file mode 100644 index 00000000..6081b957 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/index.less @@ -0,0 +1,88 @@ +@import '@/styles/token'; + +.source-control { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: @paddingXs @paddingSm @marginXxs; + flex-shrink: 0; + + .title { + font-size: @fontSizeSm; + font-weight: @fontWeightStrong; + color: @colorText; + } +} + +.header-actions { + display: flex; + align-items: center; + gap: @marginXxs; +} + +.header-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: @borderRadius; + color: @colorTextSecondary; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; + + &:hover { + color: @colorText; + background-color: @colorBgTextHover; + } + + &.active { + color: @colorText; + background-color: @colorBgTextActive; + } + + &.header-action-btn-loading { + pointer-events: none; + } + + &.header-action-btn-disabled { + color: @colorTextQuaternary; + cursor: not-allowed; + pointer-events: none; + + &:hover { + color: @colorTextQuaternary; + background-color: transparent; + } + } +} + +.commit-box { + padding: 0 @paddingSm @paddingSm; + flex-shrink: 0; + border-bottom: @lineWidth solid @colorSplit; + + .message-input { + margin-bottom: @marginXs; + resize: none; + } + + .commit-btn { + width: 100%; + font-size: @fontSizeSm; + } +} + +.changes-scroll { + flex: 1; + min-height: 0; + overflow: auto; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/index.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/index.tsx new file mode 100644 index 00000000..716c5666 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/SourceControlPanel/index.tsx @@ -0,0 +1,532 @@ +import type { ChangeFileInfo } from '@/components/business-component/FileTreePreviewPanel/types/file-tree'; +import TooltipIcon from '@/components/custom/TooltipIcon'; +import { dict } from '@/services/i18nRuntime'; +import { modalConfirm } from '@/utils/ant-custom'; +import { ReloadOutlined, UnorderedListOutlined } from '@ant-design/icons'; +import { Button, Input } from 'antd'; +import classNames from 'classnames'; +import React, { useCallback, useMemo, useState } from 'react'; +import { collectFilesUnderFolder } from '../utils/buildChangeFileTree'; +import { + splitChangeFilesForDisplay, + type ChangeListSection, + type SelectedChangeFile, +} from '../utils/changeFileStatus'; +import ChangeFileContextMenu from './ChangeFileContextMenu'; +import ChangeFileListSection, { + type ChangeListViewMode, +} from './ChangeFileListSection'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +/** 右键菜单目标 */ +type ContextMenuTarget = + | { kind: 'file'; fileId: string; isStagedSection: boolean } + | { kind: 'folder'; folderId: string; isStagedSection: boolean }; + +export interface SourceControlPanelProps { + /** 已修改文件列表 */ + changeFiles: ChangeFileInfo[]; + /** 是否正在提交 */ + isCommitting?: boolean; + /** 是否正在刷新 Git 列表 */ + isRefreshing?: boolean; + /** 是否禁用刷新 Git 变更列表 */ + refreshDisabled?: boolean; + /** 当前选中的变更文件(含区块) */ + selectedChangeFile?: SelectedChangeFile | null; + /** 提交修改(保存并推送) */ + onCommit?: (message: string) => Promise; + /** 刷新 Git 变更列表 */ + onRefresh?: () => void | Promise; + /** 点击修改项查看 diff */ + onFileClick?: (fileId: string, section: ChangeListSection) => void; + /** 打开更改(diff) */ + onOpenChanges?: (fileId: string, section: ChangeListSection) => void; + /** 打开文件 */ + onOpenFile?: (fileId: string) => void; + /** 放弃更改(Git discard + UI 同步 + 刷新) */ + onDiscardChanges?: (fileIds: string[]) => void | Promise; + /** 暂存更改(git add) */ + onStageChanges?: (fileIds: string[]) => void | Promise; + /** 取消暂存(git restore --staged) */ + onUnstageChanges?: (fileIds: string[]) => void | Promise; + /** 添加到 .gitignore */ + onAddToGitignore?: (fileId: string) => void; +} + +/** + * ConversationAgent 源代码管理面板 + * 展示暂存/未暂存变更,支持刷新、列表/树形切换、提交与 diff 预览 + */ +const SourceControlPanel: React.FC = ({ + changeFiles, + isCommitting = false, + isRefreshing = false, + refreshDisabled = false, + selectedChangeFile, + onCommit, + onRefresh, + onFileClick, + onOpenChanges, + onOpenFile, + onDiscardChanges, + onStageChanges, + onUnstageChanges, + onAddToGitignore, +}) => { + const [commitMessage, setCommitMessage] = useState(''); + // 视图模式:tree / list + const [viewMode, setViewMode] = useState('list'); + // 提交消息 + const [contextMenuVisible, setContextMenuVisible] = useState(false); + // 右键菜单位置 + const [contextMenuPosition, setContextMenuPosition] = useState({ + x: 0, + y: 0, + }); + // 右键菜单目标 + const [contextMenuTarget, setContextMenuTarget] = + useState(null); + + /** 暂存区 / 更改区列表(map + merge,角标 A/M/D/U/C/R) */ + const { stagedItems, unstagedItems } = useMemo( + () => splitChangeFilesForDisplay(changeFiles), + [changeFiles], + ); + + // 变更项列表 + const changeItems = useMemo( + () => [...stagedItems, ...unstagedItems], + [stagedItems, unstagedItems], + ); + + /** 是否存在任意变更(暂存或未暂存) */ + const hasAnyChanges = changeItems.length > 0; + + /** 文件右键时,当前文件的变更信息 */ + const contextMenuFile = useMemo(() => { + if (contextMenuTarget?.kind !== 'file') { + return undefined; + } + const sectionItems = contextMenuTarget.isStagedSection + ? stagedItems + : unstagedItems; + return sectionItems.find( + (item) => item.fileId === contextMenuTarget.fileId, + ); + }, [contextMenuTarget, stagedItems, unstagedItems]); + + /** 文件夹右键时,当前区块内该目录下的所有变更文件 */ + const contextMenuFolderFiles = useMemo(() => { + if (contextMenuTarget?.kind !== 'folder') { + return []; + } + const sectionItems = contextMenuTarget.isStagedSection + ? stagedItems + : unstagedItems; + return collectFilesUnderFolder(sectionItems, contextMenuTarget.folderId); + }, [contextMenuTarget, stagedItems, unstagedItems]); + + const isContextMenuStaged = + contextMenuTarget?.kind === 'folder' + ? contextMenuTarget.isStagedSection + : contextMenuTarget?.kind === 'file' + ? contextMenuTarget.isStagedSection + : false; + + /** 右键目标类型 */ + const contextMenuTargetType = + contextMenuTarget?.kind === 'folder' ? 'folder' : 'file'; + + /** 关闭右键菜单 */ + const closeContextMenu = useCallback(() => { + setContextMenuVisible(false); + setContextMenuTarget(null); + }, []); + + /** 文件右键打开菜单 */ + const handleContextMenu = useCallback( + (isStagedSection: boolean) => (e: React.MouseEvent, fileId: string) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenuTarget({ kind: 'file', fileId, isStagedSection }); + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + setContextMenuVisible(true); + }, + [], + ); + + /** 树形视图文件夹右键打开菜单 */ + const handleFolderContextMenu = useCallback( + (isStagedSection: boolean) => (e: React.MouseEvent, folderId: string) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenuTarget({ + kind: 'folder', + folderId, + isStagedSection, + }); + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + setContextMenuVisible(true); + }, + [], + ); + + /** + * 放弃更改:由 useSourceControl 统一执行 Git discard、UI 同步与列表刷新 + */ + const discardChanges = useCallback( + async (fileIds: string[]) => { + if (!fileIds.length) { + return; + } + await onDiscardChanges?.(fileIds); + }, + [onDiscardChanges], + ); + + /** 放弃更改二次确认(统一封装,确保 onOk 返回 Promise 供 Modal 等待) */ + const confirmDiscardChanges = useCallback( + ( + fileIds: string[], + content: string, + title = dict( + 'PC.Pages.ConversationAgentSourceControl.discardChangesConfirmTitle', + ), + ) => { + modalConfirm(title, content, async () => { + await discardChanges(fileIds); + return new Promise((resolve) => { + setTimeout(resolve, 300); + }); + }); + }, + [discardChanges], + ); + + /** 暂存更改(git add) */ + const stageChanges = useCallback( + async (fileIds: string[]) => { + if (!fileIds.length) { + return; + } + await onStageChanges?.(fileIds); + }, + [onStageChanges], + ); + + /** 取消暂存(git restore --staged) */ + const unstageChanges = useCallback( + async (fileIds: string[]) => { + if (!fileIds.length) { + return; + } + await onUnstageChanges?.(fileIds); + }, + [onUnstageChanges], + ); + + /** 放弃更改(二次确认) */ + const handleDiscardChangeWithConfirm = useCallback(() => { + if (contextMenuTarget?.kind === 'file' && contextMenuFile) { + const { fileId, fileName } = contextMenuFile; + closeContextMenu(); + confirmDiscardChanges([fileId], fileName); + return; + } + + if (contextMenuTarget?.kind === 'folder' && contextMenuFolderFiles.length) { + const { folderId } = contextMenuTarget; + const fileIds = contextMenuFolderFiles.map((item) => item.fileId); + closeContextMenu(); + confirmDiscardChanges(fileIds, folderId); + } + }, [ + closeContextMenu, + confirmDiscardChanges, + contextMenuFile, + contextMenuFolderFiles, + contextMenuTarget, + ]); + + /** 暂存文件夹下所有更改 */ + const handleStageFolderChanges = useCallback(() => { + void stageChanges(contextMenuFolderFiles.map((item) => item.fileId)); + }, [contextMenuFolderFiles, stageChanges]); + + /** 取消暂存文件夹下所有更改 */ + const handleUnstageFolderChanges = useCallback(() => { + void unstageChanges(contextMenuFolderFiles.map((item) => item.fileId)); + }, [contextMenuFolderFiles, unstageChanges]); + + /** 将文件夹下所有文件添加到 .gitignore */ + const handleAddFolderToGitignore = useCallback(() => { + contextMenuFolderFiles.forEach((item) => onAddToGitignore?.(item.fileId)); + }, [contextMenuFolderFiles, onAddToGitignore]); + + /** 提交修改 */ + const handleCommit = async () => { + if (!changeFiles.length || isCommitting) { + return; + } + await onCommit?.(commitMessage.trim()); + setCommitMessage(''); + }; + + /** 是否为合并冲突文件 */ + const isConflictFile = useCallback( + (fileId: string, section: ChangeListSection) => { + const item = changeFiles.find((file) => file.fileId === fileId); + if (!item) { + return false; + } + return section === 'staged' + ? item.stagedStatus === 'conflict' + : item.unstagedStatus === 'conflict'; + }, + [changeFiles], + ); + + /** + * 点击变更文件:冲突文件打开代码编辑器,其余文件打开 diff 对比 + */ + const handleFileClick = useCallback( + (fileId: string, section: ChangeListSection) => { + if (isConflictFile(fileId, section)) { + onOpenFile?.(fileId); + return; + } + onFileClick?.(fileId, section); + }, + [isConflictFile, onFileClick, onOpenFile], + ); + + /** 切换视图模式 */ + const handleToggleViewMode = useCallback(() => { + if (!hasAnyChanges) { + return; + } + setViewMode((prev) => (prev === 'tree' ? 'list' : 'tree')); + }, [hasAnyChanges]); + + /** 列表行悬停:打开文件 */ + const handleListOpenFile = useCallback( + (fileId: string) => { + onOpenFile?.(fileId); + }, + [onOpenFile], + ); + + /** 列表行悬停:放弃更改(与右键菜单一致,需二次确认) */ + const handleListDiscardChange = useCallback( + (fileId: string, fileName: string) => { + confirmDiscardChanges([fileId], fileName); + }, + [confirmDiscardChanges], + ); + + /** 列表行悬停:暂存更改 */ + const handleListStageChange = useCallback( + (fileId: string) => { + void stageChanges([fileId]); + }, + [stageChanges], + ); + + /** 列表行悬停:取消暂存 */ + const handleListUnstageChange = useCallback( + (fileId: string) => { + void unstageChanges([fileId]); + }, + [unstageChanges], + ); + + /** 区块标题:放弃所有未暂存更改 */ + const handleDiscardAllUnstagedChanges = useCallback(() => { + if (!unstagedItems.length) { + return; + } + confirmDiscardChanges( + unstagedItems.map((item) => item.fileId), + dict('PC.Pages.ConversationAgentSourceControl.changes'), + dict( + 'PC.Pages.ConversationAgentSourceControl.discardAllChangesConfirmTitle', + ), + ); + }, [confirmDiscardChanges, unstagedItems]); + + /** 区块标题:暂存所有未暂存更改 */ + const handleStageAllUnstagedChanges = useCallback(() => { + void stageChanges(unstagedItems.map((item) => item.fileId)); + }, [stageChanges, unstagedItems]); + + /** 区块标题:取消所有暂存 */ + const handleUnstageAllStagedChanges = useCallback(() => { + void unstageChanges(stagedItems.map((item) => item.fileId)); + }, [stagedItems, unstageChanges]); + + /** 视图模式切换标题 */ + const viewToggleTitle = + viewMode === 'tree' + ? dict('PC.Pages.ConversationAgentSourceControl.viewAsList') + : dict('PC.Pages.ConversationAgentSourceControl.viewAsTree'); + + return ( +
+
+ + {dict('PC.Pages.ConversationAgentSourceControl.title')} + +
+ {/* 刷新按钮 */} + } + onClick={refreshDisabled ? undefined : () => onRefresh?.()} + /> + {/* 视图模式切换按钮 */} + } + onClick={hasAnyChanges ? handleToggleViewMode : undefined} + /> +
+
+ + {/* 提交消息输入框 */} +
+ setCommitMessage(e.target.value)} + autoSize={{ minRows: 1, maxRows: 4 }} + disabled={isCommitting} + /> + {/* 提交按钮 */} + +
+ +
+ {/* 暂存的变更 */} + {stagedItems.length > 0 && ( + + )} + {/* 未暂存的变更 */} + +
+ + {/* 源代码管理菜单 */} + { + if (!contextMenuFile || contextMenuTarget?.kind !== 'file') { + return; + } + onOpenChanges?.( + contextMenuFile.fileId, + contextMenuTarget.isStagedSection ? 'staged' : 'unstaged', + ); + }} + /** 打开文件 */ + onOpenFile={() => + contextMenuFile && onOpenFile?.(contextMenuFile.fileId) + } + /** 放弃更改 */ + onDiscardChange={handleDiscardChangeWithConfirm} + /** 暂存更改 */ + onStageChange={() => { + if (contextMenuTarget?.kind === 'file' && contextMenuFile) { + void stageChanges([contextMenuFile.fileId]); + return; + } + handleStageFolderChanges(); + }} + /** 取消暂存 */ + onUnstageChange={() => { + if (contextMenuTarget?.kind === 'file' && contextMenuFile) { + void unstageChanges([contextMenuFile.fileId]); + return; + } + handleUnstageFolderChanges(); + }} + /** 添加到 .gitignore */ + onAddToGitignore={() => { + if (contextMenuTarget?.kind === 'file' && contextMenuFile) { + onAddToGitignore?.(contextMenuFile.fileId); + return; + } + handleAddFolderToGitignore(); + }} + /> +
+ ); +}; + +export default SourceControlPanel; + +/** @deprecated 使用 SourceControlPanel */ +export type ConversationAgentSourceControlProps = SourceControlPanelProps; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/hooks/useAppDevFileTree.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/hooks/useAppDevFileTree.tsx new file mode 100644 index 00000000..e9d0e36f --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/hooks/useAppDevFileTree.tsx @@ -0,0 +1,347 @@ +import type { FileNode } from '@/types/interfaces/appDev'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { FileTreeContainerProps } from '../types/file-tree-git-source'; + +/** AppDev 文件树所需的 fileManagement 能力子集 */ +export interface AppDevFileTreeFileManagement { + /** 插入用于新建的临时节点,返回该节点 */ + insertTempNodeForCreate?: ( + parentNode: FileNode | null, + type: 'file' | 'folder', + ) => FileNode; + /** 移除临时节点 */ + removeTempNode?: (nodeId: string) => void; + /** 刷新文件树 */ + loadFileTree: ( + preserveState?: boolean, + forceRefresh?: boolean, + ) => Promise | void; +} + +/** useAppDevFileTree 入参 */ +export interface UseAppDevFileTreeParams { + /** 文件树数据 */ + files: FileNode[]; + /** 当前选中的文件 ID */ + selectedFileId: string; + /** 文件管理能力(来自 useAppDevFileManagement) */ + fileManagement: AppDevFileTreeFileManagement; + /** 聊天加载中:禁用右键菜单与工具栏 */ + isChatLoading?: boolean; + /** 文件树初始化加载中 */ + isFileTreeInitializing?: boolean; + /** 文件选择回调 */ + onFileSelect: (fileId: string) => void; + /** 删除文件/文件夹回调 */ + onDeleteFile: (node: FileNode, event: React.MouseEvent) => void; + /** 重命名/新建确认回调(新建节点 status === 'create') */ + onRenameFile?: (node: FileNode, newName: string) => Promise; + /** 上传多个文件回调(node 为 null 表示根目录) */ + onUploadFiles: (node: FileNode | null) => void | Promise; + /** 上传文件夹回调(保留子目录结构,node 为 null 表示根目录) */ + onUploadFolder?: (node: FileNode | null) => void | Promise; + /** 文件树批量上传进行中(展示上传提示) */ + isUploadingFiles?: boolean; + /** 导入项目回调(空白区域右键菜单) */ + onImportProject?: () => void; + /** 导入项目菜单项文案 */ + importProjectLabel?: string; + /** 导出项目回调 */ + onExportProject?: () => void | Promise; +} + +/** + * AppDev 文件树适配 Hook + * 将 useAppDevFileManagement 的能力与页面回调适配为 FileTreePanel + * 所需的 TaskAgentFileViewTree 结构,内部维护右键菜单、重命名、刷新等 UI 状态 + * + * @param params 见 UseAppDevFileTreeParams + * @returns tree:可直接传给 FileTreePanel 的状态与处理器集合 + */ +export function useAppDevFileTree(params: UseAppDevFileTreeParams): { + tree: FileTreeContainerProps; +} { + const { + files, + selectedFileId, + fileManagement, + isChatLoading = false, + isFileTreeInitializing = false, + onFileSelect, + onDeleteFile, + onRenameFile, + onUploadFiles, + onUploadFolder, + isUploadingFiles = false, + onImportProject, + importProjectLabel, + onExportProject, + } = params; + + // 文件树容器(用于右键菜单相对定位) + const fileTreeContainerRef = useRef(null); + + // 右键菜单状态 + const [contextMenuVisible, setContextMenuVisible] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState({ + x: 0, + y: 0, + }); + const [contextMenuTarget, setContextMenuTarget] = useState( + null, + ); + + // 内联重命名状态 + const [renamingNode, setRenamingNode] = useState(null); + /** 文件树中选中的文件夹 ID(工具栏新建父级) */ + const [selectedFolderId, setSelectedFolderId] = useState(''); + + // 文件树刷新 loading 状态 + const [isRefreshingFileTree, setIsRefreshingFileTree] = + useState(false); + + /** + * 处理右键菜单显示(坐标转换为相对文件树容器,配合菜单的相对定位) + * @param e 鼠标事件 + * @param node 目标节点,null 表示空白区域 + */ + const handleContextMenu = useCallback( + (e: React.MouseEvent, node: FileNode | null) => { + e.preventDefault(); + e.stopPropagation(); + + // 聊天加载中禁用右键菜单 + if (isChatLoading) { + return; + } + + setContextMenuTarget(node); + if (fileTreeContainerRef.current) { + const containerRect = + fileTreeContainerRef.current.getBoundingClientRect(); + setContextMenuPosition({ + x: e.clientX - containerRect.left, + y: e.clientY - containerRect.top, + }); + } else { + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + } + setContextMenuVisible(true); + }, + [isChatLoading], + ); + + /** 关闭右键菜单 */ + const closeContextMenu = useCallback(() => { + setContextMenuVisible(false); + setContextMenuTarget(null); + }, []); + + // 点击外部关闭右键菜单 + useEffect(() => { + document.addEventListener('click', closeContextMenu); + return () => document.removeEventListener('click', closeContextMenu); + }, [closeContextMenu]); + + /** + * 取消重命名;新建节点未输入名称时移除临时占位 + * 仅当取消的是当前重命名节点时才清空状态, + * 避免上一个输入框的延迟 blur 误清掉新一轮新建的重命名状态 + */ + const handleCancelRename = useCallback( + (options?: { removeIfNew?: boolean; node?: FileNode | null }) => { + if ( + options?.removeIfNew && + options.node && + fileManagement.removeTempNode + ) { + fileManagement.removeTempNode(options.node.id); + } + setRenamingNode((prev) => + options?.node && prev && prev.id !== options.node.id ? prev : null, + ); + }, + [fileManagement], + ); + + /** 新建文件(parentNode 为 null 时在根目录创建) */ + const handleCreateFile = useCallback( + (parentNode: FileNode | null = null) => { + if (!fileManagement.insertTempNodeForCreate) { + return; + } + // 连续点击新建时,先移除上一个尚未命名的临时节点,避免残留空占位 + if (renamingNode?.status === 'create') { + fileManagement.removeTempNode?.(renamingNode.id); + } + const newNode = fileManagement.insertTempNodeForCreate( + parentNode, + 'file', + ); + setRenamingNode(newNode); + }, + [fileManagement, renamingNode], + ); + + /** 新建文件夹(parentNode 为 null 时在根目录创建) */ + const handleCreateFolder = useCallback( + (parentNode: FileNode | null = null) => { + if (!fileManagement.insertTempNodeForCreate) { + return; + } + // 连续点击新建时,先移除上一个尚未命名的临时节点,避免残留空占位 + if (renamingNode?.status === 'create') { + fileManagement.removeTempNode?.(renamingNode.id); + } + const newNode = fileManagement.insertTempNodeForCreate( + parentNode, + 'folder', + ); + setRenamingNode(newNode); + }, + [fileManagement, renamingNode], + ); + + /** 重命名/新建确认:透传给页面回调(内部区分新建与重命名) */ + const handleRenameFile = useCallback( + async (node: FileNode, newName: string) => { + const success = await onRenameFile?.(node, newName); + const isTempNode = + node.status === 'create' || node.id.includes('__new__'); + if (!success && isTempNode) { + fileManagement.removeTempNode?.(node.id); + setRenamingNode(null); + } + }, + [onRenameFile, fileManagement], + ); + + /** 删除:适配页面 (node, event) 签名 */ + const handleDelete = useCallback( + async (node: FileNode) => { + const stubEvent = { + stopPropagation: () => {}, + preventDefault: () => {}, + } as unknown as React.MouseEvent; + onDeleteFile(node, stubEvent); + }, + [onDeleteFile], + ); + + /** 从右键菜单触发重命名 */ + const handleRenameFromMenu = useCallback((node: FileNode) => { + setRenamingNode(node); + }, []); + + /** 从右键菜单/工具栏触发上传多个文件 */ + const handleUploadMultipleFiles = useCallback( + async (node: FileNode | null) => { + await onUploadFiles(node); + }, + [onUploadFiles], + ); + + /** 从右键菜单/工具栏触发上传文件夹 */ + const handleUploadFolder = useCallback( + async (node: FileNode | null) => { + await onUploadFolder?.(node); + }, + [onUploadFolder], + ); + + /** 刷新文件树:内部维护 loading 状态,避免重复触发 */ + const handleRefreshFileList = useCallback(async () => { + if (isRefreshingFileTree) { + return; + } + setIsRefreshingFileTree(true); + try { + await fileManagement.loadFileTree(true, true); + } finally { + setIsRefreshingFileTree(false); + } + }, [fileManagement, isRefreshingFileTree]); + + /** 导出项目 */ + const handleExportProject = useCallback(async () => { + await onExportProject?.(); + }, [onExportProject]); + + const tree = useMemo( + () => ({ + files, + selectedFileId, + selectedFolderId, + renamingNode, + contextMenuTarget, + contextMenuPosition, + contextMenuVisible, + isFileTreeVisible: true, + fileTreeContainerRef, + fileTreeDataLoading: isFileTreeInitializing, + isCanDeleteSkillFile: true, + isRefreshingFileTree, + isUploadingFiles, + isDownloadingFile: false, + hideFileTree: false, + showRefreshButton: true, + handleFileSelect: async ( + fileId: string, + options?: { selectFolder?: boolean }, + ) => { + if (options?.selectFolder) { + setSelectedFolderId(fileId); + return; + } + setSelectedFolderId(''); + onFileSelect(fileId); + }, + handleContextMenu, + closeContextMenu, + handleRenameFile, + handleCancelRename, + handleRefreshFileList, + handleDelete, + handleRenameFromMenu, + handleUploadMultipleFiles, + handleUploadFolder: onUploadFolder ? handleUploadFolder : undefined, + handleCreateFile, + handleCreateFolder, + handleImportProject: onImportProject, + importProjectLabel, + handleExportProject: onExportProject ? handleExportProject : undefined, + toolbarDisabled: isChatLoading, + }), + [ + files, + selectedFileId, + selectedFolderId, + renamingNode, + contextMenuTarget, + contextMenuPosition, + contextMenuVisible, + isFileTreeInitializing, + isRefreshingFileTree, + isUploadingFiles, + isChatLoading, + onFileSelect, + onImportProject, + importProjectLabel, + onExportProject, + handleContextMenu, + closeContextMenu, + handleRenameFile, + handleCancelRename, + handleRefreshFileList, + handleDelete, + handleRenameFromMenu, + handleUploadMultipleFiles, + handleUploadFolder, + handleCreateFile, + handleCreateFolder, + handleExportProject, + ], + ); + + return { tree }; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/hooks/useSourceControl.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/hooks/useSourceControl.ts new file mode 100644 index 00000000..df62e432 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/hooks/useSourceControl.ts @@ -0,0 +1,759 @@ +/** + * 源代码管理(Git)统一 Hook + * 管理修改文件列表、暂存、提交推送及 diff 预览相关逻辑 + * 支持 pageApp / taskAgent 两种 workspace,Git 操作集中在此,页面差异通过 callbacks 注入 + */ +import type { ChangeFileInfo } from '@/components/business-component/FileTreePreviewPanel/types/file-tree'; +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { getProjectContent, submitFilesUpdate } from '@/services/appDev'; +import { dict } from '@/services/i18nRuntime'; +import type { FileNode } from '@/types/interfaces/appDev'; +import { treeToFlatList } from '@/utils/appDevUtils'; +import { message } from 'antd'; +import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; +import { fetchGitChangeFileContent } from '../../GitVersionRecordPanel/gitCommitDiffUtils'; +import { apiGitCommit, apiGitStatus } from '../services/git-version-management'; +import { + buildGitWorkspaceParams, + isGitWorkspaceReady, + type GitWorkspaceConfig, +} from '../utils/buildGitWorkspaceParams'; +import type { + ChangeListSection, + SelectedChangeFile, +} from '../utils/changeFileStatus'; +import { + buildChangeFilesFromGitStatus, + mergeGitStatusFileIds, +} from '../utils/gitStatusUtils'; +import { + runGitDiscard, + runGitStage, + runGitUnstage, +} from '../utils/sourceControlGitActions'; + +export type { GitWorkspaceConfig }; + +/** 各页面在 Git 操作成功后的差异化处理 */ +export interface SourceControlCallbacks { + /** 打开更改文件(非 diff) */ + openChangeFile: (fileId: string) => void; + /** 放弃单个文件本地修改(还原编辑器内容) */ + discardChangeFile?: (fileId: string) => void; + /** 将文件添加到 .gitignore(taskAgent 由页面实现;pageApp 不传则走内置逻辑) */ + addFileToGitignore?: (fileId: string) => Promise; + /** 选中 diff 文件后的页面操作 */ + onDiffFileSelect?: (fileId: string, section: ChangeListSection) => void; + /** Git discard 成功后的页面操作 */ + onAfterDiscardChange?: (fileId: string) => void | Promise; + /** 批量 Git discard 完成后的页面操作(整批只调用一次) */ + onAfterDiscardChanges?: (fileIds: string[]) => void | Promise; + /** Git commit 成功后的页面操作 */ + onCommitSuccess?: () => Promise; + /** discard 等操作后刷新 Git 列表(taskAgent 通常刷新 fileView) */ + onRefreshGitList?: () => Promise; + /** pageApp:刷新文件树 */ + loadFileTree?: ( + preserveState?: boolean, + forceRefresh?: boolean, + ) => Promise; + /** pageApp:查找文件节点(syncChangeFiles 用) */ + findFileNode?: (fileId: string) => FileNode | null; + /** pageApp:更新编辑器文件内容 */ + updateFileContent?: (fileId: string, content: string) => void; + /** pageApp:取消编辑 */ + cancelEdit?: (silent?: boolean) => void; + /** pageApp:当前编辑中的文件状态 */ + getFileContentState?: () => { + selectedFile: string; + originalFileContent: string; + }; + /** pageApp:提交成功后刷新项目详情 */ + onRefreshProjectInfo?: () => void; +} + +export interface UseSourceControlParams { + /** Git 工作空间:pageApp 传 projectId,taskAgent 传 cid */ + workspace: GitWorkspaceConfig; + /** 页面回调 */ + callbacks: SourceControlCallbacks; + /** + * 外部维护的变更文件列表(taskAgent)。 + * 不传时由 Hook 内部维护(pageApp)。 + */ + changeFiles?: ChangeFileInfo[]; + /** 外部维护的选中变更(taskAgent);不传时 Hook 内部维护 */ + selectedChangeFile?: SelectedChangeFile | null; + setSelectedChangeFile?: Dispatch>; +} + +export interface UseSourceControlReturn { + /** 已修改文件列表 */ + changeFiles: ChangeFileInfo[]; + /** 已暂存的文件 ID 集合 */ + stagedFileIds: Set; + /** 当前选中的变更文件(含区块) */ + selectedChangeFile: SelectedChangeFile | null; + /** 当前选中查看 diff 的文件数据 */ + selectedDiffFile: ChangeFileInfo | null; + /** 是否正在提交 */ + isCommitting: boolean; + /** 是否正在刷新 Git 列表 */ + isRefreshingGitList: boolean; + /** 当前 Git 分支名(来自 git status 的 current) */ + gitBranch: string; + setSelectedChangeFile: Dispatch>; + /** 同步修改文件列表(编辑器内容变更时调用) */ + syncChangeFiles: (fileId: string, content: string) => void; + /** 清除 diff 选中状态 */ + clearSelectedDiff: () => void; + /** 选中修改文件并展示 diff */ + handleDiffFileSelect: (fileId: string, section: ChangeListSection) => void; + /** 打开文件(非 diff 预览) */ + handleOpenChangeFile: (fileId: string) => void; + /** 放弃更改(支持单文件或文件夹下多文件,含 Git discard + UI 同步) */ + handleDiscardChange: (fileIds: string[]) => Promise; + /** 暂存更改(git add) */ + handleStageChanges: (fileIds: string[]) => Promise; + /** 取消暂存(git restore --staged) */ + handleUnstageChanges: (fileIds: string[]) => Promise; + /** Git discard 成功后的 UI 同步(不再调用 discard 接口,一般无需页面直接调用) */ + handleAfterDiscardChange: (fileId: string) => void; + /** 添加到 .gitignore */ + handleAddToGitignore: (fileId: string) => Promise; + /** 提交修改(保存并推送) */ + handleCommit: (message: string) => Promise; + /** 刷新 Git 变更列表 */ + refreshGitList: () => Promise; + /** 取消编辑并同步清理 Git 状态 */ + handleCancelEdit: (silent?: boolean) => void; + /** 清除指定文件的修改记录(单文件保存成功后调用) */ + clearChangeForFile: (fileId: string) => void; +} + +/** 从变更列表派生暂存区文件 ID */ +const deriveStagedFileIds = (files: ChangeFileInfo[]): Set => + new Set(files.filter((item) => item.stagedStatus).map((item) => item.fileId)); + +/** + * 源代码管理统一 Hook + * 封装修改文件追踪、暂存、提交推送、diff 预览及 .gitignore 等 Git 相关逻辑 + * @param params Hook 配置参数 + * @returns 源代码管理状态与操作方法 + */ +export const useSourceControl = ({ + workspace, + callbacks, + changeFiles: externalChangeFiles, + selectedChangeFile: externalSelectedChangeFile, + setSelectedChangeFile: externalSetSelectedChangeFile, +}: UseSourceControlParams): UseSourceControlReturn => { + const manageChangeFilesInternally = externalChangeFiles === undefined; + + /** 本地未提交的修改文件列表(pageApp 内部维护) */ + const [internalChangeFiles, setInternalChangeFiles] = useState< + ChangeFileInfo[] + >([]); + const changeFiles = manageChangeFilesInternally + ? internalChangeFiles + : externalChangeFiles; + + /** 已暂存(git add)的文件 ID 集合,由 status 接口派生 */ + const [stagedFileIds, setStagedFileIds] = useState>( + () => new Set(), + ); + /** 当前在右侧预览区展示的变更文件(含区块) */ + const [internalSelectedChangeFile, setInternalSelectedChangeFile] = + useState(null); + const selectedChangeFile = + externalSelectedChangeFile !== undefined + ? externalSelectedChangeFile + : internalSelectedChangeFile; + const setSelectedChangeFile = + externalSetSelectedChangeFile ?? setInternalSelectedChangeFile; + + /** 通过 apiGitFileContent 拉取的 diff 内容 */ + const [diffFileContent, setDiffFileContent] = useState<{ + fileId: string; + section: ChangeListSection; + originalFileContent: string; + fileContent: string; + } | null>(null); + /** Git 提交推送进行中 */ + const [isCommitting, setIsCommitting] = useState(false); + /** Git 列表刷新进行中 */ + const [isRefreshingGitList, setIsRefreshingGitList] = useState(false); + /** 当前 Git 分支名 */ + const [gitBranch, setGitBranch] = useState('main'); + + useEffect(() => { + if (!selectedChangeFile) { + setDiffFileContent(null); + } + }, [selectedChangeFile]); + + /** 根据选中项派生完整的 diff 文件数据(优先使用 apiGitDiff 返回内容) */ + const selectedDiffFile = useMemo(() => { + if (!selectedChangeFile) { + return null; + } + + const base = + changeFiles.find((item) => item.fileId === selectedChangeFile.fileId) ?? + null; + + const apiDiff = + diffFileContent && + diffFileContent.fileId === selectedChangeFile.fileId && + diffFileContent.section === selectedChangeFile.section + ? diffFileContent + : null; + + if (base && apiDiff) { + return { + ...base, + originalFileContent: apiDiff.originalFileContent, + fileContent: apiDiff.fileContent, + }; + } + + if (base) { + return base; + } + + if (apiDiff) { + return { + fileId: apiDiff.fileId, + fileContent: apiDiff.fileContent, + originalFileContent: apiDiff.originalFileContent, + }; + } + + return null; + }, [changeFiles, selectedChangeFile, diffFileContent]); + + /** + * 清除指定文件的修改与暂存记录 + * 同时取消该文件的 diff 预览选中状态 + * @param fileId 文件 ID + */ + const clearChangeForFile = useCallback( + (fileId: string) => { + if (!manageChangeFilesInternally) { + return; + } + setInternalChangeFiles((prev) => + prev.filter((item) => item.fileId !== fileId), + ); + setStagedFileIds((prev) => { + const next = new Set(prev); + next.delete(fileId); + return next; + }); + setSelectedChangeFile((current) => + current?.fileId === fileId ? null : current, + ); + }, + [manageChangeFilesInternally, setSelectedChangeFile], + ); + + /** 清除 diff 预览选中状态(切换文件树选中项时调用) */ + const clearSelectedDiff = useCallback(() => { + setSelectedChangeFile(null); + setDiffFileContent(null); + }, [setSelectedChangeFile]); + + /** + * 同步修改文件列表 + * 编辑器内容变更时调用,维护 changeFiles 与原始内容的对应关系 + * @param fileId 文件 ID + * @param content 当前编辑内容 + */ + const syncChangeFiles = useCallback( + (fileId: string, content: string) => { + if (!manageChangeFilesInternally) { + return; + } + + setInternalChangeFiles((prevChangeFiles) => { + const existingIndex = prevChangeFiles.findIndex( + (item) => item.fileId === fileId, + ); + let originalContent = ''; + + // 优先从已有变更记录取原始内容,避免文件树已被覆盖后丢失基准 + if (existingIndex !== -1) { + originalContent = prevChangeFiles[existingIndex].originalFileContent; + } else { + const fileContentState = callbacks.getFileContentState?.(); + if (fileContentState?.selectedFile === fileId) { + originalContent = fileContentState.originalFileContent; + } else { + const fileNode = callbacks.findFileNode?.(fileId); + originalContent = fileNode?.content ?? ''; + } + } + + if (existingIndex !== -1) { + // 内容还原为原始值时,从变更列表中移除 + if (content === originalContent) { + return prevChangeFiles.filter( + (_, index) => index !== existingIndex, + ); + } + const updated = [...prevChangeFiles]; + updated[existingIndex] = { + ...updated[existingIndex], + fileContent: content, + }; + return updated; + } + + // 首次检测到变更,追加新记录 + if (content !== originalContent) { + return [ + ...prevChangeFiles, + { + fileId, + fileContent: content, + originalFileContent: originalContent, + }, + ]; + } + return prevChangeFiles; + }); + }, + [manageChangeFilesInternally, callbacks], + ); + + /** + * 选中修改文件并在右侧展示 diff 预览 + * 调用 apiGitFileContent 拉取 HEAD 与 worktree/staged 文件内容 + */ + const handleDiffFileSelect = useCallback( + (fileId: string, section: ChangeListSection) => { + setSelectedChangeFile({ fileId, section }); + setDiffFileContent(null); + callbacks.onDiffFileSelect?.(fileId, section); + + if (!isGitWorkspaceReady(workspace)) { + return; + } + + void (async () => { + try { + const content = await fetchGitChangeFileContent( + buildGitWorkspaceParams(workspace), + fileId, + section, + ); + setDiffFileContent({ + fileId, + section, + originalFileContent: content.originalFileContent, + fileContent: content.fileContent, + }); + } catch (error) { + console.error('Git file content failed:', error); + } + })(); + }, + [callbacks, workspace, setSelectedChangeFile], + ); + + /** + * 打开更改文件(选中并进入代码编辑,非 diff 模式) + * @param fileId 文件 ID + */ + const handleOpenChangeFile = useCallback( + (fileId: string) => { + setSelectedChangeFile(null); + setDiffFileContent(null); + callbacks.openChangeFile(fileId); + }, + [callbacks, setSelectedChangeFile], + ); + + /** + * 同步单个文件放弃更改后的 UI 状态 + */ + const syncAfterDiscardChange = useCallback( + async (fileId: string) => { + if (manageChangeFilesInternally) { + const changeFile = internalChangeFiles.find( + (item) => item.fileId === fileId, + ); + if (!changeFile) { + clearChangeForFile(fileId); + await callbacks.onAfterDiscardChange?.(fileId); + return; + } + + callbacks.updateFileContent?.(fileId, changeFile.originalFileContent); + + if (callbacks.getFileContentState?.()?.selectedFile === fileId) { + callbacks.cancelEdit?.(true); + } + + clearChangeForFile(fileId); + } else { + callbacks.discardChangeFile?.(fileId); + } + + await callbacks.onAfterDiscardChange?.(fileId); + }, + [ + manageChangeFilesInternally, + internalChangeFiles, + callbacks, + clearChangeForFile, + ], + ); + + /** + * 刷新 Git 变更列表(仅 status 接口) + * 后端各数组非互斥,由 resolveGitFileStatuses 按优先级归一化后 merge 到暂存区/更改区 + */ + const refreshGitList = useCallback(async () => { + if (!isGitWorkspaceReady(workspace) || isRefreshingGitList) { + return; + } + + if (!manageChangeFilesInternally) { + await callbacks.onRefreshGitList?.(); + return; + } + + setIsRefreshingGitList(true); + try { + // 刷新文件树 (网页应用开发中需要,因为网页应用开发中文件内容是通过文件树接口一次性返回的,并不是点击文件时,通过文件url请求获取的) + void callbacks.loadFileTree?.(true, true); + + const statusResponse = await apiGitStatus( + buildGitWorkspaceParams(workspace), + ); + + if (statusResponse.code !== SUCCESS_CODE || !statusResponse.data) { + return; + } + + setGitBranch(statusResponse.data.current || 'main'); + + const statusFileIds = mergeGitStatusFileIds(statusResponse.data); + + setInternalChangeFiles((prev) => { + // 将 Git status 转为变更文件列表 + const nextChangeFiles = buildChangeFilesFromGitStatus( + statusResponse.data!, + statusFileIds, + prev, + (fileId) => callbacks.findFileNode?.(fileId) ?? null, + ); + // 派生暂存区文件 ID 集合 + setStagedFileIds(deriveStagedFileIds(nextChangeFiles)); + return nextChangeFiles; + }); + } catch (error) { + console.error('Refresh git list failed:', error); + } finally { + setIsRefreshingGitList(false); + } + }, [workspace, isRefreshingGitList, manageChangeFilesInternally, callbacks]); + + /** Git 变更操作成功后刷新列表(pageApp 内部 / taskAgent 走页面回调) */ + const refreshAfterGitMutation = useCallback(async () => { + if (manageChangeFilesInternally) { + await refreshGitList(); + } else { + await callbacks.onRefreshGitList?.(); + } + }, [manageChangeFilesInternally, refreshGitList, callbacks]); + + /** + * 放弃更改:先 Git discard,再批量同步 UI 并刷新列表 + * @param fileIds 文件 ID 列表(单文件或同一文件夹下多文件) + */ + const handleDiscardChange = useCallback( + async (fileIds: string[]) => { + if (!isGitWorkspaceReady(workspace) || !fileIds.length) { + return; + } + + const isSuccess = await runGitDiscard(workspace, fileIds); + if (!isSuccess) { + return; + } + + for (const fileId of fileIds) { + await syncAfterDiscardChange(fileId); + } + + await callbacks.onAfterDiscardChanges?.(fileIds); + + setSelectedChangeFile((current) => + current && fileIds.includes(current.fileId) ? null : current, + ); + + await refreshAfterGitMutation(); + }, + [ + workspace, + syncAfterDiscardChange, + setSelectedChangeFile, + refreshAfterGitMutation, + ], + ); + + /** 暂存更改(git add) */ + const handleStageChanges = useCallback( + async (fileIds: string[]) => { + if (!isGitWorkspaceReady(workspace) || !fileIds.length) { + return; + } + + const isSuccess = await runGitStage(workspace, fileIds); + if (isSuccess) { + await refreshAfterGitMutation(); + } + }, + [workspace, refreshAfterGitMutation], + ); + + /** 取消暂存(git restore --staged) */ + const handleUnstageChanges = useCallback( + async (fileIds: string[]) => { + if (!isGitWorkspaceReady(workspace) || !fileIds.length) { + return; + } + + const isSuccess = await runGitUnstage(workspace, fileIds); + if (isSuccess) { + await refreshAfterGitMutation(); + } + }, + [workspace, refreshAfterGitMutation], + ); + + /** + * 将文件路径添加到 .gitignore(pageApp 内置实现) + * 若 .gitignore 不存在则新建,已存在则追加一行 + * @param fileId 文件 ID(相对路径) + */ + const handlePageAppAddToGitignore = useCallback( + async (fileId: string) => { + if (workspace.workspaceType !== 'pageApp' || !workspace.projectId) { + return; + } + + const projectId = String(workspace.projectId); + const gitignoreId = '.gitignore'; + const existingNode = callbacks.findFileNode?.(gitignoreId); + const currentContent = existingNode?.content ?? ''; + const entry = fileId.startsWith('/') ? fileId.slice(1) : fileId; + + // 已存在相同规则则跳过 + if ( + currentContent + .split('\n') + .some( + (line: string) => line.trim() === entry || line.trim() === fileId, + ) + ) { + message.info( + dict('PC.Pages.ConversationAgentSourceControl.alreadyInGitignore'), + ); + return; + } + + const newContent = currentContent + ? `${currentContent.replace(/\n$/, '')}\n${entry}` + : entry; + + try { + const projectResponse = await getProjectContent(projectId); + if ( + !projectResponse || + projectResponse.code !== SUCCESS_CODE || + !projectResponse.data + ) { + message.error( + dict('PC.Pages.ConversationAgentSourceControl.gitignoreFailed'), + ); + return; + } + + let filesList: any[] = []; + const files = projectResponse.data.files; + if (Array.isArray(files) && files.length > 0 && files[0].name) { + filesList = [...files]; + } else if (Array.isArray(files)) { + filesList = treeToFlatList(files as FileNode[]); + } + + if (existingNode) { + filesList = filesList.map((file) => + file.name === gitignoreId + ? { ...file, contents: newContent } + : file, + ); + } else { + filesList.push({ + name: gitignoreId, + contents: `${newContent}\n`, + binary: false, + sizeExceeded: false, + }); + } + + await submitFilesUpdate(projectId, filesList); + message.success( + dict('PC.Pages.ConversationAgentSourceControl.gitignoreSuccess'), + ); + await callbacks.loadFileTree?.(true, true); + } catch (error) { + console.error('Add to gitignore failed:', error); + } + }, + [workspace, callbacks], + ); + + /** + * 将文件路径添加到 .gitignore + * taskAgent 走页面 callbacks;pageApp 走内置逻辑 + */ + const handleAddToGitignore = useCallback( + async (fileId: string) => { + if (!isGitWorkspaceReady(workspace)) { + return; + } + + if (callbacks.addFileToGitignore) { + try { + await callbacks.addFileToGitignore(fileId); + } catch (error) { + console.error('Add to gitignore failed:', error); + } + return; + } + + if (workspace.workspaceType === 'pageApp') { + await handlePageAppAddToGitignore(fileId); + } + }, + [workspace, callbacks, handlePageAppAddToGitignore], + ); + + /** + * 提交修改(保存并推送) + * 成功后清空本地 Git 状态并刷新文件树与项目详情 + * @param commitMessage 提交说明 + */ + const handleCommit = useCallback( + async (commitMessage: string) => { + if (!isGitWorkspaceReady(workspace)) { + message.error( + dict('PC.Pages.ConversationAgent.gitPush.noConversation'), + ); + return; + } + + setIsCommitting(true); + try { + // 执行 git commit + push + const { code } = await apiGitCommit({ + ...buildGitWorkspaceParams(workspace), + message: + commitMessage || + dict('PC.Pages.ConversationAgent.gitPush.defaultMessage'), + files: changeFiles.map((file) => file.fileId), + }); + + if (code !== SUCCESS_CODE) { + return; + } + + message.success(dict('PC.Pages.ConversationAgent.gitPush.success')); + setSelectedChangeFile(null); + setDiffFileContent(null); + + if (manageChangeFilesInternally) { + setInternalChangeFiles([]); + setStagedFileIds(new Set()); + callbacks.onRefreshProjectInfo?.(); + } + + await callbacks.onCommitSuccess?.(); + } catch (error) { + console.error('Git commit push failed:', error); + } finally { + setIsCommitting(false); + } + }, + [ + workspace, + changeFiles, + manageChangeFilesInternally, + callbacks, + setSelectedChangeFile, + ], + ); + + /** SourceControlPanel 已完成 Git discard 后,仅同步本地 UI */ + const handleAfterDiscardChange = useCallback( + (fileId: string) => { + syncAfterDiscardChange(fileId); + setSelectedChangeFile((current) => + current?.fileId === fileId ? null : current, + ); + }, + [syncAfterDiscardChange, setSelectedChangeFile], + ); + + /** + * 取消编辑并同步清理当前文件的 Git 变更记录 + * @param silent 是否静默取消(不弹出提示) + */ + const handleCancelEdit = useCallback( + (silent: boolean = false) => { + const selectedFileId = callbacks.getFileContentState?.()?.selectedFile; + callbacks.cancelEdit?.(silent); + if (selectedFileId) { + clearChangeForFile(selectedFileId); + } + }, + [callbacks, clearChangeForFile], + ); + + return { + changeFiles, + stagedFileIds, + selectedChangeFile, + selectedDiffFile, + isCommitting, + isRefreshingGitList: manageChangeFilesInternally + ? isRefreshingGitList + : false, + gitBranch: manageChangeFilesInternally ? gitBranch : 'main', + setSelectedChangeFile, + syncChangeFiles, + clearSelectedDiff, + handleDiffFileSelect, + handleOpenChangeFile, + handleDiscardChange, + handleStageChanges, + handleUnstageChanges, + handleAfterDiscardChange, + handleAddToGitignore, + handleCommit, + refreshGitList, + handleCancelEdit, + clearChangeForFile, + }; +}; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/index.less b/qiming/src/components/business-component/FileTreeGitSourcePanel/index.less new file mode 100644 index 00000000..25c6cb68 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/index.less @@ -0,0 +1,122 @@ +@import '@/styles/color.less'; +@import '@/styles/token.less'; + +@middle-panel-icon-btn-height: 28px; +@icon-badge-size: 14px; +@icon-badge-font-size: 10px; +@icon-badge-offset: 2px; + +.panelSidebar { + flex: 0 0 280px; + width: 100%; + height: 100%; + min-width: 260px; + border-right: @lineWidth solid @colorBorderSecondary; + padding: 0; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), + max-width 0.3s cubic-bezier(0.4, 0, 0.2, 1), + min-width 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease; + overflow-x: auto; + opacity: 1; + + &.collapsed { + flex: 0 0 0 !important; + min-width: 0 !important; + max-width: 0 !important; + width: 0 !important; + padding: 0 !important; + border-right: none !important; + overflow: hidden !important; + visibility: hidden; + pointer-events: none; + opacity: 0; + } +} + +.iconBar { + display: flex; + align-items: center; + gap: @marginXxs; + // height: @controlHeightLg; + height: 49px; + padding: 0 @paddingXs; + flex-shrink: 0; + border-bottom: @lineWidth solid @colorSplit; + box-sizing: border-box; +} + +.iconBarItem { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + + > :global(*) { + display: flex; + align-items: center; + width: 100%; + } +} + +.iconBtn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: @middle-panel-icon-btn-height; + padding: 0; + border: none; + border-radius: @borderRadius; + background: transparent; + color: @colorTextSecondary; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; + + &:hover { + color: @colorText; + background-color: @colorBgTextHover; + } + + &.active { + color: @colorText; + background-color: @colorBgTextActive; + } +} + +.iconBadge { + position: absolute; + top: @icon-badge-offset; + right: @icon-badge-offset; + min-width: @icon-badge-size; + height: @icon-badge-size; + padding: 0 @marginXxs; + border-radius: (@icon-badge-size / 2); + background-color: @colorPrimary; + color: @colorTextLightSolid; + font-size: @icon-badge-font-size; + line-height: @icon-badge-size; + text-align: center; +} + +.panelContent { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.fileTreeContainer { + flex: 1; + overflow-y: auto; + overflow-x: auto; + padding: 8px 0; + min-height: 0; +} + +.sourceControlContainer { + flex: 1; + min-height: 0; + overflow: hidden; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/index.tsx b/qiming/src/components/business-component/FileTreeGitSourcePanel/index.tsx new file mode 100644 index 00000000..d8968fee --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/index.tsx @@ -0,0 +1,31 @@ +export { default as FileTreePanel } from './FileTreePanel'; +export type { FileTreePanelProps } from './FileTreePanel'; +export { default as FileTreeToolbar } from './FileTreeToolbar'; +export type { FileTreeToolbarProps } from './FileTreeToolbar'; +export { useAppDevFileTree } from './hooks/useAppDevFileTree'; +export type { + AppDevFileTreeFileManagement, + UseAppDevFileTreeParams, +} from './hooks/useAppDevFileTree'; +export { useSourceControl } from './hooks/useSourceControl'; +export type { + GitWorkspaceConfig, + SourceControlCallbacks, + UseSourceControlParams, + UseSourceControlReturn, +} from './hooks/useSourceControl'; +/** @deprecated 使用 SourceControlCallbacks */ +export type { SourceControlCallbacks as ConversationAgentSourceControlAdapters } from './hooks/useSourceControl'; +export { default as FileTreeGitSourcePanel, default } from './PanelCore'; +export * from './services/git-version-management'; +export { default as SourceControlPanel } from './SourceControlPanel'; +export type { + FileTreeContainerProps, + FileTreeGitSourcePanelProps, + SourceControlProps, +} from './types/file-tree-git-source'; +export type * from './types/git-version-management'; +export type { + ChangeListSection, + SelectedChangeFile, +} from './utils/changeFileStatus'; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/services/git-version-management.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/services/git-version-management.ts new file mode 100644 index 00000000..1956412a --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/services/git-version-management.ts @@ -0,0 +1,310 @@ +import type { RequestResponse } from '@/types/interfaces/request'; +import { request } from 'umi'; +import type { + GitAddParams, + GitBranchCreateParams, + GitBranchDeleteParams, + GitBranchesParams, + GitBranchSwitchParams, + GitCheckoutParams, + GitCommitParams, + GitDiffParams, + GitDiffResponseData, + GitDiscardParams, + GitFileContentParams, + GitFileContentResponseData, + GitInitParams, + GitLogParams, + GitLogResponseData, + GitResetParams, + GitRevertParams, + GitStashListParams, + GitStashListResponse, + GitStashParams, + GitStashPopParams, + GitStatusParams, + GitStatusResponse, + GitTagCreateParams, + GitTagDeleteParams, + GitTagsParams, + GitUnstageParams, +} from '../types/git-version-management'; + +/** + * Git unstage - 取消暂存 + * 将文件从暂存区移回工作区(变为 unstaged),不丢弃文件内容。对应 git restore --staged ,files 为空时取消全部暂存。 + */ +export function apiGitUnstage( + data: GitUnstageParams, +): Promise> { + return request('/api/git/unstage', { + method: 'POST', + data, + }); +} + +// Git tags - 查看标签列表 +export function apiGitTags( + data: GitTagsParams, +): Promise> { + return request('/api/git/tags', { + method: 'POST', + data, + }); +} + +// Git tag delete - 删除标签 +export function apiGitTagDelete( + data: GitTagDeleteParams, +): Promise> { + return request('/api/git/tag-delete', { + method: 'POST', + data, + }); +} + +// Git tag create - 创建标签 +export function apiGitTagCreate( + data: GitTagCreateParams, +): Promise> { + return request('/api/git/tag-create', { + method: 'POST', + data, + }); +} + +/** + * Git status - 查看工作区状态 + * 返回当前 staged / modified / untracked 文件列表,对应 git status + */ +export function apiGitStatus( + data: GitStatusParams, +): Promise> { + return request('/api/git/status', { + method: 'POST', + data, + }); +} + +/** + * Git stash - 暂存当前更改 + * 将工作区和暂存区的修改临时保存到 stash 栈(git stash push),工作区恢复到干净状态。适合切换分支前临时保存未完成的改动。 + */ +export function apiGitStash( + data: GitStashParams, +): Promise> { + return request('/api/git/stash', { + method: 'POST', + data, + }); +} + +/** + * Git stash pop - 恢复暂存的更改 + * 从 stash 栈中恢复暂存记录到工作区。指定 files 时只恢复指定文件(stash 条目保留);不指定 files 时恢复全部并从栈中移除。index 为空时恢复最新一条。 + */ +export function apiGitStashPop( + data: GitStashPopParams, +): Promise> { + return request('/api/git/stash-pop', { + method: 'POST', + data, + }); +} + +/** + * Git stash list - 查看暂存列表 + * 返回 stash 栈中所有暂存记录,每条包含 index、hash、message 和日期 + */ +export function apiGitStashList( + data: GitStashListParams, +): Promise> { + return request('/api/git/stash-list', { + method: 'POST', + data, + }); +} + +/** + * Git revert - 撤销指定 commit + * 针对某个 commit 创建一个反向新提交(git revert),不修改历史记录。适合撤销已推送的提交。 + */ +export function apiGitRevert( + data: GitRevertParams, +): Promise> { + return request('/api/git/revert', { + method: 'POST', + data, + }); +} + +/** + * Git reset - 重置到指定版本 + * 移动 HEAD 到 target 版本。 + * soft: 后续 commit 的改动保留在暂存区;mixed(默认): 改动变为 unstaged;hard: 暂存区和工作区全部恢复到 target,后续改动丢失。 + * 返回 previousHead 可用于再次 reset 回原版本。 + */ +export function apiGitReset( + data: GitResetParams, +): Promise> { + return request('/api/git/reset', { + method: 'POST', + data, + }); +} + +/** + * Git log - 查看提交历史 + * 分页返回 commit 列表,每条包含 hash、message、author、date。page 从 1 开始,默认 pageSize=50。 + */ +export function apiGitLogList( + data: GitLogParams, +): Promise> { + return request('/api/git/log', { + method: 'POST', + data, + }); +} + +/** + * Git init - 初始化 Git 仓库 + * 在项目目录中执行 git init,并生成 .gitignore。通常无需手动调用,首次 Git 操作时会自动初始化。 + */ +export function apiGitInit( + data: GitInitParams, +): Promise> { + return request('/api/git/init', { + method: 'POST', + data, + }); +} + +// +/** + * Git discard - 丢弃文件更改 + * 丢弃工作区中未提交的修改,将文件恢复到最近一次 commit 的状态,不可恢复。对应 git restore ,files 为空时丢弃全部修改。 + */ +export function apiGitDiscard( + data: GitDiscardParams, +): Promise> { + return request('/api/git/discard', { + method: 'POST', + data, + }); +} + +/** + * Git diff - 查看文件差异 + * 对比两个版本间的文件内容差异。from / to 为 commit hash,paths 指定文件范围,均可为空。 + */ +export function apiGitDiff( + data: GitDiffParams, +): Promise> { + return request('/api/git/diff', { + method: 'POST', + data, + }); +} + +/** + * Git file content - 获取指定版本的文件内容 + * 对比两个版本间的文件内容差异。from / to 为 commit hash,paths 指定文件范围,均可为空。 + */ +export function apiGitFileContent( + data: GitFileContentParams, +): Promise> { + return request('/api/git/file-content', { + method: 'POST', + data, + }); +} + +/** + * Git commit - 提交更改 + * 将暂存区的文件提交为新版本。files 为空时提交全部暂存文件;authorName / authorEmail 由后端自动从登录信息获取,无需前端传递。 + */ +export function apiGitCommit( + data: GitCommitParams, +): Promise> { + return request('/api/git/commit', { + method: 'POST', + data, + }); +} + +/** + * Git checkout - 检出目标版本文件 + * 将 target 版本的文件内容恢复到工作区和暂存区,HEAD 不移动(commit 历史不变)。暂存区中的变更是后续 commit 改动的反向,可直接 commit 生成一个回滚提交。 + */ +export function apiGitCheckout( + data: GitCheckoutParams, +): Promise> { + return request('/api/git/checkout', { + method: 'POST', + data, + }); +} + +/** + * Git branches - 查看分支列表 + * 返回所有本地分支名称,标注当前所在分支 + */ +export function apiGitBranches( + data: GitBranchesParams, +): Promise> { + return request('/api/git/branches', { + method: 'POST', + data, + }); +} + +// +/** + * Git branch switch - 切换分支 + * 切换到指定分支,工作区必须干净(无未提交修改)才能切换 + */ +export function apiGitBranchSwitch( + data: GitBranchSwitchParams, +): Promise> { + return request('/api/git/branch-switch', { + method: 'POST', + data, + }); +} + +/** + * Git branch delete - 删除分支 + * 删除指定分支,force=true 强制删除未合并的分支 + */ +export function apiGitBranchDelete( + data: GitBranchDeleteParams, +): Promise> { + return request('/api/git/branch-delete', { + method: 'POST', + data, + }); +} + +/** + * Git branch create - 创建分支 + * 基于 startPoint(commit hash / tag / 分支名)创建新分支,startPoint 为空时基于当前 HEAD + */ +export function apiGitBranchCreate( + data: GitBranchCreateParams, +): Promise> { + return request('/api/git/branch-create', { + method: 'POST', + data, + }); +} + +/** + * Git branch add - 暂存文件 + * 将文件加入暂存区(git add)。files 为空时暂存全部变更。文件写入/创建后可调用此接口让 Git 跟踪变更。 + */ +export function apiGitAdd(data: GitAddParams): Promise> { + return request('/api/git/add', { + method: 'POST', + data, + }); +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/types/file-tree-git-source.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/types/file-tree-git-source.ts new file mode 100644 index 00000000..9e7e8eaf --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/types/file-tree-git-source.ts @@ -0,0 +1,157 @@ +import type { ChangeFileInfo } from '@/components/business-component/FileTreePreviewPanel/types/file-tree'; +import type { DefaultSelectedEnum } from '@/types/enums/agent'; +import type { FileNode } from '@/types/interfaces/appDev'; +import type { ReactNode } from 'react'; +import type { + ChangeListSection, + SelectedChangeFile, +} from '../utils/changeFileStatus'; + +/** 源代码管理绑定(Git API 在 useSourceControl 内统一调用,面板仅负责 UI) */ +export interface SourceControlProps { + /** 已修改文件列表(源代码管理) */ + changeFiles: ChangeFileInfo[]; + /** 当前选中的变更文件(含区块) */ + selectedChangeFile?: SelectedChangeFile | null; + /** 是否正在提交 */ + isCommitting?: boolean; + /** 是否正在刷新 Git 列表 */ + isRefreshingGitList?: boolean; + /** 是否禁用刷新 Git 变更列表 */ + refreshDisabled?: boolean; + /** 刷新 Git 变更列表 */ + onRefreshGitList?: () => void | Promise; + /** 提交修改(保存并推送) */ + onCommit?: (message: string) => Promise; + /** 选中修改文件,在右侧预览区展示 diff */ + onDiffFileSelect?: (fileId: string, section: ChangeListSection) => void; + /** 打开文件(选中并预览,非 diff) */ + onOpenChangeFile?: (fileId: string) => void; + /** 放弃更改(Git discard + UI 同步 + 刷新列表) */ + onDiscardChanges?: (fileIds: string[]) => void | Promise; + /** 暂存更改(git add) */ + onStageChanges?: (fileIds: string[]) => void | Promise; + /** 取消暂存(git restore --staged) */ + onUnstageChanges?: (fileIds: string[]) => void | Promise; + /** 添加到 .gitignore */ + onAddToGitignore?: (fileId: string) => void; +} + +/** + * FileTreeGitSourcePanel 公共组件属性 + * 顶部切换文件树 / 源代码管理,统一样式与 Git 面板交互 + */ +export interface FileTreeGitSourcePanelProps { + className?: string; + /** 是否折叠 */ + isCollapsed?: boolean; + /** 是否显示源代码管理 Tab(默认:存在 onCommit 时显示) */ + showSourceControl?: boolean; + /** 智能体是否开启版本管理;为 No 或未开启时隐藏源代码管理 */ + enableVersionControl?: DefaultSelectedEnum; + /** 文件树状态与交互处理器(内部渲染 FileTreeContainerProps */ + tree: FileTreeContainerProps; + /** 文件树容器类名 */ + treeClassName?: string; + /** 文件树头部(搜索区)类名 */ + treeHeaderClassName?: string; + /** 文件列表为空(且非加载中)时的自定义空态内容 */ + treeEmptyState?: ReactNode; + /** 导入项目(空白区域右键菜单,覆盖 tree 内同名配置) */ + onImportProject?: () => void; + /** 导入项目菜单项文案 */ + importProjectLabel?: string; + /** 是否正在导入项目 */ + isImportingProject?: boolean; + /** 源代码管理配置 */ + sourceControl: SourceControlProps; +} + +/** 通用智能体文件树 UI 所需的状态与处理器 */ +export interface FileTreeContainerProps { + /** 是否只读(只读时禁用新建/上传等写操作) */ + readOnly?: boolean; + /** 文件树节点列表(树形结构) */ + files: FileNode[]; + /** 当前选中的文件 ID */ + selectedFileId: string; + /** 当前选中的文件夹 ID(仅用于树高亮与工具栏新建父级) */ + selectedFolderId?: string; + /** 正在内联重命名的节点(null 表示无重命名进行中) */ + renamingNode: FileNode | null; + /** 右键菜单当前目标节点 */ + contextMenuTarget: FileNode | null; + /** 右键菜单显示坐标(相对视口) */ + contextMenuPosition: { x: number; y: number }; + /** 右键菜单是否可见 */ + contextMenuVisible: boolean; + /** 文件树面板是否可见 */ + isFileTreeVisible: boolean; + /** 文件树容器 DOM 引用(滚动、定位等) */ + fileTreeContainerRef: React.RefObject; + /** 文件树数据是否加载中 */ + fileTreeDataLoading?: boolean; + /** TaskAgent 自动选中的文件 ID(外部驱动选中) */ + taskAgentSelectedFileId?: string; + /** 是否允许删除技能文件(如 SKILL.md) */ + isCanDeleteSkillFile: boolean; + /** 是否正在刷新文件树列表 */ + isRefreshingFileTree: boolean; + /** 是否正在上传文件 */ + isUploadingFiles: boolean; + /** 是否正在下载文件 */ + isDownloadingFile: boolean; + /** 是否隐藏文件树(仅保留源代码管理等场景) */ + hideFileTree: boolean; + /** 是否显示文件树刷新按钮 */ + showRefreshButton: boolean; + /** 选中文件并在右侧预览区打开;selectFolder 为 true 时仅选中文件夹 */ + handleFileSelect: ( + fileId: string, + options?: { selectFolder?: boolean }, + ) => Promise; + /** 清空文件树选中态(文件 + 文件夹) */ + clearSelection?: () => void; + /** 打开右键菜单 */ + handleContextMenu: (e: React.MouseEvent, node: FileNode | null) => void; + /** 关闭右键菜单 */ + closeContextMenu: () => void; + /** 确认重命名文件或文件夹 */ + handleRenameFile: (fileNode: FileNode, newName: string) => Promise; + /** 取消重命名;新建节点时可选择移除临时节点 */ + handleCancelRename: (options?: { + removeIfNew?: boolean; + node?: FileNode | null; + }) => void; + /** 从服务端重新拉取并刷新文件树 */ + handleRefreshFileList: () => Promise; + /** 删除文件或文件夹 */ + handleDelete: (node: FileNode) => Promise; + /** 从右键菜单进入重命名状态 */ + handleRenameFromMenu: (node: FileNode) => void; + /** 从右键菜单上传文件到指定目录 */ + handleUploadMultipleFiles: (node: FileNode | null) => Promise; + /** 从右键菜单/工具栏上传文件夹到指定目录(保留子目录结构) */ + handleUploadFolder?: (node: FileNode | null) => Promise; + /** 在指定父节点下新建文件 */ + handleCreateFile: (parentNode: FileNode | null) => void; + /** 在指定父节点下新建文件夹 */ + handleCreateFolder: (parentNode: FileNode | null) => void; + /** 通过 URL 下载文件(可选,AppDev 场景无此能力) */ + handleDownloadFileByUrl?: ( + node: FileNode, + exportAsPdf?: boolean, + ) => Promise; + /** 导入项目(空白区域右键菜单,可选) */ + handleImportProject?: () => void; + /** 导入项目菜单项文案(不传时使用默认的「导入技能」文案) */ + importProjectLabel?: string; + /** 导出项目 */ + handleExportProject?: () => Promise; + /** 是否正在导出项目 */ + isExportingProject?: boolean; + /** 是否正在导入项目 */ + isImportingProject?: boolean; + /** 工具栏是否禁用(如对比模式、聊天加载中) */ + toolbarDisabled?: boolean; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/types/git-version-management.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/types/git-version-management.ts new file mode 100644 index 00000000..134508a3 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/types/git-version-management.ts @@ -0,0 +1,308 @@ +/** Git 工作空间类型 */ +export type GitWorkspaceType = 'taskAgent' | 'pageApp'; + +/** Git 取消暂存请求参数 */ +export interface GitUnstageParams { + /** 工作空间类型: taskAgent / pageApp */ + workspaceType?: GitWorkspaceType; + /** 项目 ID(pageApp 模式) */ + projectId?: number; + /** 取消暂存的文件列表 */ + files?: string[]; + /** 会话 ID */ + cid?: number; +} + +/** Git tags 请求参数 */ +export interface GitTagsParams { + /** 工作空间类型: taskAgent / pageApp */ + workspaceType?: GitWorkspaceType; + /** 项目 ID(pageApp 模式) */ + projectId?: number; + /** 会话 ID */ + cid?: number; +} + +/** Git tag delete 请求参数 */ +export interface GitTagDeleteParams extends GitTagsParams { + /** 标签名称 */ + tagName?: string; + /*标签信息(创建时使用) */ + message?: string; +} + +/** Git tag create 请求参数 */ +export type GitTagCreateParams = GitTagDeleteParams; + +/** Git status 请求参数 */ +export type GitStatusParams = GitTagsParams; + +/** + * Git status 响应 data 字段 + * 对应接口 `/api/git/status` 返回体中的 `data`,外层为 `RequestResponse` + */ +export interface GitStatusResponse { + /** 业务是否成功 */ + success: boolean; + /** 日志 ID */ + logId: string; + /** 当前分支名(如 main、feat-618) */ + current: string; + /** 已暂存的文件路径(已 git add,将包含在下次 commit 中) */ + staged: string[]; + /** 工作区已修改但未暂存的文件路径(相对 HEAD 有变动,且不在暂存区) */ + modified: string[]; + /** 新创建且已暂存的文件路径(git 之前未跟踪的文件被 git add 后出现在这里) */ + created: string[]; + /** 已删除的文件路径(暂存区或工作区中文件被删除) */ + deleted: string[]; + /** 未跟踪的新文件路径(从未 git add) */ + untracked?: string[]; + /** 已重命名的文件路径(一般为新路径) */ + renamed?: string[]; + /** 合并冲突的文件路径 */ + conflicted: string[]; + /** 本地领先远程的 commit 数 */ + ahead: number; + /** 本地落后远程的 commit 数 */ + behind: number; + /** 当前分支跟踪的远程分支(如 origin/main),未跟踪则为 null */ + tracking?: string | null; +} + +/** Git stash 请求参数 */ +export interface GitStashParams extends GitTagsParams { + /*stash 信息(push 时使用) */ + message?: string; + + // 指定暂存的文件列表(push 时使用,为空时暂存全部变更) + files?: string[]; + + /*stash 索引(pop 时使用) */ + index?: number; +} + +/** Git stash pop 请求参数 */ +export type GitStashPopParams = GitStashParams; + +/** Git stash list 请求参数 */ +export type GitStashListParams = GitTagsParams; + +/** 单条 Git stash 记录 */ +export interface GitStashItem { + /** stash 索引(0 为最新) */ + index: number; + /** stash commit hash */ + hash: string; + /** stash 说明 */ + message: string; + /** 创建时间 */ + date: string; + /** 该条 stash 包含的文件路径 */ + files: string[]; +} + +/** + * Git stash list 响应 data 字段 + * 对应接口 `/api/git/stash-list` 返回体中的 `data` + */ +export interface GitStashListResponse { + /** 日志 ID */ + logId: string; + /** stash 栈记录列表 */ + stashes: GitStashItem[]; + /** 业务是否成功 */ + success: boolean; + /** 总条数 */ + total: number; +} + +/** Git revert 请求参数 */ +export interface GitRevertParams extends GitTagsParams { + /*要撤销的 commit hash */ + target?: string; +} + +/** Git reset 请求参数 */ +export interface GitResetParams extends GitTagsParams { + /*重置目标(commit hash / tag / branch) */ + target?: string; + + /*重置模式: soft / mixed / hard(默认 mixed) */ + mode?: string; +} + +/** Git log 请求参数 */ +export interface GitLogParams extends GitTagsParams { + /*页码,从1开始 */ + page?: number; + + /*每页条数 */ + pageSize?: number; + + /*分支名称 */ + branch?: string; +} + +/** 单条 Git 提交记录(对应 `/api/git/log` commits 单项) */ +export interface GitCommitLogItem { + hash: string; + message: string; + author_name: string; + author_email: string; + date: string; +} + +/** + * Git log 响应 data 字段 + * 对应接口 `/api/git/log` 返回体中的 `data` + */ +export interface GitLogResponseData { + commits: GitCommitLogItem[]; + logId: string; + success: string; + total: number; +} + +/** Git init 请求参数 */ +export type GitInitParams = GitTagsParams; + +/** Git discard 请求参数 */ +export interface GitDiscardParams extends GitTagsParams { + // 取消暂存的文件列表 + files?: string[]; +} + +/** Git diff 请求参数 */ +export interface GitDiffParams extends GitTagsParams { + /*起始提交/引用 */ + from?: string; + + /*目标提交/引用 */ + to?: string; + + /*对比来源: worktree(工作区vs HEAD), staged(暂存区vs HEAD), commit(两个commit之间) */ + source?: 'worktree' | 'staged' | 'commit'; + + /*文件路径列表 */ + paths?: string[]; +} + +/** Git file content 请求参数 */ +export interface GitFileContentParams extends GitTagsParams { + /*git 引用(commit hash / 分支名 / HEAD / HEAD~1 / worktree / staged) */ + ref?: string; + + /*文件相对路径 */ + filePath?: string; +} + +/** + * Git file content 响应 data 字段 + * 对应接口 `/api/git/file-content` 返回体中的 `data` + */ +export interface GitFileContentResponseData { + success?: boolean; + logId?: string; + ref?: string; + filePath?: string; + content: string; +} + +/** 提交 diff 中单文件变更状态 */ +export type GitCommitDiffFileStatus = + | 'modified' + | 'added' + | 'deleted' + | 'renamed'; + +/** 提交 diff 中单文件条目(UI 层归一化结构) */ +export interface GitCommitDiffFileItem { + path: string; + status: GitCommitDiffFileStatus; + additions: number; + deletions: number; + oldContent: string; + newContent: string; + /** 重命名前的路径 */ + oldPath?: string; + /** 单文件原始 unified diff 文本 */ + unifiedDiff?: string; +} + +/** Git diff summary 中单文件统计 */ +export interface GitDiffSummaryFileItem { + /** 文件路径 */ + file: string; + /** 总变更行数 */ + changes: number; + /** 新增行数 */ + insertions: number; + /** 删除行数 */ + deletions: number; + /** 是否二进制文件 */ + binary: boolean; +} + +/** Git diff summary */ +export interface GitDiffSummary { + files: GitDiffSummaryFileItem[]; + insertions: number; + deletions: number; +} + +/** + * Git diff 响应 data 字段 + * 对应接口 `/api/git/diff` 返回体中的 `data` + */ +export interface GitDiffResponseData { + success: boolean; + logId: string; + /** 对比来源: worktree / staged / commit */ + source: 'worktree' | 'staged' | 'commit'; + /** 原始 unified diff 文本 */ + diff: string; + summary: GitDiffSummary; +} + +/** Git commit 请求参数 */ +export interface GitCommitParams extends GitTagsParams { + /*提交信息 */ + message?: string; + + /*提交文件列表 */ + files?: string[]; +} + +/** Git checkout 请求参数 */ +export interface GitCheckoutParams extends GitTagsParams { + /*检出目标(commit hash / tag / branch) */ + target?: string; +} + +/** Git branches 请求参数 */ +export type GitBranchesParams = GitTagsParams; + +/** Git branch switch 请求参数 */ +export interface GitBranchSwitchParams extends GitTagsParams { + /*分支名称 */ + branchName?: string; + + /*起始点(创建分支时使用) */ + startPoint?: string; + + /*是否强制删除(删除分支时使用) */ + force?: boolean; +} + +/** Git branch delete 请求参数 */ +export type GitBranchDeleteParams = GitBranchSwitchParams; + +/** Git branch create 请求参数 */ +export type GitBranchCreateParams = GitBranchSwitchParams; + +/** Git add 请求参数 */ +export interface GitAddParams extends GitTagsParams { + /** 添加的文件列表 */ + files?: string[]; +} diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/buildChangeFileTree.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/buildChangeFileTree.ts new file mode 100644 index 00000000..b806be5b --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/buildChangeFileTree.ts @@ -0,0 +1,92 @@ +import type { ChangeFileInfo } from '@/components/business-component/FileTreePreviewPanel/types/file-tree'; +import type { ChangeFileStatusMeta } from './changeFileStatus'; + +/** 变更列表项(含展示元数据) */ +export interface ChangeListItem extends ChangeFileInfo { + fileName: string; + parentPath: string; + statusMeta: ChangeFileStatusMeta; +} + +/** 变更文件树节点 */ +export interface ChangeTreeNode { + id: string; + name: string; + type: 'folder' | 'file'; + depth: number; + children?: ChangeTreeNode[]; + fileItem?: ChangeListItem; +} + +const sortTreeNodes = (nodes: ChangeTreeNode[]) => { + nodes.sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'folder' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + nodes.forEach((node) => { + if (node.children?.length) { + sortTreeNodes(node.children); + } + }); +}; + +/** + * 将扁平变更列表构建为目录树(用于源代码管理树形视图) + * @param items 变更文件列表 + */ +export const buildChangeFileTree = ( + items: ChangeListItem[], +): ChangeTreeNode[] => { + const root: ChangeTreeNode[] = []; + + items.forEach((item) => { + const segments = item.fileId.split('/').filter(Boolean); + let currentLevel = root; + let pathSoFar = ''; + + segments.forEach((segment, index) => { + const isFile = index === segments.length - 1; + pathSoFar = pathSoFar ? `${pathSoFar}/${segment}` : segment; + + let node = currentLevel.find( + (existing) => + existing.name === segment && + existing.type === (isFile ? 'file' : 'folder'), + ); + + if (!node) { + node = { + id: pathSoFar, + name: segment, + type: isFile ? 'file' : 'folder', + depth: index, + children: isFile ? undefined : [], + fileItem: isFile ? item : undefined, + }; + currentLevel.push(node); + } + + if (!isFile && node.children) { + currentLevel = node.children; + } + }); + }); + + sortTreeNodes(root); + return root; +}; + +/** + * 收集文件夹下(含子目录)的所有变更文件 + * @param items 当前区块的变更列表 + * @param folderId 文件夹路径 ID + */ +export const collectFilesUnderFolder = ( + items: ChangeListItem[], + folderId: string, +): ChangeListItem[] => { + const prefix = `${folderId}/`; + return items.filter((item) => item.fileId.startsWith(prefix)); +}; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/buildGitWorkspaceParams.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/buildGitWorkspaceParams.ts new file mode 100644 index 00000000..5dc9654d --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/buildGitWorkspaceParams.ts @@ -0,0 +1,35 @@ +import type { GitWorkspaceType } from '../types/git-version-management'; + +/** Git 工作空间配置 */ +export type GitWorkspaceConfig = + | { workspaceType: 'pageApp'; projectId: string | number | null } + | { workspaceType: 'taskAgent'; cid: string | number | null }; + +/** 构建 Git API 公共请求参数 */ +export const buildGitWorkspaceParams = ( + workspace: GitWorkspaceConfig, +): { + workspaceType: GitWorkspaceType; + projectId?: number; + cid?: number; +} => { + if (workspace.workspaceType === 'pageApp') { + return { + workspaceType: 'pageApp', + projectId: workspace.projectId ? Number(workspace.projectId) : undefined, + }; + } + + return { + workspaceType: 'taskAgent', + cid: workspace.cid ? Number(workspace.cid) : undefined, + }; +}; + +/** 工作空间 ID 是否可用 */ +export const isGitWorkspaceReady = (workspace: GitWorkspaceConfig): boolean => { + if (workspace.workspaceType === 'pageApp') { + return Boolean(workspace.projectId); + } + return Boolean(workspace.cid); +}; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/changeFileStatus.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/changeFileStatus.ts new file mode 100644 index 00000000..bd6ad7e4 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/changeFileStatus.ts @@ -0,0 +1,156 @@ +import type { + ChangeFileGitStatusKind, + ChangeFileInfo, +} from '@/components/business-component/FileTreePreviewPanel/types/file-tree'; + +/** 变更列表区块:暂存区 / 工作区 */ +export type ChangeListSection = 'staged' | 'unstaged'; + +/** 当前选中的变更文件(同一 fileId 可能同时存在于两个区块) */ +export interface SelectedChangeFile { + fileId: string; + section: ChangeListSection; +} + +/** 判断列表项是否处于选中态 */ +export const isChangeFileSelected = ( + fileId: string, + section: ChangeListSection, + selected?: SelectedChangeFile | null, +): boolean => selected?.fileId === fileId && selected?.section === section; + +/** Git 风格变更类型 */ +export type ChangeFileStatusKind = ChangeFileGitStatusKind; + +/** 列表项右侧状态角标元数据 */ +export interface ChangeFileStatusMeta { + kind: ChangeFileStatusKind; + /** 角标字母:U / M / A / D / C / R */ + label: string; + isStaged: boolean; +} + +/** 变更列表展示项(含文件名与角标,与 buildChangeFileTree.ChangeListItem 结构一致) */ +export type ChangeFileDisplayItem = ChangeFileInfo & { + fileName: string; + parentPath: string; + statusMeta: ChangeFileStatusMeta; +}; + +const STATUS_LABEL: Record = { + modified: 'M', + added: 'A', + deleted: 'D', + untracked: 'U', + conflict: 'C', + renamed: 'R', +}; + +/** 更改区列表排序:冲突置顶,其余按 Git 优先级 */ +const UNSTAGED_DISPLAY_ORDER: Record = { + conflict: 0, + deleted: 1, + added: 2, + modified: 3, + renamed: 4, + untracked: 5, +}; + +const sortChangeDisplayItems = ( + items: ChangeFileDisplayItem[], + section: ChangeListSection, +): ChangeFileDisplayItem[] => + [...items].sort((a, b) => { + if (section === 'unstaged') { + const orderA = UNSTAGED_DISPLAY_ORDER[a.statusMeta.kind] ?? 99; + const orderB = UNSTAGED_DISPLAY_ORDER[b.statusMeta.kind] ?? 99; + if (orderA !== orderB) { + return orderA - orderB; + } + } + return a.fileId.localeCompare(b.fileId); + }); + +/** + * 根据 Git 状态类型与是否暂存区,生成 SCM 角标元数据 + */ +export function resolveStatusMeta( + kind: ChangeFileStatusKind, + isStaged: boolean, +): ChangeFileStatusMeta { + return { + kind, + label: STATUS_LABEL[kind], + isStaged, + }; +} + +/** + * 解析暂存区列表项角标 + */ +export function resolveStagedStatusMeta( + item: ChangeFileInfo, +): ChangeFileStatusMeta | null { + if (!item.stagedStatus) { + return null; + } + return resolveStatusMeta(item.stagedStatus, true); +} + +/** + * 解析工作区(未暂存)列表项角标 + */ +export function resolveUnstagedStatusMeta( + item: ChangeFileInfo, +): ChangeFileStatusMeta | null { + if (!item.unstagedStatus) { + return null; + } + return resolveStatusMeta(item.unstagedStatus, false); +} + +/** 将 ChangeFileInfo 转为列表展示项(补全 fileName / parentPath) */ +export const toChangeListBaseItem = ( + item: ChangeFileInfo, +): Omit => { + const segments = item.fileId.split('/'); + const fileName = segments[segments.length - 1] || item.fileId; + return { + ...item, + fileName, + parentPath: item.fileId, + }; +}; + +/** + * 将变更文件 map 并 merge 到「暂存的更改」「更改」两个列表 + * 同一 fileId 可同时出现在两区(MM、新建已暂存后再改等),角标由 resolveGitFileStatuses 决定 + */ +export const splitChangeFilesForDisplay = ( + changeFiles: ChangeFileInfo[], +): { + stagedItems: ChangeFileDisplayItem[]; + unstagedItems: ChangeFileDisplayItem[]; +} => { + const stagedItems: ChangeFileDisplayItem[] = []; + const unstagedItems: ChangeFileDisplayItem[] = []; + + changeFiles.forEach((item) => { + const base = toChangeListBaseItem(item); + + const stagedMeta = resolveStagedStatusMeta(item); + if (stagedMeta) { + stagedItems.push({ ...base, statusMeta: stagedMeta }); + } + + const unstagedMeta = resolveUnstagedStatusMeta(item); + if (unstagedMeta) { + unstagedItems.push({ ...base, statusMeta: unstagedMeta }); + } + }); + + return { + stagedItems: sortChangeDisplayItems(stagedItems, 'staged'), + unstagedItems: sortChangeDisplayItems(unstagedItems, 'unstaged'), + }; +}; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/gitStatusUtils.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/gitStatusUtils.ts new file mode 100644 index 00000000..92bd7f1a --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/gitStatusUtils.ts @@ -0,0 +1,217 @@ +import type { + ChangeFileGitStatusKind, + ChangeFileInfo, +} from '@/components/business-component/FileTreePreviewPanel/types/file-tree'; +import type { FileNode } from '@/types/interfaces/appDev'; +import type { GitStatusResponse } from '../types/git-version-management'; + +/** Git status 各数组 membership 快照 */ +export interface GitFileStatusFlags { + inStaged: boolean; + inCreated: boolean; + inModified: boolean; + inDeleted: boolean; + inUntracked: boolean; + inConflicted: boolean; + inRenamed: boolean; +} + +/** 解析后的双区状态(同一 fileId 可同时出现在暂存区与更改区,如 MM / 新建已暂存后再改) */ +export interface ResolvedGitFileStatuses { + stagedStatus?: ChangeFileGitStatusKind; + unstagedStatus?: ChangeFileGitStatusKind; +} + +const includes = (list: string[] | undefined, fileId: string): boolean => + list?.includes(fileId) ?? false; + +/** 读取 fileId 在各 Git status 数组中的归属 */ +export const getGitFileStatusFlags = ( + fileId: string, + status: GitStatusResponse, +): GitFileStatusFlags => ({ + inStaged: includes(status.staged, fileId), + inCreated: includes(status.created, fileId), + inModified: includes(status.modified, fileId), + inDeleted: includes(status.deleted, fileId), + inUntracked: includes(status.untracked, fileId), + inConflicted: includes(status.conflicted, fileId), + inRenamed: includes(status.renamed, fileId), +}); + +/** + * 将 Git status 各数组 map 为「暂存的更改 / 更改」双区状态 + * + * 后端各数组非互斥,表示文件经历过的变更类型;前端按优先级归一化后 merge 到两区展示。 + * 优先级(参考 Kimi / VS Code SCM):conflict > deleted > created > modified > staged > untracked + * + * | 用户操作场景 | 典型数组 | 暂存区 | 更改区 | + * |-------------|---------|--------|--------| + * | 新建未暂存 | created | - | A | + * | 新建后编辑未暂存 | created+modified | - | A(created 优先于 modified) | + * | git add 新建 | created+modified+staged | A | - | + * | 暂存后再改 | created+modified+staged | A | M | + * | 修改未暂存 | modified | - | M | + * | MM | staged+modified | M | M | + * | 删除已暂存 | deleted+staged | D | - | + * | 删除未暂存 | deleted(+modified) | - | D | + * | 全部累积后删除 | created+modified+deleted+staged | D | - | + * | 未跟踪 | untracked | - | U | + * | 冲突 | conflicted | - | C | + */ +export const resolveGitFileStatuses = ( + fileId: string, + status: GitStatusResponse, +): ResolvedGitFileStatuses => { + const { + inStaged, + inCreated, + inModified, + inDeleted, + inUntracked, + inConflicted, + inRenamed, + } = getGitFileStatusFlags(fileId, status); + + let stagedStatus: ChangeFileGitStatusKind | undefined; + let unstagedStatus: ChangeFileGitStatusKind | undefined; + + // 1. 冲突 — 仅更改区,置顶展示 + if (inConflicted) { + return { stagedStatus, unstagedStatus: 'conflict' }; + } + + // 2. 删除 — 最高优先级;已暂存删除只在暂存区 + if (inDeleted) { + if (inStaged) { + stagedStatus = 'deleted'; + } else { + unstagedStatus = 'deleted'; + } + return { stagedStatus, unstagedStatus }; + } + + // 3. 新建 + if (inCreated) { + if (inStaged) { + stagedStatus = 'added'; + // 新建已暂存后又修改:暂存区 A + 更改区 M + if (inModified) { + unstagedStatus = 'modified'; + } + } else { + // 新建未暂存(含 created+modified):更改区统一展示 A + unstagedStatus = 'added'; + } + return { stagedStatus, unstagedStatus }; + } + + // 4. 修改 + if (inModified) { + if (inStaged) { + stagedStatus = 'modified'; + unstagedStatus = 'modified'; + } else { + unstagedStatus = 'modified'; + } + return { stagedStatus, unstagedStatus }; + } + + // 5. 纯 staged(如重命名已暂存) + if (inStaged) { + stagedStatus = inRenamed ? 'renamed' : 'modified'; + return { stagedStatus, unstagedStatus }; + } + + // 6. 未暂存重命名 + if (inRenamed) { + unstagedStatus = 'renamed'; + return { stagedStatus, unstagedStatus }; + } + + // 7. 未跟踪 + if (inUntracked) { + unstagedStatus = 'untracked'; + } + + return { stagedStatus, unstagedStatus }; +}; + +/** 合并 Git status 中的全部变更文件路径(去重) */ +export const mergeGitStatusFileIds = (status: GitStatusResponse): string[] => + Array.from( + new Set([ + ...(status.staged ?? []), + ...(status.created ?? []), + ...(status.modified ?? []), + ...(status.deleted ?? []), + ...(status.untracked ?? []), + ...(status.renamed ?? []), + ...(status.conflicted ?? []), + ]), + ); + +/** @deprecated 请使用 resolveGitFileStatuses */ +export const resolveStagedStatus = ( + fileId: string, + status: GitStatusResponse, +): ChangeFileGitStatusKind | undefined => + resolveGitFileStatuses(fileId, status).stagedStatus; + +/** @deprecated 请使用 resolveGitFileStatuses */ +export const resolveUnstagedStatus = ( + fileId: string, + status: GitStatusResponse, +): ChangeFileGitStatusKind | undefined => + resolveGitFileStatuses(fileId, status).unstagedStatus; + +/** + * 将 Git status 转为变更文件列表 + * 优先保留本地已有变更记录,避免丢失 diff 基准内容 + */ +export const buildChangeFilesFromGitStatus = ( + status: GitStatusResponse, + fileIds: string[], + prevChangeFiles: ChangeFileInfo[], + findFileNode: (fileId: string) => FileNode | null, +): ChangeFileInfo[] => { + const prevMap = new Map(prevChangeFiles.map((item) => [item.fileId, item])); + const deletedSet = new Set(status.deleted ?? []); + + return fileIds.map((fileId) => { + const existing = prevMap.get(fileId); + const { stagedStatus, unstagedStatus } = resolveGitFileStatuses( + fileId, + status, + ); + + if (existing) { + return { + ...existing, + stagedStatus, + unstagedStatus, + }; + } + + const fileNode = findFileNode(fileId); + const nodeContent = fileNode?.content ?? ''; + + if (deletedSet.has(fileId)) { + return { + fileId, + fileContent: '', + originalFileContent: nodeContent, + stagedStatus, + unstagedStatus, + }; + } + + return { + fileId, + fileContent: nodeContent, + originalFileContent: nodeContent, + stagedStatus, + unstagedStatus, + }; + }); +}; diff --git a/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/sourceControlGitActions.ts b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/sourceControlGitActions.ts new file mode 100644 index 00000000..22c30630 --- /dev/null +++ b/qiming/src/components/business-component/FileTreeGitSourcePanel/utils/sourceControlGitActions.ts @@ -0,0 +1,74 @@ +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { + apiGitAdd, + apiGitDiscard, + apiGitUnstage, +} from '../services/git-version-management'; +import { + buildGitWorkspaceParams, + type GitWorkspaceConfig, + isGitWorkspaceReady, +} from './buildGitWorkspaceParams'; + +/** 在源代码管理面板内执行 Git discard */ +export const runGitDiscard = async ( + workspace: GitWorkspaceConfig, + files: string[], +): Promise => { + if (!isGitWorkspaceReady(workspace) || !files.length) { + return false; + } + + try { + const { code } = await apiGitDiscard({ + ...buildGitWorkspaceParams(workspace), + files, + }); + return code === SUCCESS_CODE; + } catch (error) { + console.error('Git discard failed:', error); + return false; + } +}; + +/** 在源代码管理面板内执行 Git add(暂存) */ +export const runGitStage = async ( + workspace: GitWorkspaceConfig, + files: string[], +): Promise => { + if (!isGitWorkspaceReady(workspace) || !files.length) { + return false; + } + + try { + const { code } = await apiGitAdd({ + ...buildGitWorkspaceParams(workspace), + files, + }); + return code === SUCCESS_CODE; + } catch (error) { + console.error('Git stage failed:', error); + return false; + } +}; + +/** 在源代码管理面板内执行 Git unstage(取消暂存) */ +export const runGitUnstage = async ( + workspace: GitWorkspaceConfig, + files: string[], +): Promise => { + if (!isGitWorkspaceReady(workspace) || !files.length) { + return false; + } + + try { + const { code } = await apiGitUnstage({ + ...buildGitWorkspaceParams(workspace), + files, + }); + return code === SUCCESS_CODE; + } catch (error) { + console.error('Git unstage failed:', error); + return false; + } +}; diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/MoreActionsMenu/index.less b/qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/MoreActionsMenu/index.less new file mode 100644 index 00000000..d0427ee5 --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/MoreActionsMenu/index.less @@ -0,0 +1,9 @@ +@import '@/styles/token'; + +.more-button { + color: @colorTextTertiary; + + &:hover { + background: transparent !important; + } +} diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/MoreActionsMenu/index.tsx b/qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/MoreActionsMenu/index.tsx new file mode 100644 index 00000000..9f8e0eaa --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/MoreActionsMenu/index.tsx @@ -0,0 +1,152 @@ +import SvgIcon from '@/components/base/SvgIcon'; +import TooltipIcon from '@/components/custom/TooltipIcon'; +import { dict } from '@/services/i18nRuntime'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Button, Dropdown } from 'antd'; +import classNames from 'classnames'; +import { useMemo } from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +// 更多操作相关接口 +interface MoreActionsProps { + // 导入项目 + onImportProject?: () => void; + // 重启智能体电脑 + onRestartServer?: () => void; + // 重启智能体 + onRestartAgent?: () => void; + // 导出项目 + onExportProject?: () => void; + // 是否是云电脑 + isCloudComputer?: boolean; +} + +/** + * 更多操作菜单组件 + * 负责更多操作相关的所有交互逻辑和状态管理 + */ +const MoreActionsMenu: React.FC = ({ + onImportProject, + onRestartServer, + onRestartAgent, + onExportProject, + isCloudComputer, +}) => { + // 菜单项配置 + const menuItems = useMemo( + () => [ + // 只有当 onImportProject 存在时才显示导入项目选项 + ...(onImportProject + ? [ + { + key: 'import', + icon: ( + + ), + label: dict('PC.Components.MoreActionsMenu.importProject'), + onClick: onImportProject, + }, + { + type: 'divider' as const, + }, + ] + : []), + // 只有当 onRestartServer 存在且为云电脑时才显示重启服务器选项 + ...(onRestartServer + ? [ + { + key: 'restart', + icon: ( + + ), + label: ( +
+ + {isCloudComputer + ? dict('PC.Components.MoreActionsMenu.restartComputer') + : dict('PC.Components.MoreActionsMenu.restartClient')} + + } + /> +
+ ), + onClick: onRestartServer, + }, + ] + : []), + + // 只有当 onRestartAgent 存在时才显示重启智能体选项 + ...(onRestartAgent + ? [ + { + key: 'restart-agent', + icon: ( + + ), + label: ( +
+ + {dict('PC.Components.MoreActionsMenu.restartAgent')} + + } + /> +
+ ), + onClick: onRestartAgent, + }, + ] + : []), + + // 只有当 onExportProject 存在时才显示导出项目选项和分隔线 + ...(onExportProject + ? [ + { + type: 'divider' as const, + }, + { + key: 'export', + icon: ( + + ), + label: dict('PC.Components.MoreActionsMenu.exportResult'), + onClick: onExportProject, + }, + ] + : []), + ], + [onImportProject, onRestartServer, onExportProject], + ); + + // 如果没有菜单项,则不显示 + if (!menuItems?.length) { + return null; + } + + return ( + +
+ )} + + {viewMode === 'preview' ? ( +
+ {/* 根据文件树列表是否展示来控制显隐:文件树展开时隐藏,文件树隐藏时显示 */} + {!isFileTreeVisible && fileName && ( +
+
{fileName}
+ {formattedSize && ( + ({formattedSize}) + )} +
+ )} + + {/* 只有存在 fileProxyUrl 或 content 时,才显示预览和代码视图切换按钮 */} + {(targetNode?.fileProxyUrl || + (targetNode?.content !== undefined && + targetNode?.content !== null)) && + fileName && + (fileName?.includes('.htm') || isMarkdownFile(fileName)) && ( + + + + )} +
+ ) : ( + hasFileInfoContent && ( +
+
+ {vncConnectStatus && ( +
+ )} +
+ {agentSandboxName || + `${ + userInfo?.nickName || + userInfo?.userName || + dict('PC.Components.FilePathHeader.remote') + }${dict('PC.Components.FilePathHeader.agentComputerSuffix')}`} +
+
+
+ ) + )} + +
+
+ {targetNode && + fileName && + isShowExportPdfButton && + (isMarkdownFile(fileName) || + fileName.endsWith('.html') || + fileName.endsWith('.htm')) && + viewMode === 'preview' && ( + +
+ + {showRightActionButtons && ( +
+ {/* Git 版本记录按钮 */} + {showGitVersionButton && onToggleGitVersionPanel && ( + +
+ )} +
+ + setShareDesktopModalVisible(false)} + conversationId={conversationId || ''} + /> +
+ ); +}; + +export default FilePathHeader; diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/type.ts b/qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/type.ts new file mode 100644 index 00000000..aeb3e23f --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/FilePathHeader/type.ts @@ -0,0 +1,72 @@ +import { ConnectionStatus } from '@/components/business-component/VncPreview/type'; +import { FileNode } from '@/types/interfaces/appDev'; +import type React from 'react'; + +/** Chat 页文件预览 Header 组件 Props(不含保存/取消) */ +export interface FilePathHeaderProps { + /** 会话ID */ + conversationId: string; + className?: string; + /** 文件节点 */ + targetNode: FileNode | null; + /** 当前视图模式 */ + viewMode?: 'preview' | 'desktop'; + /** 用户选择的智能体电脑名称 */ + agentSandboxName?: string; + /** 重启服务器回调 */ + onRestartServer?: () => void; + /** 重启智能体回调 */ + onRestartAgent?: () => void; + /** 导入项目回调 */ + onImportProject?: () => void; + /** 导出项目回调 */ + onExportProject?: () => void; + /** 全屏回调 */ + onFullscreen?: () => void; + /** 是否处于全屏状态 */ + isFullscreen?: boolean; + /** 是否显示全屏图标 */ + showFullscreenIcon?: boolean; + /** 是否正在导出项目 */ + isExportingProjecting?: boolean; + /** 是否正在下载文件 */ + isDownloadingFile?: boolean; + /** 是否显示更多操作菜单 */ + showMoreActions?: boolean; + /** 文件类型 */ + viewFileType?: 'preview' | 'code'; + /** 针对html、md文件,切换预览和代码视图 */ + onViewFileTypeChange?: (type: 'preview' | 'code') => void; + /** 通过URL下载文件回调 */ + onDownloadFileByUrl?: (node: FileNode) => void; + /** 分享回调 */ + onShare?: () => void; + /** 是否显示分享按钮 */ + isShowShare?: boolean; + /** 是否显示下载按钮 */ + isShowDownloadButton?: boolean; + /** 是否显示导出 PDF 按钮 */ + isShowExportPdfButton?: boolean; + /** 导出为 PDF 回调(仅 Markdown 文件) */ + onExportPdf?: (node: FileNode) => void; + /** 是否正在导出 PDF */ + isExportingPdf?: boolean; + /** 关闭整个面板 */ + onClose?: () => void; + /** VNC 预览连接状态 */ + vncConnectStatus?: ConnectionStatus | null; + /** 文件树是否可见 */ + isFileTreeVisible?: boolean; + /** 文件树是否固定(用户点击后固定) */ + isFileTreePinned?: boolean; + /** 文件树展开/折叠回调 */ + onFileTreeToggle?: () => void; + /** 是否是云电脑 */ + isCloudComputer?: boolean; + /** 是否显示 Git 版本记录按钮 */ + showGitVersionButton?: boolean; + /** 切换 Git 版本记录面板 */ + onToggleGitVersionPanel?: () => void; + /** Git 版本记录按钮后的额外操作按钮 */ + afterGitVersionActions?: React.ReactNode; +} diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/FileTreeViewPanel.tsx b/qiming/src/components/business-component/FileTreePreviewPanel/FileTreeViewPanel.tsx new file mode 100644 index 00000000..3c53c32e --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/FileTreeViewPanel.tsx @@ -0,0 +1,328 @@ +import { isAgentVersionControlEnabled } from '@/constants/agent.constants'; +import debounce from 'lodash/debounce'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { + useSourceControl, + type SelectedChangeFile, +} from '../FileTreeGitSourcePanel'; +import { useFileTreePreviewView } from './hooks/useFileTreePreviewView'; +import FileTreePreviewPanel from './index'; +import type { FileTreeViewProps, FileTreeViewRef } from './types/file-tree'; + +/** + * FileTreeView 替代组件 + * 基于 FileTreePreviewPanel + useFileTreePreviewView,兼容 FileTreeViewProps / ref + * 文件修改通过 onSaveFileContent 防抖实时保存(与 Chat 页一致),Header 无保存/取消按钮 + */ +const FileTreeViewPanel = forwardRef( + (props, ref) => { + const { + className, + headerClassName, + viewMode = 'preview', + agentSandboxId, + agentSandboxName, + onRestartServer, + onRestartAgent, + onExportProject, + onImportProject, + isImportingProject, + onUploadFiles, + onRenameFile, + onCreateFileNode, + onDeleteFile, + idleDetection, + hideDesktop, + isFullscreenPreview, + onFullscreenPreview, + onSaveFiles, + readOnly = false, + gitSourceControl, + enableVersionControl, + enableGitStatus: enableGitStatusProp, + bottomContent, + ...fileViewProps + } = props; + + const onSaveFilesRef = useRef(onSaveFiles); + onSaveFilesRef.current = onSaveFiles; + const refreshGitListRef = useRef<(() => Promise) | null>(null); + + /** 文件树写操作成功后刷新 Git status */ + const refreshGitStatusAfterSuccess = useCallback( + async (success?: boolean) => { + if (success) { + await refreshGitListRef.current?.(); + } + }, + [], + ); + + const handleUploadFiles = useCallback( + async (files: File[], filePaths: string[]) => { + if (!onUploadFiles) { + return; + } + await onUploadFiles(files, filePaths); + await refreshGitListRef.current?.(); + }, + [onUploadFiles], + ); + + const handleRenameFile = useCallback( + async ( + ...args: Parameters> + ) => { + const result = await onRenameFile?.(...args); + await refreshGitStatusAfterSuccess(result); + return result ?? false; + }, + [onRenameFile, refreshGitStatusAfterSuccess], + ); + + const handleCreateFileNode = useCallback( + async ( + ...args: Parameters> + ) => { + const result = await onCreateFileNode?.(...args); + await refreshGitStatusAfterSuccess(result); + return result ?? false; + }, + [onCreateFileNode, refreshGitStatusAfterSuccess], + ); + + const handleDeleteFile = useCallback( + async ( + ...args: Parameters> + ) => { + const result = await onDeleteFile?.(...args); + await refreshGitStatusAfterSuccess(result); + return result ?? false; + }, + [onDeleteFile, refreshGitStatusAfterSuccess], + ); + + const handleSaveFiles = useCallback( + async ( + ...args: Parameters> + ) => { + const result = await onSaveFiles?.(...args); + await refreshGitStatusAfterSuccess(result); + return result ?? false; + }, + [onSaveFiles, refreshGitStatusAfterSuccess], + ); + + /** 编辑器内容变更:防抖实时保存单个文件(与 Chat 页 useChatFiles 一致) */ + const debouncedSaveFileContent = useMemo( + () => + debounce( + async ( + fileId: string, + content: string, + originalFileContent: string, + ): Promise => { + if (!onSaveFilesRef.current) { + return false; + } + const result = await onSaveFilesRef.current([ + { fileId, fileContent: content, originalFileContent }, + ]); + if (result) { + await refreshGitListRef.current?.(); + } + return result ?? false; + }, + 500, + ), + [], + ); + + useEffect( + () => () => { + debouncedSaveFileContent.cancel(); + }, + [debouncedSaveFileContent], + ); + + const fileView = useFileTreePreviewView({ + ...fileViewProps, + className, + headerClassName, + viewMode, + agentSandboxId, + agentSandboxName, + onRestartServer, + onRestartAgent, + onExportProject, + onImportProject, + isImportingProject, + onUploadFiles: handleUploadFiles, + onRenameFile: handleRenameFile, + onCreateFileNode: handleCreateFileNode, + onDeleteFile: handleDeleteFile, + hideDesktop, + idleDetection, + isFullscreenPreview, + onFullscreenPreview, + readOnly, + enableVersionControl, + enableGitStatus: + enableGitStatusProp ?? + (Boolean(gitSourceControl) && + (enableVersionControl === undefined || + isAgentVersionControlEnabled(enableVersionControl))), + onSaveFiles: handleSaveFiles, + onSaveFileContent: readOnly + ? undefined + : async (fileId, content, originalFileContent) => { + const result = await debouncedSaveFileContent( + fileId, + content, + originalFileContent, + ); + return result ?? false; + }, + }); + + refreshGitListRef.current = gitSourceControl + ? fileView.refreshGitList + : null; + + const [selectedChangeFile, setSelectedChangeFile] = + useState(null); + + const sourceControl = useSourceControl({ + workspace: gitSourceControl?.workspace ?? { + workspaceType: 'taskAgent', + cid: null, + }, + changeFiles: fileView.changeFiles, + selectedChangeFile, + setSelectedChangeFile, + callbacks: { + openChangeFile: (fileId: string) => { + setSelectedChangeFile(null); + void fileView.tree.handleFileSelect(fileId); + }, + addFileToGitignore: gitSourceControl?.callbacks?.addFileToGitignore, + onAfterDiscardChanges: async () => { + await fileView.tree.handleRefreshFileList(); + }, + onCommitSuccess: async () => { + await fileView.preview.saveFiles(); + await fileView.refreshGitList(); + await gitSourceControl?.callbacks?.onCommitSuccess?.(); + }, + onRefreshGitList: async () => { + await fileView.refreshGitList(); + await gitSourceControl?.callbacks?.onRefreshGitList?.(); + }, + }, + }); + + const showSourceControl = + Boolean(gitSourceControl) && + (enableVersionControl === undefined || + isAgentVersionControlEnabled(enableVersionControl)); + const [gitVersionPanelOpen, setGitVersionPanelOpen] = + useState(false); + + /** 切换 Git 版本记录面板;如果正在查看 diff,先退出 diff 视图 */ + const handleToggleGitVersionPanel = () => { + if (sourceControl.selectedDiffFile) { + sourceControl.clearSelectedDiff(); + setGitVersionPanelOpen(true); + return; + } + setGitVersionPanelOpen((prev) => !prev); + }; + + useImperativeHandle( + ref, + () => ({ + changeFiles: fileView.changeFiles, + selectedFileId: fileView.tree.selectedFileId, + }), + [fileView.changeFiles, fileView.tree.selectedFileId], + ); + + return ( + { + if (!options?.selectFolder) { + setGitVersionPanelOpen(false); + sourceControl.clearSelectedDiff(); + } + await fileView.tree.handleFileSelect(fileId, options); + }, + }} + preview={fileView.preview} + sourceControl={ + showSourceControl + ? { + changeFiles: fileView.changeFiles, + selectedChangeFile: sourceControl.selectedChangeFile, + isCommitting: sourceControl.isCommitting, + isRefreshingGitList: fileView.isRefreshingGitList, + onRefreshGitList: fileView.refreshGitList, + onDiffFileSelect: sourceControl.handleDiffFileSelect, + onOpenChangeFile: sourceControl.handleOpenChangeFile, + onDiscardChanges: sourceControl.handleDiscardChange, + onStageChanges: sourceControl.handleStageChanges, + onUnstageChanges: sourceControl.handleUnstageChanges, + onAddToGitignore: (fileId) => { + void sourceControl.handleAddToGitignore(fileId); + }, + onCommit: sourceControl.handleCommit, + } + : undefined + } + showSourceControl={showSourceControl} + enableVersionControl={enableVersionControl} + viewMode={viewMode} + hideDesktop={hideDesktop} + diffFile={showSourceControl ? sourceControl.selectedDiffFile : null} + gitVersionPanelOpen={gitVersionPanelOpen} + onToggleGitVersionPanel={handleToggleGitVersionPanel} + gitVersionControl={ + showSourceControl && gitSourceControl + ? { + workspace: gitSourceControl.workspace, + branch: fileView.gitBranch, + onRollbackSuccess: async () => { + await fileView.tree.handleRefreshFileList(); + await fileView.refreshGitList(); + setGitVersionPanelOpen(false); + }, + } + : undefined + } + treeHeaderClassName={headerClassName} + bottomContent={bottomContent} + previewPanelProps={{ + agentSandboxId, + agentSandboxName, + onRestartServer, + onRestartAgent, + onExportProject, + idleDetection, + hideDesktop, + }} + /> + ); + }, +); + +export default FileTreeViewPanel; diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/hooks/useFileTreePreviewPanel.tsx b/qiming/src/components/business-component/FileTreePreviewPanel/hooks/useFileTreePreviewPanel.tsx new file mode 100644 index 00000000..e97e49be --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/hooks/useFileTreePreviewPanel.tsx @@ -0,0 +1,196 @@ +import ChangeFileGitDiffView from '@/components/business-component/ChangeFileGitDiffView'; +import VncPreview from '@/components/business-component/VncPreview'; +import type { VncPreviewRef } from '@/components/business-component/VncPreview/type'; +import { dict } from '@/services/i18nRuntime'; +import { HideDesktopEnum } from '@/types/enums/agent'; +import { Spin } from 'antd'; +import classNames from 'classnames'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import FilePathHeader from '../FilePathHeader'; +import styles from '../index.less'; +import type { UseFileTreePreviewPanelParams } from '../types'; + +const cx = classNames.bind(styles); + +/** useFileTreePreviewPanel 返回值 */ +export interface UseFileTreePreviewPanelReturn { + isFullscreen: boolean; + header: React.ReactNode; + content: React.ReactNode; + restartOverlay: React.ReactNode; +} + +/** + * 文件树预览区逻辑 Hook + * 提供顶部 Header 与右侧预览内容,供 FileTreePreviewPanel 组合布局 + */ +export function useFileTreePreviewPanel( + params: UseFileTreePreviewPanelParams, +): UseFileTreePreviewPanelReturn { + const { + preview, + viewMode, + agentSandboxId, + agentSandboxName = '', + onRestartServer, + onRestartAgent, + onExportProject, + idleDetection, + hideDesktop = HideDesktopEnum.No, + diffFile, + showGitVersionButton = false, + onToggleGitVersionPanel, + afterGitVersionActions, + } = params; + + const { + targetId, + readOnly, + isFullscreen, + renderPreviewContent, + filePathHeaderProps, + handleFullscreen, + } = preview; + + const vncPreviewRef = useRef(null); + const [isRestarting, setIsRestarting] = useState(false); + const [isExportingProjecting, setIsExportingProjecting] = + useState(false); + + // 获取 VNC 连接状态 + const renderVncPreviewStatus = useCallback(() => { + return vncPreviewRef.current?.getStatus() ?? null; + }, []); + + // 导出项目 + const handleDownloadProject = useCallback(async () => { + if (!onExportProject) { + return; + } + setIsExportingProjecting(true); + try { + await onExportProject(); + } finally { + setTimeout(() => setIsExportingProjecting(false), 1000); + } + }, [onExportProject]); + + // 重启容器 + const handleRestartServer = useCallback(async () => { + if (!onRestartServer) { + return; + } + setIsRestarting(true); + try { + await onRestartServer(); + if (viewMode === 'desktop' && vncPreviewRef.current) { + vncPreviewRef.current.disconnect(); + setTimeout(() => vncPreviewRef.current?.connect(), 0); + } + } catch (error) { + console.error('Restart server failed:', error); + } finally { + setIsRestarting(false); + } + }, [onRestartServer, viewMode]); + + /** 切换至 desktop 时自动连接 VNC */ + useEffect(() => { + if (viewMode === 'desktop') { + vncPreviewRef.current?.connect(); + } + }, [viewMode]); + + // VNC 空闲检测 + const wrappedIdleDetection = idleDetection + ? { + ...idleDetection, + onIdleTimeout: () => { + idleDetection.onIdleTimeout?.(); + }, + } + : undefined; + + // 差异文件名称 + const diffFileName = useMemo(() => { + if (!diffFile) { + return ''; + } + const segments = diffFile.fileId.split('/'); + return segments[segments.length - 1] || diffFile.fileId; + }, [diffFile]); + + // 文件预览内容区 + const content = + viewMode === 'desktop' ? ( + + ) : diffFile ? ( + + ) : ( + renderPreviewContent() + ); + + const header = ( + void handleRestartServer() : undefined + } + onRestartAgent={onRestartAgent} + isCloudComputer={agentSandboxId === '-1'} + onExportProject={onExportProject ? handleDownloadProject : undefined} + isExportingProjecting={isExportingProjecting} + onFullscreen={handleFullscreen} + isFullscreen={isFullscreen} + onClose={filePathHeaderProps.onClose} + vncConnectStatus={renderVncPreviewStatus()} + showGitVersionButton={showGitVersionButton} + onToggleGitVersionPanel={onToggleGitVersionPanel} + afterGitVersionActions={afterGitVersionActions} + /> + ); + + // 重启容器遮罩 + const restartOverlay = + isRestarting && hideDesktop !== HideDesktopEnum.Yes ? ( +
+
+
+ + + {dict('PC.Components.FileTreeView.restarting')} + +
+
+ ) : null; + + return { + isFullscreen, + header, + content, + restartOverlay, + }; +} diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/hooks/useFileTreePreviewView.tsx b/qiming/src/components/business-component/FileTreePreviewPanel/hooks/useFileTreePreviewView.tsx new file mode 100644 index 00000000..fe36682f --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/hooks/useFileTreePreviewView.tsx @@ -0,0 +1,2158 @@ +import AppDevEmptyState from '@/components/business-component/AppDevEmptyState'; +import FilePreview, { + FileType, +} from '@/components/business-component/FilePreview'; +import { apiGitStatus } from '@/components/business-component/FileTreeGitSourcePanel/services/git-version-management'; +import { + buildChangeFilesFromGitStatus, + mergeGitStatusFileIds, +} from '@/components/business-component/FileTreeGitSourcePanel/utils/gitStatusUtils'; +import CodeViewer from '@/components/CodeViewer'; +import Loading from '@/components/custom/Loading'; +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { ImageViewer } from '@/pages/AppDev/components'; +import { dict } from '@/services/i18nRuntime'; +import { fetchContentFromUrl } from '@/services/skill'; +import { HideDesktopEnum } from '@/types/enums/agent'; +import { FileNode } from '@/types/interfaces/appDev'; +import { checkFileSizeExceedLimit } from '@/utils'; +import { + buildUploadFilePaths, + filterFilesForUpload, + filterFlatFileListForVersionControl, + findBestMatchingFileNode, + findFileNode, + isAudioFile, + isDocumentFile, + isIgnoredUploadRelativePath, + isImageFile, + isPreviewableFile, + isVideoFile, + processImageContent, + resolveFileTreeUploadRelativePath, + transformFlatListToTree, +} from '@/utils/appDevUtils'; +import { isMarkdownFile } from '@/utils/common'; +import { + downloadFileByUrl, + updateFileProxyUrl, + updateFileTreeContent, + updateFileTreeName, +} from '@/utils/fileTree'; +import { message } from 'antd'; +import cloneDeep from 'lodash/cloneDeep'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { + FileTreePreviewViewProps, + FileTreePreviewViewValue, +} from '../types'; +import { ChangeFileInfo } from '../types/file-tree'; + +/** 从文件树中移除指定 ID 的节点(含子树递归) */ +const removeNodeByIdFromTree = ( + nodes: FileNode[], + targetId: string, +): FileNode[] => + nodes + .filter((node) => node.id !== targetId) + .map((node) => + node.children?.length + ? { ...node, children: removeNodeByIdFromTree(node.children, targetId) } + : node, + ); + +/** + * 文件树 + 预览视图 Hook + * 从 FileTreeView 提取的状态、副作用与处理器,供 FileTreePreviewPanel 及上层页面使用 + */ +export function useFileTreePreviewView( + props: FileTreePreviewViewProps, +): FileTreePreviewViewValue { + const { + className, + headerClassName, + taskAgentSelectedFileId, + clearTaskAgentSelectedFileId, + taskAgentSelectTrigger, + originalFiles, + fileTreeDataLoading, + readOnly = false, + targetId, + viewMode = 'preview', + onUploadFiles, + onExportProject, + onRenameFile, + // 创建文件回调 + onCreateFileNode, + // 删除文件回调 + onDeleteFile, + // 保存文件回调 + onSaveFiles, + onImportProject, + isImportingProject = false, + // 单个文件内容变更后实时保存 + onSaveFileContent, + agentSandboxId, + showMoreActions = true, + isFullscreenPreview = false, + onFullscreenPreview, + onShare, + isShowShare = true, + isShowExportPdfButton = true, + isShowDownloadButton = true, + showFullscreenIcon = true, + onClose, + hideFileTree = false, + // 文件树是否固定(用户点击后固定) + isFileTreePinned = false, + // 文件树固定状态变化回调 + onFileTreePinnedChange, + // 文件树侧边栏是否可见(受控模式) + isFileTreeSidebarVisible, + // 文件树侧边栏可见性变化回调 + onFileTreeSidebarVisibleChange, + // 是否可以删除技能文件(SKILL.md文件), 默认不可以删除(为false时,则隐藏删除菜单项,为true时,则显示删除菜单项) + isCanDeleteSkillFile = false, + // 刷新文件树回调 + onRefreshFileTree, + showRefreshButton = true, + hideDesktop = HideDesktopEnum.No, + isProjectSkill = false, + initViewFileType, + // 静态资源文件基础路径 + staticFileBasePath, + /** 选中文件后打开右侧预览面板 */ + onFileSelectOpenPreview, + /** 文件重命名成功后回调 */ + onFileRenamed, + /** 文件/文件夹删除成功后回调 */ + onFileDeleted, + /** 刷新文件树后,当前选中文件已不存在时回调 */ + onSelectedFileMissing, + isDynamicTheme = false, + /** 是否启用 Git status,仅通用型 TaskAgent 智能体为 true */ + enableGitStatus = false, + /** 智能体是否开启版本管理;关闭时文件树不展示 .gitignore */ + enableVersionControl, + } = props; + const isCloudComputer = agentSandboxId === '-1'; + // 文件树数据 + const [files, setFiles] = useState([]); + // 当前选中的文件ID + const [selectedFileId, setSelectedFileId] = useState(''); + /** + * 当前选中文件ID的同步引用 + * - 用途:在异步请求返回时,判断用户是否已经切换到其他文件 + */ + const selectedFileIdRef = useRef(''); + /** + * 当前文件选择请求 token + * - 用途:只允许“最后一次点击文件”的请求回写内容,避免慢请求/跳转请求覆盖最新选中项 + */ + const latestFileSelectTokenRef = useRef(''); + /** 文件树中选中的文件夹 ID(仅用于树高亮与工具栏新建父级,不影响预览区) */ + const [selectedFolderId, setSelectedFolderId] = useState(''); + // 选中的文件节点 + const [selectedFileNode, setSelectedFileNode] = useState( + null, + ); + // 内联重命名状态 + const [renamingNode, setRenamingNode] = useState(null); + // 右键菜单目标节点 + const [contextMenuTarget, setContextMenuTarget] = useState( + null, + ); + // 右键菜单位置 + const [contextMenuPosition, setContextMenuPosition] = useState({ + x: 0, + y: 0, + }); + // 右键菜单可见 + const [contextMenuVisible, setContextMenuVisible] = useState(false); + // 全屏状态 + const [isFullscreen, setIsFullscreen] = useState(false); + // 修改的文件列表 + const [changeFiles, setChangeFiles] = useState([]); + const changeFilesRef = useRef(changeFiles); + changeFilesRef.current = changeFiles; + const onSaveFileContentRef = useRef(onSaveFileContent); + onSaveFileContentRef.current = onSaveFileContent; + + // Git 列表刷新进行中 + const [isRefreshingGitList, setIsRefreshingGitList] = + useState(false); + const [gitBranch, setGitBranch] = useState('main'); + const isRefreshingGitListRef = useRef(false); + + // 是否正在保存文件 + const [isSavingFiles, setIsSavingFiles] = useState(false); + // 是否正在下载文件 + const [isDownloadingFile, setIsDownloadingFile] = useState(false); + // 当前下载文件的ID(用于header组件中下载图标是否显示为loading图标的判断标识) + const [currentDownloadingFileId, setCurrentDownloadingFileId] = + useState(''); + // 是否正在导出 PDF + const [isExportingPdf, setIsExportingPdf] = useState(false); + // 是否正在刷新文件树 + const [isRefreshingFileTree, setIsRefreshingFileTree] = + useState(false); + const isRefreshingFileTreeRef = useRef(false); + const refreshFileTreeTimeoutRef = useRef(null); + + /** 统一结束文件树刷新状态,避免异常或外部请求不 resolve 导致按钮持续 loading */ + const stopRefreshingFileTree = useCallback(() => { + if (refreshFileTreeTimeoutRef.current) { + window.clearTimeout(refreshFileTreeTimeoutRef.current); + refreshFileTreeTimeoutRef.current = null; + } + isRefreshingFileTreeRef.current = false; + setIsRefreshingFileTree(false); + }, []); + + // 是否正在上传文件 + const [isUploadingFiles, setIsUploadingFiles] = useState(false); + // 是否正在导出项目 + const [isExportingProject, setIsExportingProject] = useState(false); + + // 文件树是否可见(默认隐藏,但如果已固定则显示) + const [internalFileTreeVisible, setInternalFileTreeVisible] = + useState(isFileTreePinned || false); + const isFileTreeVisible = isFileTreeSidebarVisible || internalFileTreeVisible; + + // 文件树容器 ref + const fileTreeContainerRef = useRef(null); + + // 用于跟踪用户是否主动选择了文件(通过点击文件树) + const userSelectedFileRef = useRef(null); + // 用于记录上次的 taskAgentSelectedFileId 和 taskAgentSelectTrigger,避免重复选择 + const prevTaskAgentSelectedFileIdRef = useRef(''); + const prevTaskAgentSelectTriggerRef = useRef( + undefined, + ); + // 用于记录创建文件成功后需要选择的文件路径 + const pendingSelectFileRef = useRef(null); + /** 文件树异步刷新完成后,是否需要基于新文件树处理当前选中文件 */ + const pendingRefreshSelectedAfterFilesUpdateRef = useRef(false); + + // 备份文件列表,用于判断文件列表是否发生变化 + const filesRef = useRef([]); + /** TaskResult 等触发的自动选中:文件树刷新完成前暂存,避免 trigger 被过早消费 */ + const pendingTaskAgentAutoSelectRef = useRef<{ + fileId: string; + trigger?: number | string; + } | null>(null); + /** 是否已发起过文件树拉取(用于区分「初始空数组」与「接口已返回空列表」) */ + const fileTreeFetchStartedRef = useRef(false); + /** 是否已至少完成一次文件树拉取(含成功返回空列表) */ + const fileTreeFetchResolvedRef = useRef(false); + + useEffect(() => { + if (fileTreeDataLoading) { + fileTreeFetchStartedRef.current = true; + return; + } + if (fileTreeFetchStartedRef.current) { + fileTreeFetchResolvedRef.current = true; + } + }, [fileTreeDataLoading]); + + useEffect(() => { + filesRef.current = files; + }, [files]); + + useEffect(() => { + selectedFileIdRef.current = selectedFileId; + }, [selectedFileId]); + + // 用于存储文件的刷新时间戳,确保每次点击时都能刷新 + // 统一使用一个时间戳,适用于 html、office、json、视频、音频、图片等需要刷新的文件类型 + const [fileRefreshTimestamp, setFileRefreshTimestamp] = useState( + Date.now(), + ); + /** html / md:预览或代码视图 */ + const [viewFileType, setViewFileType] = useState<'preview' | 'code'>( + 'preview', + ); + const onSelectedFileMissingRef = useRef(onSelectedFileMissing); + onSelectedFileMissingRef.current = onSelectedFileMissing; + + useEffect(() => { + if (!initViewFileType) { + setViewFileType('preview'); + } + }, [selectedFileId, initViewFileType]); + + useEffect(() => { + if (initViewFileType) { + setViewFileType(initViewFileType); + } + }, [initViewFileType]); + + // 当 isFileTreePinned 变化时,同步展开文件树(与 FileTreeView 一致) + useEffect(() => { + if (isFileTreePinned && isFileTreeSidebarVisible === undefined) { + setInternalFileTreeVisible(true); + } + + return () => { + setInternalFileTreeVisible(false); + }; + }, [isFileTreePinned, isFileTreeSidebarVisible]); + + useEffect(() => { + // 如果通过父组件全屏预览模式打开,则设置全屏状态 + if (isFullscreenPreview) { + setIsFullscreen(true); + } + }, [isFullscreenPreview]); + + // 获取文件内容并更新文件树 + const fetchFileContentUpdateFiles = useCallback( + async (fileProxyUrl: string, fileId: string) => { + try { + // 获取文件内容 + const fileContent = await fetchContentFromUrl(fileProxyUrl); + + // 更新文件树中的文件内容 + setFiles((prevFiles) => { + const updatedFiles: FileNode[] = updateFileTreeContent( + fileId, + fileContent, + prevFiles, + ); + return updatedFiles; + }); + // 内容拉取成功后更新时间戳,供只读技能详情场景强制刷新 CodeViewer + setFileRefreshTimestamp(Date.now()); + + return fileContent; + } catch (error) { + console.error('Failed to fetch file content:', error); + return ''; + } + }, + [], + ); + + /** 导出 PDF 前先通过 fileProxyUrl 拉取 md/html 文件内容 */ + const resolveNodeContentForPdfExport = useCallback( + async (node: FileNode): Promise => { + const fileName = node.name || ''; + const canExportPdf = + isMarkdownFile(fileName) || + fileName.endsWith('.html') || + fileName.endsWith('.htm'); + if (!canExportPdf || !node.fileProxyUrl) { + return node; + } + + const fileContent = await fetchFileContentUpdateFiles( + node.fileProxyUrl, + node.id, + ); + return { + ...node, + content: fileContent, + }; + }, + [fetchFileContentUpdateFiles], + ); + + // 判断文件是否为图片类型 + const isImage = isImageFile(selectedFileNode?.name || ''); + // 判断文件是否为视频类型 + const isVideo = isVideoFile(selectedFileNode?.name || ''); + // 判断文件是否为音频类型 + const isAudio = isAudioFile(selectedFileNode?.name || ''); + // 判断文件是否为文档类型 + const result = isDocumentFile(selectedFileNode?.name || ''); + // 判断文件是否为office文档类型 + const isOfficeDocument = result?.isDoc || false; + const documentFileType = result?.fileType; + + /** 清空当前选中文件信息 */ + const clearSelectedFile = useCallback(() => { + selectedFileIdRef.current = ''; + setSelectedFileId(''); + setSelectedFileNode(null); + }, []); + + /** 清空文件树选中态(文件 + 文件夹) */ + const clearSelection = useCallback(() => { + clearSelectedFile(); + setSelectedFolderId(''); + userSelectedFileRef.current = null; + }, [clearSelectedFile]); + + /** + * 切换会话 / 工作区(targetId 变化)时重置文件树与预览区本地状态。 + * Chat 切换历史会话时组件不会卸载,若不清理会残留上一会话的 selectedFileNode 与预览内容。 + */ + useEffect(() => { + fileTreeFetchStartedRef.current = false; + fileTreeFetchResolvedRef.current = false; + filesRef.current = []; + setFiles([]); + clearSelectedFile(); + setSelectedFolderId(''); + setRenamingNode(null); + setContextMenuVisible(false); + setContextMenuTarget(null); + setChangeFiles([]); + setViewFileType(initViewFileType || 'preview'); + setFileRefreshTimestamp(Date.now()); + prevTaskAgentSelectedFileIdRef.current = ''; + prevTaskAgentSelectTriggerRef.current = undefined; + userSelectedFileRef.current = null; + pendingSelectFileRef.current = null; + pendingTaskAgentAutoSelectRef.current = null; + pendingRefreshSelectedAfterFilesUpdateRef.current = false; + }, [targetId, clearSelectedFile, initViewFileType]); + + /** 通过当前选中文件的 fileProxyUrl 重新拉取文件内容 */ + const refreshSelectedFileContent = useCallback( + async (targetNode?: FileNode) => { + const currentSelectedFileId = + targetNode?.id || selectedFileIdRef.current || selectedFileId; + if (!currentSelectedFileId) { + return; + } + + const currentNode = + targetNode || + findFileNode(currentSelectedFileId, filesRef.current) || + selectedFileNode; + const fileProxyUrl = currentNode?.fileProxyUrl || ''; + if (!currentNode || !fileProxyUrl || currentNode.isLink) { + return; + } + + const fileName = currentNode.name || ''; + const currentDocumentResult = isDocumentFile(fileName); + const shouldOnlyRefreshPreview = + isVideoFile(fileName) || + isAudioFile(fileName) || + currentDocumentResult?.isDoc || + isImageFile(fileName); + + setFileRefreshTimestamp(Date.now()); + + if (shouldOnlyRefreshPreview) { + setSelectedFileNode((prevNode) => + prevNode ? { ...prevNode, ...currentNode } : currentNode, + ); + return; + } + + const newFileContent = await fetchFileContentUpdateFiles( + fileProxyUrl, + currentSelectedFileId, + ); + + // 请求返回时如果用户已经切换文件,则丢弃本次回写 + if (selectedFileIdRef.current !== currentSelectedFileId) { + return; + } + + setSelectedFileNode((prevNode) => + prevNode || currentNode + ? { + ...(prevNode || currentNode), + ...currentNode, + content: newFileContent || '', + } + : prevNode, + ); + }, + [selectedFileId, selectedFileNode, fetchFileContentUpdateFiles], + ); + + // 刷新文件树和文件内容 + const handleRefreshFileList = useCallback(async () => { + // 使用 ref 防重复点击,避免闭包中 isRefreshingFileTree 过期 + if (isRefreshingFileTreeRef.current) { + return; + } + + isRefreshingFileTreeRef.current = true; + setIsRefreshingFileTree(true); + if (refreshFileTreeTimeoutRef.current) { + window.clearTimeout(refreshFileTreeTimeoutRef.current); + } + refreshFileTreeTimeoutRef.current = window.setTimeout(() => { + pendingRefreshSelectedAfterFilesUpdateRef.current = false; + stopRefreshingFileTree(); + }, 10000); + + try { + pendingRefreshSelectedAfterFilesUpdateRef.current = Boolean( + selectedFileIdRef.current || selectedFileId, + ); + // 刷新文件树 + await onRefreshFileTree?.(); + } catch (error) { + pendingRefreshSelectedAfterFilesUpdateRef.current = false; + console.error('Failed to refresh file tree: ', error); + } finally { + // 如果父级暴露了文件树 loading,则等待外部 loading 结束再复位; + // 否则直接复位,超时兜底会处理异常悬挂场景。 + if (!fileTreeDataLoading) { + stopRefreshingFileTree(); + } + } + }, [ + onRefreshFileTree, + selectedFileId, + fileTreeDataLoading, + stopRefreshingFileTree, + ]); + + useEffect(() => { + if (!fileTreeDataLoading && isRefreshingFileTreeRef.current) { + stopRefreshingFileTree(); + } + }, [fileTreeDataLoading, stopRefreshingFileTree]); + + useEffect( + () => () => { + if (refreshFileTreeTimeoutRef.current) { + window.clearTimeout(refreshFileTreeTimeoutRef.current); + } + }, + [], + ); + + /** + * 刷新 Git 变更列表(git status) + * 文件树展开或暂存/取消暂存后调用,与 AppDev 源代码管理保持一致 + */ + const refreshGitList = useCallback(async () => { + if (!enableGitStatus) { + return; + } + + const cid = Number(targetId); + if (!cid || isRefreshingGitListRef.current) { + return; + } + + isRefreshingGitListRef.current = true; + setIsRefreshingGitList(true); + + try { + // 文件树刷新不阻塞 git status,避免与 openPreviewView 等并发刷新时 Promise 悬挂 + // void onRefreshFileTree?.(); + + const statusResponse = await apiGitStatus({ + workspaceType: 'taskAgent', + cid, + }); + + if (statusResponse.code !== SUCCESS_CODE || !statusResponse.data) { + return; + } + + setGitBranch(statusResponse.data.current || 'main'); + + const statusFileIds = mergeGitStatusFileIds(statusResponse.data); + + setChangeFiles((prev) => + buildChangeFilesFromGitStatus( + statusResponse.data!, + statusFileIds, + prev, + (fileId) => findFileNode(fileId, filesRef.current), + ), + ); + } finally { + isRefreshingGitListRef.current = false; + setIsRefreshingGitList(false); + } + }, [enableGitStatus, targetId]); + + /** 启用 Git status 时拉取一次,避免新建临时节点导致 files.length 变化后误触发 */ + useEffect(() => { + if (!targetId || !enableGitStatus) { + isRefreshingGitListRef.current = false; + setIsRefreshingGitList(false); + return; + } + + void refreshGitList(); + return () => { + isRefreshingGitListRef.current = false; + setIsRefreshingGitList(false); + }; + }, [targetId, enableGitStatus]); + + // 文件选择(内部函数,执行实际的选择逻辑) + const handleFileSelectInternal = useCallback( + async (fileId: string, options?: { selectFolder?: boolean }) => { + const currentFiles = filesRef.current; + // 根据文件ID查找文件节点(精确匹配) + let fileNode = findFileNode(fileId, currentFiles); + + // 如果仍然没有找到,尝试模糊匹配 + if (!fileNode && fileId && fileId.includes('.')) { + fileNode = findBestMatchingFileNode(fileId, currentFiles); + } + + if (fileNode) { + // 文件树中点击文件夹:更新树选中态(与文件高亮互斥),不切换预览区 + if (fileNode.type === 'folder' && options?.selectFolder) { + setSelectedFolderId(fileNode.id); + return; + } + + // 选中文件时清除文件夹选中态 + setSelectedFolderId(''); + + // 为本次“选中文件”生成唯一 token(后续异步回写时用于判定是否过期) + const currentSelectedId = fileNode?.id || fileId; + const selectToken = `${currentSelectedId}-${Date.now()}`; + latestFileSelectTokenRef.current = selectToken; + + // 如果文件节点是文件夹(folder),则选择第一个子节点(点击会话中文件名时,如果文件名是文件夹,则选择第一个子节点) + if (fileNode.type === 'folder') { + // 如果文件节点是文件夹,且有子节点,则选择第一个子节点 + if (fileNode?.children?.length) { + const firstChild = fileNode.children?.[0]; + if (firstChild) { + handleFileSelectInternal(firstChild.id); + } + } + return; + } + + // 获取文件代理URL + const fileProxyUrl = fileNode?.fileProxyUrl || ''; + + /** + * kill技能页面时,文件可能有内容,也可能文件内容为空,但是没有文件代理URL + * 如果文件节点是链接文件,则不支持预览 + */ + if (!fileProxyUrl || fileNode?.isLink) { + onFileSelectOpenPreview?.(fileNode?.id || fileId); + // 同步 ref,确保异步逻辑读取到最新选中文件 + selectedFileIdRef.current = currentSelectedId; + setSelectedFileId(currentSelectedId); + if (!initViewFileType) { + setViewFileType('preview'); + } + setSelectedFileNode(fileNode); + return; + } + + // 选中文件后打开右侧预览面板(隐藏编排区域) + onFileSelectOpenPreview?.(fileNode?.id || fileId); + + // 判断文件是否为文档类型 + const result = isDocumentFile(fileNode?.name || ''); + // 判断文件是否为office文档类型 + const isOfficeFile = result?.isDoc || false; + // 判断文件是否为视频类型 + const isVideoFileType = isVideoFile(fileNode?.name || ''); + // 判断文件是否为音频类型 + const isAudioFileType = isAudioFile(fileNode?.name || ''); + // 判断文件是否为图片类型 + const isImageFileType = isImageFile(fileNode?.name || ''); + + // 更新刷新时间戳,触发预览区重渲染 + setFileRefreshTimestamp(Date.now()); + + // 先写 ref 再 setState,降低异步回调读取旧选中值的概率 + selectedFileIdRef.current = currentSelectedId; + setSelectedFileId(currentSelectedId); + + if (!initViewFileType) { + setViewFileType('preview'); + } + + // 图片、视频、音频、office 等通过 FilePreview 渲染 + if ( + isImageFileType || + isVideoFileType || + isAudioFileType || + isOfficeFile + ) { + setSelectedFileNode({ ...fileNode }); + } + // 其他类型文件:使用文件代理URL获取文件内容 + // "fileProxyUrl": "/api/computer/static/1464425/国际财经分析报告_20241222.md" + else if (fileProxyUrl) { + // 判断文件是否支持预览(白名单方案) + const isPreviewable = isPreviewableFile(fileNode?.name || '', true); + // 如果文件不支持预览,则直接设置选中文件节点(如.zip、.rar、.7z 等压缩文件,不支持预览,也不需要获取压缩文件内容) + if (!isPreviewable) { + setSelectedFileNode(fileNode); + return; + } + + const fileNameLower = (fileNode?.name || '').toLowerCase(); + const _isMarkdownFile = isMarkdownFile(fileNameLower); + if (_isMarkdownFile && !initViewFileType) { + setSelectedFileNode({ + ...fileNode, + content: '', + }); + return; + } + + // 先切到当前文件并清空内容,避免异步返回前继续显示上一个文件内容 + setSelectedFileNode({ + ...fileNode, + content: '', + }); + + // 获取文件内容并更新文件树 + const newFileContent = await fetchFileContentUpdateFiles( + fileProxyUrl, + currentSelectedId, + ); + + // 只允许“当前最新选中”的请求回写编辑器内容,避免 302 跳转/慢请求导致旧数据覆盖 + if ( + latestFileSelectTokenRef.current !== selectToken || + selectedFileIdRef.current !== currentSelectedId + ) { + return; + } + + // 设置选中文件节点 + setSelectedFileNode({ + ...fileNode, + content: newFileContent || '', + }); + } + } else { + // 所有匹配方式都失败,设置选中文件节点为 null + setSelectedFileNode(null); + setSelectedFileId(''); + } + }, + [onFileSelectOpenPreview, initViewFileType], + ); + + // 文件选择(对外接口,用于用户主动选择) + const handleFileSelect = useCallback( + async (fileId: string, options?: { selectFolder?: boolean }) => { + if (options?.selectFolder) { + await handleFileSelectInternal(fileId, options); + return; + } + // 记录用户主动选择的文件ID + userSelectedFileRef.current = fileId; + clearTaskAgentSelectedFileId?.(); + await handleFileSelectInternal(fileId, options); + }, + [handleFileSelectInternal, clearTaskAgentSelectedFileId], + ); + + /** + * 同步 originalFiles → 本地 files / filesRef。 + * 必须排在 taskAgent 自动选中 effect 之前,确保自动选中读取到最新 fileProxyUrl。 + */ + useEffect(() => { + /** + * 父级 fileTreeData 为空:同步清空本地树,并清理残留预览。 + * 典型场景:切换 Chat 历史会话、clearFilePanelInfo、删除全部文件后接口返回 []。 + * 若不清理 selectedFileNode,右侧预览会继续展示上一会话/已删文件的内容。 + */ + const visibleOriginalFiles = filterFlatFileListForVersionControl( + originalFiles, + enableVersionControl, + ); + + if (!visibleOriginalFiles || visibleOriginalFiles.length === 0) { + setFiles([]); + filesRef.current = []; + const currentSelectedFileId = selectedFileIdRef.current || selectedFileId; + if (currentSelectedFileId) { + // 仅「主动刷新文件树」场景通知外部(如清除 taskAgentSelectedFileId) + if (pendingRefreshSelectedAfterFilesUpdateRef.current) { + pendingRefreshSelectedAfterFilesUpdateRef.current = false; + onSelectedFileMissingRef.current?.(currentSelectedFileId); + } else if (originalFiles?.length) { + // 接口有数据但过滤后为空(如版本管控关闭时仅剩 .gitignore) + onSelectedFileMissingRef.current?.(currentSelectedFileId); + } + clearSelectedFile(); + } + return; + } + // 如果文件列表不为空,则转换为树形结构 + if ( + Array.isArray(visibleOriginalFiles) && + visibleOriginalFiles.length > 0 + ) { + const treeData: FileNode[] = transformFlatListToTree( + visibleOriginalFiles, + false, + ); + filesRef.current = treeData; + setFiles(treeData); + + /** 版本管控关闭后 .gitignore 被过滤,需清理仍选中该文件的预览态 */ + const currentSelectedFileId = selectedFileIdRef.current || selectedFileId; + if ( + currentSelectedFileId && + !findFileNode(currentSelectedFileId, treeData) + ) { + onSelectedFileMissingRef.current?.(currentSelectedFileId); + clearSelectedFile(); + } + + // discard / 回滚等场景必须基于“刚刷新得到的新文件树”判断当前选中文件。 + // 不再放到独立的 files effect 中,避免同一轮 effect 读取到旧 files。 + if (pendingRefreshSelectedAfterFilesUpdateRef.current) { + pendingRefreshSelectedAfterFilesUpdateRef.current = false; + + /** 刷新完成后,用最新 treeData 校验当前选中项是否仍存在 */ + const currentSelectedFileId = + selectedFileIdRef.current || selectedFileId; + if (!currentSelectedFileId) { + return; + } + + const nextSelectedFileNode = findFileNode( + currentSelectedFileId, + treeData, + ); + /** 文件已被删除或路径变更:通知外部并清空预览,避免展示过期内容 */ + if (!nextSelectedFileNode) { + onSelectedFileMissingRef.current?.(currentSelectedFileId); + clearSelectedFile(); + return; + } + + /** 仍存在:同步选中态并重新拉取内容(discard 后需展示磁盘最新版本) */ + selectedFileIdRef.current = nextSelectedFileNode.id; + setSelectedFileId(nextSelectedFileNode.id); + setSelectedFileNode(nextSelectedFileNode); + void refreshSelectedFileContent(nextSelectedFileNode); + } + } + }, [originalFiles, enableVersionControl]); + + /** + * 监听 taskAgentSelectedFileId / taskAgentSelectTrigger,自动定位并打开消息中的目标文件。 + * + * 典型入口:TaskResult、Markdown 内联文件链接点击。 + * 调用方通常会先 openPreviewView({ forceRefresh: true }) 拉取最新文件树, + * 再设置 fileId + trigger;本 effect 负责在树就绪后完成选中,并避免重复请求文件列表。 + */ + useEffect(() => { + // 重新导入项目后触发 taskAgentSelectTrigger 时,用最新 filesRef 判断是否可自动选中 + const isFileInTreeForAutoSelect = (fileId: string) => { + if (!fileId || !filesRef.current?.length) { + return false; + } + if (findFileNode(fileId, filesRef.current)) { + return true; + } + if (fileId.includes('.')) { + return Boolean(findBestMatchingFileNode(fileId, filesRef.current)); + } + return false; + }; + + /** 执行自动选中,并同步 prev ref,避免同一 trigger 重复处理 */ + const applyAutoSelect = (fileId: string) => { + void handleFileSelectInternal(fileId); + prevTaskAgentSelectedFileIdRef.current = fileId; + if (taskAgentSelectTrigger !== undefined) { + prevTaskAgentSelectTriggerRef.current = taskAgentSelectTrigger; + } + pendingTaskAgentAutoSelectRef.current = null; + }; + + // 外部清空选中目标时,重置所有自动选择相关状态 + if (!taskAgentSelectedFileId) { + prevTaskAgentSelectedFileIdRef.current = ''; + prevTaskAgentSelectTriggerRef.current = undefined; + userSelectedFileRef.current = null; + pendingTaskAgentAutoSelectRef.current = null; + return; + } + + // 用户重复点击同一文件时,仅靠 fileId 无法触发 effect,需配合 trigger 时间戳 + const isTriggerUpdate = + taskAgentSelectTrigger !== undefined && + taskAgentSelectTrigger !== prevTaskAgentSelectTriggerRef.current; + + const hasSelectionChanged = + taskAgentSelectedFileId !== prevTaskAgentSelectedFileIdRef.current; + + const pending = pendingTaskAgentAutoSelectRef.current; + /** 上次刷新后仍待选中的同一目标(files 更新后会再次进入本 effect) */ + const isPendingRetry = + pending?.fileId === taskAgentSelectedFileId && + (pending.trigger === undefined || + pending.trigger === taskAgentSelectTrigger); + + const isFileTreeFetchInFlight = + Boolean(fileTreeDataLoading) || isRefreshingFileTreeRef.current; + /** + * 父级是否已完成至少一次文件树拉取。 + * 不能只靠 originalFiles.length:接口成功返回空列表时 length 为 0, + * 若仍视为「未拉取」会与 isPendingRetry 形成无限 refresh 循环。 + */ + const hasFetchedOriginalFiles = + Boolean(originalFiles?.length) || fileTreeFetchResolvedRef.current; + + /** 拉取已完成但文件树仍为空,放弃自动选中并通知外部清理 */ + const abandonAutoSelectWhenTreeEmpty = () => { + pendingTaskAgentAutoSelectRef.current = null; + prevTaskAgentSelectedFileIdRef.current = taskAgentSelectedFileId; + if (taskAgentSelectTrigger !== undefined) { + prevTaskAgentSelectTriggerRef.current = taskAgentSelectTrigger; + } + onSelectedFileMissingRef.current?.(taskAgentSelectedFileId); + }; + + /** + * 按需刷新文件树,避免与 openPreviewView.forceRefresh 重复调用同一接口。 + * - 正在加载 / 刷新中:跳过 + * - 已完成拉取(含空列表):不再二次请求 + */ + const requestFileTreeRefreshIfNeeded = () => { + if (isFileTreeFetchInFlight || hasFetchedOriginalFiles) { + return; + } + void handleRefreshFileList(); + }; + + // 本地树尚未构建:记录 pending,必要时触发一次刷新;files 更新后依赖项变化会重入 + if (!files?.length) { + if (isTriggerUpdate || isPendingRetry || hasSelectionChanged) { + if ( + hasFetchedOriginalFiles && + !originalFiles?.length && + !isFileTreeFetchInFlight + ) { + abandonAutoSelectWhenTreeEmpty(); + return; + } + pendingTaskAgentAutoSelectRef.current = { + fileId: taskAgentSelectedFileId, + trigger: taskAgentSelectTrigger, + }; + requestFileTreeRefreshIfNeeded(); + } + return; + } + + const shouldAutoSelect = + isTriggerUpdate || + hasSelectionChanged || + isPendingRetry || + (taskAgentSelectedFileId && !prevTaskAgentSelectedFileIdRef.current) || + filesRef.current?.length !== files.length; + + if (!shouldAutoSelect) { + return; + } + + // 用户已在树中手动选了其他文件,且非本次 trigger 驱动时,不覆盖用户选择 + if ( + !isTriggerUpdate && + !isPendingRetry && + userSelectedFileRef.current && + userSelectedFileRef.current !== taskAgentSelectedFileId + ) { + return; + } + + userSelectedFileRef.current = null; + + pendingTaskAgentAutoSelectRef.current = { + fileId: taskAgentSelectedFileId, + trigger: taskAgentSelectTrigger, + }; + + if (isFileInTreeForAutoSelect(taskAgentSelectedFileId)) { + applyAutoSelect(taskAgentSelectedFileId); + return; + } + + // 目标不在当前树中(例如新产出文件):尝试刷新后再选 + if (hasFetchedOriginalFiles) { + abandonAutoSelectWhenTreeEmpty(); + return; + } + requestFileTreeRefreshIfNeeded(); + }, [ + taskAgentSelectedFileId, + taskAgentSelectTrigger, + handleFileSelectInternal, + handleRefreshFileList, + files, + fileTreeDataLoading, + originalFiles, + ]); + + // 监听 files 变化,当有待选择的文件时自动选择 + useEffect(() => { + if (pendingSelectFileRef.current && files && files.length > 0) { + const filePath = pendingSelectFileRef.current; + // 从文件树中查找新文件(通过路径或ID匹配) + const newFile = findFileNode(filePath, files); + if (newFile) { + // 找到新文件,清除待选择标记,使用内部函数选择(不设置用户选择标记) + pendingSelectFileRef.current = null; + handleFileSelectInternal(filePath); + } + } + }, [files, handleFileSelectInternal]); + + // 技能项目且无 fileProxyUrl:files 更新后同步 content(内容直接来自接口) + useEffect(() => { + if (!isProjectSkill || !files?.length) { + return; + } + + const targetSyncId = selectedFileId || taskAgentSelectedFileId; + if (!targetSyncId) { + return; + } + + const newFileNode = findFileNode(targetSyncId, files); + if (!newFileNode || newFileNode.fileProxyUrl) { + return; + } + + setSelectedFileNode(newFileNode); + setSelectedFileId(newFileNode.id); + selectedFileIdRef.current = newFileNode.id; + }, [files, isProjectSkill, selectedFileId, taskAgentSelectedFileId]); + + /** + * 处理右键菜单显示 + * @param e - 鼠标事件 + * @param node - 目标节点, 可以为 null 表示点击空白区域,清空目标节点 + */ + const handleContextMenu = (e: React.MouseEvent, node: FileNode | null) => { + if (readOnly) { + return; + } + e.preventDefault(); + e.stopPropagation(); + + setContextMenuTarget(node); + + // 计算相对于文件树容器的坐标 + // 如果文件树容器存在,使用相对于容器的坐标;否则使用视口坐标 + if (fileTreeContainerRef.current) { + const containerRect = + fileTreeContainerRef.current.getBoundingClientRect(); + const relativeX = e.clientX - containerRect.left; + const relativeY = e.clientY - containerRect.top; + setContextMenuPosition({ x: relativeX, y: relativeY }); + } else { + // 如果容器不存在,使用视口坐标作为后备方案 + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + } + + setContextMenuVisible(true); + }; + + /** + * 关闭右键菜单 + * @param e - 鼠标事件(可能是 React.MouseEvent 或原生 Event,可选) + */ + const closeContextMenu = useCallback(() => { + setContextMenuVisible(false); + setContextMenuTarget(null); + }, []); + + // 点击外部关闭右键菜单 + useEffect(() => { + // 只在右键菜单显示时才添加点击事件监听器 + if (!contextMenuVisible) { + return; + } + + const handleDocumentClick = () => { + // 只在右键菜单显示时才处理点击事件(双重检查,避免闭包问题) + // 注意:这里使用最新的 contextMenuVisible 状态可能会有延迟 + // 但由于我们在 useEffect 中已经检查了 contextMenuVisible,所以这里应该是安全的 + closeContextMenu(); + }; + + document.addEventListener('click', handleDocumentClick); + return () => document.removeEventListener('click', handleDocumentClick); + }, [contextMenuVisible, closeContextMenu]); + + /** + * 取消重命名 + */ + const handleCancelRename = (options?: { + removeIfNew?: boolean; + node?: FileNode | null; + }) => { + // 如果是新建节点且未输入内容,则需要从文件树中移除该临时节点 + if (options?.removeIfNew && options.node) { + const targetId = options.node.id; + + setFiles((prevFiles) => removeNodeByIdFromTree(prevFiles, targetId)); + + // 如果当前选中的是这个临时节点,清空选中状态 + if (selectedFileId === targetId) { + setSelectedFileId(''); + setSelectedFileNode(null); + } + } + + // 仅当取消的是当前重命名节点时才清空状态, + // 避免上一个输入框的延迟 blur 误清掉新一轮新建的重命名状态 + setRenamingNode((prev) => + options?.node && prev && prev.id !== options.node.id ? prev : null, + ); + }; + + /** + * 处理重命名操作(从右键菜单触发) + */ + const handleRenameFromMenu = (node: FileNode) => { + setRenamingNode(node); + }; + + /** + * 新建失败时:移除临时节点并退出重命名态,恢复到新建前的目录 + */ + const rollbackFailedCreate = ( + fileNode: FileNode, + filesBackup: FileNode[], + ) => { + setFiles(removeNodeByIdFromTree(filesBackup, fileNode.id)); + setRenamingNode(null); + }; + + /** + * 处理重命名操作 + */ + const handleRenameFile = async (fileNode: FileNode, newName: string) => { + if (!newName || !newName?.trim()) { + if (fileNode?.status === 'create') { + setFiles((prev) => removeNodeByIdFromTree(prev, fileNode.id)); + setRenamingNode(null); + } + return; + } + + // 备份文件列表,用于重命名失败时恢复文件树 + const filesBackup = cloneDeep(files); + + try { + // 重命名文件失败:找不到文件节点 + if (!fileNode) { + return; + } + + const isNewNode = fileNode.status === 'create'; + + // 先立即更新文件树中的显示名字,提供即时反馈 + const updatedFileTree = updateFileTreeName( + files, + fileNode.id, + newName.trim(), + ); + + setFiles(updatedFileTree); + + // 如果是新建节点(文件或文件夹),走创建逻辑;否则走重命名逻辑 + if (isNewNode) { + const isCreateSuccess = await onCreateFileNode?.(fileNode, newName); + + if (isCreateSuccess) { + const trimmedName = newName?.trim(); + if (!trimmedName) { + rollbackFailedCreate(fileNode, filesBackup); + return; + } + + // 计算新文件的完整路径:父路径 + 新文件名 + const parentPath = fileNode.parentPath || ''; + const newPath = parentPath + ? `${parentPath}/${trimmedName}` + : trimmedName; + + // 记录需要选择的文件路径,等待文件树更新后自动选择 + pendingSelectFileRef.current = newPath; + } else { + rollbackFailedCreate(fileNode, filesBackup); + } + } else { + // 直接调用现有的重命名文件功能(异步更新文件树) + const isChangeSuccess = await onRenameFile?.(fileNode, newName); + if (isChangeSuccess) { + const trimmedName = newName.trim(); + const newNodeId = fileNode.parentPath + ? `${fileNode.parentPath}/${trimmedName}` + : trimmedName; + + onFileRenamed?.(fileNode.id, newNodeId); + + if (fileNode.type === 'folder' && selectedFolderId === fileNode.id) { + setSelectedFolderId(newNodeId); + } + + // 如果当前选中的文件节点是被重命名的节点,则同步更新名称 + if ( + selectedFileNode && + (selectedFileNode.id === fileNode.id || + selectedFileNode.name === fileNode.name) + ) { + // 根据新的文件名,替换 fileProxyUrl 中的文件名部分 + const newFileProxyUrl = fileNode?.fileProxyUrl + ? updateFileProxyUrl( + fileNode.fileProxyUrl, + trimmedName, + fileNode.parentPath || undefined, + ) + : fileNode?.fileProxyUrl; + + setSelectedFileNode((prevNode) => + prevNode + ? { + ...prevNode, + name: trimmedName, + id: newNodeId, + path: newNodeId, + fullPath: newNodeId, + fileProxyUrl: newFileProxyUrl, // 更新 fileProxyUrl + } + : prevNode, + ); + + setSelectedFileId(newNodeId); + } + } else { + setFiles(filesBackup); + } + } + } catch { + if (fileNode?.status === 'create') { + rollbackFailedCreate(fileNode, filesBackup); + } else { + setFiles(filesBackup); + } + } + }; + + /** + * 打开文件/文件夹选择器并上传 + * @param node 目标目录节点 + * @param mode files=多文件上传;folder=文件夹上传(保留子目录结构) + */ + const triggerFileUpload = useCallback( + async (node: FileNode | null, mode: 'files' | 'folder') => { + const relativePath = resolveFileTreeUploadRelativePath(node); + const isFolderMode = mode === 'folder'; + + if (isIgnoredUploadRelativePath(relativePath)) { + message.warning( + dict('PC.Components.FileContextMenu.uploadTargetInvalid'), + ); + return; + } + + const input = document.createElement('input'); + input.type = 'file'; + input.style.display = 'none'; + input.accept = '*'; + if (isFolderMode) { + input.setAttribute('webkitdirectory', ''); + input.setAttribute('directory', ''); + } else { + input.multiple = true; + } + document.body.appendChild(input); + + const cleanupInput = () => { + if (input.parentNode) { + document.body.removeChild(input); + } + }; + + input.onchange = async (e) => { + const selectedFiles = Array.from( + (e.target as HTMLInputElement).files || [], + ); + if (selectedFiles.length === 0) { + cleanupInput(); + return; + } + + const files = filterFilesForUpload(selectedFiles, isFolderMode); + if (files.length === 0) { + message.warning( + isFolderMode + ? dict('PC.Components.FileContextMenu.uploadFolderEmpty') + : dict('PC.Components.FileContextMenu.uploadFileEmpty'), + ); + cleanupInput(); + return; + } + + const filePaths = buildUploadFilePaths( + files, + relativePath, + isFolderMode, + ); + + const { isExceedLimitSize, maxFileSize } = + checkFileSizeExceedLimit(files); + if (isExceedLimitSize) { + message.error( + dict('PC.Common.Global.uploadFileSizeExceed', String(maxFileSize)), + ); + cleanupInput(); + return; + } + + setIsUploadingFiles(true); + + try { + await onUploadFiles?.(files, filePaths); + setTimeout(() => { + setIsUploadingFiles(false); + }, 1000); + } catch (error) { + console.error('Failed to upload file', error); + setIsUploadingFiles(false); + } finally { + cleanupInput(); + } + }; + + input.oncancel = cleanupInput; + input.click(); + }, + [onUploadFiles], + ); + + /** 上传多个文件(扁平,不含子目录) */ + const handleUploadMultipleFiles = useCallback( + async (node: FileNode | null) => { + await triggerFileUpload(node, 'files'); + }, + [triggerFileUpload], + ); + + /** 上传文件夹(保留 webkitRelativePath 目录结构) */ + const handleUploadFolder = useCallback( + async (node: FileNode | null) => { + await triggerFileUpload(node, 'folder'); + }, + [triggerFileUpload], + ); + + /** + * 处理删除操作 + */ + const handleDelete = async (node: FileNode) => { + // 直接调用现有的删除文件功能,等待返回值 + const isDeleteSuccess = await onDeleteFile?.(node); + + // 成功删除 + if (isDeleteSuccess) { + // 如果删除的是当前选中的文件节点,则清空选中状态 + if (node.id === selectedFileNode?.id) { + setSelectedFileNode(null); + setSelectedFileId(''); + } + if (node.id === selectedFolderId) { + setSelectedFolderId(''); + } + onFileDeleted?.(node); + } + }; + + /** + * 在文件树中指定位置创建一个临时节点,并进入重命名状态 + * @param parentNode 目标父级文件夹;为空表示在根目录创建 + * @param type 创建类型:file 或 folder + */ + const createTempNodeAndStartRename = ( + parentNode: FileNode | null, + type: 'file' | 'folder', + ) => { + // 连续点击新建时,先移除上一个尚未命名的临时节点,避免残留空占位 + if (renamingNode?.status === 'create') { + setFiles((prevFiles) => + removeNodeByIdFromTree(prevFiles, renamingNode.id), + ); + } + + const parentPath = parentNode?.path || null; + const tempIdSuffix = `__new__${Date.now()}`; + const fullPath = parentPath + ? `${parentPath}/${tempIdSuffix}` + : tempIdSuffix; + + // 预先构造好要插入的临时节点 + const newNode: FileNode = { + id: fullPath, + name: '', + type, + path: fullPath, + children: type === 'folder' ? [] : undefined, + parentPath: parentPath, + content: type === 'file' ? '' : undefined, + lastModified: Date.now(), + status: 'create', + }; + + setFiles((prevFiles) => { + const parentId = parentNode?.id || null; + + const insertNodeAtTop = ( + nodes: FileNode[], + targetParentId: string | null, + ): FileNode[] => { + // 在根目录创建 + if (!targetParentId) { + return [newNode, ...nodes]; + } + + return nodes.map((node) => { + if (node.id === targetParentId) { + const children = node.children || []; + return { + ...node, + children: [newNode, ...children], + }; + } + + if (node.children && node.children.length > 0) { + return { + ...node, + children: insertNodeAtTop(node.children, targetParentId), + }; + } + + return node; + }); + }; + + const updatedFiles = insertNodeAtTop(prevFiles, parentId); + return updatedFiles; + }); + + // 将新建的节点设置为当前重命名目标和选中节点 + if (newNode) { + setRenamingNode(newNode); + } + }; + + /** + * 处理项目导出 + */ + const handleExportProject = useCallback(async () => { + if (!onExportProject) { + return; + } + setIsExportingProject(true); + try { + await onExportProject(); + } finally { + setTimeout(() => setIsExportingProject(false), 1000); + } + }, [onExportProject]); + + const handleCreateFile = (parentNode: FileNode | null) => { + createTempNodeAndStartRename(parentNode, 'file'); + }; + + /** + * 处理新建文件夹操作 + */ + const handleCreateFolder = (parentNode: FileNode | null) => { + createTempNodeAndStartRename(parentNode, 'folder'); + }; + + /** + * 处理内容变化:同步本地状态并防抖提交到服务端 + */ + const handleContentChange = useCallback( + (fileId: string, content: string) => { + const currentFile = findFileNode(fileId, filesRef.current); + const originalContent = currentFile?.content || ''; + const existingChange = changeFilesRef.current.find( + (item) => item.fileId === fileId, + ); + const originalFileContent = + existingChange?.originalFileContent ?? originalContent; + + setFiles((prevFiles) => + updateFileTreeContent(fileId, content, prevFiles), + ); + + setSelectedFileNode((prevNode) => + prevNode ? { ...prevNode, content } : prevNode, + ); + + setChangeFiles((prevChangeFiles) => { + const existingIndex = prevChangeFiles.findIndex( + (item) => item.fileId === fileId, + ); + + if (existingIndex !== -1) { + const updatedChangeFiles = [...prevChangeFiles]; + updatedChangeFiles[existingIndex] = { + ...updatedChangeFiles[existingIndex], + fileContent: content, + }; + return updatedChangeFiles; + } + if (content !== originalFileContent) { + return [ + ...prevChangeFiles, + { + fileId, + fileContent: content, + originalFileContent, + }, + ]; + } + return prevChangeFiles; + }); + + if (content !== originalFileContent && !readOnly) { + void onSaveFileContentRef + .current?.(fileId, content, originalFileContent) + .then((success) => { + if (!success) { + return; + } + setChangeFiles((prev) => { + const item = prev.find((change) => change.fileId === fileId); + if (!item || item.fileContent !== content) { + return prev; + } + return prev.filter((change) => change.fileId !== fileId); + }); + }); + } + }, + [readOnly], + ); + + /** + * 处理全屏切换 + */ + const handleFullscreen = () => { + const newFullscreenState = !isFullscreen; + setIsFullscreen(newFullscreenState); + onFullscreenPreview?.(newFullscreenState); + // 切换 body 类,用于隐藏父组件的干扰元素 + if (newFullscreenState) { + document.body.classList.add('file-tree-view-fullscreen-active'); + } else { + document.body.classList.remove('file-tree-view-fullscreen-active'); + } + }; + + /** 按 ESC 键退出全屏 */ + useEffect(() => { + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isFullscreen) { + setIsFullscreen(false); + onFullscreenPreview?.(false); + document.body.classList.remove('file-tree-view-fullscreen-active'); + } + }; + document.addEventListener('keydown', handleEscKey); + return () => { + document.removeEventListener('keydown', handleEscKey); + }; + }, [isFullscreen, onFullscreenPreview]); + + // 保存文件 + const saveFiles = async () => { + setIsSavingFiles(true); + const isSaveSuccess = await onSaveFiles?.(changeFiles); + setIsSavingFiles(false); + if (isSaveSuccess) { + // 清空已修改文件列表 + setChangeFiles([]); + } + }; + + /** + * 放弃单个文件的修改,还原为原始内容 + * @param fileId 文件 ID + */ + const discardChangeFile = useCallback( + (fileId: string) => { + const changeFile = changeFiles.find((item) => item.fileId === fileId); + if (!changeFile) { + return; + } + + setFiles((prevFiles) => + updateFileTreeContent( + fileId, + changeFile.originalFileContent, + prevFiles, + ), + ); + + if (selectedFileId === fileId) { + setSelectedFileNode((prevNode) => + prevNode + ? { ...prevNode, content: changeFile.originalFileContent } + : prevNode, + ); + } + + setChangeFiles((prev) => prev.filter((item) => item.fileId !== fileId)); + }, + [changeFiles, selectedFileId], + ); + + // 取消保存文件 + const cancelSaveFiles = () => { + // 还原所有已修改文件的内容 + let restoredFiles = files; + changeFiles.forEach((changeFile) => { + restoredFiles = updateFileTreeContent( + changeFile.fileId, + changeFile.originalFileContent, + restoredFiles, + ); + }); + setFiles(restoredFiles); + + // 从已修改文件列表中获取原始内容,用于还原当前选中的文件内容 + const changeFile = changeFiles?.find( + (item) => item.fileId === selectedFileId, + ); + if (changeFile) { + const originalFileContent = changeFile?.originalFileContent; + + setSelectedFileNode((prevNode: any) => ({ + ...prevNode, + content: originalFileContent, + })); + } + + // 清空已修改文件列表 + setChangeFiles([]); + }; + + /** + * 处理文件树展开/折叠(点击图标) + * 隐藏状态时点击展开文件树,展开时点击收起文件树 + */ + const handleFileTreeToggle = () => { + const newVisibleState = !isFileTreeVisible; + if (isFileTreeSidebarVisible !== undefined) { + onFileTreeSidebarVisibleChange?.(newVisibleState); + } else { + setInternalFileTreeVisible(newVisibleState); + } + // 如果展开文件树,同时固定它;如果隐藏文件树,取消固定 + if (newVisibleState) { + onFileTreePinnedChange?.(true); + } else { + onFileTreePinnedChange?.(false); + } + }; + + // 处理下载文件操作 + const handleDownloadFileByUrl = async ( + node: FileNode, + exportAsPdf?: boolean, + ) => { + setIsDownloadingFile(true); + setCurrentDownloadingFileId(node?.id); + try { + const targetNode = exportAsPdf + ? await resolveNodeContentForPdfExport(node) + : node; + await downloadFileByUrl?.(targetNode, exportAsPdf); + setTimeout(() => { + setIsDownloadingFile(false); + setCurrentDownloadingFileId(''); + }, 1000); + } catch (error) { + console.error('Failed to download file', error); + setIsDownloadingFile(false); + setCurrentDownloadingFileId(''); + } + }; + + /** 切换为代码视图时重新拉取文件内容(与 FileTreeView 一致) */ + const handleRefreshFileContent = useCallback(async () => { + const fileProxyUrl = selectedFileNode?.fileProxyUrl || ''; + if (!fileProxyUrl || !selectedFileId) { + return; + } + + const currentRefreshFileId = selectedFileId; + + const fileName = selectedFileNode?.name || ''; + const previewable = isPreviewableFile(fileName, true); + const isNeedRefreshFileContent = + !previewable || + selectedFileNode?.isLink || + isOfficeDocument || + isVideo || + isAudio || + isImage; + + if (isNeedRefreshFileContent) { + return; + } + + try { + const newFileContent = await fetchFileContentUpdateFiles( + fileProxyUrl, + currentRefreshFileId, + ); + // 请求返回时若用户已切换文件,则丢弃本次回写 + if (selectedFileIdRef.current !== currentRefreshFileId) { + return; + } + setSelectedFileNode((prevNode) => + prevNode + ? { + ...prevNode, + content: newFileContent || '', + } + : prevNode, + ); + } catch (error) { + console.error('Failed to refresh file content:', error); + } + }, [ + selectedFileNode, + selectedFileId, + isOfficeDocument, + isVideo, + isAudio, + isImage, + fetchFileContentUpdateFiles, + ]); + + const handleViewFileTypeChange = useCallback( + async (type: 'preview' | 'code') => { + setViewFileType(type); + if (type === 'code' && selectedFileNode) { + await handleRefreshFileContent(); + } + }, + [selectedFileNode, handleRefreshFileContent], + ); + + // 处理导出 PDF 操作 + const handleExportPdf = async (node: FileNode) => { + setIsExportingPdf(true); + try { + const targetNode = await resolveNodeContentForPdfExport(node); + await downloadFileByUrl?.(targetNode, true); + } finally { + setIsExportingPdf(false); + } + }; + + /** + * 构建文件预览的 URL 和 key,用于强制刷新 + * @param fileType - 文件类型标识(如 'html', 'office', 'json', 'video', 'audio') + * @param fileProxyUrl - 文件代理 URL + * @param selectedFileId - 选中的文件 ID + * @returns 包含 key 和 url 的对象 + */ + const buildFilePreviewProps = useCallback( + ( + fileType: string, + fileProxyUrl: string, + selectedFileId: string, + ): { key: string; url: string } => { + // 构建 key:同时包含两个值,确保任何一个变化都能触发重新渲染 + const triggerPart = + taskAgentSelectTrigger !== undefined + ? `trigger-${taskAgentSelectTrigger}` + : 'trigger-none'; + const timestampPart = `timestamp-${fileRefreshTimestamp}`; + const fileKey = `${fileType}-${selectedFileId}-${triggerPart}-${timestampPart}`; + + // 构建 URL 参数:使用组合值,确保任何一个变化都会导致 URL 变化 + // 优先使用 taskAgentSelectTrigger,如果不存在则使用时间戳 ref + const triggerValue = + taskAgentSelectTrigger !== undefined + ? taskAgentSelectTrigger + : fileRefreshTimestamp; + const separator = fileProxyUrl.includes('?') ? '&' : '?'; + const fileUrl = triggerValue + ? `${fileProxyUrl}${separator}t=${triggerValue}` + : fileProxyUrl; + + return { key: fileKey, url: fileUrl }; + }, + [taskAgentSelectTrigger, fileRefreshTimestamp], + ); + + /** + * 渲染内容区域 + * 根据文件类型渲染不同的预览组件 + */ + const renderContent = () => { + const isFileTreeLoading = + Boolean(fileTreeDataLoading) || isRefreshingFileTree; + + // 左侧文件树未展示时,在预览区展示 loading;文件树已展开时由其自身 loading 负责 + if (!files?.length && isFileTreeLoading && !isFileTreeVisible) { + return ; + } + + // 如果文件列表为空,则显示空状态 + if (!files?.length) { + return ( + + ); + } + + // 如果 taskAgentSelectedFileId 存在,但没有选中文件,则不渲染内容 + if (taskAgentSelectedFileId && !selectedFileNode && !selectedFileId) { + return ( + + ); + } + + // 未选择文件、选中文件夹或新建文件时 + if ( + !selectedFileNode || + selectedFileNode.type === 'folder' || + selectedFileNode?.id?.includes('__new__') + ) { + return ( + + ); + } + + // 获取文件代理URL + let fileProxyUrl = selectedFileNode?.fileProxyUrl || ''; + // 如果是相对路径(不以 http://, https:// 或 // 开头),则添加 BASE_URL 前缀 + if (fileProxyUrl && !/^(https?:)?\/\//i.test(fileProxyUrl)) { + fileProxyUrl = `${process.env.BASE_URL || ''}${fileProxyUrl}`; + } + + // 视频文件:使用FilePreview组件 + if (isVideo && fileProxyUrl) { + const { key: videoKey, url: videoUrl } = buildFilePreviewProps( + 'video', + fileProxyUrl, + selectedFileId, + ); + + return ; + } + + // 音频文件:使用FilePreview组件 + if (isAudio && fileProxyUrl) { + const { key: audioKey, url: audioUrl } = buildFilePreviewProps( + 'audio', + fileProxyUrl, + selectedFileId, + ); + + return ; + } + + // office文档文件:使用FilePreview组件 + if (isOfficeDocument && fileProxyUrl) { + const { key: officeKey, url: officeUrl } = buildFilePreviewProps( + 'office', + fileProxyUrl, + selectedFileId, + ); + + return ( + + ); + } + + // 图片文件:使用图片查看器 + if (isImage) { + // 如果文件代理URL存在,使用FilePreview组件 + if (fileProxyUrl) { + const { key: imageKey, url: imageUrl } = buildFilePreviewProps( + 'image', + fileProxyUrl, + selectedFileId, + ); + + return ; + } + + return ( + + ); + } + + // 软链接文件不支持编辑预览 + if (selectedFileNode?.isLink) { + const fileExtension = selectedFileId?.split('.')?.pop() || selectedFileId; + return ( + + ); + } + + // 压缩包等不支持预览的文件(如 .zip、.skill、.rar、.7z 等) + const selectedFileName = + selectedFileNode?.name || selectedFileId?.split('/')?.pop() || ''; + if (!isPreviewableFile(selectedFileName, true)) { + const fileExtension = selectedFileId?.split('.')?.pop() || selectedFileId; + return ( + + ); + } + + const fileName = selectedFileId?.split('/')?.pop() || ''; + const fileNameLower = fileName?.toLowerCase() || ''; + const isHtmlInCondition = /\.html?($|\?)/i.test(fileNameLower); + + if ( + (isHtmlInCondition || isMarkdownFile(fileNameLower)) && + viewFileType === 'preview' && + (fileProxyUrl || selectedFileNode?.content) + ) { + const fileTypeForPreview = isHtmlInCondition ? 'html' : 'markdown'; + const { key: filePreviewKey, url: filePreviewUrl } = + buildFilePreviewProps(fileTypeForPreview, fileProxyUrl, selectedFileId); + + return ( + + ); + } + + const fileContent = String(selectedFileNode?.content ?? ''); + /** + * 只读技能详情场景下,带上 fileRefreshTimestamp 强制重建 CodeViewer, + * 兜底规避 Monaco 增量更新链路在慢请求场景下可能不刷新的问题。 + */ + const codeViewerKey = + readOnly && isProjectSkill + ? `code-viewer-${selectedFileId}-${fileRefreshTimestamp}` + : `code-viewer-${selectedFileId}`; + + return ( + + ); + }; + + const filePathHeaderProps = useMemo( + () => ({ + conversationId: targetId?.toString() || '', + className: headerClassName, + targetNode: selectedFileNode, + onFullscreen: handleFullscreen, + isFullscreen, + showFullscreenIcon, + showMoreActions, + onDownloadFileByUrl: handleDownloadFileByUrl, + isDownloadingFile: + isDownloadingFile && + !!selectedFileId && + currentDownloadingFileId === selectedFileId, + isShowShare, + isShowDownloadButton, + onShare, + isShowExportPdfButton, + onExportPdf: handleExportPdf, + isExportingPdf, + onClose, + isFileTreeVisible, + isFileTreePinned, + onFileTreeToggle: handleFileTreeToggle, + isCloudComputer, + viewMode, + viewFileType, + onViewFileTypeChange: handleViewFileTypeChange, + }), + [ + targetId, + headerClassName, + selectedFileNode, + handleFullscreen, + isFullscreen, + showFullscreenIcon, + showMoreActions, + handleDownloadFileByUrl, + isDownloadingFile, + selectedFileId, + currentDownloadingFileId, + isShowShare, + isShowDownloadButton, + onShare, + isShowExportPdfButton, + handleExportPdf, + isExportingPdf, + onClose, + isFileTreeVisible, + isFileTreePinned, + handleFileTreeToggle, + isCloudComputer, + viewMode, + viewFileType, + handleViewFileTypeChange, + ], + ); + + const renderPreviewContent = useCallback( + () => renderContent(), + [ + isFullscreen, + targetId, + readOnly, + files, + fileTreeDataLoading, + isRefreshingFileTree, + isFileTreeVisible, + taskAgentSelectedFileId, + selectedFileNode, + selectedFileId, + isVideo, + isAudio, + isOfficeDocument, + documentFileType, + isImage, + buildFilePreviewProps, + isDynamicTheme, + onFullscreenPreview, + handleContentChange, + viewFileType, + staticFileBasePath, + ], + ); + + return { + className, + changeFiles, + isRefreshingGitList, + gitBranch, + refreshGitList, + tree: { + readOnly, + files, + selectedFileId, + selectedFolderId, + renamingNode, + contextMenuTarget, + contextMenuPosition, + contextMenuVisible, + isFileTreeVisible, + fileTreeContainerRef, + fileTreeDataLoading, + taskAgentSelectedFileId, + isCanDeleteSkillFile, + isRefreshingFileTree, + isUploadingFiles, + isDownloadingFile, + hideFileTree, + showRefreshButton, + handleFileSelect, + clearSelection, + handleContextMenu, + closeContextMenu, + handleRenameFile, + handleCancelRename, + handleRefreshFileList, + handleDelete, + handleRenameFromMenu, + handleUploadMultipleFiles, + handleUploadFolder, + handleCreateFile, + handleCreateFolder, + handleDownloadFileByUrl, + handleExportProject: onExportProject ? handleExportProject : undefined, + handleImportProject: onImportProject + ? () => void onImportProject() + : undefined, + isExportingProject, + isImportingProject, + toolbarDisabled: fileTreeDataLoading || isUploadingFiles, + }, + preview: { + selectedFileNode, + selectedFileId, + isFullscreen, + hideDesktop, + changeFiles, + staticFileBasePath, + targetId, + readOnly, + renderPreviewContent, + filePathHeaderProps, + handleFullscreen, + handleFileTreeToggle, + saveFiles, + cancelSaveFiles, + discardChangeFile, + refreshSelectedFileContent, + isSavingFiles, + }, + }; +} diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/index.less b/qiming/src/components/business-component/FileTreePreviewPanel/index.less new file mode 100644 index 00000000..fe93f41f --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/index.less @@ -0,0 +1,169 @@ +@import '@/styles/color'; +@import '@/styles/token'; + +.file-tree-preview-panel { + overflow: hidden; + height: 100%; + + .preview-header-shell { + flex-shrink: 0; + height: 48px; + min-height: 48px; + max-height: 48px; + overflow: hidden; + } + + .file-tree-panel { + flex-shrink: 0; + width: 280px; + min-width: 220px; + max-width: 320px; + border-right: 1px solid rgba(5, 5, 5, 6%); + } + + .preview-panel { + min-width: 0; + background: @colorBgContainer; + } + + .bottom-content { + flex-shrink: 0; + overflow: hidden; + background: transparent; + } + + .git-version-panel { + min-width: 0; + overflow: hidden; + } +} + +.content-container { + flex: 1; + position: relative; + min-height: 0; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; + contain: layout style; + + .restart-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + z-index: 10; + + &::before { + content: ''; + position: absolute; + top: -20px; + left: -20px; + width: calc(100% + 40px); + height: calc(100% + 40px); + background-image: url('~@/assets/images/vnc-bg.jpg'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + opacity: 0.6; + filter: blur(8px); + z-index: 0; + pointer-events: none; + transform: scale(1.5); + } + + .background-placeholder { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + max-width: 100%; + aspect-ratio: 1920 / 1080; + background-image: url('~@/assets/images/vnc-bg.jpg'); + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; + z-index: 1; + } + + .loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 70%); + color: #fff; + z-index: 2; + } + + .loading-spin { + z-index: 3; + position: relative; + } + + .loading-text { + margin-top: 16px; + font-size: 14px; + z-index: 3; + position: relative; + } + } +} + +// 全屏模式 - 与 FileTreeView/index.less 保持一致 +.fullscreen-mode { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 100vh !important; + z-index: 9999; + background: @colorBgContainer; + display: flex !important; + flex-direction: row !important; + + .fullscreen-content-wrapper { + width: 100% !important; + height: 100vh !important; + flex: 1 !important; + } +} + +:global { + body.file-tree-view-fullscreen-active { + [class*='collapse-button'] { + display: none !important; + } + + [class*='chat-input-container'] { + display: none !important; + } + + .react-draggable, + [class*='divider'] { + display: none !important; + } + + [class*='agent-sidebar'] { + display: none !important; + } + } +} + +.vnc-preview { + border: none !important; +} diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/index.tsx b/qiming/src/components/business-component/FileTreePreviewPanel/index.tsx new file mode 100644 index 00000000..cde13fc9 --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/index.tsx @@ -0,0 +1,145 @@ +import FileTreeGitSourcePanel from '@/components/business-component/FileTreeGitSourcePanel'; +import GitVersionRecordPanel from '@/components/business-component/GitVersionRecordPanel'; +import { isAgentVersionControlEnabled } from '@/constants/agent.constants'; +import classNames from 'classnames'; +import React, { useMemo } from 'react'; +import { useFileTreePreviewPanel } from './hooks/useFileTreePreviewPanel'; +import styles from './index.less'; +import type { FileTreePreviewPanelProps } from './types'; + +const cx = classNames.bind(styles); + +export { default as FileTreeViewPanel } from './FileTreeViewPanel'; +export { useFileTreePreviewView } from './hooks/useFileTreePreviewView'; +export type { + ChangeFileGitStatusKind, + ChangeFileInfo, + FileTreePreviewGitVersionControlProps, + FileTreePreviewPanelProps, + FileTreePreviewViewPreview, + FileTreePreviewViewProps, + FileTreePreviewViewValue, + UseFileTreePreviewPanelParams, +} from './types'; + +/** + * 文件树 + 预览区组合面板 + * 顶部 Header,下方左侧文件树、右侧预览内容 + */ +const FileTreePreviewPanel: React.FC = ({ + className, + tree, + preview, + sourceControl, + showSourceControl = Boolean(sourceControl?.onCommit), + enableVersionControl, + viewMode, + hideDesktop, + diffFile, + gitVersionPanelOpen = false, + onToggleGitVersionPanel, + afterGitVersionActions, + bottomContent, + gitVersionControl, + previewPanelProps, + treeHeaderClassName, +}) => { + const resolvedShowSourceControl = useMemo(() => { + const base = showSourceControl ?? Boolean(sourceControl?.onCommit); + if (enableVersionControl === undefined) { + return base; + } + return base && isAgentVersionControlEnabled(enableVersionControl); + }, [showSourceControl, sourceControl?.onCommit, enableVersionControl]); + + const showFileTree = viewMode !== 'desktop' && tree.isFileTreeVisible; + const showGitVersionButton = + Boolean(gitVersionControl) && resolvedShowSourceControl; + const showGitVersionPanel = + gitVersionPanelOpen && + showGitVersionButton && + !diffFile && + viewMode !== 'desktop'; + + const { header, content, restartOverlay } = useFileTreePreviewPanel({ + preview, + viewMode, + hideDesktop, + diffFile, + showGitVersionButton, + onToggleGitVersionPanel, + afterGitVersionActions, + ...previewPanelProps, + }); + + const isFullscreen = preview.isFullscreen; + + const emptySourceControl = { + changeFiles: preview.changeFiles, + }; + + // 布局与 FileTreeView/index.tsx 1742-1769 保持一致 + return ( +
+
+
{header}
+ +
+ {showFileTree && ( + + )} + +
+ {showGitVersionPanel && gitVersionControl ? ( + + ) : ( + content + )} + {restartOverlay} +
+
+ {bottomContent && ( +
{bottomContent}
+ )} +
+
+ ); +}; + +export default FileTreePreviewPanel; diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/types.ts b/qiming/src/components/business-component/FileTreePreviewPanel/types.ts new file mode 100644 index 00000000..ca8fc25e --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/types.ts @@ -0,0 +1,235 @@ +import type { GitWorkspaceConfig } from '@/components/business-component/FileTreeGitSourcePanel'; +import type { + FileTreeContainerProps, + SourceControlProps, +} from '@/components/business-component/FileTreeGitSourcePanel/types/file-tree-git-source'; +import type { DefaultSelectedEnum, HideDesktopEnum } from '@/types/enums/agent'; +import type { FileNode } from '@/types/interfaces/appDev'; +import type React from 'react'; +import type { FilePathHeaderProps } from './FilePathHeader/type'; +import type { ChangeFileInfo, IdleDetectionConfig } from './types/file-tree'; + +export type { + ChangeFileGitStatusKind, + ChangeFileInfo, + FileTreeViewProps, + FileTreeViewRef, + IdleDetectionConfig, +} from './types/file-tree'; + +/** + * 文件树 + 预览视图 Hook 入参 + * 供 FileTreePreviewPanel / FileTreeViewPanel / Chat / ConversationAgent 使用 + */ +export interface FileTreePreviewViewProps { + className?: string; + /** 文件树头部样式 */ + headerClassName?: string; + /** 通用型智能体会话中点击选中的文件ID */ + taskAgentSelectedFileId?: string; + /** 清除通用型智能体会话中点击选中的文件ID,手动切换文件时使用 */ + clearTaskAgentSelectedFileId?: () => void; + /** 通用型智能体文件选择触发标志 */ + taskAgentSelectTrigger?: number | string; + /** 原始文件列表 */ + originalFiles?: any[]; + /** 文件树数据加载状态 */ + fileTreeDataLoading?: boolean; + /** 目标ID(会话ID) */ + targetId?: string; + /** 当前视图模式 */ + viewMode?: 'preview' | 'desktop'; + /** 是否只读 */ + readOnly?: boolean; + /** 上传多个文件回调 */ + onUploadFiles?: (files: File[], filePaths: string[]) => Promise; + /** 导出项目回调 */ + onExportProject?: () => Promise; + /** 重启智能体电脑 / 客户端 */ + onRestartServer?: () => void; + /** 重启智能体 */ + onRestartAgent?: () => void; + /** 重命名文件回调 */ + onRenameFile?: (node: FileNode, newName: string) => Promise; + /** 创建文件回调 */ + onCreateFileNode?: (node: FileNode, newName: string) => Promise; + /** 删除文件回调 */ + onDeleteFile?: (node: FileNode) => Promise; + /** 保存文件回调 */ + onSaveFiles?: (data: ChangeFileInfo[]) => Promise; + /** 导入项目 */ + onImportProject?: () => Promise; + /** 是否正在导入项目 */ + isImportingProject?: boolean; + /** 单个文件内容变更后实时保存(防抖) */ + onSaveFileContent?: ( + fileId: string, + content: string, + originalFileContent: string, + ) => Promise; + /** 用户选择的智能体电脑ID */ + agentSandboxId?: string; + /** 用户选择的智能体电脑名称 */ + agentSandboxName?: string; + /** 是否显示更多操作菜单 */ + showMoreActions?: boolean; + /** 是否显示全屏预览,由父组件控制 */ + isFullscreenPreview?: boolean; + onFullscreenPreview?: (isFullscreen: boolean) => void; + /** 分享回调 */ + onShare?: () => void; + /** 是否显示分享按钮 */ + isShowShare?: boolean; + /** 是否显示全屏图标 */ + showFullscreenIcon?: boolean; + /** 是否隐藏文件树(外部控制) */ + hideFileTree?: boolean; + /** 是否显示刷新按钮 */ + showRefreshButton?: boolean; + /** VNC 空闲检测配置 */ + idleDetection?: IdleDetectionConfig; + /** 是否为项目技能模式 */ + isProjectSkill?: boolean; + /** 初始化视图类型 */ + initViewFileType?: 'preview' | 'code'; + /** 是否显示导出 PDF 按钮 */ + isShowExportPdfButton?: boolean; + /** 是否显示下载按钮 */ + isShowDownloadButton?: boolean; + /** 关闭整个面板 */ + onClose?: () => void; + /** 文件树是否固定 */ + isFileTreePinned?: boolean; + /** 文件树固定状态变化回调 */ + onFileTreePinnedChange?: (pinned: boolean) => void; + /** 文件树侧边栏是否可见(受控模式) */ + isFileTreeSidebarVisible?: boolean; + /** 文件树侧边栏可见性变化回调 */ + onFileTreeSidebarVisibleChange?: (visible: boolean) => void; + /** 是否可以删除技能文件 */ + isCanDeleteSkillFile?: boolean; + /** 刷新文件树回调 */ + onRefreshFileTree?: () => Promise; + /** 是否隐藏远程桌面 */ + hideDesktop?: HideDesktopEnum; + /** 静态资源文件基础路径 */ + staticFileBasePath?: string; + /** 选中文件后打开右侧预览面板(隐藏编排区域) */ + onFileSelectOpenPreview?: (fileId?: string) => void; + /** 文件重命名成功后回调,用于同步预览区标签页 */ + onFileRenamed?: (oldFileId: string, newFileId: string) => void; + /** 文件/文件夹删除成功后回调,用于同步预览区标签页与 Git 状态 */ + onFileDeleted?: (node: FileNode) => void; + /** 刷新文件树后,当前选中文件已不存在时回调 */ + onSelectedFileMissing?: (fileId: string) => void; + /** CodeViewer 是否使用动态主题(Chat 页为 true) */ + isDynamicTheme?: boolean; + /** 是否启用 Git status(仅通用型 TaskAgent 智能体) */ + enableGitStatus?: boolean; + /** 智能体是否开启版本管理 */ + enableVersionControl?: DefaultSelectedEnum; +} + +/** 预览 Header 组件 props */ +export type FileTreePreviewPathHeaderProps = FilePathHeaderProps; + +/** 预览 UI 所需的状态、处理器与渲染函数 */ +export interface FileTreePreviewViewPreview { + selectedFileNode: FileNode | null; + selectedFileId: string; + isFullscreen: boolean; + hideDesktop: HideDesktopEnum; + changeFiles: ChangeFileInfo[]; + staticFileBasePath?: string; + targetId?: string; + readOnly: boolean; + /** 渲染预览内容区域(VNC / 文件预览 / 代码编辑器等) */ + renderPreviewContent: () => React.ReactNode; + /** FilePathHeader 组件所需的 props */ + filePathHeaderProps: FileTreePreviewPathHeaderProps; + handleFullscreen: () => void; + handleFileTreeToggle: () => void; + saveFiles: () => Promise; + cancelSaveFiles: () => void; + /** 放弃单个文件的修改 */ + discardChangeFile: (fileId: string) => void; + /** 重新通过 fileProxyUrl 拉取当前选中文件内容 */ + refreshSelectedFileContent: () => Promise; + /** 是否正在保存文件 */ + isSavingFiles: boolean; +} + +/** useFileTreePreviewView 返回值 */ +export interface FileTreePreviewViewValue { + className?: string; + changeFiles: ChangeFileInfo[]; + /** 是否正在刷新 Git 变更列表 */ + isRefreshingGitList: boolean; + /** 当前 Git 分支名(来自 git status 的 current) */ + gitBranch: string; + /** 刷新 Git 变更列表(git status) */ + refreshGitList: () => Promise; + tree: FileTreeContainerProps; + preview: FileTreePreviewViewPreview; +} + +/** Git 版本记录面板配置 */ +export interface FileTreePreviewGitVersionControlProps { + workspace: GitWorkspaceConfig; + branch: string; + onRollbackSuccess?: () => void; +} + +/** 预览区 Hook 入参 */ +export interface UseFileTreePreviewPanelParams { + /** 文件预览状态与渲染函数(来自 useFileTreePreviewView) */ + preview: FileTreePreviewViewPreview; + /** 当前视图模式:文件预览 / 智能体电脑 */ + viewMode: 'preview' | 'desktop'; + agentSandboxId?: string; + agentSandboxName?: string; + onRestartServer?: () => void | Promise; + onRestartAgent?: () => void; + onExportProject?: () => Promise; + idleDetection?: IdleDetectionConfig; + hideDesktop?: HideDesktopEnum; + /** Git 源代码管理选中的 diff 文件(优先于普通预览) */ + diffFile?: ChangeFileInfo | null; + showGitVersionButton?: boolean; + onToggleGitVersionPanel?: () => void; + /** Git 版本记录按钮后的额外操作按钮 */ + afterGitVersionActions?: React.ReactNode; +} + +/** FileTreePreviewPanel 组件属性 */ +export interface FileTreePreviewPanelProps { + className?: string; + tree: FileTreeContainerProps; + preview: FileTreePreviewViewPreview; + sourceControl?: SourceControlProps; + /** 是否显示源代码管理 Tab */ + showSourceControl?: boolean; + /** 智能体是否开启版本管理;为 No 或未开启时隐藏源代码管理 */ + enableVersionControl?: DefaultSelectedEnum; + viewMode: 'preview' | 'desktop'; + hideDesktop?: HideDesktopEnum; + diffFile?: ChangeFileInfo | null; + gitVersionPanelOpen?: boolean; + onToggleGitVersionPanel?: () => void; + /** Git 版本记录按钮后的额外操作按钮 */ + afterGitVersionActions?: React.ReactNode; + /** 面板底部附加内容 */ + bottomContent?: React.ReactNode; + gitVersionControl?: FileTreePreviewGitVersionControlProps; + previewPanelProps: Omit< + UseFileTreePreviewPanelParams, + | 'preview' + | 'viewMode' + | 'diffFile' + | 'showGitVersionButton' + | 'isGitVersionPanelOpen' + | 'onToggleGitVersionPanel' + >; + /** 文件树头部(搜索区)类名 */ + treeHeaderClassName?: string; +} diff --git a/qiming/src/components/business-component/FileTreePreviewPanel/types/file-tree.ts b/qiming/src/components/business-component/FileTreePreviewPanel/types/file-tree.ts new file mode 100644 index 00000000..df495c59 --- /dev/null +++ b/qiming/src/components/business-component/FileTreePreviewPanel/types/file-tree.ts @@ -0,0 +1,104 @@ +import type { IdleDetectionConfig } from '@/components/business-component/VncPreview/type'; +import type { DefaultSelectedEnum, HideDesktopEnum } from '@/types/enums/agent'; +import type { FileNode } from '@/types/interfaces/appDev'; +import type React from 'react'; +import type { + GitWorkspaceConfig, + SourceControlCallbacks, +} from '../../FileTreeGitSourcePanel'; + +/** Git 变更类型(与 VS Code 源代码管理角标一致) */ +export type ChangeFileGitStatusKind = + | 'modified' + | 'added' + | 'deleted' + | 'untracked' + | 'conflict' + | 'renamed'; + +/** 修改的文件信息 */ +export interface ChangeFileInfo { + fileId: string; + fileContent: string; + originalFileContent: string; + /** 暂存区状态 */ + stagedStatus?: ChangeFileGitStatusKind; + /** 工作区未暂存状态 */ + unstagedStatus?: ChangeFileGitStatusKind; +} + +export type { IdleDetectionConfig }; + +/** FileTreeViewPanel 暴露给父组件的 ref */ +export interface FileTreeViewRef { + changeFiles: ChangeFileInfo[]; + /** 当前在文件树/预览区选中的文件 ID */ + selectedFileId: string; +} + +/** + * FileTreeViewPanel 组件属性(兼容原 FileTreeView) + */ +export interface FileTreeViewProps { + className?: string; + headerClassName?: string; + taskAgentSelectedFileId?: string; + clearTaskAgentSelectedFileId?: () => void; + taskAgentSelectTrigger?: number | string; + originalFiles?: any[]; + fileTreeDataLoading?: boolean; + readOnly?: boolean; + targetId?: string; + viewMode?: 'preview' | 'desktop'; + onUploadFiles?: (files: File[], filePaths: string[]) => Promise; + onExportProject?: () => Promise; + onRenameFile?: (node: FileNode, newName: string) => Promise; + onCreateFileNode?: (node: FileNode, newName: string) => Promise; + onDeleteFile?: (node: FileNode) => Promise; + onSaveFiles?: (data: ChangeFileInfo[]) => Promise; + onImportProject?: () => Promise; + isImportingProject?: boolean; + agentSandboxId?: string; + agentSandboxName?: string; + onRestartServer?: () => void; + onRestartAgent?: () => void; + showMoreActions?: boolean; + isFullscreenPreview?: boolean; + onFullscreenPreview?: (isFullscreen: boolean) => void; + onShare?: () => void; + isShowShare?: boolean; + onClose?: () => void; + showFullscreenIcon?: boolean; + hideFileTree?: boolean; + isFileTreePinned?: boolean; + onFileTreePinnedChange?: (pinned: boolean) => void; + isCanDeleteSkillFile?: boolean; + onRefreshFileTree?: () => Promise; + showRefreshButton?: boolean; + idleDetection?: IdleDetectionConfig; + hideDesktop?: HideDesktopEnum; + isDynamicTheme?: boolean; + isShowExportPdfButton?: boolean; + isShowDownloadButton?: boolean; + staticFileBasePath?: string; + isProjectSkill?: boolean; + initViewFileType?: 'preview' | 'code'; + /** Git 源代码管理配置;传入后显示源代码管理面板 */ + gitSourceControl?: { + workspace: GitWorkspaceConfig; + callbacks?: Partial< + Pick< + SourceControlCallbacks, + 'addFileToGitignore' | 'onCommitSuccess' | 'onRefreshGitList' + > + >; + }; + /** 智能体是否开启版本管理 */ + enableVersionControl?: DefaultSelectedEnum; + /** 文件树预览面板底部内容 */ + bottomContent?: React.ReactNode; + /** 刷新文件树后,当前选中/待选文件已不存在时回调 */ + onSelectedFileMissing?: (fileId: string) => void; + /** 是否启用 Git status 拉取 */ + enableGitStatus?: boolean; +} diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitChangesPanel/index.less b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitChangesPanel/index.less new file mode 100644 index 00000000..a7b63752 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitChangesPanel/index.less @@ -0,0 +1,320 @@ +@import '@/styles/token'; + +.panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + background-color: @colorBgContainer; +} + +.top-bar { + display: flex; + align-items: center; + gap: @marginXs; + padding: @paddingSm @paddingMd; + flex-shrink: 0; +} + +.back-btn { + padding: 0 @paddingXxs; + color: @colorTextSecondary; +} + +.branch-row { + display: flex; + align-items: center; + gap: @marginXxs; + font-size: @fontSizeSm; + color: @colorTextSecondary; +} + +.commit-card { + margin: 0 @paddingMd; + padding: @paddingSm @paddingMd; + border: @lineWidth solid @colorBorder; + border-radius: @borderRadiusLg; + background: @colorFillQuaternary; + flex-shrink: 0; +} + +.commit-card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: @marginSm; + margin-bottom: @marginXxs; +} + +.commit-hash { + font-family: @fontFamilyCode; + font-size: @fontSize; + font-weight: @fontWeightStrong; + color: @colorPrimary; + color: @colorText; +} + +.commit-meta { + font-size: @fontSizeSm; + color: @colorTextTertiary; + white-space: nowrap; +} + +.commit-message { + margin: 0; + font-size: @fontSizeSm; + font-weight: @fontWeightStrong; + color: @colorTextSecondary; + word-break: break-word; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: @marginSm; + padding: @paddingSm @paddingMd; + flex-shrink: 0; +} + +.file-count { + font-size: @fontSize; + color: @colorText; + white-space: nowrap; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: @marginSm; + min-width: 0; + flex: 1; + justify-content: flex-end; +} + +.diff-mode-toggle { + display: inline-flex; + align-items: center; + flex-shrink: 0; + padding: 2px; + border-radius: 20px; + background-color: @colorFillQuaternary; +} + +.diff-mode-option { + padding: 4px 14px; + border: none; + border-radius: 18px; + background: transparent; + color: @colorTextSecondary; + font-size: @fontSizeSm; + line-height: 20px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease; + + &:hover:not(.diff-mode-option-active) { + color: @colorText; + } +} + +.diff-mode-option-active { + background-color: @colorBgContainer; + color: @colorText; + font-weight: @fontWeightStrong; + box-shadow: 0 1px 3px rgba(0, 0, 0, 8%); +} + +.search-input { + width: 148px; + flex-shrink: 0; + border-radius: 12px; + background-color: @colorBgContainer; + border-color: @colorBorder; + box-shadow: none; + transition: border-color 0.2s ease; + + :global(.ant-input) { + font-size: @fontSizeSm; + background: transparent; + } + + :global(.ant-input-prefix) { + margin-inline-end: @marginXxs; + } + + :global(.ant-input-clear-icon) { + font-size: @fontSizeSm; + color: @colorTextQuaternary; + } + + &:hover { + border-color: @colorBorder; + background-color: @colorBgContainer; + } + + &:global(.ant-input-affix-wrapper-focused) { + border-color: @colorPrimary; + box-shadow: none; + background-color: @colorBgContainer; + } +} + +.search-icon { + color: @colorTextTertiary; + font-size: 14px; +} + +.file-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 @paddingMd @paddingMd; +} + +/** 仅 1 个变更文件且 diff 已展开:列表区交给 diff 占满剩余高度 */ +.file-list-single-expanded { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.list-loading, +.list-empty { + display: flex; + align-items: center; + justify-content: center; + padding: @paddingLg 0; +} + +.file-block { + margin-bottom: @marginSm; + border: @lineWidth solid @colorBorder; + border-radius: @borderRadiusLg; + overflow: hidden; + background: @colorBgContainer; +} + +.file-block-fill { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + margin-bottom: 0; +} + +.file-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: @marginSm; + padding: @paddingSm @paddingMd; + cursor: pointer; + transition: background-color 0.2s ease; + flex-shrink: 0; + + &:hover { + background-color: @colorFillQuaternary; + } +} + +.file-row-left { + display: flex; + align-items: center; + gap: @marginXs; + min-width: 0; + flex: 1; +} + +.file-path { + font-size: @fontSizeSm; + color: @colorText; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-row-right { + display: flex; + align-items: center; + gap: @marginXs; + flex-shrink: 0; +} + +.diff-stats-added { + font-size: @fontSizeSm; + color: @colorSuccess; +} + +.diff-stats-deleted { + font-size: @fontSizeSm; + color: @colorError; +} + +.status-tag { + padding: 0 @paddingXxs; + font-size: 10px; + line-height: 16px; + border-radius: @borderRadiusSm; + border: @lineWidth solid transparent; +} + +.status-tag-modified { + color: @colorWarning; + background: @colorWarningBg; + border-color: @colorWarningBorder; +} + +.status-tag-added { + color: @colorSuccess; + background: @colorSuccessBg; + border-color: @colorSuccessBorder; +} + +.status-tag-deleted { + color: @colorError; + background: @colorErrorBg; + border-color: @colorErrorBorder; +} + +.status-tag-renamed { + color: @colorInfo; + background: @colorInfoBg; + border-color: @colorInfoBorder; +} + +.expand-icon { + color: @colorTextTertiary; + font-size: 12px; + transition: transform 0.2s ease; + + &.expanded { + transform: rotate(180deg); + } +} + +.file-diff { + border-top: @lineWidth solid @colorSplit; + max-height: 480px; + min-height: 120px; + overflow: auto; +} + +.file-diff-fill { + flex: 1; + min-height: 0; + max-height: none; +} + +.file-diff-loading { + display: flex; + align-items: center; + justify-content: center; + padding: @paddingMd; +} + +.file-diff-loading-fill { + flex: 1; + min-height: 0; +} diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitChangesPanel/index.tsx b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitChangesPanel/index.tsx new file mode 100644 index 00000000..0f4b6ca6 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitChangesPanel/index.tsx @@ -0,0 +1,453 @@ +import ChangeFileGitDiffView from '@/components/business-component/ChangeFileGitDiffView'; +import Loading from '@/components/custom/Loading'; +import { dict } from '@/services/i18nRuntime'; +import { formatTimeAgo } from '@/utils/common'; +import { getFileIcon } from '@/utils/fileTree'; +import { + BranchesOutlined, + DownOutlined, + LeftOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import { DiffModeEnum } from '@git-diff-view/react'; +import { Button, Empty, Input } from 'antd'; +import classNames from 'classnames'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { + GitCommitDiffFileItem, + GitCommitDiffFileStatus, + GitCommitLogItem, +} from '../../FileTreeGitSourcePanel/types/git-version-management'; +import { getDisplayHash } from '../commitListUtils'; +import { + fetchGitCommitDiffFiles, + getLineDiffStats, +} from '../gitCommitDiffUtils'; +import GitVersionRollbackConfirmModal from '../GitVersionRollbackConfirmModal'; +import { useGitVersionRollback } from '../useGitVersionRollback'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +type WorkspaceParams = { + workspaceType: 'pageApp' | 'taskAgent'; + projectId?: number; + cid?: number; +}; + +export interface GitVersionCommitChangesPanelProps { + commit: GitCommitLogItem; + branch: string; + workspaceParams: WorkspaceParams; + onBack: () => void; + onRollbackSuccess?: () => void; + className?: string; +} + +const getFileName = (path: string): string => { + const segments = path.split('/'); + return segments[segments.length - 1] || path; +}; + +const getStatusLabel = (status: GitCommitDiffFileStatus): string => { + const keyMap: Record = { + modified: 'PC.Components.FileTreePanel.GitVersionRecord.statusModified', + added: 'PC.Components.FileTreePanel.GitVersionRecord.statusAdded', + deleted: 'PC.Components.FileTreePanel.GitVersionRecord.statusDeleted', + renamed: 'PC.Components.FileTreePanel.GitVersionRecord.statusRenamed', + }; + return dict(keyMap[status]); +}; + +/** + * 单次提交的变更详情:变更文件列表 + 可展开的文件 diff + */ +const GitVersionCommitChangesPanel: React.FC< + GitVersionCommitChangesPanelProps +> = ({ + commit, + branch, + workspaceParams, + onBack, + onRollbackSuccess, + className, +}) => { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const { + rollbackCommit, + rollbackModalOpen, + rollbackLoading, + openRollbackConfirm, + closeRollbackConfirm, + confirmRollback, + } = useGitVersionRollback({ + workspaceParams, + onSuccess: onRollbackSuccess, + }); + const [expandedPaths, setExpandedPaths] = useState>( + () => new Set(), + ); + const [loadingPaths, setLoadingPaths] = useState>( + () => new Set(), + ); + const [fileContentMap, setFileContentMap] = useState< + Record + >({}); + const [diffViewMode, setDiffViewMode] = useState( + DiffModeEnum.Unified, + ); + const [searchKeyword, setSearchKeyword] = useState(''); + + // 加载提交的变更文件列表 + const loadCommitFiles = useCallback(async () => { + setLoading(true); + try { + const list = await fetchGitCommitDiffFiles(workspaceParams, commit.hash); + setFiles(list); + const contentMap: Record = {}; + list.forEach((item) => { + if (item.oldContent || item.newContent) { + contentMap[item.path] = item; + } + }); + setFileContentMap(contentMap); + } catch { + setFiles([]); + setFileContentMap({}); + } finally { + setLoading(false); + } + }, [workspaceParams, commit.hash]); + + useEffect(() => { + setExpandedPaths(new Set()); + setLoadingPaths(new Set()); + setFileContentMap({}); + setSearchKeyword(''); + void loadCommitFiles(); + }, [loadCommitFiles]); + + /** 判断文件是否匹配搜索关键词(路径或变更内容) */ + const matchesSearchKeyword = useCallback( + (file: GitCommitDiffFileItem, keyword: string) => { + if (file.path.toLowerCase().includes(keyword)) { + return true; + } + + const cached = fileContentMap[file.path]; + const oldContent = ( + cached?.oldContent ?? + file.oldContent ?? + '' + ).toLowerCase(); + const newContent = ( + cached?.newContent ?? + file.newContent ?? + '' + ).toLowerCase(); + + return oldContent.includes(keyword) || newContent.includes(keyword); + }, + [fileContentMap], + ); + + // 搜索过滤文件列表(按路径或文件内容) + const filteredFiles = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase(); + if (!keyword) { + return files; + } + return files.filter((file) => matchesSearchKeyword(file, keyword)); + }, [files, searchKeyword, matchesSearchKeyword]); + + /** 仅 1 个变更文件且已展开 diff 时,diff 区域占满剩余高度 */ + const isSingleFileExpanded = useMemo(() => { + if (files.length !== 1) { + return false; + } + const onlyFile = files[0]; + return Boolean(onlyFile && expandedPaths.has(onlyFile.path)); + }, [files, expandedPaths]); + + /** 确保文件内容已加载 */ + const ensureFileContent = useCallback( + async (path: string) => { + if (fileContentMap[path]) { + return; + } + setLoadingPaths((prev) => new Set(prev).add(path)); + try { + const list = await fetchGitCommitDiffFiles( + workspaceParams, + commit.hash, + [path], + ); + const target = list.find((item) => item.path === path) ?? list[0]; + if (target) { + setFileContentMap((prev) => ({ ...prev, [path]: target })); + } + } finally { + setLoadingPaths((prev) => { + const next = new Set(prev); + next.delete(path); + return next; + }); + } + }, + [fileContentMap, workspaceParams, commit.hash], + ); + + const toggleFileExpand = useCallback( + (path: string) => { + setExpandedPaths((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + void ensureFileContent(path); + } + return next; + }); + }, + [ensureFileContent], + ); + + // 打开回滚确认弹窗 + const handleRollback = useCallback(() => { + openRollbackConfirm(commit); + }, [commit, openRollbackConfirm]); + + const renderFileDiff = ( + file: GitCommitDiffFileItem, + fillRemainingHeight?: boolean, + ) => { + const cached = fileContentMap[file.path] ?? file; + const isLoadingContent = loadingPaths.has(file.path); + + if (isLoadingContent) { + return ( +
+ +
+ ); + } + + return ( + + ); + }; + + return ( +
+ {/* 顶部栏 */} +
+ +
+ + {branch} +
+
+ + {/* 提交卡片 */} +
+
+
+ {getDisplayHash(commit.hash)} +
+
+ {commit.author_name} · {formatTimeAgo(commit.date)} +
+
+

{commit.message}

+ +
+ + {/* 工具栏 */} +
+ + {dict( + 'PC.Components.FileTreePanel.GitVersionRecord.changedFileCount', + ).replace('{0}', String(files.length))} + +
+ {/* 对比模式切换 */} +
+ + +
+ + {/* 搜索输入框 */} + } + placeholder={dict( + 'PC.Components.FileTreePanel.GitVersionRecord.searchPlaceholder', + )} + value={searchKeyword} + onChange={(e) => setSearchKeyword(e.target.value)} + /> +
+
+ + {/* 文件列表 */} +
+ {loading ? ( +
+ +
+ ) : filteredFiles.length === 0 ? ( +
+ +
+ ) : ( + filteredFiles.map((file) => { + const isExpanded = expandedPaths.has(file.path); + const stats = + file.additions || file.deletions + ? file + : { + ...file, + ...getLineDiffStats(file.oldContent, file.newContent), + }; + + return ( +
+
toggleFileExpand(file.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + toggleFileExpand(file.path); + } + }} + role="button" + tabIndex={0} + > + {/* 文件名 */} +
+ {getFileIcon(getFileName(file.path))} + + {file.path} + +
+ + {/* 文件状态 */} +
+ {/* 新增行数 */} + {stats.additions > 0 && ( + + +{stats.additions} + + )} + + {/* 删除行数 */} + {stats.deletions > 0 && ( + + -{stats.deletions} + + )} + + {/* 文件状态 */} + + {getStatusLabel(file.status)} + + + {/* 展开图标 */} + +
+
+ + {/* 文件差异 */} + {isExpanded && renderFileDiff(file, isSingleFileExpanded)} +
+ ); + }) + )} +
+ + {/* 回滚确认弹窗 */} + +
+ ); +}; + +export default GitVersionCommitChangesPanel; diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitTimeline/index.less b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitTimeline/index.less new file mode 100644 index 00000000..41b30c1d --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitTimeline/index.less @@ -0,0 +1,165 @@ +@import '@/styles/token'; + +.timeline { + position: relative; + padding-left: 18px; + + &::before { + content: ''; + position: absolute; + top: 10px; + bottom: 0; + left: 5px; + width: 1px; + background-color: @colorBorderSecondary; + } +} + +.date-group { + margin-bottom: @marginMd; + + &:last-child { + margin-bottom: 0; + } +} + +.date-header { + position: relative; + display: flex; + align-items: center; + margin-bottom: @marginSm; +} + +.date-node { + position: absolute; + left: -18px; + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 16px; + font-size: 10px; + line-height: 1; + color: @colorTextTertiary; + background-color: @colorBgContainer; +} + +.date-label { + font-size: @fontSizeSm; + color: @colorTextSecondary; + line-height: @lineHeightSm; +} + +.date-commits { + display: flex; + flex-direction: column; + gap: @marginXs; +} + +.item { + position: relative; + display: flex; + align-items: stretch; + gap: @marginSm; + padding: @paddingSm @paddingMd; + border: @lineWidth solid @colorBorderSecondary; + border-radius: @borderRadius; + background-color: @colorBgContainer; + transition: background-color 0.2s ease; + + &:hover { + background-color: @colorBorderSecondary; + } +} + +.item-main { + flex: 1; + min-width: 0; + cursor: pointer; +} + +.item-action { + position: absolute; + top: 50%; + right: @paddingMd; + display: flex; + flex-shrink: 0; + align-items: center; + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(-50%); + transition: opacity 0.2s ease, visibility 0.2s ease; +} + +.item:hover .item-action, +.item-action.item-action-visible { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.hash { + font-size: @fontSizeSm; + color: @colorTextTertiary; + font-family: @fontFamilyCode; +} + +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: @marginXs; + font-size: @fontSizeSm; + color: @colorTextTertiary; + line-height: @lineHeightSm; +} + +.divider { + color: @colorBorder; +} + +.time { + flex-shrink: 0; + font-size: @fontSizeSm; + color: @colorTextTertiary; +} + +.message { + flex: 1; + min-width: 0; + margin: 0; + font-size: @fontSize; + font-weight: @fontWeightStrong; + color: @colorText; + line-height: @lineHeight; + word-break: break-word; +} + +.author { + display: inline-flex; + align-items: center; + gap: @marginXxs; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.author-avatar { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + overflow: hidden; + border-radius: 50%; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitTimeline/index.tsx b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitTimeline/index.tsx new file mode 100644 index 00000000..f2711498 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionCommitTimeline/index.tsx @@ -0,0 +1,115 @@ +import avatarImage from '@/assets/images/avatar.png'; +import { dict } from '@/services/i18nRuntime'; +import { UndoOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import type { GitCommitLogItem } from '../../FileTreeGitSourcePanel/types/git-version-management'; +import { formatCommitTime, getDisplayHash } from '../commitListUtils'; +import type { CommitDateGroup } from '../types'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +export interface GitVersionCommitTimelineProps { + /** 按日期分组后的提交列表 */ + groups: CommitDateGroup[]; + /** 正在回滚的提交 hash */ + rollbackLoadingHash?: string | null; + /** 点击查看变更 */ + onOpenCommitChanges: (commit: GitCommitLogItem) => void; + /** 点击回滚 */ + onRollback: (commit: GitCommitLogItem) => void; +} + +/** + * Git 提交时间线列表 + * + * 按日期分组展示提交历史,支持查看变更与回滚操作。 + */ +const GitVersionCommitTimeline: React.FC = ({ + groups, + rollbackLoadingHash = null, + onOpenCommitChanges, + onRollback, +}) => { + const renderCommitItem = (commit: GitCommitLogItem) => { + const isRollbackLoading = rollbackLoadingHash === commit.hash; + + return ( +
+
onOpenCommitChanges(commit)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onOpenCommitChanges(commit); + } + }} + role="button" + tabIndex={0} + > +

{commit.message}

+
+ + {getDisplayHash(commit.hash)} + + | + + + {commit.author_name} + + {commit.author_name} + + | + + {formatCommitTime(commit.date)} + +
+
+
+ +
+
+ ); + }; + + return ( +
+ {groups.map((group) => ( +
+
+ + ◇ + + + {dict( + 'PC.Pages.ConversationAgent.AgentGitVersionRecord.commitsOn', + ).replace('{0}', group.dateLabel)} + +
+
+ {group.commits.map(renderCommitItem)} +
+
+ ))} +
+ ); +}; + +export default GitVersionCommitTimeline; diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionRollbackConfirmModal/index.less b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionRollbackConfirmModal/index.less new file mode 100644 index 00000000..0834164a --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionRollbackConfirmModal/index.less @@ -0,0 +1,90 @@ +@import '@/styles/token'; + +.modal { + :global(.ant-modal-content) { + padding: 0; + overflow: hidden; + } + + :global(.ant-modal-header) { + margin: 0; + padding: @paddingMd @paddingLg @paddingSm; + border-bottom: none; + } + + :global(.ant-modal-title) { + font-size: @fontSizeLg; + font-weight: @fontWeightStrong; + line-height: @lineHeightLg; + } + + :global(.ant-modal-body) { + padding: 0 @paddingLg @paddingMd; + } + + :global(.ant-modal-footer) { + margin: 0; + padding: @paddingSm @paddingLg @paddingMd; + border-top: none; + } +} + +.summary-card { + padding: @paddingSm @paddingMd; + border: @lineWidth solid @colorBorderSecondary; + border-radius: @borderRadiusLg; + background-color: @colorFillQuaternary; +} + +.summary-title { + margin-bottom: @marginXxs; + font-size: @fontSize; + color: @colorText; + line-height: @lineHeight; + + strong { + font-weight: @fontWeightStrong; + } +} + +.summary-meta { + font-size: @fontSizeSm; + color: @colorTextTertiary; + line-height: @lineHeightSm; +} + +.summary-message { + margin: @marginXxs 0 0; + font-size: @fontSizeSm; + color: @colorTextTertiary; + line-height: @lineHeightSm; + word-break: break-word; +} + +.effects { + margin-top: @paddingMd; +} + +.effects-title { + margin-bottom: @marginSm; + font-size: @fontSize; + font-weight: @fontWeightStrong; + color: @colorText; + line-height: @lineHeight; +} + +.effects-list { + margin: 0; + padding-left: @paddingMd; + font-size: @fontSizeSm; + color: @colorTextSecondary; + line-height: @lineHeightLg; + + li + li { + margin-top: @marginXxs; + } +} + +.effects-warning { + color: @colorWarning; +} diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionRollbackConfirmModal/index.tsx b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionRollbackConfirmModal/index.tsx new file mode 100644 index 00000000..d4a807b9 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/GitVersionRollbackConfirmModal/index.tsx @@ -0,0 +1,110 @@ +import { dict } from '@/services/i18nRuntime'; +import { formatTimeAgo } from '@/utils/common'; +import { Button, Modal } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import type { GitCommitLogItem } from '../../FileTreeGitSourcePanel/types/git-version-management'; +import { getDisplayHash } from '../commitListUtils'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +export interface GitVersionRollbackConfirmModalProps { + /** 是否显示弹窗 */ + open: boolean; + /** 待回滚的提交 */ + commit: GitCommitLogItem | null; + /** 确认按钮 loading */ + loading?: boolean; + /** 关闭弹窗 */ + onCancel: () => void; + /** 确认回滚 */ + onConfirm: () => void; +} + +/** + * Git 版本回滚确认弹窗 + */ +const GitVersionRollbackConfirmModal: React.FC< + GitVersionRollbackConfirmModalProps +> = ({ open, commit, loading = false, onCancel, onConfirm }) => { + if (!commit) { + return null; + } + + const displayHash = getDisplayHash(commit.hash); + + return ( + + + + + } + > +
+
+ {dict( + 'PC.Components.FileTreePanel.GitVersionRecord.rollbackVersionLabel', + )}{' '} + {displayHash} +
+
+ {commit.author_name} · {formatTimeAgo(commit.date)} +
+

{commit.message}

+
+ +
+
+ {dict( + 'PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectTitle', + )} +
+
    +
  • + {dict( + 'PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectAutoSave', + )} +
  • +
  • + {dict( + 'PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectBranchSync', + )} +
  • +
  • + {dict( + 'PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectWorkspace', + )} +
  • +
  • + {dict( + 'PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectUndoChanges', + )} +
  • +
  • + {dict( + 'PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectHistory', + )} +
  • +
+
+
+ ); +}; + +export default GitVersionRollbackConfirmModal; diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/commitListUtils.ts b/qiming/src/components/business-component/GitVersionRecordPanel/commitListUtils.ts new file mode 100644 index 00000000..e831d2b6 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/commitListUtils.ts @@ -0,0 +1,52 @@ +import type { GitCommitLogItem } from '@/components/business-component/FileTreeGitSourcePanel/types/git-version-management'; +import dayjs from 'dayjs'; +import type { CommitDateGroup } from './types'; + +/** 获取用于 UI 展示的 hash(取前 10 位) */ +export const getDisplayHash = (hash?: string): string => + hash?.slice(0, 10) || ''; + +/** 格式化提交日期(用于分组标题,如 06/06/2026) */ +export const formatCommitDateLabel = (date: string): string => + date ? dayjs(date).format('DD/MM/YYYY') : ''; + +/** 格式化提交时间(用于单条记录,如 16:02) */ +export const formatCommitTime = (date: string): string => + date ? dayjs(date).format('HH:mm') : ''; + +/** + * 将提交列表按日期分组,保持原有时间倒序 + */ +export const groupCommitsByDate = ( + commits: GitCommitLogItem[], +): CommitDateGroup[] => { + const groupMap = new Map(); + + commits.forEach((commit) => { + const dateKey = dayjs(commit.date).format('YYYY-MM-DD'); + const existing = groupMap.get(dateKey); + if (existing) { + existing.push(commit); + return; + } + groupMap.set(dateKey, [commit]); + }); + + const groups: CommitDateGroup[] = []; + const seenDateKeys = new Set(); + + commits.forEach((commit) => { + const dateKey = dayjs(commit.date).format('YYYY-MM-DD'); + if (seenDateKeys.has(dateKey)) { + return; + } + seenDateKeys.add(dateKey); + groups.push({ + dateKey, + dateLabel: formatCommitDateLabel(commit.date), + commits: groupMap.get(dateKey) ?? [], + }); + }); + + return groups; +}; diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/gitCommitDiffUtils.ts b/qiming/src/components/business-component/GitVersionRecordPanel/gitCommitDiffUtils.ts new file mode 100644 index 00000000..27c5a0c6 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/gitCommitDiffUtils.ts @@ -0,0 +1,311 @@ +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { + apiGitDiff, + apiGitFileContent, +} from '../FileTreeGitSourcePanel/services/git-version-management'; +import type { + GitCommitDiffFileItem, + GitCommitDiffFileStatus, + GitDiffResponseData, + GitDiffSummaryFileItem, + GitFileContentResponseData, +} from '../FileTreeGitSourcePanel/types/git-version-management'; +import type { ChangeListSection } from '../FileTreeGitSourcePanel/utils/changeFileStatus'; + +type WorkspaceParams = { + workspaceType: 'pageApp' | 'taskAgent'; + projectId?: number; + cid?: number; +}; + +interface ParsedDiffFile { + path: string; + oldContent: string; + newContent: string; + additions: number; + deletions: number; + rawChunk: string; +} + +/** 行级 diff 统计(用于接口未返回 additions/deletions 时兜底) */ +export const getLineDiffStats = ( + oldStr: string = '', + newStr: string = '', +): { additions: number; deletions: number } => { + const oldLines = oldStr.split('\n'); + const newLines = newStr.split('\n'); + const m = oldLines.length; + const n = newLines.length; + const dp = Array.from({ length: m + 1 }, () => new Int32Array(n + 1)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + const lcs = dp[m][n]; + return { additions: n - lcs, deletions: m - lcs }; +}; + +const normalizeDiffPath = (rawPath: string): string => + rawPath.replace(/^a\//, '').replace(/^b\//, '').trim(); + +const inferDiffFileStatus = ( + insertions: number, + deletions: number, + rawChunk: string, +): GitCommitDiffFileStatus => { + if (rawChunk.includes('--- /dev/null')) { + return 'added'; + } + if (rawChunk.includes('+++ /dev/null')) { + return 'deleted'; + } + if (rawChunk.includes('rename from') || rawChunk.includes('rename to')) { + return 'renamed'; + } + if (insertions > 0 && deletions === 0) { + return 'added'; + } + if (insertions === 0 && deletions > 0) { + return 'deleted'; + } + return 'modified'; +}; + +/** 从 unified diff 文本块还原旧/新文件内容 */ +const parseDiffChunkContent = ( + chunk: string, +): { + oldContent: string; + newContent: string; + additions: number; + deletions: number; +} => { + const oldLines: string[] = []; + const newLines: string[] = []; + let additions = 0; + let deletions = 0; + + chunk.split('\n').forEach((line) => { + if ( + line.startsWith('diff --git') || + line.startsWith('index ') || + line.startsWith('--- ') || + line.startsWith('+++ ') || + line.startsWith('@@') || + line.startsWith('new file mode') || + line.startsWith('deleted file mode') || + line.startsWith('similarity index') || + line.startsWith('rename from') || + line.startsWith('rename to') + ) { + return; + } + + if (line.startsWith('+')) { + additions += 1; + newLines.push(line.slice(1)); + return; + } + + if (line.startsWith('-')) { + deletions += 1; + oldLines.push(line.slice(1)); + return; + } + + const content = line.startsWith(' ') ? line.slice(1) : line; + oldLines.push(content); + newLines.push(content); + }); + + return { + oldContent: oldLines.join('\n'), + newContent: newLines.join('\n'), + additions, + deletions, + }; +}; + +/** 将 unified diff 文本按文件拆分并解析 */ +const parseUnifiedDiffToFileContents = ( + diffText: string, +): Map => { + const result = new Map(); + if (!diffText.trim()) { + return result; + } + + const chunks = diffText.split(/(?=^diff --git )/m); + const fileChunks = + chunks.length > 1 + ? chunks.filter(Boolean) + : diffText.split(/(?=^--- )/m).filter((chunk) => chunk.trim()); + + fileChunks.forEach((chunk) => { + const gitPathMatch = chunk.match(/^diff --git a\/(.+?) b\/(.+?)$/m); + const oldPathMatch = chunk.match(/^--- a\/(.+?)(?:\t|$)/m); + const newPathMatch = chunk.match(/^\+\+\+ b\/(.+?)(?:\t|$)/m); + const rawPath = + newPathMatch?.[1] ?? + oldPathMatch?.[1] ?? + gitPathMatch?.[2] ?? + gitPathMatch?.[1] ?? + ''; + + const path = normalizeDiffPath(rawPath); + if (!path) { + return; + } + + const parsed = parseDiffChunkContent(chunk); + result.set(path, { + path, + oldContent: parsed.oldContent, + newContent: parsed.newContent, + additions: parsed.additions, + deletions: parsed.deletions, + rawChunk: chunk, + }); + }); + + return result; +}; + +const buildDiffFileItem = ( + summaryItem: GitDiffSummaryFileItem, + parsed?: ParsedDiffFile, +): GitCommitDiffFileItem => { + const path = summaryItem.file; + const oldContent = parsed?.oldContent ?? ''; + const newContent = parsed?.newContent ?? ''; + const additions = summaryItem.insertions ?? parsed?.additions ?? 0; + const deletions = summaryItem.deletions ?? parsed?.deletions ?? 0; + + return { + path, + status: inferDiffFileStatus(additions, deletions, parsed?.rawChunk ?? ''), + additions, + deletions, + oldContent, + newContent, + unifiedDiff: parsed?.rawChunk, + }; +}; + +/** 将接口 data 规范为统一的 diff 文件列表 */ +export const normalizeGitDiffResponse = ( + data: GitDiffResponseData | null | undefined, +): GitCommitDiffFileItem[] => { + if (!data) { + return []; + } + + const parsedByPath = parseUnifiedDiffToFileContents(data.diff ?? ''); + const summaryFiles = data.summary?.files ?? []; + + if (summaryFiles.length > 0) { + return summaryFiles.map((item) => + buildDiffFileItem(item, parsedByPath.get(item.file)), + ); + } + + return Array.from(parsedByPath.values()).map((parsed) => + buildDiffFileItem( + { + file: parsed.path, + changes: parsed.additions + parsed.deletions, + insertions: parsed.additions, + deletions: parsed.deletions, + binary: false, + }, + parsed, + ), + ); +}; + +/** 源代码管理 diff 双端文件内容 */ +export interface GitChangeFileContent { + originalFileContent: string; + fileContent: string; +} + +/** 将接口 data 规范为字符串文件内容 */ +const normalizeGitFileContent = ( + data: GitFileContentResponseData | null | undefined, +): string => String(data?.content ?? ''); + +/** + * 拉取指定 ref 下的单文件内容 + */ +const fetchSingleGitFileContent = async ( + workspaceParams: WorkspaceParams, + ref: string, + filePath: string, +): Promise => { + const res = await apiGitFileContent({ + ...workspaceParams, + ref, + filePath, + }); + + if (res?.code !== SUCCESS_CODE) { + // 新文件在 HEAD 下可能不存在,返回空内容用于 diff 左侧 + if (ref === 'HEAD') { + return ''; + } + throw new Error(res?.message || 'Git file content failed'); + } + + return normalizeGitFileContent(res.data); +}; + +/** + * 拉取工作区或暂存区中单个文件的变更内容(HEAD vs worktree/staged) + * @param fileId 文件路径 + * @param section unstaged 对比 worktree,staged 对比 staged + */ +export const fetchGitChangeFileContent = async ( + workspaceParams: WorkspaceParams, + fileId: string, + section: ChangeListSection, +): Promise => { + const modifiedRef = section === 'staged' ? 'staged' : 'worktree'; + + // 拉取 HEAD 和 worktree/staged 的文件内容 + const [originalFileContent, fileContent] = await Promise.all([ + fetchSingleGitFileContent(workspaceParams, 'HEAD', fileId), + fetchSingleGitFileContent(workspaceParams, modifiedRef, fileId), + ]); + + return { originalFileContent, fileContent }; +}; + +/** + * 拉取某次提交相对父提交的变更文件列表 + * @param commitHash 目标提交 hash + */ +export const fetchGitCommitDiffFiles = async ( + workspaceParams: WorkspaceParams, + commitHash: string, + paths?: string[], +): Promise => { + const res = await apiGitDiff({ + ...workspaceParams, + from: commitHash, + source: 'commit', + paths, + }); + + if (res?.code !== SUCCESS_CODE) { + throw new Error(res?.message || 'Git diff failed'); + } + + return normalizeGitDiffResponse(res.data); +}; diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/gitRollbackUtils.ts b/qiming/src/components/business-component/GitVersionRecordPanel/gitRollbackUtils.ts new file mode 100644 index 00000000..78f52ebe --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/gitRollbackUtils.ts @@ -0,0 +1,61 @@ +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { dict } from '@/services/i18nRuntime'; +import { + apiGitCommit, + apiGitStatus, +} from '../FileTreeGitSourcePanel/services/git-version-management'; +import type { GitTagsParams } from '../FileTreeGitSourcePanel/types/git-version-management'; +import { mergeGitStatusFileIds } from '../FileTreeGitSourcePanel/utils/gitStatusUtils'; + +export type CommitUncommittedResult = { + /** 是否执行了 commit(存在未提交文件并已尝试提交) */ + committed: boolean; + /** 整体是否成功(无未提交文件,或 commit 成功) */ + success: boolean; + errorMessage?: string; +}; + +/** + * 回滚前检测并提交未 commit 的变更 + * 通过 git status 拉取 staged / modified / untracked 等文件,有则自动 commit + */ +export async function commitUncommittedChangesIfAny( + workspaceParams: GitTagsParams, +): Promise { + try { + const statusResponse = await apiGitStatus(workspaceParams); + if (statusResponse.code !== SUCCESS_CODE || !statusResponse.data) { + return { + committed: false, + success: false, + errorMessage: statusResponse.message, + }; + } + + /** 合并 Git status 中的全部变更文件路径(去重) */ + const fileIds = mergeGitStatusFileIds(statusResponse.data); + if (fileIds.length === 0) { + return { committed: false, success: true }; + } + + const commitResponse = await apiGitCommit({ + ...workspaceParams, + message: dict( + 'PC.Components.FileTreePanel.GitVersionRecord.autoSaveBeforeRestoreMessage', + ), + files: fileIds, + }); + + if (commitResponse.code !== SUCCESS_CODE) { + return { + committed: true, + success: false, + errorMessage: commitResponse.message, + }; + } + + return { committed: true, success: true }; + } catch { + return { committed: false, success: false }; + } +} diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/index.less b/qiming/src/components/business-component/GitVersionRecordPanel/index.less new file mode 100644 index 00000000..4db601a4 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/index.less @@ -0,0 +1,41 @@ +@import '@/styles/token'; + +.panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + background-color: @colorBgContainer; +} + +.header { + flex-shrink: 0; + padding: @paddingMd @paddingMd @paddingSm; +} + +.title { + margin: 0; + font-size: @fontSize; + font-weight: @fontWeightStrong; + color: @colorText; + line-height: @lineHeightLg; +} + +.subtitle { + margin: @marginXxs 0 0; + font-size: @fontSizeSm; + color: @colorTextSecondary; + line-height: @lineHeightSm; +} + +.list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 @paddingSm @paddingSm; +} + +.list-loading { + padding: @padding 0; +} diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/index.tsx b/qiming/src/components/business-component/GitVersionRecordPanel/index.tsx new file mode 100644 index 00000000..1f4169f5 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/index.tsx @@ -0,0 +1,338 @@ +import InfiniteScrollDiv from '@/components/custom/InfiniteScrollDiv'; +import Loading from '@/components/custom/Loading'; +import { dict } from '@/services/i18nRuntime'; +import { Empty } from 'antd'; +import classNames from 'classnames'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { apiGitLogList } from '../FileTreeGitSourcePanel/services/git-version-management'; +import type { GitCommitLogItem } from '../FileTreeGitSourcePanel/types/git-version-management'; +import { + buildGitWorkspaceParams, + isGitWorkspaceReady, + type GitWorkspaceConfig, +} from '../FileTreeGitSourcePanel/utils/buildGitWorkspaceParams'; +import { groupCommitsByDate } from './commitListUtils'; +import GitVersionCommitChangesPanel from './GitVersionCommitChangesPanel'; +import GitVersionCommitTimeline from './GitVersionCommitTimeline'; +import GitVersionRollbackConfirmModal from './GitVersionRollbackConfirmModal'; +import styles from './index.less'; +import { useGitVersionRollback } from './useGitVersionRollback'; + +const cx = classNames.bind(styles); + +/** Git log 每页条数 */ +const GIT_LOG_PAGE_SIZE = 20; + +/** 列表滚动容器 ID(InfiniteScroll 挂载目标) */ +const GIT_VERSION_LIST_SCROLL_ID = 'gitVersionRecordList'; + +export interface GitVersionRecordPanelProps { + /** Git 工作空间(pageApp / taskAgent) */ + workspace?: GitWorkspaceConfig; + /** 当前分支名(通常来自 git status 的 current,默认 main) */ + branch?: string; + /** 查看某次提交的变更 */ + onViewChanges?: (commit: GitCommitLogItem) => void; + /** 回滚成功后的回调(如刷新文件树) */ + onRollbackSuccess?: () => void; + /** 自定义根节点类名 */ + className?: string; +} + +/** + * 根据工作空间类型返回空状态文案 + */ +const getWorkspaceEmptyDescription = (workspace?: GitWorkspaceConfig) => { + if (workspace?.workspaceType === 'pageApp') { + return dict('PC.Components.FileTreePanel.GitVersionRecord.noProject'); + } + return dict( + 'PC.Pages.ConversationAgent.AgentGitVersionRecord.noConversation', + ); +}; + +/** + * Git 版本记录面板 + * + * 功能:展示 Git 提交历史列表,支持滚动分页加载、查看变更、回滚到指定提交。 + * 适用场景:AppDev(pageApp)、ConversationAgent(taskAgent)等工作空间。 + * + * 注意事项: + * - workspaceParams 仅依赖 workspaceKey,避免父组件内联 workspace 对象触发重复请求 + * - 列表滚动到底部时通过 InfiniteScrollDiv 自动加载下一页 + */ +const GitVersionRecordPanel: React.FC = ({ + workspace, + branch = 'main', + onViewChanges, + onRollbackSuccess, + className, +}) => { + // ---------- 列表与交互状态 ---------- + /** 已加载的提交列表(多页累加) */ + const [commits, setCommits] = useState([]); + /** 提交总数(接口返回,用于判断是否还有更多) */ + const [total, setTotal] = useState(0); + /** 当前已加载到的页码 */ + const [currentPage, setCurrentPage] = useState(0); + /** 首屏/刷新加载中 */ + const [loading, setLoading] = useState(false); + /** 滚动加载下一页中 */ + const [loadingMore, setLoadingMore] = useState(false); + /** 当前查看变更详情的提交(非空时展示变更文件列表) */ + const [activeCommit, setActiveCommit] = useState( + null, + ); + + const workspaceReady = workspace ? isGitWorkspaceReady(workspace) : false; + + /** 是否还有未加载的提交 */ + const hasMore = commits.length < total; + + /** 按日期分组后的提交列表 */ + const commitDateGroups = useMemo( + () => groupCommitsByDate(commits), + [commits], + ); + + /** 工作空间唯一标识:pageApp 用 projectId,taskAgent 用 cid */ + const workspaceKey = + workspace?.workspaceType === 'pageApp' + ? workspace.projectId + : workspace?.workspaceType === 'taskAgent' + ? workspace.cid + : null; + + /** + * 稳定的 Git API 请求参数 + * 仅随 workspaceKey / workspaceType 变化,不直接依赖 workspace 对象引用 + */ + const workspaceParams = useMemo(() => { + if ( + !workspaceReady || + workspaceKey === null || + workspaceKey === undefined || + !workspace?.workspaceType + ) { + return null; + } + if (workspace.workspaceType === 'pageApp') { + return buildGitWorkspaceParams({ + workspaceType: 'pageApp', + projectId: workspaceKey, + }); + } + return buildGitWorkspaceParams({ + workspaceType: 'taskAgent', + cid: workspaceKey, + }); + }, [workspaceKey, workspace?.workspaceType, workspaceReady]); + + /** + * 拉取指定页的 Git log + * @param page 页码,从 1 开始 + * @param append true 时追加到 commits,false 时替换(首屏/刷新) + */ + const fetchLogPage = useCallback( + async (page: number, append: boolean) => { + if (!workspaceParams) { + return; + } + + if (page === 1) { + setLoading(true); + } else { + setLoadingMore(true); + } + + try { + const res = await apiGitLogList({ + ...workspaceParams, + page, + pageSize: GIT_LOG_PAGE_SIZE, + }); + + const data = res.data; + const newCommits = data?.commits ?? []; + + setTotal(data?.total ?? newCommits.length); + setCurrentPage(page); + setCommits((prev) => (append ? [...prev, ...newCommits] : newCommits)); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, + [workspaceParams], + ); + + /** 重置列表并重新从第 1 页加载(回滚成功后调用) */ + const refreshLog = useCallback(() => { + setActiveCommit(null); + setCommits([]); + setTotal(0); + setCurrentPage(0); + void fetchLogPage(1, false); + }, [fetchLogPage]); + + const { + rollbackCommit, + rollbackModalOpen, + rollbackLoading, + rollbackLoadingHash, + openRollbackConfirm, + closeRollbackConfirm, + confirmRollback, + } = useGitVersionRollback({ + workspaceParams, + onSuccess: () => { + refreshLog(); + onRollbackSuccess?.(); + }, + }); + + /** 打开某次提交的变更文件列表 */ + const openCommitChanges = useCallback( + (commit: GitCommitLogItem) => { + if (onViewChanges) { + onViewChanges(commit); + return; + } + setActiveCommit(commit); + }, + [onViewChanges], + ); + + /** 滚动触底时加载下一页 */ + const loadMore = useCallback(() => { + if (loading || loadingMore || !hasMore) { + return; + } + void fetchLogPage(currentPage + 1, true); + }, [loading, loadingMore, hasMore, currentPage, fetchLogPage]); + + // 工作空间切换时重置并拉取首屏数据 + useEffect(() => { + if (!workspaceReady || !workspaceParams) { + return; + } + setActiveCommit(null); + setCommits([]); + setTotal(0); + setCurrentPage(0); + void fetchLogPage(1, false); + }, [ + workspaceKey, + workspace?.workspaceType, + workspaceReady, + workspaceParams, + fetchLogPage, + ]); + + // 工作空间 ID 未就绪时展示空状态 + if (!workspaceReady) { + return ( +
+ +
+ ); + } + + if (activeCommit && workspaceParams) { + return ( + setActiveCommit(null)} + onRollbackSuccess={() => { + refreshLog(); + onRollbackSuccess?.(); + }} + /> + ); + } + + return ( +
+ {/* 标题区:总提交数 + 分支名 */} +
+

+ {dict('PC.Pages.ConversationAgent.AgentGitVersionRecord.title')} +

+

+ {dict('PC.Pages.ConversationAgent.AgentGitVersionRecord.subtitle') + .replace('{0}', String(total)) + .replace('{1}', branch)} +

+
+ + {/* 提交列表:支持滚动分页 */} +
+ {loading && commits.length === 0 ? ( + // 首屏加载 +
+ +
+ ) : commits.length === 0 ? ( + // 无提交记录 +
+ +
+ ) : ( + // 滚动到底部自动 loadMore + + + + )} +
+ + {/* 回滚确认弹窗 */} + +
+ ); +}; + +export default GitVersionRecordPanel; diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/types.ts b/qiming/src/components/business-component/GitVersionRecordPanel/types.ts new file mode 100644 index 00000000..edfd4201 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/types.ts @@ -0,0 +1,8 @@ +import type { GitCommitLogItem } from '@/components/business-component/FileTreeGitSourcePanel/types/git-version-management'; + +/** 按日期分组的提交列表 */ +export interface CommitDateGroup { + dateKey: string; + dateLabel: string; + commits: GitCommitLogItem[]; +} diff --git a/qiming/src/components/business-component/GitVersionRecordPanel/useGitVersionRollback.ts b/qiming/src/components/business-component/GitVersionRecordPanel/useGitVersionRollback.ts new file mode 100644 index 00000000..6629c5a2 --- /dev/null +++ b/qiming/src/components/business-component/GitVersionRecordPanel/useGitVersionRollback.ts @@ -0,0 +1,86 @@ +import { apiGitRevert } from '@/components/business-component/FileTreeGitSourcePanel/services/git-version-management'; +import type { + GitCommitLogItem, + GitTagsParams, +} from '@/components/business-component/FileTreeGitSourcePanel/types/git-version-management'; +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { dict } from '@/services/i18nRuntime'; +import { message } from 'antd'; +import { useCallback, useMemo, useState } from 'react'; +import { commitUncommittedChangesIfAny } from './gitRollbackUtils'; + +export interface UseGitVersionRollbackOptions { + workspaceParams: GitTagsParams | null; + onSuccess?: () => void; +} + +/** + * Git 版本回滚交互:打开确认弹窗并执行回滚 + */ +export function useGitVersionRollback({ + workspaceParams, + onSuccess, +}: UseGitVersionRollbackOptions) { + const [rollbackCommit, setRollbackCommit] = useState( + null, + ); + const [loading, setLoading] = useState(false); + + const openRollbackConfirm = useCallback((commit: GitCommitLogItem) => { + setRollbackCommit(commit); + }, []); + + const closeRollbackConfirm = useCallback(() => { + if (loading) { + return; + } + setRollbackCommit(null); + }, [loading]); + + // 回滚功能,目前使用 git revert 命令 + const confirmRollback = useCallback(async () => { + if (!workspaceParams || !rollbackCommit) { + return; + } + + setLoading(true); + try { + const commitResult = await commitUncommittedChangesIfAny(workspaceParams); + if (!commitResult.success) { + return; + } + + const { code } = await apiGitRevert({ + ...workspaceParams, + target: rollbackCommit.hash, + // mode: 'mixed', + }); + if (code === SUCCESS_CODE) { + message.success( + dict( + 'PC.Pages.ConversationAgent.AgentGitVersionRecord.rollbackSuccess', + ), + ); + setRollbackCommit(null); + onSuccess?.(); + } + } finally { + setLoading(false); + } + }, [workspaceParams, rollbackCommit, onSuccess]); + + const rollbackLoadingHash = useMemo( + () => (loading ? rollbackCommit?.hash ?? null : null), + [loading, rollbackCommit], + ); + + return { + rollbackCommit, + rollbackModalOpen: rollbackCommit !== null, + rollbackLoading: loading, + rollbackLoadingHash, + openRollbackConfirm, + closeRollbackConfirm, + confirmRollback, + }; +} diff --git a/qiming/src/components/business-component/GuardedFormModal/FormModalForm.tsx b/qiming/src/components/business-component/GuardedFormModal/FormModalForm.tsx new file mode 100644 index 00000000..c48aba7d --- /dev/null +++ b/qiming/src/components/business-component/GuardedFormModal/FormModalForm.tsx @@ -0,0 +1,30 @@ +import type { FormProps } from 'antd'; +import { Form } from 'antd'; +import { useCallback } from 'react'; +import { useFormModalSubmit } from './submitGuard'; + +/** + * GuardedFormModal 内专用 Form:自动绑定 onFinishFailed 释放提交锁 + */ +function GuardedFormModalForm({ + onFinishFailed, + ...rest +}: FormProps) { + const modalSubmit = useFormModalSubmit(); + + const handleFinishFailed = useCallback( + ( + errorInfo: Parameters< + NonNullable['onFinishFailed']> + >[0], + ) => { + modalSubmit?.onFinishFailed(); + onFinishFailed?.(errorInfo); + }, + [modalSubmit, onFinishFailed], + ); + + return {...rest} onFinishFailed={handleFinishFailed} />; +} + +export default GuardedFormModalForm; diff --git a/qiming/src/components/business-component/GuardedFormModal/index.tsx b/qiming/src/components/business-component/GuardedFormModal/index.tsx new file mode 100644 index 00000000..e9653fec --- /dev/null +++ b/qiming/src/components/business-component/GuardedFormModal/index.tsx @@ -0,0 +1,82 @@ +import SubmitButton from '@/components/SubmitButton'; +import { dict } from '@/services/i18nRuntime'; +import { Button, Modal } from 'antd'; +import React, { PropsWithChildren } from 'react'; +import { FormModalSubmitContext, useGuardedFormSubmit } from './submitGuard'; +import type { GuardedFormModalProps } from './types'; + +/** + * 带提交防重的表单弹窗(新建场景专用,不影响 CustomFormModal) + * - ref 锁 + loading 联动,防止 async onFinish 期间重复点击 + * - 子树内可通过 useFormModalSubmit 获取 abortSubmit + */ +const GuardedFormModal: React.FC> = ({ + form, + classNames, + title, + open, + loading, + okPrefixIcon, + okText, + centered = false, + width, + zIndex, + onCancel, + onConfirm, + okDisabled, + footerExtra, + children, +}) => { + const { submitForm, isSubmitting, abortSubmit, onFinishFailed } = + useGuardedFormSubmit({ + form, + loading, + resetWhen: open, + onSubmit: onConfirm, + }); + + return ( + + + {footerExtra} + + + + } + onCancel={isSubmitting ? undefined : onCancel} + > + {children} + + + ); +}; + +export default GuardedFormModal; +export { default as GuardedFormModalForm } from './FormModalForm'; +export { + FormModalSubmitContext, + useFormModalSubmit, + useGuardedSubmit, +} from './submitGuard'; +export type { FormModalSubmitContextValue } from './submitGuard'; +export type { GuardedFormModalProps } from './types'; diff --git a/qiming/src/components/business-component/GuardedFormModal/submitGuard.ts b/qiming/src/components/business-component/GuardedFormModal/submitGuard.ts new file mode 100644 index 00000000..a7c7c2cf --- /dev/null +++ b/qiming/src/components/business-component/GuardedFormModal/submitGuard.ts @@ -0,0 +1,126 @@ +import type { FormInstance } from 'antd'; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; + +/** ref 同步锁:弥补 React loading 更新前的重复提交窗口 */ +function useSubmitLock() { + const lockedRef = useRef(false); + + const tryAcquire = useCallback((): boolean => { + if (lockedRef.current) { + return false; + } + lockedRef.current = true; + return true; + }, []); + + const release = useCallback(() => { + lockedRef.current = false; + }, []); + + const isLocked = useCallback(() => lockedRef.current, []); + + return { tryAcquire, release, isLocked }; +} + +/** + * 提交防重:ref 锁 + pending,loading 结束或 resetWhen 变化时释放 + * 非弹窗场景(如 MCP 页头)可单独使用 + */ +export function useGuardedSubmit(loading?: boolean, resetWhen?: unknown) { + const { tryAcquire, release, isLocked } = useSubmitLock(); + const [pending, setPending] = useState(false); + const prevLoadingRef = useRef(loading); + + const isSubmitting = Boolean(loading) || pending || isLocked(); + + const abortSubmit = useCallback(() => { + release(); + setPending(false); + }, [release]); + + const beginSubmit = useCallback(() => { + if (Boolean(loading) || pending || isLocked()) { + return false; + } + if (!tryAcquire()) { + return false; + } + setPending(true); + return true; + }, [loading, pending, isLocked, tryAcquire]); + + useEffect(() => { + if (prevLoadingRef.current && !loading) { + abortSubmit(); + } + prevLoadingRef.current = loading ?? false; + }, [loading, abortSubmit]); + + useEffect(() => { + if (resetWhen === false) { + abortSubmit(); + } + }, [resetWhen, abortSubmit]); + + return { beginSubmit, abortSubmit, isSubmitting }; +} + +interface UseGuardedFormSubmitOptions { + form: FormInstance; + loading?: boolean; + resetWhen?: unknown; + onSubmit?: () => void; +} + +/** GuardedFormModal 内部:校验通过后触发 onSubmit */ +export function useGuardedFormSubmit({ + form, + loading, + resetWhen, + onSubmit, +}: UseGuardedFormSubmitOptions) { + const { beginSubmit, abortSubmit, isSubmitting } = useGuardedSubmit( + loading, + resetWhen, + ); + + const submitForm = useCallback(async () => { + if (!beginSubmit()) { + return; + } + try { + await form.validateFields(); + (onSubmit ?? (() => form.submit()))(); + } catch { + abortSubmit(); + } + }, [form, onSubmit, beginSubmit, abortSubmit]); + + const onFinishFailed = useCallback(() => { + abortSubmit(); + }, [abortSubmit]); + + return { submitForm, isSubmitting, abortSubmit, onFinishFailed }; +} + +export interface FormModalSubmitContextValue { + submitForm: () => Promise; + isSubmitting: boolean; + abortSubmit: () => void; + onFinishFailed: () => void; +} + +export const FormModalSubmitContext = + createContext(null); + +/** 在 GuardedFormModal 子树内获取提交能力(onFinish 失败/早退时 abortSubmit) */ +export function useFormModalSubmit(): FormModalSubmitContextValue | null { + return useContext(FormModalSubmitContext); +} diff --git a/qiming/src/components/business-component/GuardedFormModal/types.ts b/qiming/src/components/business-component/GuardedFormModal/types.ts new file mode 100644 index 00000000..0f154280 --- /dev/null +++ b/qiming/src/components/business-component/GuardedFormModal/types.ts @@ -0,0 +1,8 @@ +import type { CustomFormModalProps } from '@/types/interfaces/common'; +import React from 'react'; + +/** 带提交防重的表单弹窗 Props(基于 CustomFormModal 扩展) */ +export interface GuardedFormModalProps extends CustomFormModalProps { + /** 底部额外按钮(显示在取消/确定左侧,如「跳过」) */ + footerExtra?: React.ReactNode; +} diff --git a/qiming/src/components/business-component/MessageQueue/QueuedMessageItem.tsx b/qiming/src/components/business-component/MessageQueue/QueuedMessageItem.tsx new file mode 100644 index 00000000..0322023c --- /dev/null +++ b/qiming/src/components/business-component/MessageQueue/QueuedMessageItem.tsx @@ -0,0 +1,123 @@ +import { t } from '@/services/i18nRuntime'; +import { + CloseOutlined, + EditOutlined, + HolderOutlined, + LoadingOutlined, + SendOutlined, +} from '@ant-design/icons'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Tooltip } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import styles from './index.less'; +import type { QueuedMessage } from './types'; + +const cx = classNames.bind(styles); + +export interface QueuedMessageItemProps { + message: QueuedMessage; + onSendNow: (message: QueuedMessage) => void; + onDelete: (id: string) => void; + onEdit: (message: QueuedMessage) => void; +} + +/** 单条待发送消息项(可拖拽排序,手柄触发) */ +const QueuedMessageItem: React.FC = ({ + message, + onSendNow, + onDelete, + onEdit, +}) => { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: message.id }); + + const style: React.CSSProperties = { + transform: CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : 1, + }; + + return ( +
+
+ {/* 拖拽手柄:仅手柄触发拖拽,避免误触操作按钮 */} + + + +
+
+ {message.text} +
+ {message.files && message.files.length > 0 && ( +
+ +{message.files.length} +
+ )} +
+
+ + { + // sending 态由队列持有:loading 期间禁止重复触发 + if (message.sending) return; + onSendNow(message); + }} + > + {message.sending ? : } + + + + onEdit(message)} + > + + + + + onDelete(message.id)} + > + + + +
+
+
+ ); +}; + +export default QueuedMessageItem; diff --git a/qiming/src/components/business-component/MessageQueue/index.less b/qiming/src/components/business-component/MessageQueue/index.less new file mode 100644 index 00000000..aba3f3b3 --- /dev/null +++ b/qiming/src/components/business-component/MessageQueue/index.less @@ -0,0 +1,296 @@ +@import '@/styles/color'; +@import '@/styles/token'; +@import '@/styles/scrollbar.less'; + +// ===== 待发送消息队列面板(与 intervention 卡片同款圆角边框)===== +.queue-panel { + max-width: 800px; + width: 100%; + margin: 0 auto 8px; + padding: @paddingSm; + background: @colorBgContainer; + border: 1px solid rgba(0, 0, 0, 8%); + border-radius: 14px; + box-shadow: 0 2px 8px -3px rgba(0, 0, 0, 6%), 0 1px 2px -1px rgba(0, 0, 0, 4%); + display: flex; + flex-direction: column; + gap: 4px; +} + +// —— header:单行紧凑标签 —— +.queue-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 20px; + padding: 0 4px; // 与下方列表项内边距对齐 +} + +.queue-header-left { + display: flex; + align-items: center; + gap: 6px; + color: @colorTextSecondary; + font-size: @fontSizeSm; + font-weight: 500; + line-height: 1; +} + +// 折叠 toggle 区域(可点击) +.queue-toggle { + cursor: pointer; + user-select: none; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.8; + } +} + +// 折叠箭头图标 +.toggle-icon { + flex-shrink: 0; + color: @colorTextTertiary; + transition: transform 0.2s ease; +} + +// 折叠时逆时针旋转 90 度 +.toggle-icon-collapsed { + transform: rotate(-90deg); +} + +.queue-title { + letter-spacing: 0.02em; +} + +.queue-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0; + font-size: 11px; + font-weight: @fontWeightStrong; + line-height: 1; + color: @colorPrimary; + background: color-mix(in srgb, @colorPrimary 14%, transparent); + border-radius: 8px; + font-variant-numeric: tabular-nums; +} + +.queue-clear { + padding: 2px 4px; + font-size: @fontSizeSm; + line-height: 1; + color: @colorTextTertiary; + background: none; + border: none; + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + color: @colorError; + } +} + +// —— 列表 —— +.queue-list { + display: flex; + flex-direction: column; + gap: 1px; + max-height: 132px; + overflow-y: auto; + overflow-x: hidden; + padding: 0; + + .scrollbar-auto-hide(); +} + +// 首次出现数据时短暂提示「第一条」的按钮区(其余仍 hover 显示) +.queue-list.is-hinting > .container:first-child .actions { + opacity: 1; + pointer-events: auto; +} + +// ===== 单条待发送消息(紧凑行 · hover 显操作 · 可拖拽排序)===== +.container { + width: 100%; +} + +// 拖拽手柄 +.drag-handle { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: @colorTextTertiary; + cursor: grab; + border-radius: 4px; + font-size: 12px; + transition: color 0.15s ease, background 0.15s ease; + + &:hover { + color: @colorTextSecondary; + background: color-mix(in srgb, @colorText 6%, transparent); + } + + &:active { + cursor: grabbing; + } +} + +// 拖拽中样式 +.is-dragging .card { + border: 1px solid color-mix(in srgb, @colorPrimary 40%, transparent); + box-shadow: 0 4px 12px rgba(0, 0, 0, 12%); +} + +.card { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 26px; + padding: 0 6px; + border-radius: 8px; + background: transparent; + transition: background 0.15s ease; + + &:hover, + &:focus-within { + background: @colorBgTextHover; + + .actions { + opacity: 1; + pointer-events: auto; + } + } +} + +.body { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + +.text { + flex: 1; + min-width: 0; + font-size: @fontSizeSm; + color: @colorText; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + height: 16px; + padding: 0 5px; + font-size: 11px; + font-weight: 500; + line-height: 1; + color: @colorTextTertiary; + background: @colorFillTertiary; + border-radius: 8px; + font-variant-numeric: tabular-nums; +} + +// —— 操作区:默认隐藏,hover 显示 —— +.actions { + display: flex; + align-items: center; + gap: 1px; + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; +} + +.action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + cursor: pointer; + border-radius: 6px; + color: @colorTextTertiary; + font-size: 12px; + opacity: 0; + transform: scale(0.85) translateY(3px); + transition: opacity 0.2s ease, + transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.15s ease, + color 0.15s ease; + + &:hover { + background: color-mix(in srgb, @colorText 8%, transparent); + color: @colorText; + } +} + +// 按钮区显示时按钮逐个弹入(stagger):delay 仅挂在触发态,离开时同步收回、不拖沓 +.card:hover .action-btn, +.card:focus-within .action-btn, +.queue-list.is-hinting > .container:first-child .action-btn { + opacity: 1; + transform: none; + + &:nth-child(1) { + transition-delay: 0.02s; + } + + &:nth-child(2) { + transition-delay: 0.07s; + } + + &:nth-child(3) { + transition-delay: 0.12s; + } +} + +.send-btn { + color: @colorPrimary; + + &:hover { + background: color-mix(in srgb, @colorPrimary 12%, transparent); + color: @colorPrimary; + } +} + +// 发送中的行:常驻显示按钮区,使 loading 图标无需 hover 即可见 +.card.is-sending { + .actions { + opacity: 1; + pointer-events: auto; + } + + .action-btn { + opacity: 1; + transform: none; + } +} + +// 发送中:保持可见(显示 loading 图标),但不可交互、无 hover 反馈 +.action-btn.is-disabled { + pointer-events: none; + cursor: default; + + &:hover { + background: transparent; + } +} + +.delete-btn { + &:hover { + color: @colorError; + background: color-mix(in srgb, @colorError 10%, transparent); + } +} diff --git a/qiming/src/components/business-component/MessageQueue/index.tsx b/qiming/src/components/business-component/MessageQueue/index.tsx new file mode 100644 index 00000000..138b7d5e --- /dev/null +++ b/qiming/src/components/business-component/MessageQueue/index.tsx @@ -0,0 +1,132 @@ +import SvgIcon from '@/components/base/SvgIcon'; +import { t } from '@/services/i18nRuntime'; +import { closestCenter, DndContext, type DragEndEvent } from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import classNames from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; +import QueuedMessageItem from './QueuedMessageItem'; +import styles from './index.less'; +import type { QueuedMessage } from './types'; + +export type { QueuedMessage } from './types'; +export { useChatMessageQueue } from './useChatMessageQueue'; +export { useUnifiedChatQueue } from './useUnifiedChatQueue'; + +const cx = classNames.bind(styles); + +export interface MessageQueuePanelProps { + /** 待发送消息列表 */ + queue: QueuedMessage[]; + /** 立即发送(停止当前会话后发送该消息) */ + onSendNow: (message: QueuedMessage) => void; + /** 删除某条 */ + onDelete: (id: string) => void; + /** 编辑某条(回填到输入框) */ + onEdit: (message: QueuedMessage) => void; + /** 清空全部 */ + onClear: () => void; + /** 拖拽排序:把 fromIndex 处的项移动到 toIndex */ + onReorder: (fromIndex: number, toIndex: number) => void; +} + +/** + * 待发送消息队列面板 + * 渲染于聊天输入框上方,展示会话活跃期间排队等待发送的消息 + */ +const MessageQueuePanel: React.FC = ({ + queue, + onSendNow, + onDelete, + onEdit, + onClear, + onReorder, +}) => { + const [collapsed, setCollapsed] = useState(false); + + // 队列从空 → 有数据时,短暂提示按钮区(之后恢复为 hover 显示) + const [hinting, setHinting] = useState(false); + const prevLengthRef = useRef(0); + useEffect(() => { + const prev = prevLengthRef.current; + prevLengthRef.current = queue.length; + if (prev !== 0 || queue.length === 0) return; + setHinting(true); + const timer = window.setTimeout(() => setHinting(false), 2500); + return () => window.clearTimeout(timer); + }, [queue.length]); + + if (!queue.length) return null; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const fromIndex = queue.findIndex((q) => q.id === active.id); + const toIndex = queue.findIndex((q) => q.id === over.id); + if (fromIndex < 0 || toIndex < 0) return; + onReorder(fromIndex, toIndex); + }; + + return ( +
+
+
setCollapsed((c) => !c)} + > + +
+ {t('PC.Components.MessageQueue.title')} +
+
{queue.length}
+
+ {!collapsed && ( + + )} +
+ {!collapsed && ( + + q.id)} + strategy={verticalListSortingStrategy} + > +
+ {queue.map((qMsg) => ( + + ))} +
+
+
+ )} +
+ ); +}; + +export default MessageQueuePanel; diff --git a/qiming/src/components/business-component/MessageQueue/queueStorage.ts b/qiming/src/components/business-component/MessageQueue/queueStorage.ts new file mode 100644 index 00000000..8b9e1f65 --- /dev/null +++ b/qiming/src/components/business-component/MessageQueue/queueStorage.ts @@ -0,0 +1,67 @@ +import type { QueuedMessage } from './types'; + +const STORAGE_PREFIX = 'msg_queue:'; + +/** + * 持久化队列的有效期(ms)。加载时丢弃超过该时长的陈旧项, + * 避免几小时前排队的消息在刷新恢复后被自动消费发出。 + */ +const QUEUE_TTL_MS = 24 * 60 * 60 * 1000; + +const getStorageKey = (conversationId: string | number) => + `${STORAGE_PREFIX}${conversationId}`; + +/** + * 读取并恢复某会话的持久化队列: + * - 还原 queuedAt 为 Date + * - 过滤超过 TTL 的陈旧项与结构非法项 + * - localStorage 不可用 / 解析失败时安全降级为空队列 + */ +export const loadQueue = ( + conversationId: string | number | null | undefined, +): QueuedMessage[] => { + if (conversationId === null || conversationId === undefined) return []; + try { + const raw = localStorage.getItem(getStorageKey(conversationId)); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const now = Date.now(); + return parsed + .map((item) => ({ ...item, queuedAt: new Date(item?.queuedAt) })) + .filter( + (item): item is QueuedMessage => + typeof item?.id === 'string' && + typeof item?.text === 'string' && + item.queuedAt instanceof Date && + !Number.isNaN(item.queuedAt.getTime()) && + now - item.queuedAt.getTime() <= QUEUE_TTL_MS, + ); + } catch { + return []; + } +}; + +/** + * 持久化某会话的队列。空队列直接删除存储键,避免残留空记录。 + * localStorage 不可用 / 配额超限时静默降级(仅内存)。 + */ +export const saveQueue = ( + conversationId: string | number | null | undefined, + queue: QueuedMessage[], +): void => { + if (conversationId === null || conversationId === undefined) return; + try { + const key = getStorageKey(conversationId); + if (!queue.length) { + localStorage.removeItem(key); + return; + } + // sending 为运行态标记,不入库:刷新恢复后队列不应残留 loading 态 + // (JSON.stringify 会省略值为 undefined 的键) + const persistable = queue.map((item) => ({ ...item, sending: undefined })); + localStorage.setItem(key, JSON.stringify(persistable)); + } catch { + // ignore: localStorage 不可用或配额超限,降级为纯内存队列 + } +}; diff --git a/qiming/src/components/business-component/MessageQueue/types.ts b/qiming/src/components/business-component/MessageQueue/types.ts new file mode 100644 index 00000000..1978c2ba --- /dev/null +++ b/qiming/src/components/business-component/MessageQueue/types.ts @@ -0,0 +1,22 @@ +import type { AgentMode } from '@/components/business-component/AgentIntervention'; +import type { UploadFileInfo } from '@/types/interfaces/common'; + +/** 队列中的待发送消息 */ +export interface QueuedMessage { + /** 队列项唯一 ID */ + id: string; + /** 用户输入的文本内容 */ + text: string; + /** 入队时间戳 */ + queuedAt: Date; + /** 入队时快照 - 附件文件 */ + files?: UploadFileInfo[]; + /** 入队时快照 - 选中的技能(@技能),消费时需原样回放,否则会丢失 */ + skillIds?: number[]; + /** 入队时快照 - 选中的模型 ID */ + modelId?: number; + /** 入队时快照 - 智能体模式 */ + selectedAgentMode?: AgentMode; + /** 立即发送中:置位后该条展示 loading 且不可重复点击,实际消费发送后随队列出列移除 */ + sending?: boolean; +} diff --git a/qiming/src/components/business-component/MessageQueue/useChatMessageQueue.ts b/qiming/src/components/business-component/MessageQueue/useChatMessageQueue.ts new file mode 100644 index 00000000..dcaaaeb0 --- /dev/null +++ b/qiming/src/components/business-component/MessageQueue/useChatMessageQueue.ts @@ -0,0 +1,293 @@ +import { isSessionStreamBusy } from '@/hooks/useExecutingTaskStatusPoll'; +import { MessageStatusEnum } from '@/types/enums/common'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { QueuedMessage } from './types'; +import { useMessageQueue } from './useMessageQueue'; + +/** 真正发送消息的函数签名(对齐 useChatConversation.handleMessageSend) */ +type SendMessage = ( + messageInfo: string, + files?: UploadFileInfo[], + skillIds?: number[], + modelId?: number, + selectedAgentMode?: any, +) => void; + +export interface UseChatMessageQueueParams { + /** + * 流式活跃信号(model/context);会与 messageList 末条 Loading/Incomplete 合并。 + */ + isConversationActive: boolean; + /** + * 入队拦截:streamActive || taskExecuting(与发送/停止按钮一致)。 + * 未传时回退为 streamActive || isTaskExecuting。 + */ + isEnqueueBlocked?: boolean; + /** 后台 taskStatus===EXECUTING */ + isTaskExecuting?: boolean; + messageList: MessageInfo[]; + conversationId: any; + sendMessage: SendMessage; + runStopConversation: (id: any) => void; + minConsumeInterval?: number; + hasPendingIntervention?: boolean; +} + +export const useChatMessageQueue = ({ + isConversationActive, + isEnqueueBlocked, + isTaskExecuting = false, + messageList, + conversationId, + sendMessage, + runStopConversation, + minConsumeInterval = 1200, + hasPendingIntervention = false, +}: UseChatMessageQueueParams) => { + const messageQueue = useMessageQueue(); + + /** 以 messageList 为准兜底,避免 model isConversationActive 与真实流式状态脱节 */ + const streamActive = useMemo( + () => isConversationActive || isSessionStreamBusy(messageList), + [isConversationActive, messageList], + ); + + const enqueueBlocked = useMemo( + () => isEnqueueBlocked ?? (streamActive || isTaskExecuting), + [isEnqueueBlocked, streamActive, isTaskExecuting], + ); + + /** 队列消费阻塞:流式 OR 后台任务 OR intervention,须全部解除后才可 auto-consume */ + const consumeBlocked = enqueueBlocked || hasPendingIntervention; + + const streamActiveRef = useRef(streamActive); + const taskExecutingRef = useRef(isTaskExecuting); + const consumeBlockedRef = useRef(consumeBlocked); + const prevConsumeBlockedRef = useRef(consumeBlocked); + const hasPendingInterventionRef = useRef(hasPendingIntervention); + /** + * 用户主动停止会话后置 true:暂停队列自动消费,避免停止后立即发送下一条排队消息。 + * 用户再次发送新提示词(或点击「立即发送」)后置 false,恢复自动消费。 + */ + const userPausedRef = useRef(false); + hasPendingInterventionRef.current = hasPendingIntervention; + const minIntervalRef = useRef(minConsumeInterval); + minIntervalRef.current = minConsumeInterval; + /** 最近一次「消费阻塞解除(≈流式结束)」的时刻,作为消费间隔的起算基准 */ + const blockReleasedAtRef = useRef(0); + const consumeLockRef = useRef(false); + const consumeTimerRef = useRef(null); + const releaseTimerRef = useRef(null); + const messageListRef = useRef(messageList); + messageListRef.current = messageList; + + const clearTimers = useCallback(() => { + if (consumeTimerRef.current) { + window.clearTimeout(consumeTimerRef.current); + consumeTimerRef.current = null; + } + if (releaseTimerRef.current) { + window.clearTimeout(releaseTimerRef.current); + releaseTimerRef.current = null; + } + }, []); + + const canAttemptConsume = useCallback(() => { + if (consumeLockRef.current) { + return false; + } + if (!messageQueue.hasQueuedMessages) { + return false; + } + // 用户主动停止会话后暂停自动消费,等待用户再次发送新消息后才恢复 + if (userPausedRef.current) { + return false; + } + if (streamActiveRef.current || taskExecutingRef.current) { + return false; + } + if (hasPendingInterventionRef.current) { + return false; + } + const lastMessage = + messageListRef.current?.[messageListRef.current.length - 1]; + if (lastMessage?.status === MessageStatusEnum.Error) { + return false; + } + return true; + }, [messageQueue.hasQueuedMessages]); + + const scheduleAutoConsume = useCallback(() => { + if (!canAttemptConsume()) { + return; + } + + consumeLockRef.current = true; + clearTimers(); + + // 从「流式结束时刻」起算,而非上次发送时刻:上一条响应通常耗时数秒, + // 若以发送时刻为基准,elapsed 恒大于间隔会使 wait≈0,间隔形同虚设。 + const elapsed = Date.now() - blockReleasedAtRef.current; + const wait = Math.max(minIntervalRef.current - elapsed, 0); + + consumeTimerRef.current = window.setTimeout(() => { + consumeTimerRef.current = null; + if ( + streamActiveRef.current || + taskExecutingRef.current || + hasPendingInterventionRef.current + ) { + consumeLockRef.current = false; + return; + } + const next = messageQueue.dequeueFirst(); + if (next) { + // 回放入队时的快照参数,避免 skillIds/modelId/agentMode 丢失 + sendMessage( + next.text, + next.files || [], + next.skillIds, + next.modelId, + next.selectedAgentMode, + ); + } else { + consumeLockRef.current = false; + } + }, wait); + + releaseTimerRef.current = window.setTimeout(() => { + releaseTimerRef.current = null; + if ( + consumeLockRef.current && + !streamActiveRef.current && + !taskExecutingRef.current + ) { + consumeLockRef.current = false; + } + }, 2000); + }, [canAttemptConsume, clearTimers, messageQueue.dequeueFirst, sendMessage]); + + /** 用户主动停止会话:暂停队列自动消费,清掉待执行的消费定时器 */ + const pauseAutoConsume = useCallback(() => { + userPausedRef.current = true; + clearTimers(); + consumeLockRef.current = false; + }, [clearTimers]); + + /** + * 用户再次发送新消息:仅解除暂停位,不主动触发消费。 + * 新消息发送后流式结束时的 consumeBlocked 释放信号会自然驱动消费下一条; + * 此处若主动 schedule,会在会话仍空闲的瞬间抢先消费,导致排队消息抢在 + * 用户刚输入的消息之前(或与之同时)被发送。 + */ + const resumeAutoConsume = useCallback(() => { + userPausedRef.current = false; + }, []); + + const trySend = useCallback( + ( + messageInfo: string, + files?: UploadFileInfo[], + skillIds?: number[], + modelId?: number, + selectedAgentMode?: QueuedMessage['selectedAgentMode'], + ) => { + if (enqueueBlocked) { + // 入队时一并快照 skillIds/modelId/agentMode,消费时原样回放,避免丢失(尤其 @技能) + messageQueue.enqueue({ + text: messageInfo, + files, + skillIds, + modelId, + selectedAgentMode, + }); + return; + } + sendMessage(messageInfo, files, skillIds, modelId, selectedAgentMode); + }, + [enqueueBlocked, messageQueue, sendMessage], + ); + + useEffect(() => { + messageQueue.clearQueue(); + consumeLockRef.current = false; + userPausedRef.current = false; + clearTimers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversationId]); + + useEffect(() => { + streamActiveRef.current = streamActive; + taskExecutingRef.current = isTaskExecuting; + consumeBlockedRef.current = consumeBlocked; + + if (streamActive || isTaskExecuting) { + consumeLockRef.current = false; + clearTimers(); + } + }, [streamActive, isTaskExecuting, consumeBlocked, clearTimers]); + + useEffect(() => { + if (hasPendingIntervention) { + clearTimers(); + consumeLockRef.current = false; + } + }, [hasPendingIntervention, clearTimers]); + + // 仅在「流式 + 后台任务 + intervention」全部解除后触发消费(不再单独监听流式结束) + useEffect(() => { + const wasBlocked = prevConsumeBlockedRef.current; + prevConsumeBlockedRef.current = consumeBlocked; + + if (wasBlocked && !consumeBlocked && messageQueue.hasQueuedMessages) { + // 记录「消费阻塞解除(≈流式结束)」时刻,作为本次消费间隔的起算基准 + blockReleasedAtRef.current = Date.now(); + scheduleAutoConsume(); + } + }, [consumeBlocked, messageQueue.hasQueuedMessages, scheduleAutoConsume]); + + useEffect(() => () => clearTimers(), [clearTimers]); + + const sendNow = useCallback( + (qMsg: QueuedMessage) => { + // 「立即发送」视为用户重新参与,恢复自动消费,确保停止后队列中的下一条能继续发送 + userPausedRef.current = false; + // 移到队首并标记 sending:保留原 id,UI 立即显示 loading 且不可重复点击; + // 该条会一直留在队列里(保持 loading),直到会话停止、队列消费实际发送后 + // 随 dequeueFirst 出列移除——即 loading 的结束与队列出列移除是同一时刻。 + messageQueue.markSending(qMsg.id); + if (conversationId) { + runStopConversation(conversationId); + } + // 会话已空闲(如暂停态下点击立即发送)时不会有流式结束信号,需主动触发一次消费 + scheduleAutoConsume(); + }, + [messageQueue, conversationId, runStopConversation, scheduleAutoConsume], + ); + + const deleteQueued = useCallback( + (queuedId: string) => messageQueue.remove(queuedId), + [messageQueue], + ); + + const editQueued = useCallback( + (qMsg: QueuedMessage): QueuedMessage | undefined => + messageQueue.dequeueForEdit(qMsg.id), + [messageQueue], + ); + + return { + queue: messageQueue.queue, + hasQueuedMessages: messageQueue.hasQueuedMessages, + clearQueue: messageQueue.clearQueue, + trySend, + sendNow, + deleteQueued, + editQueued, + reorder: messageQueue.reorder, + pauseAutoConsume, + resumeAutoConsume, + }; +}; diff --git a/qiming/src/components/business-component/MessageQueue/useMessageQueue.ts b/qiming/src/components/business-component/MessageQueue/useMessageQueue.ts new file mode 100644 index 00000000..36dfd994 --- /dev/null +++ b/qiming/src/components/business-component/MessageQueue/useMessageQueue.ts @@ -0,0 +1,145 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { loadQueue, saveQueue } from './queueStorage'; +import type { QueuedMessage } from './types'; + +/** + * 待发送消息队列(按 conversationId 持久化到 localStorage)。 + * 传入 conversationId 时:mount / 切换会话自动恢复对应队列,任何变更即写回; + * 不传时退化为纯内存队列。 + */ +export const useMessageQueue = (conversationId?: string | number | null) => { + const conversationIdRef = useRef(conversationId); + conversationIdRef.current = conversationId; + + const [queue, setQueue] = useState(() => + loadQueue(conversationId), + ); + const queueRef = useRef(queue); + + const updateQueue = useCallback( + (updater: (prev: QueuedMessage[]) => QueuedMessage[]) => { + setQueue((prev) => { + const next = updater(prev); + queueRef.current = next; + saveQueue(conversationIdRef.current, next); + return next; + }); + }, + [], + ); + + // 切换会话:同步加载持久化队列,避免 effect 延迟导致误用上一会话的内存队列 + useLayoutEffect(() => { + const loaded = loadQueue(conversationId); + queueRef.current = loaded; + setQueue(loaded); + }, [conversationId]); + + const enqueue = useCallback( + (item: Omit) => { + const newItem: QueuedMessage = { + ...item, + id: `queued_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`, + queuedAt: new Date(), + }; + updateQueue((prev) => [...prev, newItem]); + }, + [updateQueue], + ); + + /** 将消息插入到队列头部(用于"立即发送"场景) */ + const prepend = useCallback( + (item: Omit) => { + const newItem: QueuedMessage = { + ...item, + id: `queued_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`, + queuedAt: new Date(), + }; + updateQueue((prev) => [newItem, ...prev]); + }, + [updateQueue], + ); + + const remove = useCallback( + (id: string) => { + updateQueue((prev) => prev.filter((item) => item.id !== id)); + }, + [updateQueue], + ); + + /** + * 立即发送:原地标记指定项 sending(不移到队首、保留原位置与 id,避免组件 + * 卸载重挂导致 loading 态丢失,也使用户始终在点击的那一行看到 loading)。 + * UI 上立即显示 loading 且不可重复点击,待会话停止、队列消费实际发送后 + * 随 dequeueFirst 出列移除——loading 的结束与队列出列移除是同一时刻。 + */ + const markSending = useCallback( + (id: string) => { + updateQueue((prev) => + prev.map((item) => + item.id === id ? { ...item, sending: true } : item, + ), + ); + }, + [updateQueue], + ); + + const dequeueForEdit = useCallback( + (id: string): QueuedMessage | undefined => { + const found = queueRef.current.find((item) => item.id === id); + if (found) { + updateQueue((prev) => prev.filter((item) => item.id !== id)); + } + return found; + }, + [updateQueue], + ); + + const dequeueFirst = useCallback((): QueuedMessage | undefined => { + const current = queueRef.current; + if (current.length === 0) return undefined; + // 优先消费「立即发送」(sending)的项,无论其在队列中的位置; + // 否则按队首顺序消费。按 id 出列,避免索引在并发更新下错位。 + const sendingIdx = current.findIndex((item) => item.sending); + const target = sendingIdx >= 0 ? current[sendingIdx] : current[0]; + updateQueue((prev) => prev.filter((item) => item.id !== target.id)); + return target; + }, [updateQueue]); + + const clearQueue = useCallback(() => updateQueue(() => []), [updateQueue]); + + /** 拖拽排序:把 fromIndex 处的项移动到 toIndex */ + const reorder = useCallback( + (fromIndex: number, toIndex: number) => { + updateQueue((prev) => { + if ( + fromIndex < 0 || + fromIndex >= prev.length || + toIndex < 0 || + toIndex >= prev.length || + fromIndex === toIndex + ) { + return prev; + } + const next = [...prev]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + return next; + }); + }, + [updateQueue], + ); + + return { + queue, + hasQueuedMessages: queue.length > 0, + enqueue, + prepend, + remove, + markSending, + dequeueForEdit, + dequeueFirst, + clearQueue, + reorder, + }; +}; diff --git a/qiming/src/components/business-component/MessageQueue/useUnifiedChatQueue.ts b/qiming/src/components/business-component/MessageQueue/useUnifiedChatQueue.ts new file mode 100644 index 00000000..20918444 --- /dev/null +++ b/qiming/src/components/business-component/MessageQueue/useUnifiedChatQueue.ts @@ -0,0 +1,136 @@ +import type { AgentMode } from '@/components/business-component/AgentIntervention'; +import { isSessionStreamBusy } from '@/hooks/useExecutingTaskStatusPoll'; +import { TaskStatus } from '@/types/enums/agent'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import eventBus, { EVENT_NAMES } from '@/utils/eventBus'; +import { useCallback } from 'react'; +import { useModel } from 'umi'; +import type { QueuedMessage } from './types'; +import { useChatMessageQueue } from './useChatMessageQueue'; + +/** 隔离会话源(如 ConversationAgent 预览 Tab)时覆盖队列上下文 */ +export interface UnifiedChatQueueContext { + /** 流式活跃(messageList Loading/Incomplete),驱动 auto-consume */ + streamActive?: boolean; + /** 后台任务执行中(taskStatus===EXECUTING),仅参与入队拦截 */ + taskExecuting?: boolean; + /** 停止当前会话(立即发送队列消息时调用) */ + runStopConversation?: (id: number | string) => void; +} + +export interface UseUnifiedChatQueueParams { + /** 当前会话 ID */ + conversationId?: number; + /** 消息列表(用于错误时暂停消费判定) */ + messageList?: MessageInfo[]; + /** 当前选中的模型 ID */ + selectedModelId?: number; + /** 智能体模式 ref(发送时取最新值) */ + agentModeRef: React.MutableRefObject; + /** 真正发送消息的回调(来自 UnifiedChatSession 的 onSendMessage prop) */ + onSendMessage?: ( + messageInfo: string, + files?: UploadFileInfo[], + skillIds?: number[], + modelId?: number, + selectedAgentMode?: AgentMode, + ) => void; + /** + * 队列消费下一条前的最小等待间隔(ms),默认 1200,从「流式结束(消费阻塞解除)时刻」起算。 + * 用于规避会话状态切换的中间空白,避免队列在一次响应结束后过早消费下一条。 + */ + minConsumeInterval?: number; + /** 当前是否有待处理 intervention(ask/question/审批),为 true 时暂停队列消费 */ + hasPendingIntervention?: boolean; + /** + * 可选:覆盖 conversationInfo model 的活跃/停止上下文。 + * 预览 Tab 使用 conversationAgent model 时传入,避免与左侧主聊天串扰。 + */ + queueContext?: UnifiedChatQueueContext; +} + +/** + * UnifiedChatSession 专用消息队列 hook + * + * 信号: + * - streamActive = model/context 流式 OR messageList 末条 Loading/Incomplete + * - 入队 / 消费阻塞:streamActive || taskExecuting(+ intervention 仅消费) + * - auto-consume:上述阻塞全部解除后才触发 + */ +export const useUnifiedChatQueue = ({ + conversationId, + messageList, + selectedModelId, + agentModeRef, + onSendMessage, + minConsumeInterval, + hasPendingIntervention, + queueContext, +}: UseUnifiedChatQueueParams) => { + const { + isConversationActive: modelStreamActive, + conversationInfo, + runStopConversation: modelRunStop, + } = useModel('conversationInfo'); + + const streamActiveByModel = queueContext?.streamActive ?? modelStreamActive; + const streamActive = streamActiveByModel || isSessionStreamBusy(messageList); + const taskExecuting = + queueContext?.taskExecuting ?? + conversationInfo?.taskStatus === TaskStatus.EXECUTING; + const isEnqueueBlocked = streamActive || taskExecuting; + const runStopConversation = queueContext?.runStopConversation ?? modelRunStop; + + const rawSend = useCallback( + ( + messageInfo: string, + files?: UploadFileInfo[], + skillIds?: number[], + modelId?: number, + selectedAgentMode?: AgentMode, + ) => { + onSendMessage?.( + messageInfo, + files, + skillIds, + modelId || selectedModelId, + selectedAgentMode || agentModeRef.current, + ); + }, + [onSendMessage, selectedModelId, agentModeRef], + ); + + const messageQueueCtrl = useChatMessageQueue({ + isConversationActive: streamActive, + isEnqueueBlocked, + isTaskExecuting: taskExecuting, + messageList: messageList || [], + conversationId, + sendMessage: rawSend, + runStopConversation, + minConsumeInterval, + hasPendingIntervention, + }); + + const handleEditQueued = useCallback( + (qMsg: QueuedMessage) => { + const item = messageQueueCtrl.editQueued(qMsg); + if (item) { + eventBus.emit(EVENT_NAMES.QUEUE_EDIT_MESSAGE, { + text: item.text, + files: item.files, + // 带上会话 id,监听方(ChatInputHome)据此过滤,避免多实例(主聊天/预览 Tab)串扰 + conversationId, + }); + } + }, + [messageQueueCtrl, conversationId], + ); + + return { + ...messageQueueCtrl, + handleEditQueued, + rawSend, + }; +}; diff --git a/qiming/src/components/business-component/ModelPriceTierList/index.less b/qiming/src/components/business-component/ModelPriceTierList/index.less new file mode 100644 index 00000000..a52d1cf7 --- /dev/null +++ b/qiming/src/components/business-component/ModelPriceTierList/index.less @@ -0,0 +1,41 @@ +@import '@/styles/token.less'; + +.model-tier-list { + display: grid; + grid-template-columns: repeat(2, max-content); + column-gap: 4px; + row-gap: 4px; +} + +.model-tier-item { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + width: fit-content; + padding: 2px @paddingXs; + font-size: @fontSizeSm; + white-space: nowrap; + border: 1px solid @colorSplit; + border-radius: @borderRadiusOuter; + background: #eff6ff; + color: @colorTextSecondary; +} + +.model-tier-context { + color: @colorPrimary; + font-weight: @fontWeightStrong; +} + +.model-tier-separator { + color: @colorTextQuaternary; +} + +.model-tier-price { + color: @colorTextSecondary; +} + +.model-tier-cache-price { + color: @green7; + font-weight: 400; +} diff --git a/qiming/src/components/business-component/ModelPriceTierList/index.tsx b/qiming/src/components/business-component/ModelPriceTierList/index.tsx new file mode 100644 index 00000000..35c45bb7 --- /dev/null +++ b/qiming/src/components/business-component/ModelPriceTierList/index.tsx @@ -0,0 +1,55 @@ +import type { ModelPricingInfo } from '@/pages/SpaceResource/types/resource'; +import { dict } from '@/services/i18nRuntime'; +import { Tag } from 'antd'; +import React from 'react'; +import styles from './index.less'; + +export interface ModelPriceTierListProps { + /** 模型阶梯价格档位 */ + tiers?: ModelPricingInfo[]; +} + +/** + * 模型阶梯价格展示(与 ModelPricingTab 定价档位列布局、样式一致) + * 布局:每行 2 个 Tag,单个 Tag 宽度随内容自适应 + */ +const ModelPriceTierList: React.FC = ({ tiers }) => { + if (!tiers?.length) { + return <>-; + } + + return ( +
+ {tiers.map((tier, index) => ( + + + {`≤${tier.contextLength}K`} + + + {/* 输入价格 */} + | + + {dict('PC.Pages.SpaceResourcePricing.inputPriceLabel')}¥ + {tier.inputPrice} + + + {/* 输出价格 */} + | + + {dict('PC.Pages.SpaceResourcePricing.outputPriceLabel')}¥ + {tier.outputPrice} + + + {/* 缓存价格 */} + | + + {dict('PC.Pages.SpaceResourcePricing.cachePriceLabel')}¥ + {tier.cachePrice} + + + ))} +
+ ); +}; + +export default ModelPriceTierList; diff --git a/qiming/src/components/business-component/Terminal/EmbeddedConsoleTerminal.tsx b/qiming/src/components/business-component/Terminal/EmbeddedConsoleTerminal.tsx new file mode 100644 index 00000000..e33b7658 --- /dev/null +++ b/qiming/src/components/business-component/Terminal/EmbeddedConsoleTerminal.tsx @@ -0,0 +1,852 @@ +import classNames from 'classnames'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; + +import type { ITheme } from '@xterm/xterm'; +import { + DISABLE_TERMINAL_MOUSE_TRACKING, + shouldForwardTerminalInput, +} from './terminalMouseUtils'; +import { + DEFAULT_TERMINAL_RECONNECT, + getTerminalMaxRetries, + getTerminalReconnectDelay, + type TerminalReconnectConfig, +} from './terminalReconnect'; +import { + decodeTtydMessage, + encodeTtydInit, + encodeTtydInput, + encodeTtydResize, +} from './ttydWire'; +import type { TerminalWireProtocol } from './type'; +import { FitAddon, Terminal } from './xtermBundle'; + +// xterm.js 核心样式 +import './xterm.css'; + +export interface EmbeddedConsoleTerminalRef { + writeln: (data: string) => void; + getTerminal: () => Terminal | null; + /** 重新计算 cols/rows 以适配容器尺寸(容器从隐藏变为可见后调用) */ + fit: () => void; + /** fit 后向后端同步 ttyd 尺寸(init 尚未发送时会补发 init) */ + fitAndSyncBackend: () => void; + /** + * 面板从 display:none / 折叠恢复可见后调用: + * 等待容器有尺寸 → fit → 同步 ttyd resize → refresh → focus + */ + restoreAfterVisibilityChange: () => void; + /** 聚焦终端以接收键盘输入 */ + focus: () => void; + /** 建立 WebSocket 连接 */ + connect: (url?: string) => void; + /** 断开 WebSocket 连接 */ + disconnect: () => void; + /** 重置重连计数后重新连接 */ + reconnect: (url?: string) => void; + /** 向 shell 发送空行,触发重新输出命令提示符(重连后使用) */ + requestShellPrompt: () => void; +} + +export interface EmbeddedConsoleTerminalProps { + className?: string; + wsUrl?: string; + wsSubprotocols?: string | string[]; + wireProtocol?: TerminalWireProtocol; + theme?: ITheme; + fontSize?: number; + fontFamily?: string; + lineHeight?: number; + cursorBlink?: boolean; + autoConnect?: boolean; + reconnect?: TerminalReconnectConfig; + onConnect?: () => void; + onDisconnect?: (event?: CloseEvent) => void; + /** 自动重连达到最大次数后触发 */ + onReconnectFailed?: () => void; +} + +const scheduleFit = (fit: () => void, afterFit?: () => void) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + try { + fit(); + } catch { + /* ignore */ + } + afterFit?.(); + }); + }); +}; + +/** 容器从 display:none / 0 尺寸恢复时,fit 可能无效;等到可见且有宽高再执行 */ +const scheduleFitWhenVisible = ( + getViewport: () => HTMLDivElement | null, + fit: () => void, + afterFit?: () => void, + retries = 12, +) => { + const attempt = (left: number) => { + const el = getViewport(); + const rect = el?.getBoundingClientRect(); + const visible = !!rect && rect.width > 0 && rect.height > 0; + + if (visible || left <= 0) { + scheduleFit(fit, afterFit); + return; + } + requestAnimationFrame(() => attempt(left - 1)); + }; + attempt(retries); +}; + +const ensureDimensions = (term: Terminal): { cols: number; rows: number } => { + let { cols, rows } = term; + if (cols <= 0 || rows <= 0) { + cols = 80; + rows = 24; + term.resize(cols, rows); + } + return { cols, rows }; +}; + +/** + * 底部控制台专用轻量终端:仅静态依赖 xterm + FitAddon,避免完整 XtermTerminal 在生产环境分包崩溃 + */ +const EmbeddedConsoleTerminal = forwardRef< + EmbeddedConsoleTerminalRef, + EmbeddedConsoleTerminalProps +>( + ( + { + className, + wsUrl, + wsSubprotocols, + wireProtocol = 'ttyd', + theme, + fontSize = 13, + fontFamily = "'JetBrains Mono', monospace, 'Fira Code', Consolas, 'Courier New'", + lineHeight = 1.35, + cursorBlink = true, + autoConnect = true, + reconnect = DEFAULT_TERMINAL_RECONNECT, + onConnect, + onDisconnect, + onReconnectFailed, + }, + ref, + ) => { + const viewportRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const wsRef = useRef(null); + const ttydInitSentRef = useRef(false); + const reconnectTimerRef = useRef | null>( + null, + ); + const reconnectCountRef = useRef(0); + const isManualDisconnectRef = useRef(false); + /** xterm 是否已完成 open,WS 早到的输出暂存于此 */ + const pendingWritesRef = useRef([]); + const terminalReadyRef = useRef(false); + + /** 心跳保活定时器:周期性同步终端尺寸 + 借 send 失败检测断连 */ + const heartbeatTimerRef = useRef | null>( + null, + ); + + /** + * 将回调和配置存入 ref,保持 connect/disconnect 的引用稳定, + * 避免因父组件每次渲染传入新的 onConnect/onDisconnect/reconnect 对象 + * 导致 useEffect 反复触发 disconnect → connect 循环。 + */ + const onConnectRef = useRef(onConnect); + onConnectRef.current = onConnect; + const onDisconnectRef = useRef(onDisconnect); + onDisconnectRef.current = onDisconnect; + const onReconnectFailedRef = useRef(onReconnectFailed); + onReconnectFailedRef.current = onReconnectFailed; + const reconnectConfigRef = useRef(reconnect); + reconnectConfigRef.current = reconnect; + const wsUrlRef = useRef(wsUrl); + wsUrlRef.current = wsUrl; + const wsSubprotocolsRef = useRef(wsSubprotocols); + wsSubprotocolsRef.current = wsSubprotocols; + const autoConnectRef = useRef(autoConnect); + autoConnectRef.current = autoConnect; + /** 连接成功但尚未成功 focus 时置 true,容器变为可见后补 focus */ + const pendingFocusRef = useRef(false); + /** 是否曾成功连通过(用于区分首次连接与自动重连) */ + const hasEverConnectedRef = useRef(false); + + const isViewportMeasurable = useCallback(() => { + const rect = viewportRef.current?.getBoundingClientRect(); + return !!rect && rect.width > 0 && rect.height > 0; + }, []); + + const syncBackendSize = useCallback( + ( + ws: WebSocket, + options?: { sendTtydInit?: boolean; allowFallbackDimensions?: boolean }, + ) => { + const term = terminalRef.current; + if (!term || ws.readyState !== WebSocket.OPEN) return; + + let { cols, rows } = term; + if (cols <= 0 || rows <= 0) { + if (isViewportMeasurable()) { + try { + fitAddonRef.current?.fit(); + } catch { + /* ignore */ + } + ({ cols, rows } = term); + } + if (cols <= 0 || rows <= 0) { + if (!options?.allowFallbackDimensions) { + return; + } + ({ cols, rows } = ensureDimensions(term)); + } + } + + if (wireProtocol === 'ttyd') { + const shouldSendInit = + options?.sendTtydInit === true || + (options?.sendTtydInit !== false && !ttydInitSentRef.current); + if (shouldSendInit && !ttydInitSentRef.current) { + ws.send(encodeTtydInit(cols, rows)); + ttydInitSentRef.current = true; + } else if (ttydInitSentRef.current) { + ws.send(encodeTtydResize(cols, rows)); + } + } + }, + // wireProtocol 是基本值,几乎不会变化;此处保持稳定引用 + // eslint-disable-next-line react-hooks/exhaustive-deps + [isViewportMeasurable], + ); + + /** 重置 xterm 本地鼠标追踪状态,避免 shell 提示符下点击/滚轮产生乱码输入 */ + const resetLocalMouseTracking = useCallback(() => { + terminalRef.current?.write(DISABLE_TERMINAL_MOUSE_TRACKING); + }, []); + + const focusTerminal = useCallback(() => { + const term = terminalRef.current; + const viewport = viewportRef.current; + if (!term || !viewport) return; + + const rect = viewport.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + pendingFocusRef.current = true; + return; + } + + term.focus(); + pendingFocusRef.current = false; + }, []); + + const fitAndSyncBackend = useCallback(() => { + const ws = wsRef.current; + const term = terminalRef.current; + if (!term || ws?.readyState !== WebSocket.OPEN) { + if (!isViewportMeasurable()) { + pendingFocusRef.current = true; + } + return; + } + + if (isViewportMeasurable()) { + try { + fitAddonRef.current?.fit(); + } catch { + /* ignore */ + } + } + + syncBackendSize(ws, { + sendTtydInit: !ttydInitSentRef.current, + allowFallbackDimensions: true, + }); + focusTerminal(); + }, [focusTerminal, isViewportMeasurable, syncBackendSize]); + + /** 向 shell 发送换行,触发 bash 等重新输出 `user@host:path$` 提示符 */ + const requestShellPrompt = useCallback(() => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + return; + } + try { + if (wireProtocol === 'ttyd') { + ws.send(encodeTtydInput('\n')); + } else { + ws.send('\n'); + } + } catch { + /* ignore */ + } + }, [wireProtocol]); + + const restoreAfterVisibilityChange = useCallback(() => { + scheduleFitWhenVisible( + () => viewportRef.current, + () => { + try { + fitAddonRef.current?.fit(); + } catch { + /* ignore */ + } + }, + () => { + const ws = wsRef.current; + const term = terminalRef.current; + if (ws?.readyState === WebSocket.OPEN) { + syncBackendSize(ws, { + sendTtydInit: !ttydInitSentRef.current, + allowFallbackDimensions: true, + }); + } + if (term) { + try { + term.refresh(0, Math.max(term.rows - 1, 0)); + } catch { + /* ignore */ + } + } + focusTerminal(); + }, + 24, + ); + }, [focusTerminal, syncBackendSize]); + + /** 停止心跳检测 */ + const stopHeartbeat = useCallback(() => { + if (heartbeatTimerRef.current) { + clearInterval(heartbeatTimerRef.current); + heartbeatTimerRef.current = null; + } + }, []); + + /** + * 启动保活定时器 + * + * 1. 周期性把 xterm 尺寸同步给后端(ttyd 协议;折叠/隐藏时跳过,避免发错误的 80x24) + * 2. 借 ws.send 失败检测连接是否已断,失败则 close 触发自动重连 + * + * 不再以「无服务端消息超时」判活:ttyd 是被动协议,终端空闲时本就没有输出, + * 误判会让连接每 ~90s 被关一次(前端表现为 close 1005、随后自动重连)。 + * 连接真正断开由浏览器 onclose(TCP FIN/RST)兜底,触发自动重连流程。 + */ + const startHeartbeat = useCallback(() => { + stopHeartbeat(); + const cfg = reconnectConfigRef.current; + const interval = + cfg.heartbeatInterval ?? DEFAULT_TERMINAL_RECONNECT.heartbeatInterval; + if (interval <= 0) return; + + heartbeatTimerRef.current = setInterval(() => { + const ws = wsRef.current; + const term = terminalRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN || !term) { + stopHeartbeat(); + return; + } + + // 折叠/隐藏期间容器尺寸为 0,跳过 resize 避免向后端发送错误的 80x24 + if (wireProtocol === 'ttyd') { + if (!isViewportMeasurable()) { + return; + } + const { cols, rows } = term; + if (cols <= 0 || rows <= 0) { + return; + } + try { + if (!ttydInitSentRef.current) { + syncBackendSize(ws, { + sendTtydInit: true, + allowFallbackDimensions: true, + }); + } else { + ws.send(encodeTtydResize(cols, rows)); + } + } catch (err) { + console.warn('[EmbeddedTerminal] Heartbeat send failed:', err); + ws.close(); + } + } + }, interval); + }, [isViewportMeasurable, stopHeartbeat, syncBackendSize, wireProtocol]); + + const clearReconnectTimer = useCallback(() => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }, []); + + const connect = useCallback( + (url?: string) => { + const targetUrl = url || wsUrlRef.current; + if (!targetUrl) { + console.warn('[EmbeddedTerminal] No WebSocket URL provided'); + return; + } + + if ( + wsRef.current?.readyState === WebSocket.OPEN || + wsRef.current?.readyState === WebSocket.CONNECTING + ) { + return; + } + + // 关闭尚未完全释放的旧连接,避免 CLOSING 期间新建 WS 后旧 onclose 误清空 wsRef + const existingWs = wsRef.current; + if (existingWs) { + isManualDisconnectRef.current = true; + try { + existingWs.close(); + } catch { + /* ignore */ + } + wsRef.current = null; + isManualDisconnectRef.current = false; + } + + const scheduleReconnect = () => { + const reconnectConfig = reconnectConfigRef.current; + if ( + reconnectConfig.enabled === false || + isManualDisconnectRef.current + ) { + return; + } + + const maxRetries = getTerminalMaxRetries(reconnectConfig); + if (reconnectCountRef.current >= maxRetries) { + console.error( + `[EmbeddedTerminal] Max reconnection attempts reached (${maxRetries})`, + ); + onReconnectFailedRef.current?.(); + return; + } + + const delay = getTerminalReconnectDelay( + reconnectCountRef.current, + reconnectConfig, + ); + reconnectCountRef.current += 1; + console.log( + `[EmbeddedTerminal] Reconnect #${reconnectCountRef.current}/${maxRetries} in ${delay}ms`, + ); + + clearReconnectTimer(); + reconnectTimerRef.current = setTimeout(() => { + reconnectTimerRef.current = null; + connect(targetUrl); + }, delay); + }; + + isManualDisconnectRef.current = false; + ttydInitSentRef.current = false; + pendingWritesRef.current = []; + + console.log('[EmbeddedTerminal] Connecting to', targetUrl); + + let ws: WebSocket; + try { + const subprotocols = wsSubprotocolsRef.current; + ws = subprotocols + ? new WebSocket(targetUrl, subprotocols) + : new WebSocket(targetUrl); + ws.binaryType = 'arraybuffer'; + wsRef.current = ws; + } catch (err) { + console.error('[EmbeddedTerminal] WebSocket creation failed:', err); + scheduleReconnect(); + return; + } + + ws.onopen = () => { + if (wsRef.current !== ws) { + return; + } + console.log('[EmbeddedTerminal] WebSocket connected'); + reconnectCountRef.current = 0; + clearReconnectTimer(); + + const isReconnect = hasEverConnectedRef.current; + const term = terminalRef.current; + + // ttyd 必须在首包发送 JSON init 才会 fork shell;同步发送,避免 fit 异步竞态 + if (wireProtocol === 'ttyd') { + if (term) { + let { cols, rows } = term; + if (cols <= 0 || rows <= 0) { + ({ cols, rows } = ensureDimensions(term)); + } + ws.send(encodeTtydInit(cols, rows)); + ttydInitSentRef.current = true; + } + } + + hasEverConnectedRef.current = true; + + startHeartbeat(); + scheduleFitWhenVisible( + () => viewportRef.current, + () => fitAddonRef.current?.fit(), + () => { + if (wsRef.current !== ws) { + return; + } + syncBackendSize(ws, { + sendTtydInit: !ttydInitSentRef.current, + allowFallbackDimensions: true, + }); + resetLocalMouseTracking(); + const pending = pendingWritesRef.current.splice(0); + const term = terminalRef.current; + if (term && pending.length > 0) { + for (const chunk of pending) { + term.write(chunk); + } + } + focusTerminal(); + onConnectRef.current?.(); + if (isReconnect) { + window.setTimeout(() => { + if ( + wsRef.current !== ws || + ws.readyState !== WebSocket.OPEN + ) { + return; + } + requestShellPrompt(); + focusTerminal(); + }, 80); + window.setTimeout(() => { + if ( + wsRef.current !== ws || + ws.readyState !== WebSocket.OPEN + ) { + return; + } + requestShellPrompt(); + focusTerminal(); + }, 280); + } + // 父组件 onConnect 可能写入提示行,延迟再 fit + init + focus 确保重连后可输入 + scheduleFitWhenVisible( + () => viewportRef.current, + () => fitAddonRef.current?.fit(), + () => { + if (wsRef.current !== ws) { + return; + } + syncBackendSize(ws, { + sendTtydInit: !ttydInitSentRef.current, + allowFallbackDimensions: true, + }); + resetLocalMouseTracking(); + focusTerminal(); + }, + ); + window.setTimeout(() => { + if (wsRef.current !== ws || ws.readyState !== WebSocket.OPEN) { + return; + } + syncBackendSize(ws, { + sendTtydInit: !ttydInitSentRef.current, + allowFallbackDimensions: true, + }); + focusTerminal(); + }, 300); + }, + ); + }; + + ws.onmessage = (event: MessageEvent) => { + if (wsRef.current !== ws) { + return; + } + const term = terminalRef.current; + const data = + wireProtocol === 'ttyd' + ? decodeTtydMessage(event.data) + : typeof event.data === 'string' + ? event.data + : new TextDecoder().decode(event.data); + if (!data) return; + if (!term || !terminalReadyRef.current) { + pendingWritesRef.current.push(data); + return; + } + term.write(data); + }; + + ws.onclose = (event: CloseEvent) => { + // 忽略已被新连接取代的旧 WebSocket 的 close,防止误清空 wsRef 导致「已连接但无法输入」 + if (wsRef.current !== ws) { + return; + } + stopHeartbeat(); + console.log( + '[EmbeddedTerminal] WebSocket closed:', + event.code, + event.reason, + ); + ttydInitSentRef.current = false; + wsRef.current = null; + onDisconnectRef.current?.(event); + + if (!isManualDisconnectRef.current) { + scheduleReconnect(); + } + }; + + ws.onerror = (event) => { + if (wsRef.current !== ws) { + return; + } + console.error('[EmbeddedTerminal] WebSocket error:', event); + }; + }, + // 所有外部依赖已通过 ref 访问,保持 connect 引用稳定 + // eslint-disable-next-line react-hooks/exhaustive-deps + [clearReconnectTimer, syncBackendSize, wireProtocol], + ); + + const disconnect = useCallback(() => { + stopHeartbeat(); + isManualDisconnectRef.current = true; + clearReconnectTimer(); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + pendingWritesRef.current = []; + ttydInitSentRef.current = false; + }, [clearReconnectTimer, stopHeartbeat]); + + const reconnectTerminal = useCallback( + (url?: string) => { + reconnectCountRef.current = 0; + clearReconnectTimer(); + isManualDisconnectRef.current = false; + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + pendingWritesRef.current = []; + ttydInitSentRef.current = false; + window.setTimeout(() => { + connect(url || wsUrlRef.current); + }, 0); + }, + [clearReconnectTimer, connect], + ); + + useImperativeHandle( + ref, + () => ({ + writeln: (data: string) => { + terminalRef.current?.writeln(data); + }, + getTerminal: () => terminalRef.current, + fit: () => { + try { + fitAddonRef.current?.fit(); + } catch { + /* ignore */ + } + }, + fitAndSyncBackend, + restoreAfterVisibilityChange, + focus: focusTerminal, + connect, + disconnect, + reconnect: reconnectTerminal, + requestShellPrompt, + }), + [ + connect, + disconnect, + fitAndSyncBackend, + focusTerminal, + reconnectTerminal, + requestShellPrompt, + restoreAfterVisibilityChange, + ], + ); + + useEffect(() => { + if (!viewportRef.current) return; + + const term = new Terminal({ + cursorBlink, + fontSize, + fontFamily, + lineHeight, + scrollback: 5000, + theme, + cursorStyle: 'block', + allowProposedApi: false, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(viewportRef.current); + + terminalRef.current = term; + fitAddonRef.current = fitAddon; + terminalReadyRef.current = true; + + term.onData((data) => { + const termInstance = terminalRef.current; + if (termInstance && !shouldForwardTerminalInput(termInstance, data)) { + return; + } + if (wsRef.current?.readyState === WebSocket.OPEN) { + if (wireProtocol === 'ttyd') { + wsRef.current.send(encodeTtydInput(data)); + } else { + wsRef.current.send(data); + } + } + }); + + term.onResize(({ cols, rows }) => { + if ( + wsRef.current?.readyState !== WebSocket.OPEN || + wireProtocol !== 'ttyd' + ) { + return; + } + if (!ttydInitSentRef.current) { + wsRef.current.send(encodeTtydInit(cols, rows)); + ttydInitSentRef.current = true; + } else { + wsRef.current.send(encodeTtydResize(cols, rows)); + } + }); + + scheduleFit( + () => fitAddon.fit(), + () => { + // xterm 就绪后刷掉 WS 早到的输出 + const pending = pendingWritesRef.current.splice(0); + if (pending.length > 0) { + for (const chunk of pending) { + term.write(chunk); + } + } + // 若 WS 已连接但尚未发 ttyd init(竞态),补发 + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + syncBackendSize(ws, { sendTtydInit: true }); + } + }, + ); + + /** + * 监听容器尺寸变化(如面板折叠/展开、布局切换), + * 自动调用 fit 重新适配 cols/rows,避免 xterm-screen 停留在旧尺寸。 + * 容器尺寸为 0(处于 display:none 状态)时跳过,防止 fit 出极小值。 + */ + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + try { + fitAddon.fit(); + } catch { + /* ignore */ + } + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + syncBackendSize(ws, { sendTtydInit: !ttydInitSentRef.current }); + } + if (pendingFocusRef.current) { + term.focus(); + pendingFocusRef.current = false; + } + } + } + }); + resizeObserver.observe(viewportRef.current); + + return () => { + resizeObserver.disconnect(); + terminalReadyRef.current = false; + pendingWritesRef.current = []; + ttydInitSentRef.current = false; + disconnect(); + fitAddon.dispose(); + term.dispose(); + terminalRef.current = null; + fitAddonRef.current = null; + }; + }, [ + cursorBlink, + disconnect, + fontFamily, + fontSize, + lineHeight, + syncBackendSize, + wireProtocol, + ]); + + useEffect(() => { + if (!autoConnect || !wsUrl || !terminalReadyRef.current) return; + + let cancelled = false; + const tryConnect = (retries = 12) => { + if (cancelled || !autoConnectRef.current || !wsUrlRef.current) return; + + const viewport = viewportRef.current; + const rect = viewport?.getBoundingClientRect(); + const visible = !!rect && rect.width > 0 && rect.height > 0; + + if (visible || retries <= 0) { + connect(wsUrlRef.current); + return; + } + requestAnimationFrame(() => tryConnect(retries - 1)); + }; + + tryConnect(); + return () => { + cancelled = true; + disconnect(); + }; + }, [autoConnect, wsUrl, connect, disconnect]); + + useEffect(() => { + if (!terminalRef.current) return; + terminalRef.current.options.theme = theme; + }, [theme]); + + return ( +
{ + if (event.button !== 0) return; + focusTerminal(); + }} + /> + ); + }, +); + +export default EmbeddedConsoleTerminal; diff --git a/qiming/src/components/business-component/Terminal/index.less b/qiming/src/components/business-component/Terminal/index.less new file mode 100644 index 00000000..8176743b --- /dev/null +++ b/qiming/src/components/business-component/Terminal/index.less @@ -0,0 +1,234 @@ +@import '@/styles/token'; + +.terminalWrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + border-radius: @borderRadius; + overflow: hidden; + position: relative; + background-color: @colorBgContainer; + + // 全屏模式样式 + &:fullscreen { + border: none; + border-radius: 0; + } + + // ─── 工具栏(保留样式,供外部组合或后续扩展)──────────────── + .controlsBar { + display: flex; + align-items: center; + justify-content: space-between; + padding: @paddingXxs @paddingSm; + background-color: @colorBgElevated; + border-bottom: @lineWidth solid @colorSplit; + flex-shrink: 0; + min-height: @controlHeightLg; + user-select: none; + + .statusArea { + display: flex; + align-items: center; + gap: @marginXs; + overflow: hidden; + min-width: 0; + + .terminalIcon { + font-size: @fontSizeLg; + color: @colorTextSecondary; + flex-shrink: 0; + } + + .title { + font-size: @fontSizeSm; + font-weight: 500; + color: @colorText; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .actionsArea { + flex-shrink: 0; + } + } + + // ─── 搜索面板 ────────────────────────────────────────────── + .searchPanel { + display: flex; + align-items: center; + gap: @marginXxs; + padding: @marginXxs @paddingSm; + background-color: @colorBgElevated; + border-bottom: @lineWidth solid @colorSplit; + flex-shrink: 0; + + .searchInput { + width: 240px; + flex-shrink: 0; + } + + .searchOptionActive { + background-color: @colorPrimary !important; + color: @colorTextLightSolid !important; + border-color: @colorPrimary !important; + } + } + + // ─── 终端视口 ────────────────────────────────────────────── + .terminalViewport { + flex: 1; + position: relative; + overflow: hidden; + min-height: 200px; + + // xterm.js 内部样式覆盖 + :global { + .xterm { + height: 100%; + padding: @marginXxs 0 0 @marginXxs; + box-sizing: border-box; + } + + .xterm .xterm-viewport { + overflow-y: auto !important; + } + } + } + + // ─── 遮罩层 ──────────────────────────────────────────────── + .placeholderOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: @colorMask; + color: @colorText; + z-index: @zIndexPopupBase; + gap: @marginXs; + + .placeholderIcon { + font-size: @fontSizeHeading1; + color: @colorTextTertiary; + margin-bottom: @marginXs; + } + + .placeholderText { + font-size: @fontSize; + opacity: 0.7; + margin: 0; + } + } + + .errorOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: @colorMask; + z-index: @zIndexPopupBase; + } + + .loadingOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: @colorMask; + color: @colorText; + z-index: @zIndexPopupBase; + gap: @paddingSm; + + .loadingText { + font-size: @fontSize; + opacity: 0.8; + } + } + + /** 嵌入底部面板等场景:无边框、视口随容器伸缩 */ + &.embedded { + border: none; + border-radius: 0; + min-height: 0; + + .terminalViewport { + min-height: 0; + } + } + + // ─── 浅色主题变体 ────────────────────────────────────────── + &.lightTheme { + background-color: @colorBgContainer; + border-color: @colorBorderSecondary; + + .controlsBar { + background-color: @colorBgLayout; + border-bottom-color: @colorBorderSecondary; + + .terminalIcon { + color: @colorTextSecondary; + } + + .title { + color: @colorText; + } + } + + .searchPanel { + background-color: @colorBgContainer; + border-bottom-color: @colorBorderSecondary; + + .searchInput { + :global(.ant-input) { + background-color: @colorBgContainer; + color: @colorText; + border-color: @colorBorder; + } + + :global(.ant-input::placeholder) { + color: @colorTextPlaceholder; + } + } + + :global(.ant-btn-text) { + color: @colorTextSecondary; + } + } + + .placeholderOverlay { + background-color: @colorMask; + color: @colorText; + + .placeholderIcon { + color: @colorTextQuaternary; + } + } + + .errorOverlay { + background-color: @colorMask; + } + + .loadingOverlay { + background-color: @colorMask; + color: @colorText; + } + } +} diff --git a/qiming/src/components/business-component/Terminal/index.tsx b/qiming/src/components/business-component/Terminal/index.tsx new file mode 100644 index 00000000..487364c5 --- /dev/null +++ b/qiming/src/components/business-component/Terminal/index.tsx @@ -0,0 +1,1250 @@ +import { + CloseOutlined, + CodeOutlined, + DownOutlined, + UpOutlined, +} from '@ant-design/icons'; +import { Alert, Button, Input, Spin, Tooltip } from 'antd'; +import classNames from 'classnames'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; + +// xterm.js 核心样式 +import './xterm.css'; + +import styles from './index.less'; +import { + decodeTtydMessage, + encodeTtydInit, + encodeTtydInput, + encodeTtydResize, + sendTtydFlowControl, + TTYD_CMD_PAUSE, + TTYD_CMD_RESUME, +} from './ttydWire'; +import { + DEFAULT_TERMINAL_SEARCH_OPTIONS, + DEFAULT_TTYD_FLOW_CONTROL, + type ConnectionStatus, + type ReconnectConfig, + type SearchOptions, + type TerminalAddonsMap, + type TerminalOnConnect, + type TerminalOnData, + type TerminalOnDisconnect, + type TerminalOnError, + type TerminalOnInput, + type TerminalSearchOptionsState, + type TerminalTheme, + type XtermTerminalProps, + type XtermTerminalRef, +} from './type'; +import { FitAddon, Terminal } from './xtermBundle'; + +import { createLogger } from '@/utils/logger'; + +const terminalLogger = createLogger('[XtermTerminal]'); + +// ─── 内置主题预设 ──────────────────────────────────────────────── +const DARK_THEME = { + background: '#1e1e1e', + foreground: '#d4d4d4', + cursor: '#d4d4d4', + cursorAccent: '#1e1e1e', + selectionBackground: '#264f78', + selectionForeground: '#d4d4d4', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#ffffff', +}; + +const LIGHT_THEME = { + background: '#ffffff', + foreground: '#383a42', + cursor: '#383a42', + cursorAccent: '#ffffff', + selectionBackground: '#add6ff', + selectionForeground: '#383a42', + black: '#383a42', + red: '#e45649', + green: '#50a14f', + yellow: '#c18401', + blue: '#4078f2', + magenta: '#a626a4', + cyan: '#0184bc', + white: '#fafafa', + brightBlack: '#4f525e', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#ffffff', +}; + +const resolveTheme = (theme: TerminalTheme = 'dark') => { + if (theme === 'dark') return DARK_THEME; + if (theme === 'light') return LIGHT_THEME; + return theme; +}; + +/** 根据背景色亮度判断是否为浅色主题(含自定义 ITheme) */ +const isLightTerminalTheme = (theme: TerminalTheme = 'dark'): boolean => { + if (theme === 'light') return true; + if (theme === 'dark') return false; + const bg = theme.background; + if (!bg || typeof bg !== 'string') return false; + const hex = bg.replace('#', '').slice(0, 6); + if (hex.length < 6) return false; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + if ([r, g, b].some((v) => Number.isNaN(v))) return false; + return (r * 299 + g * 587 + b * 114) / 1000 > 180; +}; + +/** 延迟执行 fit,确保容器已完成布局 */ +const scheduleTerminalFit = ( + getFit: () => (() => void) | undefined, + afterFit?: () => void, +) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + try { + getFit()?.(); + } catch { + /* fit 可能在容器尺寸为 0 时失败 */ + } + afterFit?.(); + }); + }); +}; + +/** 保证终端有效行列(fit 失败时的兜底) */ +const ensureTerminalDimensions = ( + term: import('@xterm/xterm').Terminal, +): { cols: number; rows: number } => { + let cols = term.cols; + let rows = term.rows; + if (cols <= 0 || rows <= 0) { + cols = 80; + rows = 24; + term.resize(cols, rows); + } + return { cols, rows }; +}; + +// ─── 组件实现 ──────────────────────────────────────────────────── +const XtermTerminal = forwardRef( + ( + { + wsUrl, + wsSubprotocols, + wireProtocol = 'plain', + ttydFlowControl, + autoConnect = false, + reconnect, + readOnly = false, + theme = 'dark', + fontSize = 14, + fontFamily, + cursorBlink = true, + lineHeight = 1.2, + scrollback = 5000, + ligatures = false, + enableWebgl = true, + enableImages = false, + embedded = false, + className, + style, + onConnect, + onDisconnect, + onError, + onData, + onInput, + }, + ref, + ) => { + // ─── 状态 ──────────────────────────────────────────────── + const [status, setStatus] = useState('disconnected'); + const [errorMessage, setErrorMessage] = useState(''); + const [searchVisible, setSearchVisible] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [searchOptions, setSearchOptions] = + useState(DEFAULT_TERMINAL_SEARCH_OPTIONS); + const [isFullscreen, setIsFullscreen] = useState(false); + /** xterm 是否已完成 open + addon 加载 */ + const [terminalReady, setTerminalReady] = useState(false); + + // ─── Refs ──────────────────────────────────────────────── + const wrapperRef = useRef(null); + const viewportRef = useRef(null); + const terminalRef = useRef(null); + const wsRef = useRef(null); + const addonsRef = useRef(new Map()); + const reconnectTimerRef = useRef | null>( + null, + ); + const reconnectCountRef = useRef(0); + const isManualDisconnectRef = useRef(false); + /** WS 早于 xterm 就绪时暂存待写入内容 */ + const pendingWritesRef = useRef([]); + const ttydInitSentRef = useRef(false); + + // ─── ttyd 下行背压(PAUSE / RESUME)──────────────────────── + /** 自上次重置以来写入 xterm 的累计字节数,用于触发「带回调」写入 */ + const ttydWrittenRef = useRef(0); + /** 尚未执行完的 term.write 回调数量,反映前端渲染队列深度 */ + const ttydPendingRef = useRef(0); + + /** 合并 props 与 DEFAULT_TTYD_FLOW_CONTROL,与 ttyd 官方 html 客户端默认值一致 */ + const ttydFlowControlConfig = useMemo( + () => ({ + enabled: ttydFlowControl?.enabled !== false, + limit: ttydFlowControl?.limit ?? DEFAULT_TTYD_FLOW_CONTROL.limit, + highWater: + ttydFlowControl?.highWater ?? DEFAULT_TTYD_FLOW_CONTROL.highWater, + lowWater: + ttydFlowControl?.lowWater ?? DEFAULT_TTYD_FLOW_CONTROL.lowWater, + }), + [ttydFlowControl], + ); + + // 回调 ref 化,避免闭包陈旧值(须在 handleWsOpen 之前定义) + const onConnectRef = useRef(onConnect); + const onDisconnectRef = useRef( + onDisconnect, + ); + const onErrorRef = useRef(onError); + const onDataRef = useRef(onData); + const onInputRef = useRef(onInput); + + /** 重置本地背压计数(重连 / 断开后使用) */ + const resetTtydFlowControlState = useCallback(() => { + ttydWrittenRef.current = 0; + ttydPendingRef.current = 0; + }, []); + + /** + * 释放 ttyd 服务端 pty:先发 RESUME 再清零计数。 + * 防止客户端断开后服务端仍停留在 pause 状态。 + */ + const releaseTtydFlowControl = useCallback(() => { + if (wireProtocol === 'ttyd') { + sendTtydFlowControl(wsRef.current, TTYD_CMD_RESUME); + } + resetTtydFlowControlState(); + }, [wireProtocol, resetTtydFlowControlState]); + + /** + * ttyd 下行写入:按官方 writeData 做背压。 + * + * 1. 累计写入未超 limit:同步 write,低开销。 + * 2. 超过 limit:改用 write(data, callback) 统计 pending。 + * 3. pending > highWater:向服务端发 PAUSE,减缓 WebSocket 下行。 + * 4. 某次回调后 pending < lowWater:发 RESUME,恢复 pty 输出。 + * + * 适用于 cat 大文件、npm install 等海量终端输出,避免 xterm/浏览器内存溢出。 + */ + const writeTtydOutputWithFlowControl = useCallback( + (data: string) => { + if (!data) return; + const term = terminalRef.current; + if (!term) { + pendingWritesRef.current.push(data); + return; + } + + const { enabled, limit, highWater, lowWater } = ttydFlowControlConfig; + if (!enabled) { + term.write(data); + return; + } + + const ws = wsRef.current; + ttydWrittenRef.current += data.length; + + if (ttydWrittenRef.current > limit) { + // 带回调写入:pending 反映 xterm 尚未渲染完的块数 + term.write(data, () => { + ttydPendingRef.current = Math.max(ttydPendingRef.current - 1, 0); + if (ttydPendingRef.current < lowWater) { + sendTtydFlowControl(ws, TTYD_CMD_RESUME); + } + }); + ttydPendingRef.current += 1; + ttydWrittenRef.current = 0; + if (ttydPendingRef.current > highWater) { + sendTtydFlowControl(ws, TTYD_CMD_PAUSE); + } + } else { + term.write(data); + } + }, + [ttydFlowControlConfig], + ); + + /** WS 下行 / 外部写入统一入口;ttyd 协议走背压写入 */ + const writeToTerminal = useCallback( + (data: string) => { + if (!data) return; + if (wireProtocol === 'ttyd') { + writeTtydOutputWithFlowControl(data); + return; + } + const term = terminalRef.current; + if (!term) { + pendingWritesRef.current.push(data); + return; + } + term.write(data); + }, + [wireProtocol, writeTtydOutputWithFlowControl], + ); + + /** xterm 就绪后刷掉 WS 早到的输出;ttyd 须走同一背压路径 */ + const flushPendingWrites = useCallback(() => { + const term = terminalRef.current; + if (!term || pendingWritesRef.current.length === 0) return; + const chunks = pendingWritesRef.current.splice(0); + for (const chunk of chunks) { + if (wireProtocol === 'ttyd') { + writeTtydOutputWithFlowControl(chunk); + } else { + term.write(chunk); + } + } + }, [wireProtocol, writeTtydOutputWithFlowControl]); + + /** fit 后向后端同步尺寸;ttyd 首连发 JSON init,之后发 resize */ + const syncBackendTerminalSize = useCallback( + (ws: WebSocket, options?: { sendTtydInit?: boolean }) => { + const term = terminalRef.current; + if (!term || ws.readyState !== WebSocket.OPEN) return; + + const { cols, rows } = ensureTerminalDimensions(term); + + if (wireProtocol === 'ttyd') { + if (options?.sendTtydInit && !ttydInitSentRef.current) { + ws.send(encodeTtydInit(cols, rows)); + ttydInitSentRef.current = true; + } else if (ttydInitSentRef.current) { + ws.send(encodeTtydResize(cols, rows)); + } + } else { + ws.send(JSON.stringify({ type: 'resize', cols, rows })); + } + }, + [wireProtocol], + ); + + const handleWsOpen = useCallback( + (ws: WebSocket) => { + setStatus('connected'); + reconnectCountRef.current = 0; + // 新连接重置背压计数,避免沿用上次的 pending + resetTtydFlowControlState(); + terminalLogger.log('WebSocket connected'); + + scheduleTerminalFit( + () => addonsRef.current.get('fit')?.fit, + () => { + syncBackendTerminalSize(ws, { sendTtydInit: true }); + flushPendingWrites(); + onConnectRef.current?.(); + }, + ); + }, + [flushPendingWrites, resetTtydFlowControlState, syncBackendTerminalSize], + ); + + useEffect(() => { + onConnectRef.current = onConnect; + }, [onConnect]); + useEffect(() => { + onDisconnectRef.current = onDisconnect; + }, [onDisconnect]); + useEffect(() => { + onErrorRef.current = onError; + }, [onError]); + useEffect(() => { + onDataRef.current = onData; + }, [onData]); + useEffect(() => { + onInputRef.current = onInput; + }, [onInput]); + + // 重连配置解构 + const reconnectConfig: Required = useMemo( + () => ({ + enabled: reconnect?.enabled ?? true, + maxRetries: reconnect?.maxRetries ?? 5, + retryDelay: reconnect?.retryDelay ?? 1000, + }), + [reconnect], + ); + + // ─── 加载 Addons ───────────────────────────────────────── + const loadAddons = useCallback( + async (terminal: Terminal): Promise => { + const addons: TerminalAddonsMap = new Map(); + + // 嵌入式控制台仅加载 FitAddon(静态导入),避免生产环境 async chunk + // 各自打包 @xterm/xterm 副本导致「Super constructor null」运行时错误 + if (embedded) { + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + addons.set('fit', fitAddon); + return addons; + } + + // 1. FitAddon + try { + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + addons.set('fit', fitAddon); + } catch (e) { + terminalLogger.warn('FitAddon failed to load:', e); + } + + // 2. WebLinksAddon + try { + const { WebLinksAddon } = await import('@xterm/addon-web-links'); + const webLinksAddon = new WebLinksAddon(); + terminal.loadAddon(webLinksAddon); + addons.set('webLinks', webLinksAddon); + } catch (e) { + terminalLogger.warn('WebLinksAddon failed to load:', e); + } + + // 3. SearchAddon + try { + const { SearchAddon } = await import('@xterm/addon-search'); + const searchAddon = new SearchAddon(); + terminal.loadAddon(searchAddon); + addons.set('search', searchAddon); + } catch (e) { + terminalLogger.warn('SearchAddon failed to load:', e); + } + + // 4. Unicode11Addon + try { + const { Unicode11Addon } = await import('@xterm/addon-unicode11'); + const unicode11Addon = new Unicode11Addon(); + terminal.loadAddon(unicode11Addon); + terminal.unicode.activeVersion = '11'; + addons.set('unicode11', unicode11Addon); + } catch (e) { + terminalLogger.warn('Unicode11Addon failed to load:', e); + } + + // 5. SerializeAddon + try { + const { SerializeAddon } = await import('@xterm/addon-serialize'); + const serializeAddon = new SerializeAddon(); + terminal.loadAddon(serializeAddon); + addons.set('serialize', serializeAddon); + } catch (e) { + terminalLogger.warn('SerializeAddon failed to load:', e); + } + + // 6. WebglAddon (条件加载,带回退) + if (enableWebgl) { + try { + const { WebglAddon } = await import('@xterm/addon-webgl'); + const webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + webglAddon.dispose(); + addons.delete('webgl'); + terminalLogger.warn('WebGL context lost, falling back to canvas'); + }); + terminal.loadAddon(webglAddon); + addons.set('webgl', webglAddon); + } catch (e) { + terminalLogger.warn( + 'WebGL addon failed to load, using canvas renderer:', + e, + ); + } + } + + // 7. ImageAddon (条件加载) + if (enableImages) { + try { + const { ImageAddon } = await import('@xterm/addon-image'); + const imageAddon = new ImageAddon(); + terminal.loadAddon(imageAddon); + addons.set('image', imageAddon); + } catch (e) { + terminalLogger.warn('ImageAddon failed to load:', e); + } + } + + // 8. LigaturesAddon (条件加载) + if (ligatures) { + try { + const { LigaturesAddon } = await import('@xterm/addon-ligatures'); + const ligaturesAddon = new LigaturesAddon(); + terminal.loadAddon(ligaturesAddon); + addons.set('ligatures', ligaturesAddon); + } catch (e) { + terminalLogger.warn('LigaturesAddon failed to load:', e); + } + } + + return addons; + }, + [embedded, enableWebgl, enableImages, ligatures], + ); + + // ─── 初始化终端 ────────────────────────────────────────── + useEffect(() => { + if (!viewportRef.current) return; + + let disposed = false; + + const initTerminal = async () => { + if (disposed || !viewportRef.current) return; + + const resolvedTheme = resolveTheme(theme); + + const term = new Terminal({ + cursorBlink, + fontSize, + fontFamily: + fontFamily || + "'JetBrains Mono', monospace, 'Fira Code', Consolas, 'Courier New'", + lineHeight, + scrollback, + theme: resolvedTheme, + cursorStyle: 'block', + allowProposedApi: true, + disableStdin: readOnly, + }); + + terminalRef.current = term; + + // 加载 addons + const addons = await loadAddons(term); + if (disposed) { + term.dispose(); + return; + } + addonsRef.current = addons; + + // 挂载到 DOM + term.open(viewportRef.current); + + // 初始 fit(嵌入式面板需延迟等待父级布局) + if (embedded) { + scheduleTerminalFit(() => addons.get('fit')?.fit); + } else { + try { + addons.get('fit')?.fit?.(); + } catch { + /* fit 可能在 DOM 未完全就绪时失败 */ + } + } + + // 监听用户输入 → 发送到 WebSocket + 触发回调 + term.onData((data: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + if (wireProtocol === 'ttyd') { + wsRef.current.send(encodeTtydInput(data)); + } else { + wsRef.current.send(data); + } + } + onInputRef.current?.(data); + }); + + // 终端 resize → 通知后端 + term.onResize(({ cols, rows }: { cols: number; rows: number }) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + if (wireProtocol === 'ttyd') { + wsRef.current.send(encodeTtydResize(cols, rows)); + } else { + wsRef.current.send( + JSON.stringify({ type: 'resize', cols, rows }), + ); + } + } + }); + + // 自定义键盘事件处理:Ctrl+Shift+F 打开搜索 + term.attachCustomKeyEventHandler((event: KeyboardEvent) => { + if ( + (event.ctrlKey || event.metaKey) && + event.shiftKey && + event.key === 'f' + ) { + event.preventDefault(); + setSearchVisible((prev) => !prev); + return false; + } + return true; + }); + + terminalLogger.log('Terminal initialized'); + setTerminalReady(true); + + // 若 WS 已先连上(竞态),补发 ttyd init 并刷新缓冲 + if (wsRef.current?.readyState === WebSocket.OPEN) { + scheduleTerminalFit( + () => addons.get('fit')?.fit, + () => { + syncBackendTerminalSize(wsRef.current!, { sendTtydInit: true }); + flushPendingWrites(); + }, + ); + } + }; + + initTerminal(); + + // 清理 + return () => { + disposed = true; + setTerminalReady(false); + pendingWritesRef.current = []; + ttydInitSentRef.current = false; + // 卸载时释放 ttyd 背压,避免服务端 pty 残留 pause + releaseTtydFlowControl(); + + // 关闭 WebSocket + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + // 清除重连定时器 + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + + // 释放 addons + addonsRef.current.forEach((addon) => { + try { + addon.dispose?.(); + } catch { + // ignore + } + }); + addonsRef.current.clear(); + + // 释放终端 + if (terminalRef.current) { + try { + terminalRef.current.dispose(); + } catch { + // ignore + } + terminalRef.current = null; + } + + terminalLogger.log('Terminal disposed'); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ─── ResizeObserver → fitAddon.fit() ───────────────────── + useEffect(() => { + if (!viewportRef.current) return; + + let resizeTimer: ReturnType | null = null; + + const observer = new ResizeObserver(() => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + try { + addonsRef.current.get('fit')?.fit?.(); + } catch { + // fit 可能在终端未就绪时抛出 + } + }, 50); + }); + + observer.observe(viewportRef.current); + + return () => { + if (resizeTimer) clearTimeout(resizeTimer); + observer.disconnect(); + }; + }, []); + + // ─── 全屏变化监听 ──────────────────────────────────────── + useEffect(() => { + const handleChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + document.addEventListener('fullscreenchange', handleChange); + return () => { + document.removeEventListener('fullscreenchange', handleChange); + }; + }, []); + + // ─── Props 变化同步 → 终端配置 ──────────────────────────── + useEffect(() => { + if (!terminalRef.current) return; + const resolved = resolveTheme(theme); + terminalRef.current.options.theme = resolved; + + // 同步背景色 + if (wrapperRef.current) { + wrapperRef.current.style.backgroundColor = + resolved.background || '#1e1e1e'; + } + }, [theme]); + + useEffect(() => { + if (!terminalRef.current) return; + terminalRef.current.options.fontSize = fontSize; + try { + addonsRef.current.get('fit')?.fit?.(); + } catch { + /* ignore */ + } + }, [fontSize]); + + useEffect(() => { + if (!terminalRef.current) return; + if (fontFamily) { + terminalRef.current.options.fontFamily = fontFamily; + } + }, [fontFamily]); + + useEffect(() => { + if (!terminalRef.current) return; + terminalRef.current.options.cursorBlink = cursorBlink; + }, [cursorBlink]); + + useEffect(() => { + if (!terminalRef.current) return; + terminalRef.current.options.disableStdin = readOnly; + }, [readOnly]); + + // ─── WebSocket 连接管理 ────────────────────────────────── + const connect = useCallback( + (url?: string) => { + const targetUrl = url || wsUrl; + if (!targetUrl) { + setStatus('error'); + setErrorMessage('No WebSocket URL provided'); + return; + } + + // 防止重复连接 + if ( + wsRef.current?.readyState === WebSocket.OPEN || + wsRef.current?.readyState === WebSocket.CONNECTING + ) { + return; + } + + setStatus('connecting'); + setErrorMessage(''); + isManualDisconnectRef.current = false; + + try { + const ws = wsSubprotocols + ? new WebSocket(targetUrl, wsSubprotocols) + : new WebSocket(targetUrl); + ws.binaryType = 'arraybuffer'; + wsRef.current = ws; + + ws.onopen = () => { + handleWsOpen(ws); + }; + + ws.onmessage = (event: MessageEvent) => { + // ttyd:解析 OUTPUT 帧;写入时 wireProtocol=ttyd 会自动 PAUSE/RESUME + const data = + wireProtocol === 'ttyd' + ? decodeTtydMessage(event.data) + : typeof event.data === 'string' + ? event.data + : new TextDecoder().decode(event.data); + writeToTerminal(data); + onDataRef.current?.(data); + }; + + ws.onclose = (event: CloseEvent) => { + terminalLogger.log('WebSocket closed:', event.code, event.reason); + ttydInitSentRef.current = false; + // 连接关闭时尽量 RESUME,避免 ttyd 服务端 pty 挂起 + releaseTtydFlowControl(); + setStatus('disconnected'); + onDisconnectRef.current?.(event); + + // 自动重连 + if ( + reconnectConfig.enabled && + reconnectCountRef.current < reconnectConfig.maxRetries && + !isManualDisconnectRef.current + ) { + const delay = + reconnectConfig.retryDelay * + Math.pow(2, reconnectCountRef.current); + reconnectCountRef.current += 1; + terminalLogger.log( + `Reconnecting in ${delay}ms (attempt ${reconnectCountRef.current}/${reconnectConfig.maxRetries})`, + ); + reconnectTimerRef.current = setTimeout(() => { + connect(targetUrl); + }, delay); + } else if ( + !isManualDisconnectRef.current && + reconnectCountRef.current >= reconnectConfig.maxRetries + ) { + setStatus('error'); + setErrorMessage( + `Connection lost. Max retries (${reconnectConfig.maxRetries}) reached.`, + ); + } + }; + + ws.onerror = () => { + const errMsg = 'WebSocket connection error'; + terminalLogger.error(errMsg); + setStatus('error'); + setErrorMessage(errMsg); + onErrorRef.current?.(new Error(errMsg)); + }; + } catch (err) { + setStatus('error'); + setErrorMessage(String(err)); + onErrorRef.current?.( + err instanceof Error ? err : new Error(String(err)), + ); + } + }, + [ + wsUrl, + wsSubprotocols, + wireProtocol, + reconnectConfig, + handleWsOpen, + writeToTerminal, + releaseTtydFlowControl, + ], + ); + + /** 嵌入式面板:布局稳定后再次 fit 并同步 ttyd 尺寸 */ + useEffect(() => { + if (!embedded || status !== 'connected' || !terminalReady) return; + const timer = window.setTimeout(() => { + scheduleTerminalFit( + () => addonsRef.current.get('fit')?.fit, + () => { + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + syncBackendTerminalSize(ws); + } + flushPendingWrites(); + }, + ); + }, 200); + return () => window.clearTimeout(timer); + }, [ + embedded, + status, + terminalReady, + flushPendingWrites, + syncBackendTerminalSize, + ]); + + const disconnect = useCallback(() => { + isManualDisconnectRef.current = true; + ttydInitSentRef.current = false; + + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + + // 主动断开前先 RESUME,再 close + releaseTtydFlowControl(); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + setStatus('disconnected'); + + // 在微任务后重置标志,以允许 close 事件处理器执行 + setTimeout(() => { + isManualDisconnectRef.current = false; + }, 0); + }, [releaseTtydFlowControl]); + + // ─── wsUrl 变化时重连 ───────────────────────────────────── + const prevWsUrlRef = useRef(wsUrl); + useEffect(() => { + if ( + wsUrl && + wsUrl !== prevWsUrlRef.current && + (status === 'connected' || status === 'connecting') + ) { + disconnect(); + connect(wsUrl); + } + prevWsUrlRef.current = wsUrl; + }, [wsUrl, status, connect, disconnect]); + + // ─── 自动连接(须等 xterm 就绪,避免首包输出丢失)──────────────── + useEffect(() => { + if (autoConnect && wsUrl && terminalReady && status === 'disconnected') { + connect(); + } + }, [autoConnect, wsUrl, terminalReady, status, connect]); + + // ─── 搜索功能 ──────────────────────────────────────────── + const handleFindNext = useCallback(() => { + if (!searchTerm) return; + const findNext = addonsRef.current.get('search')?.findNext; + if (!findNext) return; + findNext(searchTerm, { + caseSensitive: searchOptions.caseSensitive, + wholeWord: searchOptions.wholeWord, + regex: searchOptions.regex, + incremental: true, + }); + }, [searchTerm, searchOptions]); + + const handleFindPrevious = useCallback(() => { + if (!searchTerm) return; + const findPrevious = addonsRef.current.get('search')?.findPrevious; + if (!findPrevious) return; + findPrevious(searchTerm, { + caseSensitive: searchOptions.caseSensitive, + wholeWord: searchOptions.wholeWord, + regex: searchOptions.regex, + }); + }, [searchTerm, searchOptions]); + + const toggleSearchOption = useCallback( + (key: keyof TerminalSearchOptionsState) => { + setSearchOptions((prev) => ({ ...prev, [key]: !prev[key] })); + }, + [], + ); + + // 搜索项变化时自动搜索 + useEffect(() => { + if (searchVisible && searchTerm) { + handleFindNext(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchOptions, searchVisible]); + + // ─── 下载缓冲区 ────────────────────────────────────────── + const handleDownloadBuffer = useCallback((filename?: string) => { + const serializeAddon = addonsRef.current.get('serialize'); + if (!serializeAddon) return; + + const content = serializeAddon.serialize?.(); + if (!content) return; + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename || `terminal-${Date.now()}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, []); + + // ─── 全屏切换 ──────────────────────────────────────────── + const toggleFullscreen = useCallback(async () => { + if (!wrapperRef.current) return; + + if (!document.fullscreenElement) { + try { + await wrapperRef.current.requestFullscreen(); + setIsFullscreen(true); + } catch (err) { + terminalLogger.warn('Fullscreen request failed:', err); + } + } else { + await document.exitFullscreen(); + setIsFullscreen(false); + } + }, []); + + const clearSearchDecorations = useCallback(() => { + try { + addonsRef.current.get('search')?.clearDecorations?.(); + } catch { + /* ignore */ + } + }, []); + + const closeSearchPanel = useCallback(() => { + setSearchVisible(false); + clearSearchDecorations(); + }, [clearSearchDecorations]); + + const openSearchPanel = useCallback(() => { + setSearchVisible(true); + }, []); + + const toggleSearchPanel = useCallback(() => { + setSearchVisible((v) => { + if (v) { + clearSearchDecorations(); + } + return !v; + }); + }, [clearSearchDecorations]); + + const runFindNext = useCallback( + (term?: string, options?: SearchOptions) => { + const query = term ?? searchTerm; + if (!query) return false; + const sa = addonsRef.current.get('search'); + if (!sa) return false; + return ( + sa.findNext?.(query, { + ...searchOptions, + ...options, + incremental: true, + }) ?? false + ); + }, + [searchTerm, searchOptions], + ); + + const runFindPrevious = useCallback( + (term?: string, options?: SearchOptions) => { + const query = term ?? searchTerm; + if (!query) return false; + const sa = addonsRef.current.get('search'); + if (!sa) return false; + return ( + sa.findPrevious?.(query, { ...searchOptions, ...options }) ?? false + ); + }, + [searchTerm, searchOptions], + ); + + // ─── 命令式 API(原工具栏能力均通过 ref 暴露)──────────────── + useImperativeHandle( + ref, + () => ({ + connect, + disconnect, + write: (data: string) => terminalRef.current?.write(data), + writeln: (data: string) => terminalRef.current?.writeln(data), + clear: () => terminalRef.current?.clear(), + focus: () => terminalRef.current?.focus(), + blur: () => terminalRef.current?.blur(), + search: openSearchPanel, + toggleSearch: toggleSearchPanel, + closeSearch: closeSearchPanel, + isSearchVisible: () => searchVisible, + setSearchTerm, + getSearchTerm: () => searchTerm, + setSearchOptions: (options: Partial) => { + setSearchOptions((prev) => ({ ...prev, ...options })); + }, + findNext: runFindNext, + findPrevious: runFindPrevious, + clearSearchDecorations, + toggleFullscreen, + isFullscreen: () => isFullscreen, + getTerminal: () => terminalRef.current, + getStatus: () => status, + downloadBuffer: (filename?: string) => handleDownloadBuffer(filename), + /** 手动 PAUSE(一般无需调用,大量输出时组件会自动背压) */ + ttydPause: () => sendTtydFlowControl(wsRef.current, TTYD_CMD_PAUSE), + /** 手动 RESUME */ + ttydResume: () => sendTtydFlowControl(wsRef.current, TTYD_CMD_RESUME), + }), + [ + connect, + disconnect, + status, + searchVisible, + searchTerm, + handleDownloadBuffer, + openSearchPanel, + toggleSearchPanel, + closeSearchPanel, + clearSearchDecorations, + runFindNext, + runFindPrevious, + toggleFullscreen, + isFullscreen, + ], + ); + + // ─── 渲染 ──────────────────────────────────────────────── + const isLight = isLightTerminalTheme(theme); + + return ( +
+ {/* 搜索面板(通过 ref.search / ref.toggleSearch 打开) */} + {searchVisible && ( +
+ setSearchTerm(e.target.value)} + onPressEnter={handleFindNext} + onKeyDown={(e) => { + if (e.key === 'Escape') { + closeSearchPanel(); + } + if (e.key === 'Enter' && e.shiftKey) { + e.preventDefault(); + handleFindPrevious(); + } + }} + autoFocus + className={styles.searchInput} + /> + + + + + + + + + + +
+ )} + + {/* 终端视口 */} +
+ + {/* 连接中遮罩 */} + {status === 'connecting' && ( +
+ + Connecting... +
+ )} + + {/* 错误遮罩 */} + {status === 'error' && ( +
+ connect()}> + Retry + + } + /> +
+ )} + + {/* 断开连接占位 */} + {status === 'disconnected' && wsUrl && !autoConnect && ( +
+ +

Terminal is not connected

+ +
+ )} +
+ ); + }, +); + +export default XtermTerminal; diff --git a/qiming/src/components/business-component/Terminal/terminalMouseUtils.ts b/qiming/src/components/business-component/Terminal/terminalMouseUtils.ts new file mode 100644 index 00000000..418e4574 --- /dev/null +++ b/qiming/src/components/business-component/Terminal/terminalMouseUtils.ts @@ -0,0 +1,36 @@ +import type { Terminal } from './xtermBundle'; + +/** 关闭 xterm / PTY 侧各类鼠标追踪模式(1000/1002/1003/1006) */ +export const DISABLE_TERMINAL_MOUSE_TRACKING = + '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l'; + +/** 是否为 xterm 鼠标上报序列(点击 / 滚轮 / 拖拽) */ +export const isXtermMouseReport = (data: string): boolean => { + if (!data) { + return false; + } + // SGR 模式(DECSET 1006 / 1002) + if (/^\x1b\[<[\d;]*\d[mM]$/.test(data)) { + return true; + } + // 经典模式(DECSET 1000 / 1003) + if (/^\x1b\[M[\u0020-\u003f\u0060-\u007e]{3}$/.test(data)) { + return true; + } + return false; +}; + +/** + * 是否应将用户输入转发到 WebSocket + * 主缓冲区(shell 提示符)下过滤鼠标序列,避免点击/滚轮污染命令行; + * 备用屏(vim/less/tmux)内保留鼠标上报。 + */ +export const shouldForwardTerminalInput = ( + term: Terminal, + data: string, +): boolean => { + if (!isXtermMouseReport(data)) { + return true; + } + return term.buffer.active !== term.buffer.normal; +}; diff --git a/qiming/src/components/business-component/Terminal/terminalReconnect.ts b/qiming/src/components/business-component/Terminal/terminalReconnect.ts new file mode 100644 index 00000000..e101a7f2 --- /dev/null +++ b/qiming/src/components/business-component/Terminal/terminalReconnect.ts @@ -0,0 +1,74 @@ +/** 终端 WebSocket 自动重连配置 */ +export interface TerminalReconnectConfig { + /** 是否启用自动重连 @default true */ + enabled?: boolean; + /** 最大重连次数(不含首次连接) @default 5 */ + maxRetries?: number; + /** 首次重连等待(ms),兼容旧字段 retryDelay */ + initialInterval?: number; + retryDelay?: number; + /** 重连间隔上限(ms) @default 30000 */ + maxInterval?: number; + /** 指数退避乘数 @default 2 */ + multiplier?: number; + /** 抖动比例 ±jitter,避免惊群 @default 0.2 */ + jitter?: number; + /** 心跳检测间隔(ms),0 为禁用 @default 30000 */ + heartbeatInterval?: number; + /** 心跳超时(ms) @default 90000 */ + heartbeatTimeout?: number; +} + +export const DEFAULT_TERMINAL_RECONNECT: Required< + Pick< + TerminalReconnectConfig, + | 'enabled' + | 'maxRetries' + | 'initialInterval' + | 'maxInterval' + | 'multiplier' + | 'jitter' + | 'heartbeatInterval' + | 'heartbeatTimeout' + > +> = { + enabled: true, + maxRetries: 5, + initialInterval: 2000, + maxInterval: 30000, + multiplier: 2, + jitter: 0.2, + heartbeatInterval: 30000, + heartbeatTimeout: 90000, +}; + +/** + * 计算下一次重连间隔:指数退避 + 上限 + 抖动 + * @param attemptIndex 当前已失败次数(0 = 首次重连) + */ +export const getTerminalReconnectDelay = ( + attemptIndex: number, + config?: TerminalReconnectConfig, +): number => { + const initialInterval = + config?.initialInterval ?? + config?.retryDelay ?? + DEFAULT_TERMINAL_RECONNECT.initialInterval; + const maxInterval = + config?.maxInterval ?? DEFAULT_TERMINAL_RECONNECT.maxInterval; + const multiplier = + config?.multiplier ?? DEFAULT_TERMINAL_RECONNECT.multiplier; + const jitter = config?.jitter ?? DEFAULT_TERMINAL_RECONNECT.jitter; + + let delay = initialInterval * Math.pow(multiplier, attemptIndex); + delay = Math.min(delay, maxInterval); + + const jitterAmount = delay * jitter; + delay = delay + (Math.random() * 2 - 1) * jitterAmount; + + return Math.floor(Math.max(delay, initialInterval)); +}; + +export const getTerminalMaxRetries = ( + config?: TerminalReconnectConfig, +): number => config?.maxRetries ?? DEFAULT_TERMINAL_RECONNECT.maxRetries; diff --git a/qiming/src/components/business-component/Terminal/ttydWire.ts b/qiming/src/components/business-component/Terminal/ttydWire.ts new file mode 100644 index 00000000..f64b818c --- /dev/null +++ b/qiming/src/components/business-component/Terminal/ttydWire.ts @@ -0,0 +1,47 @@ +/** ttyd WebSocket 帧首字节(与官方 Command 枚举一致) */ +const TTYD_CMD_INPUT = 0x30; +const TTYD_CMD_OUTPUT = 0x30; + +/** ttyd 首包:以 '{' 开头的 JSON,触发 fork shell */ +export const encodeTtydInit = (cols: number, rows: number): string => + JSON.stringify({ columns: cols, rows }); + +/** ttyd:'0' + 原始输入字节 */ +export const encodeTtydInput = (data: string): Uint8Array => { + const bytes = new TextEncoder().encode(data); + const out = new Uint8Array(bytes.length + 1); + out[0] = TTYD_CMD_INPUT; + out.set(bytes, 1); + return out; +}; + +/** ttyd:'1' + { columns, rows } */ +export const encodeTtydResize = (cols: number, rows: number): string => + '1' + JSON.stringify({ columns: cols, rows }); + +/** 解析 ttyd 二进制下行帧,仅提取 OUTPUT(0x30) 写入 xterm */ +export const decodeTtydMessage = (raw: ArrayBuffer | string): string => { + const buf = + typeof raw === 'string' + ? new TextEncoder().encode(raw) + : new Uint8Array(raw); + if (buf.length === 0 || buf[0] !== TTYD_CMD_OUTPUT) { + return ''; + } + return new TextDecoder().decode(buf.subarray(1)); +}; + +/** 客户端 → 服务端:pty_pause(),在 xterm 渲染积压时暂停下行 */ +export const TTYD_CMD_PAUSE = 0x32; +/** 客户端 → 服务端:pty_resume(),渲染队列消化后恢复下行 */ +export const TTYD_CMD_RESUME = 0x33; + +/** 向 ttyd 发送单字节流控命令(PAUSE / RESUME),无 payload */ +export const sendTtydFlowControl = ( + ws: WebSocket | null | undefined, + cmd: number, +) => { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(new Uint8Array([cmd])); + } +}; diff --git a/qiming/src/components/business-component/Terminal/type.ts b/qiming/src/components/business-component/Terminal/type.ts new file mode 100644 index 00000000..5a88790d --- /dev/null +++ b/qiming/src/components/business-component/Terminal/type.ts @@ -0,0 +1,238 @@ +import type { ITheme, Terminal } from '@xterm/xterm'; + +// ─── 连接状态 ──────────────────────────────────────────────────── +export type ConnectionStatus = + | 'disconnected' + | 'connecting' + | 'connected' + | 'error'; + +// ─── 主题预设 ──────────────────────────────────────────────────── +export type TerminalThemePreset = 'dark' | 'light'; +export type TerminalTheme = TerminalThemePreset | ITheme; + +// ─── 搜索选项 ──────────────────────────────────────────────────── +export interface SearchOptions { + /** 是否区分大小写 */ + caseSensitive?: boolean; + /** 是否使用正则表达式 */ + regex?: boolean; + /** 是否全词匹配 */ + wholeWord?: boolean; + /** 是否增量搜索 */ + incremental?: boolean; +} + +/** 组件内搜索选项 state(三项均为必填布尔值) */ +export type TerminalSearchOptionsState = Required< + Pick +>; + +/** 搜索选项默认值 */ +export const DEFAULT_TERMINAL_SEARCH_OPTIONS: TerminalSearchOptionsState = { + caseSensitive: false, + wholeWord: false, + regex: false, +}; + +/** 动态加载的 xterm addon 最小能力描述 */ +export interface TerminalAddonInstance { + fit?: () => void; + dispose?: () => void; + findNext?: (term: string, options?: SearchOptions) => boolean; + findPrevious?: (term: string, options?: SearchOptions) => boolean; + clearDecorations?: () => void; + serialize?: () => string; +} + +export type TerminalAddonsMap = Map; + +// ─── WebSocket 线缆协议 ────────────────────────────────────────── +/** plain:纯文本输入 + JSON resize;ttyd:0x30 输入帧 + '1' resize + '2'/'3' 流控 */ +export type TerminalWireProtocol = 'plain' | 'ttyd'; + +/** + * ttyd 下行背压配置(与官方 html 客户端 writeData 一致)。 + * + * xterm 在大量输出时渲染变慢;通过 WebSocket 向 ttyd 发送 PAUSE/RESUME, + * 让服务端暂时停止 pty 写入,从而控制下行流量,避免浏览器/xterm 缓冲溢出。 + * + * @see https://github.com/tsl0922/ttyd/blob/master/html/src/components/terminal/xterm/index.ts + */ +export interface TtydFlowControlConfig { + /** 是否启用自动 PAUSE/RESUME @default true */ + enabled?: boolean; + /** + * 累计写入 xterm 的字节阈值;超过后改用 term.write(data, callback) 统计 pending + * @default 100000 + */ + limit?: number; + /** + * pending(未完成 write 回调数)超过该值时发送 PAUSE ('2') + * @default 10 + */ + highWater?: number; + /** + * 某次 write 回调执行后 pending 低于该值时发送 RESUME ('3') + * @default 4 + */ + lowWater?: number; +} + +/** ttyd 流控默认值(与 ttyd 官方 FlowControl 一致) */ +export const DEFAULT_TTYD_FLOW_CONTROL: Required< + Pick +> = { + limit: 100000, + highWater: 10, + lowWater: 4, +}; + +// ─── 重连配置 ──────────────────────────────────────────────────── +export interface ReconnectConfig { + /** 是否启用自动重连 @default true */ + enabled?: boolean; + /** 最大重连次数 @default 5 */ + maxRetries?: number; + /** 重连基础延迟(毫秒),使用指数退避 @default 1000 */ + retryDelay?: number; +} + +// ─── 组件 Props ────────────────────────────────────────────────── +export interface XtermTerminalProps { + /** WebSocket 连接地址 (如 "wss://example.com/terminal") */ + wsUrl?: string; + /** WebSocket 子协议(ttyd 需传 ['tty']) */ + wsSubprotocols?: string | string[]; + /** 与后端约定的消息格式 @default 'plain' */ + wireProtocol?: TerminalWireProtocol; + /** + * ttyd 下行背压:通过 WebSocket 发送 PAUSE/RESUME 控制服务端 pty 输出, + * 避免大量输出导致 xterm 缓冲溢出(仅 wireProtocol='ttyd' 生效) + */ + ttydFlowControl?: TtydFlowControlConfig; + /** 挂载时是否自动连接(需要 wsUrl) @default false */ + autoConnect?: boolean; + /** 重连配置 */ + reconnect?: ReconnectConfig; + + /** 工具栏标题 */ + title?: string; + + /** 只读模式,禁用键盘输入 @default false */ + readOnly?: boolean; + + /** 主题预设 ('dark' | 'light') 或自定义 ITheme @default 'dark' */ + theme?: TerminalTheme; + + /** 字体大小(像素) @default 14 */ + fontSize?: number; + /** 字体族 */ + fontFamily?: string; + /** 光标是否闪烁 @default true */ + cursorBlink?: boolean; + /** 行高倍数 @default 1.2 */ + lineHeight?: number; + /** 回滚行数 @default 5000 */ + scrollback?: number; + + /** 启用字体连字 @default false */ + ligatures?: boolean; + /** 启用 WebGL 渲染器(失败时回退到 canvas) @default true */ + enableWebgl?: boolean; + /** 启用图片支持 (sixel/iTerm2) @default false */ + enableImages?: boolean; + + /** + * 嵌入式布局(如底部面板) + * 去掉边框、降低 min-height,并在连接后自动 fit 尺寸 + */ + embedded?: boolean; + /** 自定义 CSS 类名 */ + className?: string; + /** 自定义内联样式 */ + style?: React.CSSProperties; + + // ─── 回调函数 ───────────────────────────────────────────── + /** WebSocket 连接成功时触发 */ + onConnect?: () => void; + /** WebSocket 断开时触发 */ + onDisconnect?: (event?: CloseEvent) => void; + /** 连接或终端出错时触发 */ + onError?: (error: Error | string) => void; + /** 接收到数据时触发(来自 WS 或外部写入) */ + onData?: (data: string) => void; + /** 用户键盘输入时触发 */ + onInput?: (data: string) => void; +} + +/** 回调 ref 类型(与 Props 保持一致) */ +export type TerminalOnConnect = NonNullable; +export type TerminalOnDisconnect = NonNullable< + XtermTerminalProps['onDisconnect'] +>; +export type TerminalOnError = NonNullable; +export type TerminalOnData = NonNullable; +export type TerminalOnInput = NonNullable; + +// ─── Ref 命令式 API ────────────────────────────────────────────── +export interface XtermTerminalRef { + /** 建立 WebSocket 连接(不传参则使用 wsUrl prop) */ + connect: (url?: string) => void; + /** 断开 WebSocket 连接 */ + disconnect: () => void; + + /** 向终端写入原始数据 */ + write: (data: string) => void; + /** 向终端写入数据并追加换行 */ + writeln: (data: string) => void; + /** 清空终端屏幕和回滚缓冲区 */ + clear: () => void; + + /** 聚焦终端 */ + focus: () => void; + /** 取消聚焦 */ + blur: () => void; + + /** 打开搜索栏 */ + search: () => void; + /** 切换搜索栏显示/隐藏 */ + toggleSearch: () => void; + /** 关闭搜索栏并清除高亮 */ + closeSearch: () => void; + /** 搜索栏是否可见 */ + isSearchVisible: () => boolean; + /** 设置搜索关键词 */ + setSearchTerm: (term: string) => void; + /** 获取当前搜索关键词 */ + getSearchTerm: () => string; + /** 更新搜索选项 */ + setSearchOptions: (options: Partial) => void; + /** 查找下一个匹配项(不传 term 则使用当前搜索词) */ + findNext: (term?: string, options?: SearchOptions) => boolean; + /** 查找上一个匹配项(不传 term 则使用当前搜索词) */ + findPrevious: (term?: string, options?: SearchOptions) => boolean; + /** 清除搜索高亮 */ + clearSearchDecorations: () => void; + + /** 切换全屏 */ + toggleFullscreen: () => Promise; + /** 是否处于全屏 */ + isFullscreen: () => boolean; + + /** 获取原始 xterm.js Terminal 实例 */ + getTerminal: () => Terminal | null; + /** 获取当前连接状态 */ + getStatus: () => ConnectionStatus; + + /** 将当前缓冲区内容下载为 .txt 文件 */ + downloadBuffer: (filename?: string) => void; + + /** + * 手动 PAUSE('2'),暂停 ttyd 服务端 pty 输出。 + * 常规场景由组件根据 pending 自动背压,仅调试或特殊需求时使用。 + */ + ttydPause?: () => void; + /** 手动 RESUME('3'),恢复 pty 输出 */ + ttydResume?: () => void; +} diff --git a/qiming/src/components/business-component/Terminal/xterm.css b/qiming/src/components/business-component/Terminal/xterm.css new file mode 100644 index 00000000..a19daf9f --- /dev/null +++ b/qiming/src/components/business-component/Terminal/xterm.css @@ -0,0 +1,252 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + left: 0; + width: 100%; + + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #fff; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: auto; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; + width: 100%; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm .xterm-scroll-area { + visibility: hidden; +} + +.xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + top: 0; + left: -9999em; + line-height: normal; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility:not(.debug), +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; + pointer-events: none; +} + +.xterm .xterm-accessibility-tree:not(.debug) *::selection { + color: transparent; +} + +.xterm .xterm-accessibility-tree { + user-select: text; + white-space: pre; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; +} + +.xterm-underline-1 { + text-decoration: underline; +} + +.xterm-underline-2 { + text-decoration: double underline; +} + +.xterm-underline-3 { + text-decoration: wavy underline; +} + +.xterm-underline-4 { + text-decoration: dotted underline; +} + +.xterm-underline-5 { + text-decoration: dashed underline; +} + +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { + text-decoration: overline underline; +} + +.xterm-overline.xterm-underline-2 { + text-decoration: overline double underline; +} + +.xterm-overline.xterm-underline-3 { + text-decoration: overline wavy underline; +} + +.xterm-overline.xterm-underline-4 { + text-decoration: overline dotted underline; +} + +.xterm-overline.xterm-underline-5 { + text-decoration: overline dashed underline; +} + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-screen + .xterm-decoration-container + .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + +.xterm-decoration-overview-ruler { + z-index: 8; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} diff --git a/qiming/src/components/business-component/Terminal/xtermBundle.ts b/qiming/src/components/business-component/Terminal/xtermBundle.ts new file mode 100644 index 00000000..e0bdcbe7 --- /dev/null +++ b/qiming/src/components/business-component/Terminal/xtermBundle.ts @@ -0,0 +1,5 @@ +/** + * xterm 统一静态入口,避免生产分包后出现多份 @xterm/xterm 导致 class 继承链断裂 + */ +export { FitAddon } from '@xterm/addon-fit'; +export { Terminal } from '@xterm/xterm'; diff --git a/qiming/src/components/business-component/UnifiedChatSession/components/ChatContentArea/index.less b/qiming/src/components/business-component/UnifiedChatSession/components/ChatContentArea/index.less new file mode 100644 index 00000000..99341e68 --- /dev/null +++ b/qiming/src/components/business-component/UnifiedChatSession/components/ChatContentArea/index.less @@ -0,0 +1,89 @@ +@import '@/styles/color'; +@import '@/styles/token'; +@import '@/styles/scrollbar.less'; + +@chat-max-width: 800px; +@zero-value: 0; +@margin-bottom-gap: 5px; + +.chat-wrapper-content { + overflow: auto; + margin-bottom: @margin-bottom-gap; + padding-left: 8px; + display: flex; + flex-direction: column; + flex: 1; + min-height: @zero-value; + width: 100%; + + // 使用滚动条自动隐藏 mixin + .scrollbar-auto-hide(); + + .chat-wrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: @zero-value; + max-width: @chat-max-width; + width: 100%; + margin: @zero-value auto; + + // 使用伪元素优雅解决 flex 子容器下 padding-bottom 在滚动高度中坍塌的 bug + &::after { + content: ''; + display: block; + height: @paddingLg; + flex-shrink: 0; + } + + .loading-wrapper { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + } + + .loading-icon { + color: @colorPrimary; + } + + // 加载更多按钮容器 + .load-more-container { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: @paddingSm; + text-align: center; + padding: @padding @zero-value; + color: @colorTextTertiary; + } + + // 任务执行中容器 + .task-executing-container { + display: flex; + align-items: center; + height: @controlHeight; + color: @colorTextSecondary; + font-size: @fontSizeSm; + gap: @paddingSm; + } + + .opening-chat-msg { + margin-top: @paddingMd; + margin-bottom: @paddingMd; + color: @colorTextSecondary; + font-size: @fontSize; + } + } +} + +.recommend-list-box { + padding-bottom: @marginLg; +} + +// 支持外层全局状态 only-input 对本展示区域的隐藏控制 +:global(.only-input) { + .chat-wrapper-content { + display: none !important; + } +} diff --git a/qiming/src/components/business-component/UnifiedChatSession/components/ChatContentArea/index.tsx b/qiming/src/components/business-component/UnifiedChatSession/components/ChatContentArea/index.tsx new file mode 100644 index 00000000..f287f8d2 --- /dev/null +++ b/qiming/src/components/business-component/UnifiedChatSession/components/ChatContentArea/index.tsx @@ -0,0 +1,205 @@ +import AgentChatEmpty from '@/components/AgentChatEmpty'; +import ChatView from '@/components/ChatView'; +import NewConversationSet from '@/components/NewConversationSet'; +import RecommendList from '@/components/RecommendList'; +import { LoadingOutlined } from '@ant-design/icons'; +import classNames from 'classnames'; +import * as React from 'react'; + +import { MESSAGE_PAGE_SIZE } from '@/constants/common.constants'; +import { dict } from '@/services/i18nRuntime'; +import { AgentTypeEnum } from '@/types/enums/space'; +import type { + MessageInfo, + RoleInfo, +} from '@/types/interfaces/conversationInfo'; +import type { UnifiedAgentInfo } from '../../types'; + +import styles from './index.less'; + +const cx = classNames.bind(styles); + +export interface ChatContentAreaProps { + messageViewRef: React.RefObject; + handleMouseEnter: () => void; + handleMouseLeave: () => void; + isLoading: boolean; + form?: any; + variables?: any[]; + agentInfo?: UnifiedAgentInfo; + userFillVariables?: any; + isVariablesFilled?: boolean; + isVariablesDisabled?: boolean; + variableParams?: Record | null; + messageList?: MessageInfo[]; + isMoreMessage?: boolean; + loadMoreRef: any; + loadingMore?: boolean; + renderMessageItem?: ( + message: MessageInfo, + isLastMessage: boolean, + ) => React.ReactNode; + effectiveRoleInfo: RoleInfo; + messageBottomMode?: 'none' | 'home' | 'chat'; + showDebug?: boolean; + shouldShowSessionSuggest: boolean; + loadingSuggest?: boolean; + chatSuggestList?: any[]; + handleMessageSend: (...args: any[]) => void; + showTaskExecutingWait: boolean; + renderEmptyState?: () => React.ReactNode; +} + +export const ChatContentArea: React.FC = ({ + messageViewRef, + handleMouseEnter, + handleMouseLeave, + isLoading, + form, + variables, + agentInfo = {}, + userFillVariables, + isVariablesFilled, + isVariablesDisabled, + variableParams, + messageList = [], + isMoreMessage, + loadMoreRef, + loadingMore, + renderMessageItem, + effectiveRoleInfo, + messageBottomMode = 'home', + showDebug, + shouldShowSessionSuggest, + loadingSuggest, + chatSuggestList = [], + handleMessageSend, + showTaskExecutingWait, + renderEmptyState, +}) => { + const renderedMessageList = React.useMemo(() => { + if (!messageList || messageList.length <= 1) { + return messageList; + } + const isOpeningMessage = (item: MessageInfo) => { + return !item.id; + }; + const hasRealMessage = messageList.some((item) => !isOpeningMessage(item)); + if (hasRealMessage) { + return messageList.filter((item) => !isOpeningMessage(item)); + } + return messageList; + }, [messageList]); + + return ( +
+
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {/* 变量参数配置表单 */} + {form && ( + + )} + + {renderedMessageList?.length > 0 ? ( + <> + {/* 加载历史消息的触发探测节点 */} + {isMoreMessage && + (renderedMessageList?.length || 0) >= MESSAGE_PAGE_SIZE && ( +
+ {loadingMore ? ( + + + {dict('PC.Pages.Chat.loadingHistoryConversation')} + + ) : null} +
+ )} + + {/* 消息渲染列表 */} + {renderedMessageList?.map((item: MessageInfo, idx: number) => { + const isLastMessage = idx === renderedMessageList.length - 1; + if (renderMessageItem) { + return renderMessageItem(item, isLastMessage); + } + return ( + + ); + })} + + {/* 问题建议:仅会话空闲且队列已排空时展示,避免与队列中的下一轮消息割裂 */} + {shouldShowSessionSuggest && ( + + )} + + {/* 通用型智能体:后台任务执行中且流式已结束 */} + {showTaskExecutingWait && ( +
+ + {dict('PC.Pages.Chat.agentExecutingWait')} +
+ )} + + ) : // 空状态展现 + renderEmptyState ? ( + renderEmptyState?.() + ) : ( + +
+ {agentInfo?.openingChatMsg} +
+ +
+ } + /> + )} + + )} +
+
+ ); +}; + +export default ChatContentArea; diff --git a/qiming/src/components/business-component/UnifiedChatSession/components/ChatInputHomeIndependent/index.tsx b/qiming/src/components/business-component/UnifiedChatSession/components/ChatInputHomeIndependent/index.tsx new file mode 100644 index 00000000..78ec4631 --- /dev/null +++ b/qiming/src/components/business-component/UnifiedChatSession/components/ChatInputHomeIndependent/index.tsx @@ -0,0 +1,1088 @@ +import SvgIcon from '@/components/base/SvgIcon'; +import type { AgentMode } from '@/components/business-component/AgentIntervention'; +import PaymentSubscriptionModal from '@/components/business-component/PaymentSubscriptionModal'; +import { + ChatInputVoiceFooter, + mergeVoiceTranscript, +} from '@/components/business-component/VoiceInput'; +import AtMentionIcon from '@/components/ChatInputHome/AtMentionIcon'; +import ComputerTypeSelector from '@/components/ChatInputHome/ComputerTypeSelector'; +import styles from '@/components/ChatInputHome/index.less'; +import ManualComponentItem from '@/components/ChatInputHome/ManualComponentItem'; +import MentionEditor from '@/components/ChatInputHome/MentionEditor'; +import type { + MentionEditorHandle, + MentionItem, +} from '@/components/ChatInputHome/MentionPopup/types'; +import ModelSelector from '@/components/ChatInputHome/ModelSelector'; +import ChatUploadFile from '@/components/ChatUploadFile'; +import ConditionRender from '@/components/ConditionRender'; +import PermissionMask from '@/components/PermissionMask'; +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { UPLOAD_FILE_ACTION } from '@/constants/common.constants'; +import { ENABLE_CHAT_MESSAGE_QUEUE } from '@/constants/feature.constants'; +import { ACCESS_TOKEN } from '@/constants/home.constants'; +import { isSessionStreamBusy } from '@/hooks/useExecutingTaskStatusPoll'; +import useSubscription from '@/hooks/useSubscription'; +import { t } from '@/services/i18nRuntime'; +import { DefaultSelectedEnum, TaskStatus } from '@/types/enums/agent'; +import { UploadFileStatus } from '@/types/enums/common'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import type { + ConversationInfo, + MessageInfo, +} from '@/types/interfaces/conversationInfo'; +import eventBus, { EVENT_NAMES } from '@/utils/eventBus'; +import { handleUploadFileList } from '@/utils/upload'; +import { + ArrowDownOutlined, + CheckOutlined, + DesktopOutlined, + LoadingOutlined, +} from '@ant-design/icons'; +import { Dropdown, message, Tooltip, Upload, UploadProps } from 'antd'; +import classNames from 'classnames'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useModel } from 'umi'; +import { v4 as uuidv4 } from 'uuid'; + +const cx = classNames.bind(styles); + +const VoiceFooter = ChatInputVoiceFooter; + +const AGENT_MODE_OPTIONS: AgentMode[] = ['yolo', 'ask']; + +const AGENT_MODE_I18N: Record = { + yolo: { + label: 'PC.Components.ChatInputHome.agentModeAuto', + desc: 'PC.Components.ChatInputHome.agentModeAutoDesc', + }, + ask: { + label: 'PC.Components.ChatInputHome.agentModeApproval', + desc: 'PC.Components.ChatInputHome.agentModeApprovalDesc', + }, +}; + +/** + * ChatInputHomeIndependent 组件的 Props 类型 + * 将原 ChatInputHome 中 useModel('conversationInfo') 的数据改为外部传入, + * 实现组件独立性,避免与 model 强关联。 + */ +export interface ChatInputHomeIndependentProps { + // ===== 原 ChatInputHome 的受控属性 ===== + className?: React.CSSProperties; + wholeDisabled?: boolean; + clearDisabled?: boolean; + clearLoading?: boolean; + visible?: boolean; + isClearInput?: boolean; + onScrollBottom?: () => void; + onClear?: () => void; + onEnter: ( + message: string, + files: UploadFileInfo[], + skillIds?: number[], + modelId?: number, + agentMode?: AgentMode, + ) => void; + enableMention?: boolean; + mentionPlacement?: 'auto' | 'up' | 'down'; + showAnnouncement?: boolean; + onTempChatStop?: (requestId: string) => void; + loadingStopTempConversation?: boolean; + showTaskAgentToggle?: boolean; + isTaskAgentActive?: boolean; + onToggleTaskAgent?: () => void; + selectedComputerId?: string; + onComputerSelect?: (id: string) => void; + agentId?: number; + agentSandboxId?: string | number; + fixedSelection?: boolean; + hasPermission?: boolean; + isSandboxUnavailable?: boolean; + readonly?: boolean; + maskText?: string; + autoSelectComputer?: boolean; + saveComputerOnSelect?: boolean; + isPersonalComputer?: boolean; + allowOtherModel?: DefaultSelectedEnum; + selectedModelId?: number; + onModelSelect?: (modelId: number) => void; + agentType?: string; + agentMode?: AgentMode; + onAgentModeChange?: (mode: AgentMode) => void; + showAgentModeSelector?: boolean; + placeholder?: string; + defaultMentions?: MentionItem[]; + tabsSlot?: React.ReactNode; + usageScenarios?: any[]; + manualComponents?: any[]; + selectedComponentList?: any[]; + onSelectComponent?: (infos: any) => void; + prefix?: React.ReactNode; + /** 演示模式:语音输入走本地模拟,不访问麦克风与 STT(示例页用) */ + voiceInputMock?: boolean; + + // ===== 原 useModel('conversationInfo') 数据,改为从外部传入 ===== + /** 停止会话的异步函数 */ + runStopConversation?: (id: string) => Promise; + /** 用户点击「停止」主动中止会话时的回调(用于暂停队列自动消费) */ + onUserStopConversation?: () => void; + /** 停止会话接口的加载状态 */ + loadingStopConversation?: boolean; + /** 获取当前会话 ID */ + getCurrentConversationId?: () => number | null; + /** 获取当前会话请求 ID */ + getCurrentConversationRequestId?: () => string; + /** 会话是否正在活跃(SSE 流式交互中) */ + isConversationActive?: boolean; + /** 强制将会话设置为非活跃状态 */ + disabledConversationActive?: () => void; + /** 当前消息列表 */ + messageList?: MessageInfo[]; + /** 会话消息加载中状态 */ + loadingConversation?: boolean; + /** 其它接口加载中状态(用于禁用发送按钮) */ + isLoadingOtherInterface?: boolean; + /** 当前会话详情 */ + conversationInfo?: ConversationInfo | null; +} + +/** + * 独立版聊天输入组件 + * 与原 ChatInputHome 功能完全一致,但 conversationInfo model 数据全部从外部 props 传入, + * 实现与 model 的解耦,便于在不同上下文(如 conversationAgent model)中复用。 + */ +const ChatInputHomeIndependent: React.FC = ({ + className, + wholeDisabled = false, + clearDisabled = false, + clearLoading = false, + onEnter, + visible, + selectedComponentList, + onSelectComponent, + onClear, + isClearInput = true, + manualComponents, + onScrollBottom, + showAnnouncement = false, + onTempChatStop, + loadingStopTempConversation, + showTaskAgentToggle = false, + isTaskAgentActive = false, + onToggleTaskAgent, + selectedComputerId, + onComputerSelect, + agentId, + agentSandboxId, + fixedSelection, + hasPermission = true, + isSandboxUnavailable = false, + maskText, + autoSelectComputer, + saveComputerOnSelect, + isPersonalComputer, + readonly, + enableMention = true, + mentionPlacement = 'auto', + placeholder, + defaultMentions, + allowOtherModel, + selectedModelId, + onModelSelect, + agentType, + tabsSlot, + prefix, + voiceInputMock = false, + agentMode = 'yolo', + onAgentModeChange, + showAgentModeSelector = false, + usageScenarios, + + // 原 useModel('conversationInfo') 数据 + runStopConversation, + onUserStopConversation, + loadingStopConversation = false, + getCurrentConversationId, + getCurrentConversationRequestId, + isConversationActive = false, + disabledConversationActive, + messageList = [], + loadingConversation = false, + isLoadingOtherInterface = false, + conversationInfo, +}) => { + // 获取租户配置信息 + const { tenantConfigInfo } = useModel('tenantConfigInfo'); + const isEnableSubscription = tenantConfigInfo?.enableSubscription !== 0; + + const { + createSubscriptionOrder, + querySkillSubscriptionPlans, + loadingTargetPricing, + targetSubscriptionPlans, + mySubscriptionInfo, + loadingMySubscription, + } = useSubscription(); + + const [openPaymentModal, setOpenPaymentModal] = useState(false); + const [uploadFiles, setUploadFiles] = useState([]); + const [files, setFiles] = useState([]); + const [messageInfo, setMessageInfo] = useState(''); + const [skillIds, setSkillIds] = useState([]); + const [isStoppingConversation, setIsStoppingConversation] = + useState(false); + const mentionEditorRef = useRef(null); + const [isHoveringBtn, setIsHoveringBtn] = useState(false); + const [delayedVisible, setDelayedVisible] = useState(false); + const timerRef = useRef(null); + const dragCounterRef = useRef(0); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + if (visible || isHoveringBtn) { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setDelayedVisible(true); + } else { + timerRef.current = setTimeout(() => { + setDelayedVisible(false); + }, 300); + } + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [visible, isHoveringBtn]); + + const token = localStorage.getItem(ACCESS_TOKEN) ?? ''; + + useEffect(() => { + setFiles( + uploadFiles.filter( + (item) => item.status === UploadFileStatus.done && item.url && item.key, + ), + ); + }, [uploadFiles]); + + const disabledSend = useMemo(() => { + return !messageInfo && !files?.length; + }, [messageInfo, files]); + + /** + * 会话活跃态:上层传入的流式/任务信号 + messageList 末条 Loading 兜底 + * 与 ChatInputHome 的 streamActive 逻辑对齐,避免上层漏传时停止按钮不显示 + */ + const isSessionActive = useMemo( + () => + isConversationActive || + isSessionStreamBusy(messageList) || + conversationInfo?.taskStatus === TaskStatus.EXECUTING, + [isConversationActive, messageList, conversationInfo?.taskStatus], + ); + + useEffect(() => { + if (!isSessionActive) { + setIsStoppingConversation(false); + } + }, [isSessionActive]); + + const confirmSendMessage = (value: string) => { + if (!!value.trim() || !!files?.length) { + onEnter(value, files, skillIds, selectedModelId, agentMode); + if (isClearInput) { + setUploadFiles([]); + setMessageInfo(''); + setSkillIds([]); + mentionEditorRef.current?.clear(); + } + } + }; + + const handleSendMessage = () => { + if ( + disabledSend || + wholeDisabled || + loadingConversation || + isLoadingOtherInterface || + // 队列关闭时,会话活跃期间禁止点击发送(仅保留停止) + (!ENABLE_CHAT_MESSAGE_QUEUE && isSessionActive) + ) { + return; + } + confirmSendMessage(messageInfo); + }; + + const handlePressEnter = () => { + // 中止会话过程中不能触发 enter + if (isStoppingConversation) { + return; + } + // 队列关闭时,会话活跃期间拦截回车(无队列入队能力,由 trySend 乐观锁兜底) + if (!ENABLE_CHAT_MESSAGE_QUEUE && isSessionActive) { + return; + } + confirmSendMessage(messageInfo); + }; + + const applyServerUploadResult = useCallback( + (fileInfo: UploadFileInfo, result: any): UploadFileInfo => { + const data = result.data || {}; + return { + ...fileInfo, + status: UploadFileStatus.done, + percent: 100, + url: data?.url || '', + key: data?.key || '', + name: data?.fileName || fileInfo.name, + type: data?.mimeType || fileInfo.type, + response: result, + }; + }, + [], + ); + + const handleChange: UploadProps['onChange'] = (info) => { + const { fileList } = info; + setUploadFiles(handleUploadFileList(fileList)); + }; + + const handleDelFile = (uid: string) => { + setUploadFiles((uploadFiles) => + uploadFiles.filter((item) => item.uid !== uid), + ); + }; + + const extractClipboardFiles = useCallback( + (clipboardData: DataTransfer | null): File[] => { + if (!clipboardData?.items) { + return []; + } + const files: File[] = []; + for (let i = 0; i < clipboardData.items.length; i++) { + const item = clipboardData.items[i]; + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + return files; + }, + [], + ); + + const getDefaultFileName = useCallback((file: File, index: number) => { + if (file.type.startsWith('image/')) { + return t( + 'PC.Components.ChatInputHome.pastedImageFileName', + Date.now(), + index + 1, + ); + } + return t( + 'PC.Components.ChatInputHome.pastedFileName', + Date.now(), + index + 1, + ); + }, []); + + const uploadFilesToServer = useCallback( + async (filesToUpload: File[]) => { + if (wholeDisabled || !filesToUpload.length) { + return; + } + + const newUploadFiles: UploadFileInfo[] = filesToUpload.map( + (file, index) => ({ + uid: uuidv4(), + name: file.name || getDefaultFileName(file, index), + size: file.size, + type: file.type, + url: '', + status: UploadFileStatus.uploading, + percent: 0, + originFileObj: file, + }), + ); + + setUploadFiles((prev) => [ + ...prev, + ...handleUploadFileList(newUploadFiles), + ]); + + for (let i = 0; i < filesToUpload.length; i++) { + const file = filesToUpload[i]; + const uploadFile = newUploadFiles[i]; + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('type', 'tmp'); + + const response = await fetch(UPLOAD_FILE_ACTION, { + method: 'POST', + headers: { + Authorization: token ? `Bearer ${token}` : '', + }, + body: formData, + }); + + const result = await response.json(); + + if (result.code === SUCCESS_CODE && result.data) { + setUploadFiles((prev) => + prev.map((item) => + item.uid === uploadFile.uid + ? applyServerUploadResult(item, result) + : item, + ), + ); + } else { + throw new Error(result.message || 'Upload failed'); + } + } catch (error) { + console.error('File upload failed:', error); + message.error( + t( + 'PC.Components.ChatInputHome.uploadFailedWithName', + uploadFile.name, + ), + ); + + setUploadFiles((prev) => + prev.map((item) => + item.uid === uploadFile.uid + ? { + ...item, + status: UploadFileStatus.error, + percent: 0, + } + : item, + ), + ); + } + } + }, + [applyServerUploadResult, getDefaultFileName, token, wholeDisabled], + ); + + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + if (wholeDisabled) { + return; + } + const pastedFiles = extractClipboardFiles(e.clipboardData); + if (!pastedFiles.length) { + return; + } + e.preventDefault(); + await uploadFilesToServer(pastedFiles); + }, + [extractClipboardFiles, uploadFilesToServer, wholeDisabled], + ); + + const handleDragEnter = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (wholeDisabled || !e.dataTransfer.types.includes('Files')) { + return; + } + dragCounterRef.current += 1; + setIsDragging(true); + }, + [wholeDisabled], + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current -= 1; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setIsDragging(false); + } + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (wholeDisabled) { + return; + } + e.dataTransfer.dropEffect = 'copy'; + }, + [wholeDisabled], + ); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current = 0; + setIsDragging(false); + + if (wholeDisabled) { + return; + } + + const droppedFiles = Array.from(e.dataTransfer.files || []); + if (droppedFiles.length) { + await uploadFilesToServer(droppedFiles); + } + }, + [uploadFilesToServer, wholeDisabled], + ); + + const handleClear = () => { + if (clearDisabled || wholeDisabled) { + return; + } + disabledConversationActive?.(); + onClear?.(); + }; + + const handleStopConversation = useCallback(async () => { + if (isStoppingConversation) { + return; + } + setIsStoppingConversation(true); + + const requestId = getCurrentConversationRequestId?.() ?? ''; + const conversationId = getCurrentConversationId?.() ?? null; + + if (onTempChatStop && requestId) { + onTempChatStop(requestId); + } else if (conversationId && runStopConversation) { + // 停止的是当前会话:暂停队列自动消费,避免停止后立即发送下一条排队消息。 + // 仅真实会话停止才暂停;临时会话(onTempChatStop)停止与本会话队列无关。 + onUserStopConversation?.(); + runStopConversation(conversationId.toString()); + } + }, [ + isStoppingConversation, + getCurrentConversationRequestId, + getCurrentConversationId, + runStopConversation, + onTempChatStop, + onUserStopConversation, + ]); + + const getButtonTooltip = () => { + if (wholeDisabled) { + return t('PC.Components.ChatInputHome.conversationDisabled'); + } + if (disabledSend) { + return t('PC.Components.ChatInputHome.enterQuestion'); + } + if (isSessionActive) { + return t('PC.Components.ChatInputHome.clickStopConversation'); + } + return t('PC.Components.ChatInputHome.clickSendMessage'); + }; + + const getStopButtonTooltip = () => { + if (conversationInfo?.taskStatus === TaskStatus.EXECUTING) { + if ( + isStoppingConversation || + loadingStopConversation || + loadingStopTempConversation + ) { + return t('PC.Components.ChatInputHome.stoppingTask'); + } + return t('PC.Components.ChatInputHome.clickStopAgentTask'); + } + + if (!isSessionActive) { + return t('PC.Components.ChatInputHome.noActiveConversation'); + } + if ( + isStoppingConversation || + loadingStopConversation || + loadingStopTempConversation + ) { + return t('PC.Components.ChatInputHome.stoppingConversation'); + } + return t('PC.Components.ChatInputHome.clickStopConversation'); + }; + + useEffect(() => { + return () => { + disabledConversationActive?.(); + setUploadFiles([]); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 本输入框所属会话 id,用于过滤队列编辑回填(避免主聊天 / 预览 Tab 串扰) + const ownConversationId = + getCurrentConversationId?.() ?? conversationInfo?.id ?? null; + const ownConversationIdRef = useRef(ownConversationId); + ownConversationIdRef.current = ownConversationId; + + // 监听队列消息编辑回填(含 skillIds / modelId / agentMode 快照) + useEffect(() => { + const handleEditMessage = ({ + text, + files: editFiles, + skillIds: editSkillIds, + modelId: editModelId, + selectedAgentMode: editAgentMode, + conversationId: targetConversationId, + }: { + text: string; + files?: UploadFileInfo[]; + skillIds?: number[]; + modelId?: number; + selectedAgentMode?: AgentMode; + conversationId?: number | string; + }) => { + if ( + targetConversationId !== undefined && + targetConversationId !== null && + String(targetConversationId) !== String(ownConversationIdRef.current) + ) { + return; + } + setMessageInfo((prev) => (prev ? `${prev}\n${text}` : text)); + if (editFiles?.length) { + setUploadFiles((prev) => [...prev, ...editFiles]); + } + if (editSkillIds?.length) { + setSkillIds(editSkillIds); + } + if (editModelId !== undefined) { + onModelSelect?.(editModelId); + } + if (editAgentMode !== undefined) { + onAgentModeChange?.(editAgentMode); + } + }; + eventBus.on(EVENT_NAMES.QUEUE_EDIT_MESSAGE, handleEditMessage); + return () => + eventBus.off(EVENT_NAMES.QUEUE_EDIT_MESSAGE, handleEditMessage); + }, [onModelSelect, onAgentModeChange]); + + const handleInsertAtMention = useCallback( + (item: MentionItem) => { + mentionEditorRef.current?.handleAtIconMentionSelect(item); + }, + [mentionEditorRef], + ); + + const handleUnsubscribedSkillSelect = useCallback( + (item: MentionItem) => { + if (!isEnableSubscription || !item.paymentRequired || item.subscribed) { + return; + } + querySkillSubscriptionPlans(item.targetId); + setOpenPaymentModal(true); + }, + [isEnableSubscription, querySkillSubscriptionPlans], + ); + + return ( +
+
+ +
+ {t('PC.Components.ChatInputHome.dropFilesHint')} +
+
+ + + {tabsSlot && ( +
{tabsSlot}
+ )} + +
+ + + + + + setMessageInfo((prev) => mergeVoiceTranscript(prev, text)) + } + onSend={(text) => + confirmSendMessage(mergeVoiceTranscript(messageInfo, text)) + } + > + {(isVoiceActive) => ( +
+ {!!messageList?.filter((item: MessageInfo) => item.id) + ?.length && ( + + + + {clearLoading ? ( + + ) : ( + + )} + + + + )} + + + + + + + + + + + + + + {showAgentModeSelector && ( + ({ + key: mode, + label: ( +
+
+ + {t(AGENT_MODE_I18N[mode].label)} + + + {t(AGENT_MODE_I18N[mode].desc)} + +
+ {agentMode === mode && ( + + )} +
+ ), + onClick: () => onAgentModeChange?.(mode), + })), + }} + trigger={['click']} + placement="topLeft" + disabled={wholeDisabled || isSessionActive} + overlayClassName="agent-mode-dropdown-overlay" + > + + + + {t(AGENT_MODE_I18N[agentMode].label)} + + + + +
+ )} +
+ + {showTaskAgentToggle && ( + + + + + + )} + + + + + + + + + + + {isStoppingConversation ? ( +
+ +
+ ) : ( + + )} +
+ + ) : ( + <> + + + + + + + ) + } + > + {prefix} + {isTaskAgentActive && !readonly && ( + onComputerSelect?.(id)} + disabled={wholeDisabled} + agentId={agentId} + fixedSelection={fixedSelection || isSessionActive} + unavailable={isSandboxUnavailable} + autoSelect={autoSelectComputer} + saveOnSelect={saveComputerOnSelect} + isPersonalComputer={isPersonalComputer} + readonly={readonly} + /> + )} + {allowOtherModel === DefaultSelectedEnum.Yes && ( + + )} +
+
+ )} +
+
+
+ {showAnnouncement && ( +
+ {t('PC.Components.ChatInputHome.generatedByAiNotice')} +
+ )} +
+
{ + setIsHoveringBtn(false); + setDelayedVisible(false); + onScrollBottom?.(); + }} + onMouseEnter={() => setIsHoveringBtn(true)} + onMouseLeave={() => setIsHoveringBtn(false)} + > + +
+
+ + + setOpenPaymentModal(false)} + onSubscribe={createSubscriptionOrder} + /> + +
+ ); +}; + +export default ChatInputHomeIndependent; diff --git a/qiming/src/components/business-component/UnifiedChatSession/hooks/useConversationStreamResume.ts b/qiming/src/components/business-component/UnifiedChatSession/hooks/useConversationStreamResume.ts new file mode 100644 index 00000000..b12086b1 --- /dev/null +++ b/qiming/src/components/business-component/UnifiedChatSession/hooks/useConversationStreamResume.ts @@ -0,0 +1,311 @@ +import { EVENT_TYPE } from '@/constants/event.constants'; +import { GLOBAL_POLLING_INTERVAL } from '@/constants/home.constants'; +import { TaskStatus } from '@/types/enums/agent'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { fetchConversationTaskStatus } from '@/utils/conversationTaskStatusSync'; +import eventBus from '@/utils/eventBus'; +import { useRequest } from 'ahooks'; +import { useEffect, useRef, useState } from 'react'; + +/** + * 会话流式恢复(sub)编排 Hook(model-agnostic,UnifiedChatSession 专用) + * + * 用途:刷新页面 / 新开标签后,重新订阅 EXECUTING 会话的输出流(/api/agent/conversation/chat/sub/:id), + * 把「执行中的助手消息」重建出来。与 useLoadMoreHistory / useUnifiedChatScroll 同属 UnifiedChatSession + * 的会话生命周期 hooks 聚合于此。各会话页从自身所属 model(conversationInfo 或 conversationAgent) + * 注入状态与 action 即可复用;未注入 action 的页面(如隔离会话源)不启用恢复。 + * + * 轮询时机:仅在【未订阅 sub】时轮询会话状态——一旦续上 sub(执行中),立即停止状态轮询, + * 由 sub 流接管输出;sub 关闭后才恢复轮询,继续检测会话再次变为 EXECUTING。 + */ +export interface UseConversationStreamResumeOptions { + /** 当前会话 ID */ + conversationId?: number; + /** 会话任务状态(conversationInfo?.taskStatus) */ + taskStatus?: TaskStatus; + /** 本地是否正在流式发送(model 的 isConversationActive) */ + isLocallyStreaming?: boolean; + /** 最新 messageList 快照(用于决定占位:复用末尾半成品 / 追加空白占位) */ + messageList?: MessageInfo[]; + /** + * 刷新历史并返回最新 messageList(model 的 runAsync 包装)。 + * 多页签/查看中变 EXECUTING 时,先 reload 拿到含最新发送用户消息的列表,再追加 assistant 占位, + * 避免 sub 续上后少显示用户消息。未提供则用当前 messageList。 + */ + reloadHistoryAsync?: ( + conversationId: number | string, + ) => Promise; + /** 订阅 sub 流(model 的 resumeConversationStream);未提供则整体不启用恢复 */ + resumeStream?: ( + conversationId: number | string, + currentList: MessageInfo[], + onClose?: () => void, + ) => void; + /** 中断 sub 流(model 的 abortResumeStream);未提供则跳过中断 */ + abortSub?: () => void; +} + +/** 本地流式结束后,订阅 sub 的冷却时间(ms):等 taskStatus 稳定,避免对刚完成的输出重复重放 */ +const RESUME_COOLDOWN_AFTER_LOCAL_MS = 5000; + +export function useConversationStreamResume( + options: UseConversationStreamResumeOptions, +): void { + const { + conversationId, + taskStatus, + isLocallyStreaming, + messageList, + reloadHistoryAsync, + resumeStream, + abortSub, + } = options; + + // sub 是否已订阅(开/闭之间)。ref 用于回调闭包安全读取;state 用于驱动 ready 重算 + const isResumeSubscribedRef = useRef(false); + const [isResumeSubscribed, setIsResumeSubscribed] = useState(false); + // 用 ref 保存最新值,避免轮询 onSuccess / subscribe 异步回调闭包过期 + // conversationId 一并放入:subscribe 的 await 与 sub onClose 都是异步,需要回调执行时 + // 能读到「当前会话」以判断本次回调是否仍属于同一会话(防跨会话覆盖/误杀) + const latestRef = useRef({ + conversationId, + taskStatus, + isLocallyStreaming, + messageList, + }); + latestRef.current = { + conversationId, + taskStatus, + isLocallyStreaming, + messageList, + }; + + // 轮询启停句柄:subscribe 在 useRequest 之前定义、需要调用其 run/cancel, + // 用 ref 解耦前向引用(subscribe 调用 pollingControlsRef.current.stop/start, + // useRequest 在下方把 run/cancel 赋给它)。 + const pollingControlsRef = useRef<{ + start: () => void; + stop: () => void; + }>({ start: () => {}, stop: () => {} }); + + // 记录最近一次本地流式结束的 {会话, 时间}:冷却仅对同一会话生效,切换会话不继承, + // 避免离开一个刚发完消息的会话、进入另一个 EXECUTING 会话时被误抑制。 + const localStreamEndedAtRef = useRef<{ + convId: number | string | undefined; + at: number; + }>({ convId: undefined, at: 0 }); + const prevLocallyStreamingRef = useRef(false); + useEffect(() => { + if (prevLocallyStreamingRef.current && !isLocallyStreaming) { + localStreamEndedAtRef.current = { + convId: conversationId, + at: Date.now(), + }; + } + prevLocallyStreamingRef.current = !!isLocallyStreaming; + }, [isLocallyStreaming, conversationId]); + + // 订阅 sub 流。续上后立即 stopPolling(同步,不等 ready 异步生效); + // sub onClose 时 startPolling 恢复,继续检测会话再次变为 EXECUTING。 + const subscribe = async (id: number | string) => { + if (!resumeStream) return; // 未注入 action(页面未启用恢复) + if (isResumeSubscribedRef.current) return; // 防重复订阅 + if (latestRef.current.isLocallyStreaming) return; // live 正在驱动输出,不重复订阅 + // 同一会话内,本地流式刚结束的冷却窗口内不订阅(等待 taskStatus 稳定后再由轮询决定) + const ended = localStreamEndedAtRef.current; + if ( + ended.convId === id && + Date.now() - ended.at < RESUME_COOLDOWN_AFTER_LOCAL_MS + ) { + return; + } + // 先标记订阅 + 停轮询(reload 期间防重入,执行中不轮询) + isResumeSubscribedRef.current = true; + setIsResumeSubscribed(true); + pollingControlsRef.current.stop(); + + // 触发事件将对应的会话在列表中标记为“执行中” + eventBus.emit(EVENT_TYPE.UpdateConversationListTaskStatus, { + conversationId: id, + taskStatus: TaskStatus.EXECUTING, + }); + + // 多页签/查看中变 EXECUTING:先 reload 历史,确保 messageList 含最新发送的用户消息, + // 再追加 assistant 占位由 sub 流重建(否则 sub 续上后会少显示那条用户消息) + let list = latestRef.current.messageList || []; + if (reloadHistoryAsync) { + try { + const reloaded = await reloadHistoryAsync(id); + if (reloaded && reloaded.length) { + list = reloaded; + } + } catch (e) { + console.error('[useConversationStreamResume] reloadHistory failed:', e); + } + } + + // await 期间可能已切会话、本地开始流式,或切会话 effect 已重置订阅标记。 + // 此时再调 resumeStream 会用旧 id 触发 model 级共享 abortResumeStream,误杀新会话 sub。 + // 放弃本次订阅并回滚乐观标记,由当前会话自身的轮询/订阅逻辑接管。 + if ( + latestRef.current.conversationId !== id || + latestRef.current.isLocallyStreaming || + !isResumeSubscribedRef.current + ) { + isResumeSubscribedRef.current = false; + setIsResumeSubscribed(false); + return; + } + + resumeStream(id, list, async () => { + // sub 自动断开(end_turn/completed/超时)或被 abort 时回调 + isResumeSubscribedRef.current = false; + setIsResumeSubscribed(false); + // 过期 sub 的延迟关闭(切会话后 cleanup 触发 abort 后回调): + // 不再回写状态,否则 reloadHistoryAsync(旧id) 与 RefreshConversationList 会覆盖/干扰新会话。 + if (latestRef.current.conversationId !== id) { + return; + } + // sub 关闭后恢复状态轮询,以便检测会话再次变为 EXECUTING + pollingControlsRef.current.start(); + + // 流式恢复结束后,主动拉取一次最新历史状态以更新本地 model 状态(同步 taskStatus) + if (reloadHistoryAsync) { + try { + await reloadHistoryAsync(id); + } catch (e) { + console.error( + '[useConversationStreamResume] final reloadHistory failed:', + e, + ); + } + } + + // 发送事件,刷新会话列表以清除“执行中”标记,使其消失 + eventBus.emit(EVENT_TYPE.RefreshConversationList, { + conversationId: id, + reason: 'stream-closed', + }); + }); + }; + + const subscribeRef = useRef(subscribe); + subscribeRef.current = subscribe; + + // 轮询会话状态:仅标签可见时触发(pollingWhenHidden:false),复用全局轮询方案。 + // ready 含 !isResumeSubscribed:续上 sub 后不再轮询(subscribe 的 stopPolling 作立即兜底)。 + const { run, cancel } = useRequest( + () => + conversationId + ? fetchConversationTaskStatus(conversationId) + : Promise.resolve(undefined), + { + pollingInterval: GLOBAL_POLLING_INTERVAL, + // 屏幕不可见时暂停定时任务(多窗口/多标签仅可见者轮询) + pollingWhenHidden: false, + pollingErrorRetryCount: -1, + // resumeStream 未注入(如 ConversationAgent 预览 Tab,dev 调试会话)则整体不启用:不轮询、不订阅 + ready: + !!conversationId && + !isLocallyStreaming && + !isResumeSubscribed && + !!resumeStream, + refreshDeps: [ + conversationId, + isLocallyStreaming, + isResumeSubscribed, + resumeStream, + ], + onSuccess: (status) => { + if (!conversationId) return; + if (status === TaskStatus.EXECUTING) { + if (latestRef.current.isLocallyStreaming) { + // 本地正在发送:中断 sub,由 live 驱动输出 + if (isResumeSubscribedRef.current && abortSub) { + abortSub(); + isResumeSubscribedRef.current = false; + setIsResumeSubscribed(false); + } + return; + } + if (!isResumeSubscribedRef.current) { + // 同一会话内,本地流式刚结束的冷却窗口内跳过,等 taskStatus 稳定后再决定 + const ended = localStreamEndedAtRef.current; + if ( + ended.convId === conversationId && + Date.now() - ended.at < RESUME_COOLDOWN_AFTER_LOCAL_MS + ) { + return; + } + subscribe(conversationId); + } + } else if (isResumeSubscribedRef.current && abortSub) { + // 非 EXECUTING:任务已结束,兜底中断 sub(end_turn 自动断开应已触发) + abortSub(); + isResumeSubscribedRef.current = false; + setIsResumeSubscribed(false); + } + }, + }, + ); + // 把 run/cancel 注入 pollingControlsRef,供 subscribe / onClose 调用 + pollingControlsRef.current.start = run; + pollingControlsRef.current.stop = cancel; + + // 切换会话:先重置订阅状态。必须在 entry effect 之前执行,否则 entry subscribe 后会被这里覆盖。 + // cleanup 里 abortSub 触发的 onClose 有 ~500ms 延迟,这里立即重置 state,避免新会话卡在「不轮询」。 + useEffect(() => { + isResumeSubscribedRef.current = false; + setIsResumeSubscribed(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversationId]); + + // 进入会话 / taskStatus 到达 EXECUTING:尝试订阅(subscribe 内部防重入 & 冷却保护 & stopPolling)。 + // 不把 isLocallyStreaming 放入依赖,避免「本地发送结束」瞬间触发订阅; + // 该窗口由冷却时间 + 轮询兜底覆盖。 + useEffect(() => { + if (!conversationId) return; + if ( + taskStatus === TaskStatus.EXECUTING && + !latestRef.current.isLocallyStreaming + ) { + subscribe(conversationId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversationId, taskStatus]); + + // 监听浏览器切回前台(页签可见)事件,立即检查是否有任务在执行,并尝试进行流式恢复 + useEffect(() => { + const handleVisibilityChange = () => { + if ( + document.visibilityState === 'visible' && + conversationId && + !isLocallyStreaming && + !isResumeSubscribedRef.current && + resumeStream + ) { + fetchConversationTaskStatus(conversationId).then((status) => { + if (status === TaskStatus.EXECUTING) { + subscribeRef.current(conversationId); + } + }); + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [conversationId, isLocallyStreaming, resumeStream]); + + // 离开 / 切换会话:清除轮询 + 中断 sub(约束:退出会话页必须清除轮询) + useEffect(() => { + return () => { + cancel(); + if (abortSub) { + abortSub(); + } + isResumeSubscribedRef.current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversationId]); +} diff --git a/qiming/src/components/business-component/UnifiedChatSession/hooks/useLoadMoreHistory.ts b/qiming/src/components/business-component/UnifiedChatSession/hooks/useLoadMoreHistory.ts new file mode 100644 index 00000000..d43fbf7b --- /dev/null +++ b/qiming/src/components/business-component/UnifiedChatSession/hooks/useLoadMoreHistory.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef } from 'react'; +import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'; + +interface UseLoadMoreHistoryProps { + conversationId?: number; + messageList?: any[]; + isMoreMessage?: boolean; + loadingMore?: boolean; + onLoadMoreMessage?: (id: number) => void; +} + +export function useLoadMoreHistory({ + conversationId, + messageList = [], + isMoreMessage = false, + loadingMore = false, + onLoadMoreMessage, +}: UseLoadMoreHistoryProps) { + const { ref: loadMoreRef, inView: loadMoreInView } = useIntersectionObserver({ + rootMargin: '10px 0px 0px 0px', + threshold: 0, + }); + + const prevLoadMoreInViewRef = useRef(false); + + useEffect(() => { + const isEntering = loadMoreInView && !prevLoadMoreInViewRef.current; + prevLoadMoreInViewRef.current = loadMoreInView; + + if ( + isEntering && + isMoreMessage && + !loadingMore && + messageList?.length > 0 && + conversationId + ) { + onLoadMoreMessage?.(conversationId); + } + }, [ + loadMoreInView, + isMoreMessage, + loadingMore, + messageList?.length, + conversationId, + onLoadMoreMessage, + ]); + + return { loadMoreRef }; +} diff --git a/qiming/src/components/business-component/UnifiedChatSession/hooks/useUnifiedChatScroll.ts b/qiming/src/components/business-component/UnifiedChatSession/hooks/useUnifiedChatScroll.ts new file mode 100644 index 00000000..dad308be --- /dev/null +++ b/qiming/src/components/business-component/UnifiedChatSession/hooks/useUnifiedChatScroll.ts @@ -0,0 +1,331 @@ +import { useConversationScrollDetection } from '@/hooks/useConversationScrollDetection'; +import { MessageStatusEnum } from '@/types/enums/common'; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type MutableRefObject, + type RefObject, +} from 'react'; + +export interface UseUnifiedChatScrollProps { + messageList?: any[]; + isConversationActive?: boolean; + chatSuggestList?: any[]; + isLoading?: boolean; + loadingMore?: boolean; + externalMessageViewRef?: RefObject; + externalAllowAutoScrollRef?: MutableRefObject; + externalScrollTimeoutRef?: MutableRefObject; + onScrollBtnVisibleChange?: (visible: boolean) => void; + showScrollBtn?: boolean; +} + +export function useUnifiedChatScroll({ + messageList = [], + isConversationActive = false, + chatSuggestList = [], + isLoading = false, + loadingMore = false, + externalMessageViewRef, + externalAllowAutoScrollRef, + externalScrollTimeoutRef, + onScrollBtnVisibleChange, + showScrollBtn = false, +}: UseUnifiedChatScrollProps) { + const [isHoveringChat, setIsHoveringChat] = useState(false); + const internalMessageViewRef = useRef(null); + const messageViewRef = externalMessageViewRef || internalMessageViewRef; + const internalAllowAutoScrollRef = useRef(true); + const allowAutoScrollRef = + externalAllowAutoScrollRef || internalAllowAutoScrollRef; + const lastMsgCountRef = useRef(0); + const lastTextLengthRef = useRef(0); + // 记录上一轮最后一条消息是否处于流式(loading/incomplete)状态。 + // 会话结束时末条消息会从流式态切换到完成态(stopped/complete), + // 此时 DOM 高度会因状态切换、processingList 渲染、markdown 排版等再次变化, + // 但文本长度已不再增长,需要单独识别这一切换并补触发一次置底。 + const lastWasStreamingRef = useRef(false); + // 记录上一轮 isConversationActive,用于检测会话结束的下降沿。 + // isConversationActive 从 true → false 是会话结束的可靠信号。 + const prevConvActiveRef = useRef(isConversationActive); + const internalScrollTimeoutRef = useRef(null); + const scrollTimeoutRef = externalScrollTimeoutRef || internalScrollTimeoutRef; + const programmaticTimerRef = useRef(null); + const [scrollBtnVisible, setScrollBtnVisibleState] = + useState(showScrollBtn); + const setScrollBtnVisible = useCallback( + (visible: boolean) => { + setScrollBtnVisibleState(visible); + onScrollBtnVisibleChange?.(visible); + }, + [onScrollBtnVisibleChange], + ); + + // 程序化瞬间置底:清理上一个置底复位定时器 → 标记程序滚动 → 立即 scrollTo 到底 → + // 100ms 后复位标记。供各处置底逻辑(发送、流式、会话结束等)复用,避免同一套实现 + // 被复制多份后产生不一致(曾出现 4 份近乎相同的副本)。 + const pinToBottomInstant = useCallback((el: HTMLDivElement) => { + if (programmaticTimerRef.current) { + clearTimeout(programmaticTimerRef.current); + } + (el as any).__isProgrammaticScroll = true; + el.scrollTo({ top: el.scrollHeight, behavior: 'instant' }); + programmaticTimerRef.current = setTimeout(() => { + if (messageViewRef.current) { + (messageViewRef.current as any).__isProgrammaticScroll = false; + } + programmaticTimerRef.current = null; + }, 100); + }, []); + + // 1. 滚动检测逻辑 + useConversationScrollDetection( + messageViewRef, + allowAutoScrollRef, + scrollTimeoutRef, + setScrollBtnVisible, + ); + + // 发送消息时强制重置自动滚动状态并立即置底。 + const handleSendScrollReset = () => { + allowAutoScrollRef.current = true; + setScrollBtnVisible(false); + const el = messageViewRef.current; + if (el) { + pinToBottomInstant(el); + } + }; + + // 点击回到底部(平滑滚动) + const onScrollBottom = () => { + allowAutoScrollRef.current = true; + const element = messageViewRef.current; + if (element) { + (element as any).__isProgrammaticScroll = 'smooth'; + element.scrollTo({ + top: element.scrollHeight, + behavior: 'smooth', + }); + if (programmaticTimerRef.current) { + clearTimeout(programmaticTimerRef.current); + } + programmaticTimerRef.current = setTimeout(() => { + if (messageViewRef.current) { + (messageViewRef.current as any).__isProgrammaticScroll = false; + } + programmaticTimerRef.current = null; + }, 500); + } + setScrollBtnVisible(false); + }; + + // 大模型流式输出或更新时自动平滑滚动置底 + useEffect(() => { + const lastMessage = messageList[messageList.length - 1]; + const isStreaming = + lastMessage?.status === MessageStatusEnum.Loading || + lastMessage?.status === MessageStatusEnum.Incomplete || + isConversationActive; + const textLength = lastMessage?.text?.length || 0; + const msgCount = messageList.length; + + const isFirstMessageLoad = + lastMsgCountRef.current === 0 && msgCount > 0 && !isStreaming; + + let shouldScroll = false; + + if (msgCount > lastMsgCountRef.current) { + shouldScroll = true; + } else if (isStreaming && textLength > lastTextLengthRef.current) { + shouldScroll = true; + } + + // 会话结束兜底:末条消息从流式态切换到完成态时,文本已不再增长,但 DOM 会因 + // 状态切换(loading->stopped/complete)、processingList 渲染、markdown 排版等再次撑高, + // 此时需要补触发一次置底,避免会话结束后视图停在偏上位置、没有顶到底部。 + const justFinishedStreaming = + lastWasStreamingRef.current && !isStreaming && msgCount > 0; + if (justFinishedStreaming) { + shouldScroll = true; + } + + lastMsgCountRef.current = msgCount; + lastTextLengthRef.current = textLength; + lastWasStreamingRef.current = isStreaming; + + if (shouldScroll && allowAutoScrollRef.current) { + const element = messageViewRef.current; + if (element) { + const performScroll = () => { + const el = messageViewRef.current; + if (el) { + pinToBottomInstant(el); + } + }; + + performScroll(); + + // 会话结束(isFirstMessageLoad / justFinishedStreaming)时,markdown/图片/processingList + // 等异步渲染会持续撑高 DOM,需要多级延迟兜底确保最终顶到底部。 + if (isFirstMessageLoad || justFinishedStreaming) { + const t1 = setTimeout(() => { + if (allowAutoScrollRef.current) performScroll(); + }, 150); + const t2 = setTimeout(() => { + if (allowAutoScrollRef.current) performScroll(); + }, 400); + const t3 = setTimeout(() => { + if (allowAutoScrollRef.current) performScroll(); + }, 800); + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; + } else { + const timer = setTimeout(performScroll, 60); + return () => { + clearTimeout(timer); + }; + } + } + } + }, [messageList, isConversationActive, chatSuggestList, pinToBottomInstant]); + + // 组件卸载时清理定时器 + useEffect(() => { + return () => { + if (programmaticTimerRef.current) { + clearTimeout(programmaticTimerRef.current); + } + }; + }, []); + + // 安全网:isLoading 从 true → false 时(消息刚完成渲染),补触发多级延迟置底。 + const prevIsLoadingRef = useRef(isLoading); + useEffect(() => { + const wasLoading = prevIsLoadingRef.current; + prevIsLoadingRef.current = isLoading; + + if ( + wasLoading && + !isLoading && + messageList.length > 0 && + allowAutoScrollRef.current + ) { + const doScroll = () => { + if (!allowAutoScrollRef.current || !messageViewRef.current) return; + pinToBottomInstant(messageViewRef.current); + }; + + doScroll(); + const t1 = setTimeout(() => { + if (allowAutoScrollRef.current) doScroll(); + }, 150); + const t2 = setTimeout(() => { + if (allowAutoScrollRef.current) doScroll(); + }, 400); + const t3 = setTimeout(() => { + if (allowAutoScrollRef.current) doScroll(); + }, 800); + + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; + } + }, [isLoading, messageList.length, pinToBottomInstant]); + + // 会话结束兜底:isConversationActive 从 true → false 时触发多级延迟置底。 + // 关键设计:此 effect 仅依赖 [isConversationActive],不会被 onClose 中 messageList + // 变更触发的重渲染 cleanup,确保延迟滚动定时器能完整执行。 + useEffect(() => { + const wasActive = prevConvActiveRef.current; + prevConvActiveRef.current = isConversationActive; + + // 仅在活跃→非活跃下降沿、且有消息、且允许自动滚动时触发 + if (!wasActive || isConversationActive) { + return; + } + if (messageList.length === 0 || !allowAutoScrollRef.current) { + return; + } + + const doScroll = () => { + const el = messageViewRef.current; + if (!el || !allowAutoScrollRef.current) return; + pinToBottomInstant(el); + }; + + // 会话结束后 markdown/图片/processingList 等异步渲染会持续撑高 DOM, + // 需要多级延迟兜底确保最终顶到底部 + doScroll(); + const delays = [100, 250, 500, 900, 1500]; + const timers = delays.map((d) => + setTimeout(() => { + if (allowAutoScrollRef.current) doScroll(); + }, d), + ); + + return () => { + timers.forEach(clearTimeout); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isConversationActive]); + + // 向上滚动加载更多历史消息时的滚动锁定机制 + const lastScrollHeightRef = useRef(0); + const lastScrollTopRef = useRef(0); + const prevLoadingMoreRef = useRef(false); + + useLayoutEffect(() => { + const element = messageViewRef.current; + if (!element) return; + + if (prevLoadingMoreRef.current && !loadingMore) { + const heightDifference = + element.scrollHeight - lastScrollHeightRef.current; + if (heightDifference > 0) { + element.scrollTop = lastScrollTopRef.current + heightDifference; + } + } + + lastScrollHeightRef.current = element.scrollHeight; + lastScrollTopRef.current = element.scrollTop; + prevLoadingMoreRef.current = loadingMore || false; + }, [messageList, loadingMore]); + + // 处理滚动区域 hover 及滚动按钮显示逻辑 + const handleMouseEnter = () => { + setIsHoveringChat(true); + const el = messageViewRef.current; + if (el) { + const { scrollTop, scrollHeight, clientHeight } = el; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + if (scrollHeight > clientHeight && distanceFromBottom > 50) { + setScrollBtnVisible(true); + } else { + setScrollBtnVisible(false); + } + } + }; + + const handleMouseLeave = () => { + setIsHoveringChat(false); + }; + + return { + messageViewRef, + scrollBtnVisible, + isHoveringChat, + handleSendScrollReset, + onScrollBottom, + handleMouseEnter, + handleMouseLeave, + setScrollBtnVisible, + }; +} diff --git a/qiming/src/components/business-component/UnifiedChatSession/index.less b/qiming/src/components/business-component/UnifiedChatSession/index.less new file mode 100644 index 00000000..91b883d8 --- /dev/null +++ b/qiming/src/components/business-component/UnifiedChatSession/index.less @@ -0,0 +1,77 @@ +@import '@/styles/color'; +@import '@/styles/token'; +@import '@/styles/scrollbar.less'; + +@chat-max-width: 800px; +@zero-value: 0; +@slide-in-offset: 10px; +@scroll-btn-bottom-offset: 100px; + +.session-container { + display: flex; + flex-direction: column; + flex: 1; + min-height: @zero-value; + overflow: hidden; + height: 100%; + position: relative; + + &.only-input { + // 隐藏核心展现内容区(针对 hash 化局部类选择器由子组件 index.less 内部 `:global` 实现) + .conversation-status-bar, + .intervention-dock { + display: none !important; + } + } +} + +// 统一干预固定栏:与 ChatInputHome .chat-container 外框同宽 +.intervention-dock { + flex-shrink: 0; + position: relative; // 让 z-index 生效,确保 ask/question/审批卡片置于输入框(sticky,z-index:10)之上 + z-index: 30; + width: 100%; + max-width: @chat-max-width; + margin: @zero-value auto; + padding: @zero-value @zero-value @paddingXs; + box-sizing: border-box; +} + +// 会话状态栏样式 +.conversation-status-bar { + max-width: @chat-max-width; + width: 100%; + margin: @zero-value auto @paddingXs; + animation: slide-in-up 0.3s ease-out; +} + +@keyframes slide-in-up { + from { + opacity: @zero-value; + transform: translateY(@slide-in-offset); + } + + to { + opacity: 1; + transform: translateY(@zero-value); + } +} + +// 统一会话输入框容器 +.chat-input-container { + position: sticky; + bottom: @zero-value; + z-index: 10; + display: flex; + flex-direction: column; + background-color: transparent; + border-radius: @borderRadiusLg @borderRadiusLg @zero-value @zero-value; + width: 100%; + max-width: @chat-max-width; + margin: @zero-value auto; + + // 调整滚动按钮位置,让它显示在会话状态栏的上方 + :global(.chat-action .to-bottom) { + bottom: @scroll-btn-bottom-offset !important; + } +} diff --git a/qiming/src/components/business-component/UnifiedChatSession/index.tsx b/qiming/src/components/business-component/UnifiedChatSession/index.tsx new file mode 100644 index 00000000..3e4fa731 --- /dev/null +++ b/qiming/src/components/business-component/UnifiedChatSession/index.tsx @@ -0,0 +1,412 @@ +import { + AgentInterventionChatLayer, + type AgentMode, + useAgentInterventionLayer, +} from '@/components/business-component/AgentIntervention'; +import { useActiveInterventionQueue } from '@/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue'; +import MessageQueuePanel, { + useUnifiedChatQueue, +} from '@/components/business-component/MessageQueue'; +import ConversationStatus from '@/pages/Chat/components/ConversationStatus'; +import classNames from 'classnames'; +import { useMemo, useRef } from 'react'; + +import { ENABLE_CHAT_MESSAGE_QUEUE } from '@/constants/feature.constants'; +import { dict } from '@/services/i18nRuntime'; +import { DefaultSelectedEnum, TaskStatus } from '@/types/enums/agent'; +import { MessageStatusEnum } from '@/types/enums/common'; +import { AgentTypeEnum } from '@/types/enums/space'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import type { RoleInfo } from '@/types/interfaces/conversationInfo'; +import { isGeneratedDefaultIconUrl } from '@/utils/defaultIcon'; +import ChatContentArea from './components/ChatContentArea'; +import ChatInputHomeIndependent from './components/ChatInputHomeIndependent'; +import { useConversationStreamResume } from './hooks/useConversationStreamResume'; +import { useLoadMoreHistory } from './hooks/useLoadMoreHistory'; +import { useUnifiedChatScroll } from './hooks/useUnifiedChatScroll'; + +import styles from './index.less'; +import type { UnifiedChatSessionProps } from './types'; + +const cx = classNames.bind(styles); + +const DEFAULT_ROLE_INFO: RoleInfo = { + assistant: { name: 'Assistant', avatar: '' }, + system: { name: 'System', avatar: '' }, +}; + +const UnifiedChatSession: React.FC = ({ + conversationId, + messageList = [], + roleInfo, + isLoading = false, + loadingMore = false, + isMoreMessage = false, + isConversationActive = false, + isLocallyStreaming, + messageBottomMode = 'home', + showDebug, + loadingSuggest = false, + chatSuggestList = [], + agentInfo = {}, + initialAgentMode, + onSendMessage, + onClear, + onLoadMoreMessage, + selectedModelId, + onModelSelect, + allowOtherModel, + manualComponents = [], + selectedComponentList = [], + onSelectComponent, + requiredNameList = [], + variableParams, + form, + variables, + userFillVariables, + isVariablesFilled, + isVariablesDisabled, + clearLoading = false, + isSelectionLocked = false, + hasUserSentMessage = false, + readonly, + showAnnouncement, + mentionPlacement, + selectedComputerId = '', + onComputerSelect, + + showScrollBtn = false, + allowAutoScrollRef, + scrollTimeoutRef, + setShowScrollBtn, + renderMessageItem, + renderEmptyState, + enableMention = true, + placeholder, + + messageViewRef: externalMessageViewRef, + className, + style, + chatInputDisabled = false, + voiceInputMock = false, + chatInputProps, + queueMinConsumeInterval, + queueContext, + + // 原 ChatInputHome 中 useModel('conversationInfo') 数据 + runStopConversation, + loadingStopConversation, + getCurrentConversationId, + getCurrentConversationRequestId, + disabledConversationActive, + loadingConversation, + isLoadingOtherInterface, + conversationInfo, + interventionHandlers, + // 会话流式恢复(sub) + onResumeConversationStream, + onAbortResumeStream, + onReloadConversationHistoryAsync, +}) => { + // 滚动管理 Hook + const { + messageViewRef, + scrollBtnVisible, + isHoveringChat, + handleSendScrollReset, + onScrollBottom, + handleMouseEnter, + handleMouseLeave, + } = useUnifiedChatScroll({ + messageList, + isConversationActive, + chatSuggestList, + isLoading, + loadingMore, + externalMessageViewRef, + externalAllowAutoScrollRef: allowAutoScrollRef, + externalScrollTimeoutRef: scrollTimeoutRef, + onScrollBtnVisibleChange: setShowScrollBtn, + showScrollBtn, + }); + + // 历史消息交叉加载 Hook + const { loadMoreRef } = useLoadMoreHistory({ + conversationId, + messageList, + isMoreMessage, + loadingMore, + onLoadMoreMessage, + }); + + // 会话流式恢复(sub):刷新页面 / 新开标签时,重建 EXECUTING 会话的流式输出。 + // action 未注入(如隔离会话源)时整体不启用。轮询仅标签可见时触发,离开页面自动清轮询 + 断 sub。 + useConversationStreamResume({ + conversationId, + taskStatus: conversationInfo?.taskStatus, + isLocallyStreaming: isLocallyStreaming ?? isConversationActive, + messageList, + reloadHistoryAsync: onReloadConversationHistoryAsync, + resumeStream: onResumeConversationStream, + abortSub: onAbortResumeStream, + }); + + const agentModeRef = useRef('yolo'); + + // 角色信息(名称、头像)默认逻辑:优先使用外部传入,其次根据传入的 agentInfo 自适应组装,最后使用 DEFAULT_ROLE_INFO 兜底 + const effectiveRoleInfo = useMemo(() => { + if (roleInfo && roleInfo !== DEFAULT_ROLE_INFO) { + return roleInfo; + } + const uploadedIcon = isGeneratedDefaultIconUrl(agentInfo?.icon) + ? '' + : agentInfo?.icon; + return { + assistant: { + name: (agentInfo?.name as string) || 'Assistant', + avatar: (uploadedIcon as string) || '', + }, + system: { + name: (agentInfo?.name as string) || 'System', + avatar: (uploadedIcon as string) || '', + }, + }; + }, [roleInfo, agentInfo?.name, agentInfo?.icon]); + + // 是否有待处理的 intervention(ask/question/审批):有则暂停队列消费并隐藏队列面板 + const activeInterventions = useActiveInterventionQueue(messageList); + const hasPendingIntervention = activeInterventions.length > 0; + + // 消息队列:会话活跃时消息入队,空闲时自动消费(逻辑收敛于 hook) + const messageQueue = useUnifiedChatQueue({ + conversationId, + messageList, + selectedModelId, + agentModeRef, + onSendMessage, + minConsumeInterval: queueMinConsumeInterval, + hasPendingIntervention, + queueContext, + }); + + // 消息发送代理:经队列拦截(活跃时入队,否则真正发送) + const handleMessageSend = ( + messageInfo: string, + files: UploadFileInfo[] = [], + skillIds: number[] = [], + modelId?: number, + selectedAgentMode?: AgentMode, + ) => { + // 用户在会话中发送新提示词:恢复队列自动消费(解除此前主动停止造成的暂停) + messageQueue.resumeAutoConsume(); + + // 发送消息时强制重置自动滚动状态并立即置底 + handleSendScrollReset(); + + messageQueue.trySend( + messageInfo, + files, + skillIds, + modelId, + selectedAgentMode, + ); + }; + + // 智能体指令介入图层 (ACP/MCP 审批交互) + // intervention 响应的 resume 消息走 rawSend 绕过队列拦截,避免回复被错误入队 + const interventionLayer = useAgentInterventionLayer({ + conversationId, + agentId: agentInfo?.id, + messageList, + initialAgentMode, + allowChooseMode: agentInfo?.allowChooseMode, + onSendMessage: (msg, files) => messageQueue.rawSend(msg, files), + interventionHandlers, + }); + agentModeRef.current = interventionLayer.agentMode; + + // 输入框是否禁用(表单必填验证) + const inputDisabled = useMemo(() => { + if (requiredNameList?.length > 0) { + if (!variableParams) { + return true; + } + // 判断所填参数是否包含所有必填 + const hasAllRequired = requiredNameList.every( + (name) => + variableParams[name] !== undefined && variableParams[name] !== '', + ); + return !hasAllRequired; + } + return false; + }, [requiredNameList, variableParams]); + + // 是否有活跃的流式消息(即最后一条消息正在加载或未完成) + const hasActiveStreamingMessage = useMemo(() => { + if (!messageList || messageList.length === 0) return false; + const lastMessage = messageList[messageList.length - 1]; + return ( + lastMessage.status === MessageStatusEnum.Loading || + lastMessage.status === MessageStatusEnum.Incomplete + ); + }, [messageList]); + + /** + * 「智能体正在执行,请稍等」仅在后端 taskStatus=EXECUTING 且流式已结束时展示。 + * 不用 isConversationActive:队列自动发送会乐观置活跃,末条仍为 Complete 时会误显示。 + */ + const showTaskExecutingWait = useMemo(() => { + return ( + conversationInfo?.taskStatus === TaskStatus.EXECUTING && + !hasActiveStreamingMessage + ); + }, [conversationInfo?.taskStatus, hasActiveStreamingMessage]); + + /** + * 会话 suggest 仅在整轮结束且队列已排空时展示。 + * 队列自动消费下一条时,上一轮 suggest 若仍挂在底部会与新一轮消息割裂成两块。 + */ + const shouldShowSessionSuggest = useMemo(() => { + if (!messageList?.length) { + return false; + } + if (messageQueue.hasQueuedMessages) { + return false; + } + if (isConversationActive) { + return false; + } + return true; + }, [ + messageList?.length, + messageQueue.hasQueuedMessages, + isConversationActive, + ]); + + /** Agent 模式选择器:由智能体 allowChooseMode 配置控制 */ + const showAgentModeSelector = useMemo( + () => agentInfo?.allowChooseMode === DefaultSelectedEnum.Yes, + [agentInfo?.allowChooseMode], + ); + + return ( +
+ {/* 核心聊天展现内容区 */} + + + {/* 会话执行状态栏 */} + {messageList?.length > 0 && + agentInfo?.type === AgentTypeEnum.TaskAgent && ( + + )} + + {/* 指令介入审批卡片区 */} + + + {/* 统一会话输入框(使用独立版组件,避免与 conversationInfo model 强耦合) */} +
+ {/* 待发送消息队列面板:功能开关关闭或有待处理 intervention 时隐藏 */} + {ENABLE_CHAT_MESSAGE_QUEUE && !hasPendingIntervention && ( + + )} + Boolean(message?.id)) + } + isPersonalComputer={!!agentInfo?.sandboxId} + {...interventionLayer.agentModeInputProps} + showAgentModeSelector={showAgentModeSelector} + enableMention={enableMention} + placeholder={placeholder} + readonly={readonly} + showAnnouncement={showAnnouncement} + mentionPlacement={mentionPlacement} + allowOtherModel={allowOtherModel} + selectedModelId={selectedModelId} + onModelSelect={onModelSelect} + agentType={agentInfo?.type} + voiceInputMock={voiceInputMock} + {...chatInputProps} + // 传入原 conversationInfo model 数据 + runStopConversation={runStopConversation} + onUserStopConversation={messageQueue.pauseAutoConsume} + loadingStopConversation={loadingStopConversation} + getCurrentConversationId={getCurrentConversationId} + getCurrentConversationRequestId={getCurrentConversationRequestId} + isConversationActive={isConversationActive} + disabledConversationActive={disabledConversationActive} + messageList={messageList} + loadingConversation={loadingConversation} + isLoadingOtherInterface={isLoadingOtherInterface} + conversationInfo={conversationInfo} + /> +
+
+ ); +}; + +export default UnifiedChatSession; diff --git a/qiming/src/components/business-component/UnifiedChatSession/types.ts b/qiming/src/components/business-component/UnifiedChatSession/types.ts new file mode 100644 index 00000000..876b59ac --- /dev/null +++ b/qiming/src/components/business-component/UnifiedChatSession/types.ts @@ -0,0 +1,171 @@ +import type { + AgentInterventionHandlersOverride, + AgentMode, +} from '@/components/business-component/AgentIntervention'; +import type { UnifiedChatQueueContext } from '@/components/business-component/MessageQueue/useUnifiedChatQueue'; +import type { DefaultSelectedEnum } from '@/types/enums/agent'; +import type { ChatInputProps, UploadFileInfo } from '@/types/interfaces/common'; +import type { + ConversationInfo, + MessageInfo, + RoleInfo, +} from '@/types/interfaces/conversationInfo'; +import * as React from 'react'; + +export interface UnifiedAgentInfo { + id?: number; + name?: string; + icon?: string; + type?: string; + openingChatMsg?: string; + guidQuestionDtos?: any[]; + eventBindConfig?: any; + hasPermission?: boolean; + sandboxId?: string; + hideDesktop?: number; + expandPageArea?: number; + /** 是否允许用户在对话框中选择 Agent 模式(1 允许,其他不允许) */ + allowChooseMode?: DefaultSelectedEnum | number; +} + +export interface UnifiedChatSessionProps { + // 会话的核心数据 + conversationId?: number; + messageList?: MessageInfo[]; + roleInfo?: RoleInfo; + isLoading?: boolean; // 会话正在进行首次加载的 Loading 状态 + loadingMore?: boolean; // 是否正在向上拉取历史消息 + isMoreMessage?: boolean; // 是否还有历史消息可拉取 + /** + * 会话流式/任务活跃(停止按钮、队列入队等)。 + * 与 showTaskExecutingWait 分离:后者仅 taskStatus=EXECUTING 且无流式消息时展示横幅。 + */ + isConversationActive?: boolean; + /** + * 本地是否正在通过 SSE 发送/接收(model 原始 isConversationActive,【不】含「后台 taskStatus===EXECUTING」)。 + * 供流式恢复 hook 判断「本地是否在驱动输出」——若混入 taskStatus,EXECUTING 会话会被误判为 + * 本地流式中,导致既不轮询也不订阅 sub(续不上)。未传时回退到 isConversationActive。 + */ + isLocallyStreaming?: boolean; + messageBottomMode?: 'none' | 'home' | 'chat'; // 消息底部操作栏模式:none | home | chat + showDebug?: boolean; + loadingSuggest?: boolean; // 会话建议加载状态 + chatSuggestList?: string[]; // 页面会话建议(开场白问题推荐) + + // 智能体配置与信息 + agentInfo?: UnifiedAgentInfo; + + /** 由上层(如新建项目页)透传的初始 Agent 模式,用于初始化介入图层的选择器 */ + initialAgentMode?: AgentMode; + + // 事件与业务回调 + onSendMessage?: ( + messageInfo: string, + files?: UploadFileInfo[], + skillIds?: number[], + modelId?: number, + selectedAgentMode?: AgentMode, + ) => void; + onClear?: () => Promise; // 刷新/清空会话的回调 + onLoadMoreMessage?: (id: number) => void; // 向上滚动到顶加载历史消息的回调 + + // 输入框相关受控状态与属性 + selectedModelId?: number; + onModelSelect?: (modelId: number) => void; + allowOtherModel?: number; + manualComponents?: any[]; + selectedComponentList?: any[]; + onSelectComponent?: (comp: any) => void; + requiredNameList?: string[]; + variableParams?: Record | null; + form?: any; // Form 实例 + variables?: any[]; + userFillVariables?: any; + isVariablesFilled?: boolean; + isVariablesDisabled?: boolean; + clearLoading?: boolean; + isSelectionLocked?: boolean; + hasUserSentMessage?: boolean; + readonly?: boolean; + showAnnouncement?: boolean; + mentionPlacement?: 'up' | 'down'; + + // 文件预览与智能体电脑状态/操作 (通用型智能体 TaskAgent 专属) + selectedComputerId?: string; + onComputerSelect?: (id: string) => void; + + showScrollBtn?: boolean; + allowAutoScrollRef?: React.MutableRefObject; + scrollTimeoutRef?: React.MutableRefObject; + setShowScrollBtn?: (show: boolean) => void; + + // 自定义差异化组件渲染插槽 (极高扩展性,用以兼容 AppDev 中特有的 Diff markdown 渲染和空状态) + renderMessageItem?: ( + message: MessageInfo, + isLastMessage: boolean, + ) => React.ReactNode; + renderEmptyState?: () => React.ReactNode; + + // 功能配置开关 + enableMention?: boolean; // 是否支持 @ 提及项目文件/技能 + placeholder?: string; + messageViewRef?: React.RefObject; + className?: string; + style?: React.CSSProperties; + /** 额外禁用输入框,用于跨会话联动控制输入态 */ + chatInputDisabled?: boolean; + /** 演示模式:语音输入走本地模拟(示例页预览交互) */ + voiceInputMock?: boolean; + + // 输入框属性透传,用于支持展示不同的工具栏、工具列表配置 + chatInputProps?: Partial; + + /** + * 队列两次消费之间的最小间隔(ms),用于规避会话状态切换的中间空白;默认 500。 + */ + queueMinConsumeInterval?: number; + + /** + * 消息队列上下文覆盖(预览 Tab 等隔离会话源场景)。 + * 未传时使用全局 conversationInfo model。 + */ + queueContext?: UnifiedChatQueueContext; + // ===== 原 ChatInputHome 中 useModel('conversationInfo') 数据,改为从外部传入 ===== + /** 停止会话的异步函数 */ + runStopConversation?: (id: string) => Promise; + /** 停止会话接口的加载状态 */ + loadingStopConversation?: boolean; + /** 获取当前会话 ID */ + getCurrentConversationId?: () => number | null; + /** 获取当前会话请求 ID */ + getCurrentConversationRequestId?: () => string; + /** 强制将会话设置为非活跃状态 */ + disabledConversationActive?: () => void; + /** 会话消息加载中状态 */ + loadingConversation?: boolean; + /** 其它接口加载中状态(用于禁用发送按钮) */ + isLoadingOtherInterface?: boolean; + /** 当前会话详情 */ + conversationInfo?: ConversationInfo | null; + + /** + * 隔离会话源(如 ConversationAgent 预览 Tab)的干预回执注入; + * 未传时默认使用全局 conversationInfo model。 + */ + interventionHandlers?: AgentInterventionHandlersOverride; + + // ===== 会话流式恢复(sub):刷新页面 / 新开标签时重建 EXECUTING 会话的流式输出 ===== + // 未注入下列 action 的页面(如隔离会话源)将不启用恢复。 + /** 订阅 sub 流(model 的 resumeConversationStream) */ + onResumeConversationStream?: ( + conversationId: number | string, + currentList: MessageInfo[], + onClose?: () => void, + ) => void; + /** 中断 sub 流(model 的 abortResumeStream) */ + onAbortResumeStream?: () => void; + /** 刷新历史并返回最新 messageList(model 的 runAsync 包装);多页签续上时补全用户消息 */ + onReloadConversationHistoryAsync?: ( + conversationId: number | string, + ) => Promise; +} diff --git a/qiming/src/components/business-component/VoiceInput/chatInputFooter/Expand.tsx b/qiming/src/components/business-component/VoiceInput/chatInputFooter/Expand.tsx new file mode 100644 index 00000000..e113f376 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/chatInputFooter/Expand.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import VoiceRecordingBar from '../components/RecordingBar'; +import { useVoiceFooter } from './context'; + +/** 录音/转写时占满底栏中间区域的波形展示 */ +export const VoiceFooterExpand: React.FC = () => { + const { isActive, voiceControl } = useVoiceFooter(); + if (!isActive || !voiceControl) { + return null; + } + return ( + + ); +}; diff --git a/qiming/src/components/business-component/VoiceInput/chatInputFooter/HideWhenActive.tsx b/qiming/src/components/business-component/VoiceInput/chatInputFooter/HideWhenActive.tsx new file mode 100644 index 00000000..5b81f6ba --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/chatInputFooter/HideWhenActive.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { useVoiceFooterActive } from './context'; + +export interface VoiceFooterHideWhenActiveProps { + children: React.ReactNode; +} + +/** 录音/转写活跃时隐藏子节点(用于 @、模型选择等底栏项) */ +export const VoiceFooterHideWhenActive: React.FC< + VoiceFooterHideWhenActiveProps +> = ({ children }) => { + const isActive = useVoiceFooterActive(); + if (isActive) { + return null; + } + return <>{children}; +}; diff --git a/qiming/src/components/business-component/VoiceInput/chatInputFooter/Provider.tsx b/qiming/src/components/business-component/VoiceInput/chatInputFooter/Provider.tsx new file mode 100644 index 00000000..62a99b22 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/chatInputFooter/Provider.tsx @@ -0,0 +1,77 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import type { VoiceInputControl, VoiceSubmitMode } from '../types'; +import { VoiceFooterContext } from './context'; + +export interface VoiceFooterProviderProps { + disabled?: boolean; + mock?: boolean; + /** 转写后回填输入框 */ + onFill: (text: string) => void; + /** 转写后自动发送 */ + onSend: (text: string) => void; + children: React.ReactNode | ((isActive: boolean) => React.ReactNode); +} + +/** 将转写文本追加到现有草稿,供“回填”和“直接发送”保持一致语义 */ +export const mergeVoiceTranscript = ( + draft: string, + transcript: string, +): string => { + return draft ? `${draft}\n${transcript}` : transcript; +}; + +/** + * 会话框底栏语音能力 Provider:统一管理录音态与转写回调 + */ +export const VoiceFooterProvider: React.FC = ({ + disabled, + mock, + onFill, + onSend, + children, +}) => { + const [voiceControl, setVoiceControl] = useState( + null, + ); + const isActive = voiceControl !== null; + const latestResultHandlersRef = useRef({ disabled, onFill, onSend }); + latestResultHandlersRef.current = { disabled, onFill, onSend }; + + const handleVoiceResult = useCallback( + (text: string, mode: VoiceSubmitMode) => { + const latest = latestResultHandlersRef.current; + if (mode === 'send' && !latest.disabled) { + latest.onSend(text); + return; + } + // 自动发送等待期间如果会话已进入禁用态,降级为回填以保留识别结果, + // 不绕过输入框最新的发送条件。 + // 回填依赖 MentionEditor 的外部 value 同步,而编辑器聚焦时会跳过该同步: + // 若用户在转写期间点回输入框,回填文本只进 state 不上屏、随后被下次输入覆盖。 + // 回填前先失焦,确保同步生效。 + if (typeof document !== 'undefined') { + (document.activeElement as HTMLElement | null)?.blur?.(); + } + latest.onFill(text); + }, + [], + ); + + const value = useMemo( + () => ({ + disabled, + mock, + voiceControl, + isActive, + setVoiceControl, + handleVoiceResult, + }), + [disabled, mock, voiceControl, isActive, handleVoiceResult], + ); + + return ( + + {typeof children === 'function' ? children(isActive) : children} + + ); +}; diff --git a/qiming/src/components/business-component/VoiceInput/chatInputFooter/Right.tsx b/qiming/src/components/business-component/VoiceInput/chatInputFooter/Right.tsx new file mode 100644 index 00000000..888f6563 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/chatInputFooter/Right.tsx @@ -0,0 +1,140 @@ +import SvgIcon from '@/components/base/SvgIcon'; +import { ENABLE_VOICE_INPUT } from '@/constants/feature.constants'; +import { t } from '@/services/i18nRuntime'; +import { LoadingOutlined } from '@ant-design/icons'; +import { Tooltip } from 'antd'; +import React, { useEffect, useState } from 'react'; +import VoiceButton from '../components/Button'; +import { useVoiceFooter } from './context'; +import styles from './index.less'; + +const I18N_PREFIX = 'PC.Components.VoiceInput'; + +export interface VoiceFooterRightProps { + /** 非语音活跃时展示的默认操作(发送/停止会话等) */ + defaultActions: React.ReactNode; + /** 麦克风左侧扩展区(模型选择等),语音活跃时自动隐藏 */ + children?: React.ReactNode; + className?: string; +} + +/** + * 底栏右侧:扩展区 + 麦克风 + 语音停止/发送 或 默认操作按钮 + */ +export const VoiceFooterRight: React.FC = ({ + defaultActions, + children, + className, +}) => { + const { + disabled, + mock, + isActive, + voiceControl, + setVoiceControl, + handleVoiceResult, + } = useVoiceFooter(); + + // 转写期间:被点击的按钮展示 loading(保持主题色),另一个置灰 + const [pendingAction, setPendingAction] = useState<'fill' | 'send' | null>( + null, + ); + useEffect(() => { + if (voiceControl?.phase !== 'transcribing') { + setPendingAction(null); + } + }, [voiceControl?.phase]); + + const rootClass = [ + styles['voice-footer-right'], + isActive ? styles['voice-footer-right-active'] : '', + className ?? '', + ] + .filter(Boolean) + .join(' '); + + // 转写阶段未被点击的那个按钮置灰 + const phase = voiceControl?.phase; + const stopUnavailable = phase !== 'recording'; + const sendUnavailable = !!disabled || phase !== 'recording'; + const stopVisuallyDisabled = stopUnavailable && pendingAction !== 'fill'; + const sendVisuallyDisabled = + (phase !== 'recording' && pendingAction !== 'send') || + (!!disabled && phase === 'recording'); + + return ( +
+ {!isActive && children} + {ENABLE_VOICE_INPUT && ( + + )} + {isActive ? ( + <> + + + + + + + + ) : ( + defaultActions + )} +
+ ); +}; diff --git a/qiming/src/components/business-component/VoiceInput/chatInputFooter/context.ts b/qiming/src/components/business-component/VoiceInput/chatInputFooter/context.ts new file mode 100644 index 00000000..40d67e11 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/chatInputFooter/context.ts @@ -0,0 +1,31 @@ +import { createContext, useContext } from 'react'; +import type { VoiceInputControl, VoiceSubmitMode } from '../types'; + +export interface VoiceFooterContextValue { + disabled?: boolean; + mock?: boolean; + voiceControl: VoiceInputControl | null; + isActive: boolean; + setVoiceControl: (control: VoiceInputControl | null) => void; + handleVoiceResult: (text: string, mode: VoiceSubmitMode) => void; +} + +export const VoiceFooterContext = createContext( + null, +); + +/** 读取语音底栏上下文(需在 Provider 内使用) */ +export const useVoiceFooter = (): VoiceFooterContextValue => { + const ctx = useContext(VoiceFooterContext); + if (!ctx) { + throw new Error( + 'useVoiceFooter must be used within ChatInputVoiceFooter.Provider', + ); + } + return ctx; +}; + +/** 是否在录音/转写中 */ +export const useVoiceFooterActive = (): boolean => { + return useContext(VoiceFooterContext)?.isActive ?? false; +}; diff --git a/qiming/src/components/business-component/VoiceInput/chatInputFooter/index.less b/qiming/src/components/business-component/VoiceInput/chatInputFooter/index.less new file mode 100644 index 00000000..7676e3ed --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/chatInputFooter/index.less @@ -0,0 +1,68 @@ +@import '@/styles/color'; +@import '@/styles/token'; + +.voice-footer-right { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 4px; + margin-left: auto; +} + +.voice-footer-right-active { + margin-left: 0; +} + +.voice-input-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.voice-footer-action-box { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: @paddingXs; + appearance: none; + border: none; + border-radius: @borderRadius; + font: inherit; +} + +.voice-action-stop { + width: 36px; + height: 36px; + border-radius: 50%; + color: @colorPrimary; + background-color: @colorPrimaryBg; + transition: background-color ease-in-out 0.2s; + + &:hover:not(.voice-action-disabled) { + background-color: @colorPrimaryBgHover; + } +} + +.voice-action-send { + width: 36px; + height: 36px; + border-radius: 50%; + color: @colorWhite; + background-color: @colorPrimary; + transition: background-color ease-in-out 0.2s; + + &:hover:not(.voice-action-disabled) { + background-color: @colorPrimaryHover; + } +} + +.voice-action-disabled { + cursor: not-allowed; + color: @colorIcon; + background-color: @colorBgContainerDisabled; + opacity: 1; +} diff --git a/qiming/src/components/business-component/VoiceInput/chatInputFooter/index.ts b/qiming/src/components/business-component/VoiceInput/chatInputFooter/index.ts new file mode 100644 index 00000000..a2a03fc5 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/chatInputFooter/index.ts @@ -0,0 +1,20 @@ +import { VoiceFooterExpand } from './Expand'; +import { VoiceFooterHideWhenActive } from './HideWhenActive'; +import { VoiceFooterProvider } from './Provider'; +import { VoiceFooterRight } from './Right'; + +export { useVoiceFooter, useVoiceFooterActive } from './context'; +export type { VoiceFooterProviderProps } from './Provider'; +export type { VoiceFooterRightProps } from './Right'; + +/** + * 会话框底栏语音集成 —— 封装录音态布局、波形区与停止/发送按钮,降低 ChatInput 侵入 + */ +export const ChatInputVoiceFooter = { + Provider: VoiceFooterProvider, + HideWhenActive: VoiceFooterHideWhenActive, + Expand: VoiceFooterExpand, + Right: VoiceFooterRight, +}; + +export default ChatInputVoiceFooter; diff --git a/qiming/src/components/business-component/VoiceInput/components/Button.tsx b/qiming/src/components/business-component/VoiceInput/components/Button.tsx new file mode 100644 index 00000000..06067184 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/components/Button.tsx @@ -0,0 +1,326 @@ +import SvgIcon from '@/components/base/SvgIcon'; +import { speechToText } from '@/services/audio'; +import { t } from '@/services/i18nRuntime'; +import { LoadingOutlined } from '@ant-design/icons'; +import { message, Tooltip } from 'antd'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { VOICE_INPUT_DEFAULTS, VOICE_INPUT_MOCK_TRANSCRIPT } from '../config'; +import { RecorderError, useAudioRecorder } from '../hooks/useAudioRecorder'; +import { + MOCK_STT_DELAY_MS, + useMockAudioRecorder, +} from '../hooks/useMockAudioRecorder'; +import { warmUpRecorder } from '../loaders/recorderContext'; +import type { VoiceInputControl, VoiceSubmitMode } from '../types'; +import { createEmptyWaveLevels } from '../utils/waveLevels'; +import VoiceRecordingBar from './RecordingBar'; +import styles from './index.less'; + +export interface VoiceButtonProps { + /** + * 转写成功后的回调 + * @param text 识别文本(已 trim) + * @param mode fill=回填输入框;send=自动发送 + */ + onResult: (text: string, mode: VoiceSubmitMode) => void; + /** 整体禁用(如会话进行中) */ + disabled?: boolean; + /** 自定义类名 */ + className?: string; + /** + * 演示模式:不访问麦克风与 STT,仅模拟录音/转写 UI 流程 + */ + mock?: boolean; + /** 演示模式下的模拟转写文案 */ + mockTranscript?: string; + /** 录音/转写状态变化,供外层复用会话框停止与发送按钮 */ + onControlChange?: (control: VoiceInputControl | null) => void; + /** 录音/转写 UI 由外层底栏承接 */ + footerExpand?: boolean; +} + +const I18N_PREFIX = 'PC.Components.VoiceInput'; + +/** 把录音/识别错误映射为国际化 key 并提示 */ +const showError = (e: unknown) => { + let key = 'recognizeFailed'; + if (e instanceof RecorderError) { + switch (e.kind) { + case 'permission-denied': + key = 'permissionDenied'; + break; + case 'not-supported': + key = 'notSupported'; + break; + case 'too-short': + key = 'tooShort'; + break; + case 'aborted': + return; + default: + key = 'recognizeFailed'; + } + } + message.error(t(`${I18N_PREFIX}.${key}`)); +}; + +/** + * 语音输入控件 + * + * 交互: + * - 空闲:麦克风按钮开始录音 + * - 录音中:波形 + 计时(停止/发送由会话框原有按钮承接) + * - 转写中:loading 态 + */ +const VoiceButton: React.FC = ({ + onResult, + disabled, + className, + mock = false, + mockTranscript = VOICE_INPUT_MOCK_TRANSCRIPT, + onControlChange, + footerExpand = false, +}) => { + const realRecorder = useAudioRecorder(); + const mockRecorder = useMockAudioRecorder(); + const recorder = mock ? mockRecorder : realRecorder; + const { status, durationSec, waveLevels } = recorder; + const isRecording = status === 'recording'; + const isStopping = status === 'stopping'; + const isConnecting = status === 'connecting'; + const [transcribing, setTranscribing] = useState(false); + const mountedRef = useRef(true); + const transcriptionIdRef = useRef(0); + const finishingRef = useRef(false); + + const isBusy = disabled || transcribing || isStopping || isConnecting; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + transcriptionIdRef.current += 1; + }; + }, []); + + // 挂载即预热 worklet(真实模式),让首次点击更快、有即时反馈 + useEffect(() => { + if (!mock) { + warmUpRecorder(); + } + }, [mock]); + + /** + * 停止录音 -> STT -> 按模式回填或发送 + */ + const finishRecording = useCallback( + async (mode: VoiceSubmitMode) => { + if (finishingRef.current) { + return; + } + finishingRef.current = true; + + try { + let blob: Blob; + try { + blob = await recorder.stop(); + } catch (e) { + if (mountedRef.current) { + showError(e); + recorder.reset(); + } + return; + } + + // 后端 STT 有单文件 10MB 上限;16kHz 下 maxDurationSec 内不会触达, + // 此处兜底配置漂移或采样率异常,避免整段录音上传必败 + if (blob.size > VOICE_INPUT_DEFAULTS.maxFileSizeBytes) { + if (mountedRef.current) { + message.error(t(`${I18N_PREFIX}.tooLarge`)); + recorder.reset(); + } + return; + } + + if (!mountedRef.current) { + return; + } + const transcriptionId = ++transcriptionIdRef.current; + const isTranscriptionActive = () => + mountedRef.current && transcriptionIdRef.current === transcriptionId; + + setTranscribing(true); + try { + let trimmed: string; + if (mock) { + await new Promise((resolve) => { + setTimeout(resolve, MOCK_STT_DELAY_MS); + }); + trimmed = mockTranscript.trim(); + } else { + const text = await speechToText(blob, { + timeoutMs: VOICE_INPUT_DEFAULTS.sttTimeoutMs, + }); + trimmed = text?.trim() ?? ''; + } + if (!isTranscriptionActive()) { + return; + } + if (trimmed) { + onResult(trimmed, mode); + } else { + message.error(t(`${I18N_PREFIX}.recognizeFailed`)); + } + } catch { + if (isTranscriptionActive()) { + message.error(t(`${I18N_PREFIX}.recognizeFailed`)); + } + } finally { + if (isTranscriptionActive()) { + setTranscribing(false); + recorder.reset(); + } + } + } finally { + finishingRef.current = false; + } + }, + [recorder, onResult, mock, mockTranscript], + ); + + const finishRecordingRef = useRef(finishRecording); + finishRecordingRef.current = finishRecording; + + /** 向父级同步录音/转写态,便于复用会话框停止与发送按钮 */ + useEffect(() => { + if (!onControlChange) { + return; + } + if (transcribing || isStopping) { + onControlChange({ + phase: 'transcribing', + durationSec: 0, + waveLevels: createEmptyWaveLevels(), + submit: () => {}, + }); + } else if (isRecording) { + onControlChange({ + phase: 'recording', + durationSec, + waveLevels, + submit: (mode) => void finishRecordingRef.current(mode), + }); + } else { + // 连接麦克风阶段不上报 control:底栏保持原布局, + // 由麦克风图标自身的呼吸动效反馈“正在启动、稍候再说” + onControlChange(null); + } + return () => onControlChange(null); + }, [ + isRecording, + transcribing, + isStopping, + durationSec, + waveLevels, + onControlChange, + ]); + + /** 空闲态:点击麦克风开始录音 */ + const handleStartRecording = useCallback(async () => { + if (isBusy || isRecording) { + return; + } + try { + await recorder.start(); + } catch (e) { + if (mountedRef.current) { + showError(e); + } + } + }, [isBusy, isRecording, recorder]); + + // 录音启动后若会话转为禁用态(例如用户同时通过回车发出了消息), + // 自动停止并回填,避免继续占用麦克风或绕过最新发送条件。 + useEffect(() => { + if (!disabled) { + return; + } + if (isConnecting) { + recorder.reset(); + } else if (isRecording) { + void finishRecording('fill'); + } + }, [disabled, isConnecting, isRecording, recorder, finishRecording]); + + // 到达最大时长自动停止并回填(保守默认,用户可再点发送) + useEffect(() => { + if (isRecording && durationSec >= VOICE_INPUT_DEFAULTS.maxDurationSec) { + void finishRecording('fill'); + } + }, [isRecording, durationSec, finishRecording]); + + // 转写中:外层 footerExpand 时由底栏展示 + if (transcribing || isStopping) { + if (footerExpand) { + return null; + } + return ( + + + + ); + } + + // 录音中:外层 footerExpand 时由底栏占满中间区域展示 + if (isRecording) { + if (footerExpand) { + return null; + } + return ( + + ); + } + + // 空闲 / 连接中:麦克风入口(连接中图标呼吸闪动;悬停再预热一次,幂等) + const buttonLabel = t( + `${I18N_PREFIX}.${isConnecting ? 'connecting' : 'startTooltip'}`, + ); + + return ( + + + + ); +}; + +export default VoiceButton; diff --git a/qiming/src/components/business-component/VoiceInput/components/RecordingBar.tsx b/qiming/src/components/business-component/VoiceInput/components/RecordingBar.tsx new file mode 100644 index 00000000..aba858a4 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/components/RecordingBar.tsx @@ -0,0 +1,117 @@ +import React, { useLayoutEffect, useRef, useState } from 'react'; +import styles from './index.less'; + +const WAVE_MIN_PX = 3; +const WAVE_MAX_PX = 26; +/** 与 less 中 .voice-wave i 的宽度、gap 保持一致 */ +const WAVE_BAR_WIDTH_PX = 2; +const WAVE_BAR_GAP_PX = 2; +/** 轨道左端为虚线基线保留的最小宽度(含与波形的间距) */ +const BASELINE_RESERVE_PX = 20; +/** 容器测量完成前的兜底可见条数 */ +const FALLBACK_VISIBLE_COUNT = 48; + +const formatDuration = (sec: number): string => { + const m = Math.floor(sec / 60); + const s = (sec % 60).toString().padStart(2, '0'); + return `${m}:${s}`; +}; + +export interface VoiceRecordingBarProps { + /** recording=波形计时;transcribing=转写中 */ + phase: 'recording' | 'transcribing'; + durationSec?: number; + /** 实时波形条高度(0~1),由录音 Hook 驱动 */ + waveLevels?: number[]; + /** 占满会话框底部中间区域(与左侧 +/刷子、右侧停止/发送配合) */ + expanded?: boolean; + className?: string; +} + +/** + * 录音/转写展示条:虚线基线 + 实时波形时间线 + REC 红点 + 计时 + * + * 波形从右端逐格生长、直到填满整条轨道后向左滚动; + * 可见条数按轨道实际宽度自适应(数据层保留更长历史,视图裁尾部窗口)。 + */ +const VoiceRecordingBar: React.FC = ({ + phase, + durationSec = 0, + waveLevels, + expanded = false, + className, +}) => { + // 时间线初始为空:开录后波形条随时间从右端逐格生长 + const levels = waveLevels ?? []; + + const trackRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(FALLBACK_VISIBLE_COUNT); + + // 按轨道实际宽度计算可见条数,宽度变化(窗口缩放/侧栏开合)时自适应 + useLayoutEffect(() => { + const el = trackRef.current; + if (!el || typeof ResizeObserver === 'undefined') { + return; + } + const update = () => { + const width = el.clientWidth; + if (width > 0) { + setVisibleCount( + Math.max( + 8, + Math.floor( + (width - BASELINE_RESERVE_PX) / + (WAVE_BAR_WIDTH_PX + WAVE_BAR_GAP_PX), + ), + ), + ); + } + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const rootClass = [ + styles['voice-recording-bar'], + expanded ? styles['voice-recording-bar-expanded'] : '', + className ?? '', + ] + .filter(Boolean) + .join(' '); + + // 转写中:不展示 loading(由底栏右侧动作按钮承接),仅保留基线维持布局 + if (phase === 'transcribing') { + return ( +
+ +
+ ); + } + + // 只渲染时间线尾部能放下的窗口:未满时右端生长,满后即向左滚动 + const visibleLevels = levels.slice(-visibleCount); + + return ( +
+ + + + {visibleLevels.map((level, index) => { + const height = WAVE_MIN_PX + level * (WAVE_MAX_PX - WAVE_MIN_PX); + return ; + })} + + + + + {formatDuration(durationSec)} + +
+ ); +}; + +export default VoiceRecordingBar; diff --git a/qiming/src/components/business-component/VoiceInput/components/index.less b/qiming/src/components/business-component/VoiceInput/components/index.less new file mode 100644 index 00000000..9528ecae --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/components/index.less @@ -0,0 +1,214 @@ +@import '@/styles/color'; +@import '@/styles/token'; + +// 空闲态麦克风:与工具栏其它图标一致的描述色 +.voice-idle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + appearance: none; + border: none; + border-radius: 16px; + cursor: pointer; + color: @colorTextDescription; + font-size: @fontSize; + background-color: transparent; + transition: color ease-in-out 0.2s, background-color ease-in-out 0.2s; + user-select: none; + margin-left: -6px; + + .voice-icon { + color: @colorTextDescription; + } + + &:hover { + background-color: @colorFillTertiary; + } +} + +// 连接中:已点击麦克风,正在申请权限/复用 worklet,给脉冲反馈避免“点了没反应” +.voice-connecting { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + appearance: none; + border: none; + border-radius: 16px; + cursor: progress; + color: @colorTextDescription; + font-size: @fontSize; + background-color: transparent; + user-select: none; + margin-left: 4px; + + .voice-icon { + color: @colorTextDescription; + animation: voice-connecting-pulse 1s ease-in-out infinite; + } +} + +@keyframes voice-connecting-pulse { + 0%, + 100% { + opacity: 0.45; + transform: scale(0.9); + } + + 50% { + opacity: 1; + transform: scale(1.1); + } +} + +.voice-transcribing { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 32px; + min-width: 32px; + padding: 0 10px; + border-radius: 16px; + border: none; + cursor: progress; + color: @colorTextDescription; + font-size: @fontSize; + background-color: transparent; + user-select: none; +} + +.voice-disabled { + cursor: not-allowed; + opacity: 0.45; + + &:hover { + background-color: transparent; + } +} + +.voice-action-label { + font-size: @fontSizeSm; + color: @colorTextDescription; + white-space: nowrap; +} + +// 录音中红点:通用 REC 信号,出现即代表麦克风已在采集、可以说话 +.voice-rec-dot { + flex-shrink: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: @colorError; + animation: voice-rec-pulse 1.2s ease-in-out infinite; +} + +@keyframes voice-rec-pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.35; + } +} + +// 录音/转写展示条:无背景、无边框 +.voice-recording-bar { + display: inline-flex; + align-items: center; + gap: 10px; + height: 32px; + padding: 0; + border: none; + background: transparent; + user-select: none; +} + +.voice-recording-bar-expanded { + flex: 1; + min-width: 0; + justify-content: flex-start; + height: 36px; + padding: 0 4px 0 0; +} + +// 波形轨道:虚线基线 + 波形时间线共享的中间区域,实际宽度决定可见条数 +.voice-track { + display: inline-flex; + flex: 1; + align-items: center; + gap: 10px; + min-width: 0; +} + +// 虚线基线:占据波形未生长到的剩余空间,随时间线变满而缩短 +.voice-baseline { + flex: 1; + min-width: 8px; + max-width: 72px; + height: 0; + border-bottom: 1px dotted @colorBorderSecondary; + align-self: center; +} + +.voice-recording-bar-expanded .voice-baseline { + max-width: none; +} + +.voice-timer { + flex-shrink: 0; + font-size: @fontSize; + font-variant-numeric: tabular-nums; + line-height: 1; + color: @colorTextSecondary; + min-width: 32px; +} + +// 实时波形时间线(高度由 PCM 音量驱动):靠右生长、随时间向左滚动, +// 左侧剩余空间由虚线基线占据;空间不足时最旧(左端)的条被裁掉 +.voice-wave { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + height: 28px; + flex-shrink: 1; + min-width: 0; + overflow: hidden; + + i { + display: block; + flex-shrink: 0; + width: 2px; + height: 4px; + background-color: @colorText; + border-radius: 1px; + } +} + +// 高度过渡须明显短于波形推进间隔(waveShiftIntervalMs=80ms), +// 否则相邻两步互相糊化,看不出时间线向左滚动的移动感。 +// 弹入动画:每根新条在右端出现时轻弹一下,强化“正在随时间生长”的感知 +.voice-wave-live i { + transition: height 40ms ease-out; + animation: voice-bar-pop 0.2s ease-out backwards; +} + +@keyframes voice-bar-pop { + from { + opacity: 0; + transform: scaleY(0.2); + } + + to { + opacity: 1; + transform: scaleY(1); + } +} diff --git a/qiming/src/components/business-component/VoiceInput/config.ts b/qiming/src/components/business-component/VoiceInput/config.ts new file mode 100644 index 00000000..db7ee328 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/config.ts @@ -0,0 +1,37 @@ +/** + * 语音输入模块默认配置 + * + * 集中管理可调参数,便于后续按需调整,避免散落到各文件。 + */ +export const VOICE_INPUT_DEFAULTS = { + /** 采样率:16kHz 对中文语音识别足够,且文件体积小(60s≈1.9MB) */ + sampleRate: 16000, + /** 声道数:单声道即可满足语音识别 */ + channelCount: 1, + /** + * 最大录音时长(秒),到达后自动停止并识别。 + * 300s × 16kHz × 16bit ≈ 9.6MB,须保持在 maxFileSizeBytes(10MB) 以内 + */ + maxDurationSec: 300, + /** 最小有效录音时长(秒),低于此值视为误触 */ + minDurationSec: 0.5, + /** STT 接口超时(毫秒) */ + sttTimeoutMs: 30000, + /** 录音链路启动超时(毫秒):AudioWorklet 须在此时间内产出首帧 PCM */ + startupTimeoutMs: 10000, + /** 麦克风预热时长(毫秒):连续产出足量 PCM 后才进入正式录制 */ + startupStabilizationMs: 500, + /** 上传文件大小硬上限(字节),与后端 10MB 约束对齐 */ + maxFileSizeBytes: 10 * 1024 * 1024, + /** + * 波形时间线保留的最大历史条数(数据层上限,约 41s)。 + * 实际可见条数由展示层按容器宽度自适应裁切,此值只需大于任何屏宽的可见条数 + */ + waveBarCount: 512, + /** 波形推进间隔(毫秒):每步新音量从右端进入时间线 */ + waveShiftIntervalMs: 80, +} as const; + +/** 演示模式默认模拟转写文案 */ +export const VOICE_INPUT_MOCK_TRANSCRIPT = + '这是模拟识别的语音内容,用于预览回填与自动发送交互。'; diff --git a/qiming/src/components/business-component/VoiceInput/hooks/useAudioRecorder.ts b/qiming/src/components/business-component/VoiceInput/hooks/useAudioRecorder.ts new file mode 100644 index 00000000..e14fc714 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/hooks/useAudioRecorder.ts @@ -0,0 +1,426 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { VOICE_INPUT_DEFAULTS } from '../config'; +import { ensureWorkletReady } from '../loaders/recorderContext'; +import { downsamplePcm } from '../utils/resample'; +import { encodeWav } from '../utils/wavEncoder'; +import { + appendSmoothedWaveLevel, + computeFrameRms, + createEmptyWaveLevels, + normalizeWaveLevel, +} from '../utils/waveLevels'; + +export type RecorderStatus = 'idle' | 'connecting' | 'recording' | 'stopping'; + +export interface UseAudioRecorder { + /** 当前状态 */ + status: RecorderStatus; + /** 已录音时长(秒,整数) */ + durationSec: number; + /** 实时波形条高度(0~1) */ + waveLevels: number[]; + /** 开始录音(连接中 → 申请权限 + 复用单例 AudioContext/Worklet → 录音) */ + start: () => Promise; + /** 停止录音并返回 WAV Blob;时长过短抛 RecorderError('too-short') */ + stop: () => Promise; + /** 重置到 idle,释放本次录音资源(不断开共享 ctx) */ + reset: () => void; +} + +/** 录音错误类型,便于 UI 层映射国际化文案 */ +export class RecorderError extends Error { + kind: + | 'permission-denied' + | 'not-supported' + | 'too-short' + | 'aborted' + | 'unknown'; + + constructor(kind: RecorderError['kind'], message?: string) { + super(message || kind); + this.name = 'RecorderError'; + this.kind = kind; + } +} + +/** + * 麦克风录音 Hook + * + * 通路:getUserMedia -> 共享 AudioContext(指定采样率) -> AudioWorklet(recorder-processor) + * -> 主线程累加 Float32 帧 -> 停止时合并并 encodeWav -> WAV Blob + * + * 性能:AudioContext 与 worklet 模块走 loaders/recorderContext 的会话级单例, + * 跨录音复用,避免每次点击都重建 ctx 与重新 fetch/compile worklet(这是点击->录音 + * 延迟的最大块)。麦克风仍按需采集,停止即释放。 + * + * 采用 AudioWorklet 而非 MediaRecorder,是为了在 Safari 上也能得到后端可识别的 WAV。 + */ +export const useAudioRecorder = (): UseAudioRecorder => { + const [status, setStatus] = useState('idle'); + const [durationSec, setDurationSec] = useState(0); + const [waveLevels, setWaveLevels] = useState(createEmptyWaveLevels); + + const waveLevelsRef = useRef(createEmptyWaveLevels()); + const pendingRmsRef = useRef([]); + const rafRef = useRef(null); + const lastShiftAtRef = useRef(0); + const streamRef = useRef(null); + const sourceRef = useRef(null); + const nodeRef = useRef(null); + const muteGainRef = useRef(null); + const chunksRef = useRef([]); + const sampleRateRef = useRef(VOICE_INPUT_DEFAULTS.sampleRate); + const startedAtRef = useRef(0); + const timerRef = useRef | null>(null); + const mountedRef = useRef(true); + const operationIdRef = useRef(0); + const startingRef = useRef(false); + const abortStartupRef = useRef<((error: RecorderError) => void) | null>(null); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + /** + * 释放本次录音的资源:断开节点、停麦克风。 + * 不关闭共享 AudioContext、不清 worklet 缓存——它们由单例跨录音复用。 + */ + const releaseRecordingResources = useCallback(() => { + clearTimer(); + abortStartupRef.current?.(new RecorderError('aborted')); + abortStartupRef.current = null; + if (nodeRef.current) { + try { + nodeRef.current.port.onmessage = null; + nodeRef.current.disconnect(); + } catch { + /* noop */ + } + nodeRef.current = null; + } + if (sourceRef.current) { + try { + sourceRef.current.disconnect(); + } catch { + /* noop */ + } + sourceRef.current = null; + } + if (muteGainRef.current) { + try { + muteGainRef.current.disconnect(); + } catch { + /* noop */ + } + muteGainRef.current = null; + } + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + }, [clearTimer]); + + const start = useCallback(async () => { + if (status !== 'idle' || startingRef.current) { + return; + } + + if ( + typeof navigator === 'undefined' || + !navigator.mediaDevices || + typeof navigator.mediaDevices.getUserMedia !== 'function' + ) { + throw new RecorderError('not-supported'); + } + + const operationId = ++operationIdRef.current; + const isOperationActive = () => + mountedRef.current && operationIdRef.current === operationId; + startingRef.current = true; + + // 入口即给“连接中”反馈,避免点击后 1-2s 看似无响应 + setStatus('connecting'); + + let stream: MediaStream | null = null; + try { + // 1. AudioContext/Worklet 就绪与麦克风申请并行:预热未命中(如页面刚加载 + // 即点击)时两段耗时不再叠加,最大限度缩短“点击 -> 开录”的丢字窗口 + const ctxPromise = ensureWorkletReady(); + // 预挂 handler:若下方 getUserMedia 先失败抛出,此 promise 不产生 unhandledrejection + ctxPromise.catch(() => {}); + + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: VOICE_INPUT_DEFAULTS.channelCount, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + video: false, + }); + } catch (e) { + const name = (e as DOMException)?.name; + if (name === 'NotAllowedError' || name === 'SecurityError') { + throw new RecorderError('permission-denied'); + } + if (name === 'NotFoundError' || name === 'OverconstrainedError') { + throw new RecorderError('not-supported'); + } + if (name === 'AbortError') { + throw new RecorderError('aborted'); + } + throw new RecorderError('unknown', (e as Error)?.message); + } + + // 权限弹窗等待期间可能发生路由切换或 reset;新取得的流必须立即释放, + // 不能继续挂到已经卸载的组件与共享 AudioContext 上。 + if (!isOperationActive()) { + stream.getTracks().forEach((track) => track.stop()); + throw new RecorderError('aborted'); + } + streamRef.current = stream; + + // 2. 等待共享 AudioContext + worklet 就绪(通常已被预热,近乎瞬时) + let ctx: AudioContext; + try { + ctx = await ctxPromise; + } catch (e) { + throw new RecorderError( + 'not-supported', + e instanceof Error ? e.message : undefined, + ); + } + + if (!isOperationActive()) { + stream.getTracks().forEach((track) => track.stop()); + throw new RecorderError('aborted'); + } + sampleRateRef.current = ctx.sampleRate; // 浏览器实际可能调整采样率 + + // 3. 建链:source -> recorder-processor -> muteGain -> destination + // 连到 destination 以驱动 worklet 拉取样本;静音 GainNode 避免把麦克风外放 + const source = ctx.createMediaStreamSource(stream); + const node = new AudioWorkletNode(ctx, 'recorder-processor'); + const muteGain = ctx.createGain(); + muteGain.gain.value = 0; + + chunksRef.current = []; + pendingRmsRef.current = []; + waveLevelsRef.current = createEmptyWaveLevels(); + setWaveLevels(createEmptyWaveLevels()); + const startupSampleTarget = Math.ceil( + (ctx.sampleRate * VOICE_INPUT_DEFAULTS.startupStabilizationMs) / 1000, + ); + let startupSampleCount = 0; + let recordingReady = false; + let resolveStartup: (() => void) | null = null; + let startupBoundaryFrame: Float32Array | null = null; + let startupCompletedAt = 0; + node.port.onmessage = (event: MessageEvent) => { + const frame = event.data as Float32Array | undefined; + if (frame && frame.length) { + if (!recordingReady) { + startupSampleCount += frame.length; + if (startupSampleCount >= startupSampleTarget) { + startupBoundaryFrame = frame; + startupCompletedAt = Date.now(); + resolveStartup?.(); + } + return; + } + chunksRef.current.push(frame); + pendingRmsRef.current.push(computeFrameRms(frame)); + } + }; + + source.connect(node); + node.connect(muteGain); + muteGain.connect(ctx.destination); + + sourceRef.current = source; + nodeRef.current = node; + muteGainRef.current = muteGain; + + // 节点连通或单个 PCM 帧都不代表设备已经稳定。保持 connecting,直到 + // worklet 连续产出指定时长的 PCM;预热帧不进入最终录音与波形。 + await new Promise((resolve, reject) => { + let settled = false; + let timeout: ReturnType; + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + abortStartupRef.current = null; + resolveStartup = null; + callback(); + }; + timeout = setTimeout(() => { + settle(() => + reject(new RecorderError('unknown', 'recorder startup timeout')), + ); + }, VOICE_INPUT_DEFAULTS.startupTimeoutMs); + + resolveStartup = () => settle(resolve); + abortStartupRef.current = (error) => settle(() => reject(error)); + }); + + if (!isOperationActive()) { + throw new RecorderError('aborted'); + } + recordingReady = true; + const firstRecordingFrame = startupBoundaryFrame ?? new Float32Array(0); + chunksRef.current = firstRecordingFrame.length + ? [firstRecordingFrame] + : []; + pendingRmsRef.current = []; + waveLevelsRef.current = firstRecordingFrame.length + ? appendSmoothedWaveLevel( + [], + normalizeWaveLevel(computeFrameRms(firstRecordingFrame)), + ) + : createEmptyWaveLevels(); + setWaveLevels(waveLevelsRef.current); + startedAtRef.current = startupCompletedAt || Date.now(); + + startingRef.current = false; + setDurationSec(0); + setStatus('recording'); + + // 4. 计时 + clearTimer(); + timerRef.current = setInterval(() => { + setDurationSec(Math.floor((Date.now() - startedAtRef.current) / 1000)); + }, 1000); + } catch (e) { + // 仅当前操作有权修改状态和共享引用;已被 reset/卸载淘汰的异步操作 + // 只释放自己刚取得的 MediaStream,避免误伤后续录音。 + if (isOperationActive()) { + startingRef.current = false; + releaseRecordingResources(); + setStatus('idle'); + } else if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } + throw e; + } + }, [status, clearTimer, releaseRecordingResources]); + + const stop = useCallback(async (): Promise => { + if (status !== 'recording') { + throw new RecorderError('unknown', 'recorder is not recording'); + } + + setStatus('stopping'); + clearTimer(); + + const duration = (Date.now() - startedAtRef.current) / 1000; + + // 合并所有帧 + const chunks = chunksRef.current; + const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); + const merged = new Float32Array(totalLength); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.length; + } + + releaseRecordingResources(); + chunksRef.current = []; + + setStatus('idle'); + setDurationSec(0); + + // 时长过短或无数据视为误触 + if (duration < VOICE_INPUT_DEFAULTS.minDurationSec || totalLength === 0) { + throw new RecorderError('too-short'); + } + + // 共享 ctx 可能回退到设备默认采样率(如 48kHz):统一下采样到目标采样率, + // 保证上传体积与 16kHz 预期一致(maxDurationSec 的 10MB 预算按 16kHz 计算) + const targetRate = VOICE_INPUT_DEFAULTS.sampleRate; + const actualRate = sampleRateRef.current; + const pcm = + actualRate > targetRate + ? downsamplePcm(merged, actualRate, targetRate) + : merged; + const wavBuffer = encodeWav(pcm, Math.min(actualRate, targetRate)); + return new Blob([wavBuffer], { type: 'audio/wav' }); + }, [status, clearTimer, releaseRecordingResources]); + + const reset = useCallback(() => { + operationIdRef.current += 1; + startingRef.current = false; + releaseRecordingResources(); + chunksRef.current = []; + pendingRmsRef.current = []; + waveLevelsRef.current = createEmptyWaveLevels(); + setWaveLevels(createEmptyWaveLevels()); + setStatus('idle'); + setDurationSec(0); + }, [releaseRecordingResources]); + + // 录音中:按帧累积 RMS,按固定节奏向时间线右端追加一格音量; + // 挤满可见长度后最旧的从左端滚出(从右到左的时间线滚动)。 + // 固定间隔(而非每帧刷新)让滚动节奏肉眼可辨,同时把重渲染频率降到 ~12次/秒 + useEffect(() => { + if (status !== 'recording') { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + + lastShiftAtRef.current = 0; + const tick = () => { + const now = Date.now(); + if ( + now - lastShiftAtRef.current >= + VOICE_INPUT_DEFAULTS.waveShiftIntervalMs + ) { + const pending = pendingRmsRef.current; + if (pending.length > 0) { + // 间隔内所有帧的平均 RMS 作为这一格的真实音量 + const avgRms = + pending.reduce((sum, value) => sum + value, 0) / pending.length; + pendingRmsRef.current = []; + waveLevelsRef.current = appendSmoothedWaveLevel( + waveLevelsRef.current, + normalizeWaveLevel(avgRms), + ); + setWaveLevels(waveLevelsRef.current); + lastShiftAtRef.current = now; + } + } + rafRef.current = requestAnimationFrame(tick); + }; + + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [status]); + + // 组件卸载:仅释放活动录音资源(共享 ctx 保留给会话复用) + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + operationIdRef.current += 1; + startingRef.current = false; + releaseRecordingResources(); + }; + }, [releaseRecordingResources]); + + return { status, durationSec, waveLevels, start, stop, reset }; +}; diff --git a/qiming/src/components/business-component/VoiceInput/hooks/useMockAudioRecorder.ts b/qiming/src/components/business-component/VoiceInput/hooks/useMockAudioRecorder.ts new file mode 100644 index 00000000..293beb52 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/hooks/useMockAudioRecorder.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { VOICE_INPUT_DEFAULTS } from '../config'; +import { + appendSmoothedWaveLevel, + createEmptyWaveLevels, + normalizeWaveLevel, +} from '../utils/waveLevels'; +import type { UseAudioRecorder } from './useAudioRecorder'; +import { RecorderError } from './useAudioRecorder'; + +/** + * 模拟录音 Hook(仅用于演示页预览交互,不访问麦克风) + * + * 与 useAudioRecorder 保持相同状态机,便于 VoiceButton 复用同一套 UI。 + */ +export const useMockAudioRecorder = (): UseAudioRecorder => { + const [status, setStatus] = useState('idle'); + const [durationSec, setDurationSec] = useState(0); + const [waveLevels, setWaveLevels] = useState(createEmptyWaveLevels); + const startedAtRef = useRef(0); + const timerRef = useRef | null>(null); + const waveTimerRef = useRef | null>(null); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + if (waveTimerRef.current) { + clearInterval(waveTimerRef.current); + waveTimerRef.current = null; + } + }, []); + + const reset = useCallback(() => { + clearTimer(); + setWaveLevels(createEmptyWaveLevels()); + setStatus('idle'); + setDurationSec(0); + }, [clearTimer]); + + const start = useCallback(async () => { + if (status !== 'idle') { + return; + } + startedAtRef.current = Date.now(); + setDurationSec(0); + setWaveLevels(createEmptyWaveLevels()); + setStatus('recording'); + clearTimer(); + timerRef.current = setInterval(() => { + setDurationSec(Math.floor((Date.now() - startedAtRef.current) / 1000)); + }, 1000); + + // 演示态:用伪随机音量驱动波形时间线,便于预览动效。 + // 伪 RMS 覆盖 ~0.004-0.15(约 -48dB~-16dB),配合 dB 感知映射呈现明显高低起伏 + waveTimerRef.current = setInterval(() => { + const t = Date.now() / 180; + const pseudoRms = + 0.004 + Math.abs(Math.sin(t)) * 0.1 + Math.random() * 0.05; + setWaveLevels((prev) => + appendSmoothedWaveLevel(prev, normalizeWaveLevel(pseudoRms)), + ); + }, VOICE_INPUT_DEFAULTS.waveShiftIntervalMs); + }, [status, clearTimer]); + + const stop = useCallback(async (): Promise => { + if (status !== 'recording') { + throw new RecorderError('unknown', 'recorder is not recording'); + } + + setStatus('stopping'); + clearTimer(); + + const duration = (Date.now() - startedAtRef.current) / 1000; + setStatus('idle'); + setDurationSec(0); + setWaveLevels(createEmptyWaveLevels()); + + if (duration < VOICE_INPUT_DEFAULTS.minDurationSec) { + throw new RecorderError('too-short'); + } + + return new Blob(['mock-audio'], { type: 'audio/wav' }); + }, [status, clearTimer]); + + useEffect(() => { + return () => { + clearTimer(); + }; + }, [clearTimer]); + + return { status, durationSec, waveLevels, start, stop, reset }; +}; + +/** 模拟 STT 转写延迟(毫秒) */ +export const MOCK_STT_DELAY_MS = 800; diff --git a/qiming/src/components/business-component/VoiceInput/index.tsx b/qiming/src/components/business-component/VoiceInput/index.tsx new file mode 100644 index 00000000..0435cfae --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/index.tsx @@ -0,0 +1,17 @@ +/** + * 语音输入模块对外入口 + * + * 业务侧(聊天输入框底栏)通过 ChatInputVoiceFooter 套件接入: + * - Provider:管理录音/转写态与「回填 / 自动发送」回调 + * - HideWhenActive:录音时隐藏 @、模型选择等底栏项 + * - Expand:录音波形与计时占满底栏中间区域 + * - Right:麦克风入口与语音停止/发送按钮(内部判断 ENABLE_VOICE_INPUT, + * 关闭开关即整体下线,业务文件无需回改) + */ +import { ChatInputVoiceFooter } from './chatInputFooter'; +import VoiceRecordingBar from './components/RecordingBar'; +import type { VoiceInputControl, VoiceSubmitMode } from './types'; + +export { mergeVoiceTranscript } from './chatInputFooter/Provider'; +export { ChatInputVoiceFooter, VoiceRecordingBar }; +export type { VoiceInputControl, VoiceSubmitMode }; diff --git a/qiming/src/components/business-component/VoiceInput/loaders/recorderContext.ts b/qiming/src/components/business-component/VoiceInput/loaders/recorderContext.ts new file mode 100644 index 00000000..ee2ce69c --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/loaders/recorderContext.ts @@ -0,0 +1,107 @@ +import { VOICE_INPUT_DEFAULTS } from '../config'; +import { resolveRecorderWorkletUrl } from './recorderWorklet'; + +/** + * 会话级共享 AudioContext + Worklet 模块单例 + * + * 目的:消除每次录音都 `new AudioContext` + `audioWorklet.addModule` 的开销—— + * worklet 模块的网络拉取与编译,是“点击 → 进入录音态”延迟里最大且最易避免的一块。 + * AudioContext 与已加载的 worklet 在整个页面会话内复用,麦克风流仍按需采集/释放。 + * + * 不替代 `recorderWorklet.ts`,仅复用其 `resolveRecorderWorkletUrl()`。 + */ + +type AudioCtxCtor = typeof AudioContext; + +let sharedCtx: AudioContext | null = null; +let workletPromise: Promise | null = null; + +function pickCtor(): AudioCtxCtor | null { + if (typeof window === 'undefined') { + return null; + } + return ( + (window as any).AudioContext || (window as any).webkitAudioContext || null + ); +} + +/** + * 懒创建 / 复用共享 AudioContext。 + * 已关闭(被系统或显式 dispose)则重建;指定采样率不被支持时回退到默认。 + */ +export function getSharedAudioContext(): AudioContext | null { + const Ctor = pickCtor(); + if (!Ctor) { + return null; + } + if (sharedCtx && sharedCtx.state !== 'closed') { + return sharedCtx; + } + try { + sharedCtx = new Ctor({ sampleRate: VOICE_INPUT_DEFAULTS.sampleRate }); + } catch { + sharedCtx = new Ctor(); + } + return sharedCtx; +} + +/** + * 确保共享 AudioContext 已就绪、worklet 已加载(整会话只 fetch+compile 一次), + * 并在 suspended 时 resume。返回就绪的 ctx。 + * 浏览器不支持时抛 Error('not-supported'),由调用方归类。 + */ +export async function ensureWorkletReady(): Promise { + const ctx = getSharedAudioContext(); + if (!ctx) { + throw new Error('not-supported'); + } + if (!workletPromise) { + workletPromise = ctx.audioWorklet.addModule(resolveRecorderWorkletUrl()); + } + try { + await workletPromise; + } catch (e) { + // 加载失败:清缓存以便下次点击可重试 + workletPromise = null; + throw e; + } + if (ctx.state !== 'running') { + try { + await ctx.resume(); + } catch (e) { + throw new Error( + e instanceof Error ? e.message : 'audio-context-resume-failed', + ); + } + } + if ((ctx.state as string) !== 'running') { + throw new Error('audio-context-not-running'); + } + return ctx; +} + +/** + * 预热:挂载 / 悬停时尽力提前加载 worklet,忽略错误(点击时会再次尝试)。 + * 创建 AudioContext 与 addModule 不需要用户手势(ctx 处于 suspended 也可加载模块)。 + */ +export function warmUpRecorder(): void { + void ensureWorkletReady().catch(() => { + /* noop */ + }); +} + +/** + * 释放共享 AudioContext。仅页面级下线 / 模块禁用时调用;默认不调用, + * 以便会话内多次录音复用同一 ctx 与已加载的 worklet。 + */ +export function disposeRecorder(): void { + workletPromise = null; + if (sharedCtx) { + try { + void sharedCtx.close(); + } catch { + /* noop */ + } + sharedCtx = null; + } +} diff --git a/qiming/src/components/business-component/VoiceInput/loaders/recorderWorklet.ts b/qiming/src/components/business-component/VoiceInput/loaders/recorderWorklet.ts new file mode 100644 index 00000000..5b872765 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/loaders/recorderWorklet.ts @@ -0,0 +1,18 @@ +/** + * 录音 AudioWorklet 静态资源定位 + * + * recorder-worklet.js 位于 public/,随本前端构建部署、由前端站点自身服务, + * 因此必须按前端 origin 解析,而不能按 API 域名(process.env.BASE_URL): + * - audioWorklet.addModule 加载跨域模块要求响应带 CORS 头,API 域的静态资源 + * 通常没有——本地开发(BASE_URL 指向远端测试环境)会直接加载失败; + * - 按 API 域加载还会出现「本地新代码 + 远端旧 worklet」的版本漂移。 + * + * 应用当前以根路径(publicPath '/')部署;若未来引入子路径需同步调整这里。 + * 模块加载与缓存由 loaders/recorderContext 的会话级单例负责。 + */ +export function resolveRecorderWorkletUrl(): string { + if (typeof window !== 'undefined') { + return `${window.location.origin}/recorder-worklet.js`; + } + return '/recorder-worklet.js'; +} diff --git a/qiming/src/components/business-component/VoiceInput/types.ts b/qiming/src/components/business-component/VoiceInput/types.ts new file mode 100644 index 00000000..7ffd8734 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/types.ts @@ -0,0 +1,20 @@ +/** + * 语音转写完成后的提交模式 + * - fill:仅回填到输入框,由用户确认后再发送 + * - send:转写完成后直接走发送链路 + */ +export type VoiceSubmitMode = 'fill' | 'send'; + +/** + * 语音控件对外暴露的运行时状态(供会话框复用停止/发送按钮) + */ +export interface VoiceInputControl { + /** recording=录音中;transcribing=转写中(连接麦克风阶段不上报 control) */ + phase: 'recording' | 'transcribing'; + /** 录音已进行秒数(转写中为 0) */ + durationSec: number; + /** 实时波形条高度(0~1,仅 recording 阶段有效) */ + waveLevels: number[]; + /** 结束录音并按模式提交(仅 recording 阶段可调用) */ + submit: (mode: VoiceSubmitMode) => void; +} diff --git a/qiming/src/components/business-component/VoiceInput/utils/resample.ts b/qiming/src/components/business-component/VoiceInput/utils/resample.ts new file mode 100644 index 00000000..978656d5 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/utils/resample.ts @@ -0,0 +1,42 @@ +/** + * PCM 下采样(区间均值法,单声道) + * + * 共享 AudioContext 在不支持指定采样率的环境会回退到设备默认(常见 44.1/48kHz), + * 上传前统一降到目标采样率,把体积压回 16kHz 预期(48kHz 时约缩为 1/3), + * 避免触碰后端 STT 的 10MB 上限。 + * 区间均值等价于“盒式低通 + 抽取”,对语音识别精度足够;纯函数便于 vitest 单测。 + */ +export function downsamplePcm( + samples: Float32Array, + fromRate: number, + toRate: number, +): Float32Array { + if ( + !Number.isFinite(fromRate) || + !Number.isFinite(toRate) || + toRate <= 0 || + toRate >= fromRate || + samples.length === 0 + ) { + return samples; + } + + const ratio = fromRate / toRate; + const outLength = Math.max(1, Math.floor(samples.length / ratio)); + const out = new Float32Array(outLength); + + for (let i = 0; i < outLength; i += 1) { + const start = Math.floor(i * ratio); + const end = Math.min( + Math.max(start + 1, Math.floor((i + 1) * ratio)), + samples.length, + ); + let sum = 0; + for (let j = start; j < end; j += 1) { + sum += samples[j]; + } + out[i] = sum / (end - start); + } + + return out; +} diff --git a/qiming/src/components/business-component/VoiceInput/utils/wavEncoder.ts b/qiming/src/components/business-component/VoiceInput/utils/wavEncoder.ts new file mode 100644 index 00000000..73544033 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/utils/wavEncoder.ts @@ -0,0 +1,67 @@ +/** + * PCM Float32 -> 16-bit PCM WAV 编码(单声道) + * + * 纯函数,无任何副作用与运行环境依赖,便于在 vitest 中单测。 + * WAV 文件由 44 字节 RIFF/WAVE 头 + 原始 PCM 数据组成, + * 后端 /api/audio/stt 支持 wav/PCM 格式,且所有浏览器编码一致,确保 Safari 兼容。 + */ + +const WAV_HEADER_SIZE = 44; +const BYTES_PER_SAMPLE = 2; // 16-bit PCM + +function writeString(view: DataView, offset: number, str: string): void { + for (let i = 0; i < str.length; i += 1) { + view.setUint8(offset + i, str.charCodeAt(i)); + } +} + +/** + * 将 Float32 采样(范围 [-1, 1])编码为单声道 16-bit PCM WAV。 + * @param samples PCM 浮点采样数据 + * @param sampleRate 采样率(需与采集时 AudioContext.sampleRate 一致) + * @returns 包含完整 WAV 文件的 ArrayBuffer + */ +export function encodeWav( + samples: Float32Array, + sampleRate: number, +): ArrayBuffer { + const numChannels = 1; + const blockAlign = numChannels * BYTES_PER_SAMPLE; + const byteRate = sampleRate * blockAlign; + const dataSize = samples.length * BYTES_PER_SAMPLE; + const bufferLength = WAV_HEADER_SIZE + dataSize; + + const buffer = new ArrayBuffer(bufferLength); + const view = new DataView(buffer); + + // —— RIFF chunk descriptor —— + writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + dataSize, true); // 文件大小 - 8 + writeString(view, 8, 'WAVE'); + + // —— fmt sub-chunk —— + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); // PCM 子块大小 + view.setUint16(20, 1, true); // audio format = 1 (PCM) + view.setUint16(22, numChannels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, byteRate, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, 16, true); // 每采样位数 + + // —— data sub-chunk —— + writeString(view, 36, 'data'); + view.setUint32(40, dataSize, true); + + // —— PCM 采样:Float32 [-1,1] -> Int16 —— + let offset = WAV_HEADER_SIZE; + for (let i = 0; i < samples.length; i += 1) { + const s = Math.max(-1, Math.min(1, samples[i])); + // 有符号 16 位量化 + const int16 = s < 0 ? s * 0x8000 : s * 0x7fff; + view.setInt16(offset, int16, true); + offset += BYTES_PER_SAMPLE; + } + + return buffer; +} diff --git a/qiming/src/components/business-component/VoiceInput/utils/waveLevels.ts b/qiming/src/components/business-component/VoiceInput/utils/waveLevels.ts new file mode 100644 index 00000000..e5275424 --- /dev/null +++ b/qiming/src/components/business-component/VoiceInput/utils/waveLevels.ts @@ -0,0 +1,74 @@ +import { VOICE_INPUT_DEFAULTS } from '../config'; + +/** 波形条最低可视高度比例(0~1) */ +const MIN_LEVEL = 0.1; + +/** + * 由 PCM 帧计算 RMS 音量 + */ +export const computeFrameRms = (frame: Float32Array): number => { + if (!frame.length) { + return 0; + } + let sum = 0; + for (let i = 0; i < frame.length; i++) { + const sample = frame[i]; + sum += sample * sample; + } + return Math.sqrt(sum / frame.length); +}; + +/** 噪声地板(dB):低于视为安静,贴最低高度 */ +const NOISE_FLOOR_DB = -50; +/** 响度上限(dB):达到即满高(AGC 开启后大声说话可触及) */ +const CEIL_DB = -12; + +/** + * 将 RMS 映射为波形条高度比例(0~1) + * + * 人声经 AGC 后 RMS 多在 0.02~0.2,线性映射会全部挤在低段、看不出起伏; + * 改按 dB(对数)做感知映射:安静贴地、正常说话过半、大声近满, + * 与人耳的响度感知一致,波形高低差异明显。 + */ +export const normalizeWaveLevel = (rms: number): number => { + if (rms <= 0) { + return MIN_LEVEL; + } + const db = 20 * Math.log10(rms); + const ratio = (db - NOISE_FLOOR_DB) / (CEIL_DB - NOISE_FLOOR_DB); + return Math.min(1, Math.max(MIN_LEVEL, ratio)); +}; + +/** 平滑系数:起音快(新峰值立刻反映)、衰减慢(词尾不瞬间塌回) */ +const ATTACK = 0.85; +const RELEASE = 0.4; + +/** + * 相邻两格音量的非对称平滑:上行快、下行慢,更接近语音包络的自然形态 + */ +export const smoothWaveLevel = (prev: number, next: number): number => + prev + (next - prev) * (next > prev ? ATTACK : RELEASE); + +/** + * 初始波形时间线:为空。 + * 录音开始后波形条随时间从右端逐格生长(安静为最低 stub,说话为高条), + * 达到最大可见条数后最旧的从左端滚出——即“从右到左按时间线滚动”。 + */ +export const createEmptyWaveLevels = (): number[] => []; + +/** + * 时间线右端追加一格平滑后的音量: + * - 首格无历史时直接落点(不平滑) + * - 超过最大可见条数时裁掉左端最旧的 + */ +export const appendSmoothedWaveLevel = ( + levels: number[], + nextLevel: number, + maxCount: number = VOICE_INPUT_DEFAULTS.waveBarCount, +): number[] => { + const prev = levels.length > 0 ? levels[levels.length - 1] : nextLevel; + const appended = [...levels, smoothWaveLevel(prev, nextLevel)]; + return appended.length > maxCount + ? appended.slice(appended.length - maxCount) + : appended; +}; diff --git a/qiming/src/components/business-component/index.ts b/qiming/src/components/business-component/index.ts index 149a44b0..5098729c 100644 --- a/qiming/src/components/business-component/index.ts +++ b/qiming/src/components/business-component/index.ts @@ -1,5 +1,58 @@ export { default as AppDevEmptyState } from './AppDevEmptyState'; export { default as CardWrapper } from './CardWrapper'; +export { + default as ChangeFileGitDiffView, + DiffModeEnum, +} from './ChangeFileGitDiffView'; +export type { + ChangeFileGitDiffViewProps, + GitDiffViewTheme, +} from './ChangeFileGitDiffView'; +export { + default as ConversationBottomConsole, + type ConsoleLayoutMode, + type ConversationBottomConsoleDevLogProps, + type ConversationBottomConsoleProps, + type TerminalAppearanceMode, +} from './ConversationBottomConsole'; +export { + default as DevLogActions, + type DevLogActionsProps, +} from './ConversationBottomConsole/DevLogActions'; export { default as CopyToSpaceComponent } from './CopyToSpaceComponent'; export { default as FilePreview } from './FilePreview'; +export { + default as FileTreeGitSourcePanel, + FileTreePanel, + SourceControlPanel, + apiGitAdd, + apiGitCommit, + apiGitLogList, + apiGitRevert, + apiGitStatus, + apiGitUnstage, + useAppDevFileTree, + useSourceControl, +} from './FileTreeGitSourcePanel'; +export { + default as FileTreePreviewPanel, + FileTreeViewPanel, + useFileTreePreviewView, + type FileTreePreviewGitVersionControlProps, + type FileTreePreviewPanelProps, + type FileTreePreviewViewPreview, + type FileTreePreviewViewProps, + type FileTreePreviewViewValue, +} from './FileTreePreviewPanel'; +export { + default as GitVersionRecordPanel, + type GitVersionRecordPanelProps, +} from './GitVersionRecordPanel'; +export { + default as MessageQueuePanel, + useChatMessageQueue, + type QueuedMessage, +} from './MessageQueue'; export { default as PagePreviewIframe } from './PagePreviewIframe'; +export { default as XtermTerminal } from './Terminal'; +export { default as UnifiedChatSession } from './UnifiedChatSession'; diff --git a/qiming/src/constants/agent.constants.tsx b/qiming/src/constants/agent.constants.tsx index 3e8be991..64ea052c 100644 --- a/qiming/src/constants/agent.constants.tsx +++ b/qiming/src/constants/agent.constants.tsx @@ -50,6 +50,11 @@ export const AGENT_ENGINE_OPTIONS = [ }, ]; +/** 智能体是否开启版本管理(Git 源代码管理 / 版本记录) */ +export const isAgentVersionControlEnabled = ( + enableVersionControl?: DefaultSelectedEnum, +) => enableVersionControl === DefaultSelectedEnum.Yes; + // 调用方式 export const CALL_METHOD_OPTIONS = [ { diff --git a/qiming/src/constants/common.constants.ts b/qiming/src/constants/common.constants.ts index 619f15c0..8e1232fc 100644 --- a/qiming/src/constants/common.constants.ts +++ b/qiming/src/constants/common.constants.ts @@ -5,8 +5,13 @@ import { DataTypeEnum } from '@/types/enums/common'; // 文件上传地址 export const UPLOAD_FILE_ACTION = `${process.env.BASE_URL}/api/file/upload`; +// 语音转文字(STT)接口地址:multipart/form-data 上传音频,返回识别文本 +export const AUDIO_STT_URL = `${process.env.BASE_URL}/api/audio/stt`; + // 会话 Connection地址 export const CONVERSATION_CONNECTION_URL = `${process.env.BASE_URL}/api/agent/conversation/chat`; +// 会话流式恢复(sub)地址 +export const CONVERSATION_CHAT_SUB_URL = `${process.env.BASE_URL}/api/agent/conversation/chat/sub`; // 临时会话 Connection地址 export const TEMP_CONVERSATION_CONNECTION_URL = `${process.env.BASE_URL}/api/temp/chat/completions`; diff --git a/qiming/src/constants/feature.constants.ts b/qiming/src/constants/feature.constants.ts new file mode 100644 index 00000000..f4524536 --- /dev/null +++ b/qiming/src/constants/feature.constants.ts @@ -0,0 +1,14 @@ +/** + * 功能开关常量 + * 用于临时启用/关闭尚未全量发布或需快速回滚的能力 + */ + +/** 聊天消息队列:待发送面板、入队拦截、空闲自动消费 */ +export const ENABLE_CHAT_MESSAGE_QUEUE = true; + +/** + * 语音输入:在聊天输入框底栏提供“录音 -> 识别 -> 回填/发送”能力。 + * 默认开启;需整体下线时改为 false 即可——ChatInputVoiceFooter.Right + * 将不再渲染麦克风与语音按钮,业务文件无需任何回改(可插拔)。 + */ +export const ENABLE_VOICE_INPUT = true; diff --git a/qiming/src/constants/home.constants.ts b/qiming/src/constants/home.constants.ts index 6eb3dab9..5cc6b85c 100644 --- a/qiming/src/constants/home.constants.ts +++ b/qiming/src/constants/home.constants.ts @@ -14,6 +14,9 @@ export const TENANT_CONFIG_INFO = 'TENANT_CONFIG_INFO'; // 应用版本号存储 key export const APP_VERSION = 'APP_VERSION'; +// 全局轮询基准间隔(ms):事件收集与会话流式恢复共用 +export const GLOBAL_POLLING_INTERVAL = 5000; + // 推荐聊天主题 export const RECOMMEND_TEXT = [ dict('PC.Constants.Home.exampleQ1'), diff --git a/qiming/src/constants/hook.constants.ts b/qiming/src/constants/hook.constants.ts new file mode 100644 index 00000000..6c5b8298 --- /dev/null +++ b/qiming/src/constants/hook.constants.ts @@ -0,0 +1,109 @@ +/** Hook 事件选项 */ +export const HOOK_EVENT_OPTIONS = [ + { label: 'PreToolUse', value: 'PreToolUse' }, + { label: 'PostToolUse', value: 'PostToolUse' }, + { label: 'PostToolUseFailure', value: 'PostToolUseFailure' }, + { label: 'PostToolBatch', value: 'PostToolBatch' }, + { label: 'PermissionRequest', value: 'PermissionRequest' }, + { label: 'PermissionDenied', value: 'PermissionDenied' }, + { label: 'Stop', value: 'Stop' }, + { label: 'StopFailure', value: 'StopFailure' }, + { label: 'SessionStart', value: 'SessionStart' }, + { label: 'UserPromptSubmit', value: 'UserPromptSubmit' }, + { label: 'UserPromptExpansion', value: 'UserPromptExpansion' }, + { label: 'SubagentStart', value: 'SubagentStart' }, + { label: 'SubagentStop', value: 'SubagentStop' }, + { label: 'TaskCreated', value: 'TaskCreated' }, + { label: 'TaskCompleted', value: 'TaskCompleted' }, + { label: 'PreCompact', value: 'PreCompact' }, + { label: 'PostCompact', value: 'PostCompact' }, +]; + +/** 匹配规则占位符类型 */ +export enum HookMatcherPlaceholderType { + /** 工具名称,如 Bash、Edit|Write、mcp... */ + ToolName = 'ToolName', + /** 不支持匹配规则 */ + NotSupported = 'NotSupported', + /** 错误类型,如 rate_limit、overloaded */ + StopFailure = 'StopFailure', + /** 启动方式,如 startup、resume、clear、compact */ + SessionStart = 'SessionStart', + /** Agent 类型,如 general-purpose、Explore、Plan */ + SubagentStart = 'SubagentStart', +} + +/** 匹配规则字段配置 */ +export interface HookMatcherFieldConfig { + disabled: boolean; + placeholderType: HookMatcherPlaceholderType; +} + +/** 根据 Hook 事件获取匹配规则输入框配置 */ +export const getHookMatcherFieldConfig = ( + event?: string, +): HookMatcherFieldConfig => { + switch (event) { + case 'PreToolUse': + case 'PostToolUse': + case 'PostToolUseFailure': + case 'PermissionRequest': + case 'PermissionDenied': + return { + disabled: false, + placeholderType: HookMatcherPlaceholderType.ToolName, + }; + case 'StopFailure': + return { + disabled: false, + placeholderType: HookMatcherPlaceholderType.StopFailure, + }; + case 'SessionStart': + return { + disabled: false, + placeholderType: HookMatcherPlaceholderType.SessionStart, + }; + case 'SubagentStart': + return { + disabled: false, + placeholderType: HookMatcherPlaceholderType.SubagentStart, + }; + case 'PostToolBatch': + case 'Stop': + case 'UserPromptSubmit': + case 'UserPromptExpansion': + case 'SubagentStop': + case 'TaskCreated': + case 'TaskCompleted': + case 'PreCompact': + case 'PostCompact': + return { + disabled: true, + placeholderType: HookMatcherPlaceholderType.NotSupported, + }; + default: + return { + disabled: false, + placeholderType: HookMatcherPlaceholderType.ToolName, + }; + } +}; + +/** Hook 类型选项 */ +export const HOOK_TYPE_OPTIONS = [ + { label: 'command', value: 'command' }, + { label: 'http', value: 'http' }, + { label: 'mcp_tool', value: 'mcp_tool' }, + { label: 'prompt', value: 'prompt' }, +]; + +/** 按类型生成默认配置 JSON */ +export const getDefaultHookConfigJson = (type: string): string => { + const templates: Record = { + command: { type: 'command', command: '', args: [] }, + http: { type: 'http', url: '', timeout: 30 }, + mcp_tool: { type: 'mcp_tool', server: '', tool: '', input: {} }, + prompt: { type: 'prompt', prompt: '' }, + }; + return JSON.stringify(templates[type] ?? templates.command, null, 2); +}; diff --git a/qiming/src/contexts/CanvasFullscreenContext.tsx b/qiming/src/contexts/CanvasFullscreenContext.tsx new file mode 100644 index 00000000..799019a5 --- /dev/null +++ b/qiming/src/contexts/CanvasFullscreenContext.tsx @@ -0,0 +1,29 @@ +import React, { createContext, ReactNode, useContext } from 'react'; + +/** + * 画布全屏状态上下文 + * + * 仅 AgentFlowCanvas 提供:全屏时控制条恢复原始尺寸, + * 内嵌(默认画布大小)时走紧凑态。未提供时默认 false。 + */ +interface CanvasFullscreenContextType { + fullscreen: boolean; +} + +const CanvasFullscreenContext = createContext({ + fullscreen: false, +}); + +export const useCanvasFullscreen = () => + useContext(CanvasFullscreenContext).fullscreen; + +export const CanvasFullscreenProvider: React.FC<{ + fullscreen: boolean; + children: ReactNode; +}> = ({ fullscreen, children }) => { + return ( + + {children} + + ); +}; diff --git a/qiming/src/contexts/FlowKindContext.tsx b/qiming/src/contexts/FlowKindContext.tsx new file mode 100644 index 00000000..22de44fb --- /dev/null +++ b/qiming/src/contexts/FlowKindContext.tsx @@ -0,0 +1,23 @@ +import { FlowKindEnum } from '@/types/enums/common'; +import React, { createContext, ReactNode, useContext } from 'react'; + +interface FlowKindContextType { + flowKind: FlowKindEnum; +} + +const FlowKindContext = createContext({ + flowKind: FlowKindEnum.Workflow, +}); + +export const useFlowKind = () => useContext(FlowKindContext).flowKind; + +export const FlowKindProvider: React.FC<{ + flowKind: FlowKindEnum; + children: ReactNode; +}> = ({ flowKind, children }) => { + return ( + + {children} + + ); +}; diff --git a/qiming/src/examples/AgentInterventionDemo/index.tsx b/qiming/src/examples/AgentInterventionDemo/index.tsx new file mode 100644 index 00000000..feb47ad2 --- /dev/null +++ b/qiming/src/examples/AgentInterventionDemo/index.tsx @@ -0,0 +1,139 @@ +/** + * AgentIntervention 组件示例页 + * + * 功能:独立展示 ACP 权限审批卡片与 MCP Ask 结构化提问卡片的 UI 与交互(Mock 数据,不依赖后端)。 + * 访问:/examples/agent-intervention-demo + * 模块文档:src/components/business-component/AgentIntervention/README.md + */ +import AcpPermissionCard from '@/components/business-component/AgentIntervention/AcpPermissionCard'; +import McpAskQuestionCard from '@/components/business-component/AgentIntervention/McpAskQuestionCard'; +import type { + AcpPermissionInteraction, + AcpRequestPermissionResponse, +} from '@/components/business-component/AgentIntervention/types/acpIntervention'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '@/components/business-component/AgentIntervention/types/mcpAskIntervention'; +import { Button, Card, Divider, message, Space, Typography } from 'antd'; +import React, { useState } from 'react'; +import { Link } from 'umi'; +import { createAcpDemoList, createMcpDemoList } from './mockData'; + +const { Title, Paragraph } = Typography; + +const AgentInterventionDemoPage: React.FC = () => { + const [acpList, setAcpList] = useState(createAcpDemoList); + const [mcpList, setMcpList] = useState(createMcpDemoList); + + const handleAcpRespond = ( + interaction: AcpPermissionInteraction, + response: AcpRequestPermissionResponse, + ) => { + console.log('[ACP Respond]', interaction.intervention.id, response); + const id = interaction.intervention.id; + + setAcpList((prev) => + prev.map((item) => + item.intervention.id === id + ? { ...item, responseStatus: 'submitting' as const } + : item, + ), + ); + + setTimeout(() => { + setAcpList((prev) => + prev.map((item) => + item.intervention.id === id + ? { ...item, responseStatus: 'submitted' as const } + : item, + ), + ); + const kind = response.outcome.outcome === 'selected' ? '批准' : '取消'; + message.success( + `已${kind}: ${interaction.intervention.acp.request.toolCall.title}`, + ); + }, 1000); + }; + + const handleMcpRespond = ( + interaction: McpAskInteraction, + payload: McpAskRespondPayload, + ) => { + console.log('[MCP Respond]', interaction.toolCallId, payload); + const id = interaction.toolCallId; + + setMcpList((prev) => + prev.map((item) => { + if (item.toolCallId !== id) return item; + let status: McpAskInteraction['responseStatus'] = 'submitted'; + if (payload.action === 'cancel') status = 'cancelled'; + if (payload.action === 'skip') status = 'skipped'; + return { ...item, responseStatus: status, formData: payload.formData }; + }), + ); + message.info(`${interaction.input.title}: ${payload.action}`); + }; + + const resetAll = () => { + setAcpList(createAcpDemoList(true)); + setMcpList(createMcpDemoList()); + message.success('已重置所有状态'); + }; + + return ( +
+ + ← 返回示例中心 + + 干预交互卡片 Demo + + 此页面用于展示 ACP Permission 权限审批和 MCP Ask + 结构化提问卡片的渲染效果。路由:/examples/agent-intervention-demo + + + + + + + + + ACP Permission 权限审批卡片 + + + + {acpList.map((acp) => ( + + handleAcpRespond(acp, response)} + /> + + ))} + + + + + MCP Ask 结构化提问卡片 + + + + {mcpList.map((mcp) => ( + + handleMcpRespond(mcp, payload)} + /> + + ))} + +
+ ); +}; + +export default AgentInterventionDemoPage; diff --git a/qiming/src/examples/AgentInterventionDemo/mockData.ts b/qiming/src/examples/AgentInterventionDemo/mockData.ts new file mode 100644 index 00000000..ce535c60 --- /dev/null +++ b/qiming/src/examples/AgentInterventionDemo/mockData.ts @@ -0,0 +1,367 @@ +/** + * AgentIntervention 示例页 Mock 数据工厂 + */ +import type { AcpPermissionInteraction } from '@/components/business-component/AgentIntervention/types/acpIntervention'; +import type { McpAskInteraction } from '@/components/business-component/AgentIntervention/types/mcpAskIntervention'; + +/** 创建 ACP 权限审批 Mock 交互数据 */ +export function createAcpInteraction( + overrides: Partial = {}, +): AcpPermissionInteraction { + return { + intervention: { + id: `itv-${Date.now()}`, + revision: 1, + kind: 'approval', + status: 'pending', + sessionId: 'sess-demo', + source: 'acp_permission', + engine: 'claude-code', + protocol: 'acp', + callbackTarget: { kind: 'electron', targetId: 'demo-target' }, + schemaRef: 'acp/permission/v1', + acp: { + method: 'session/request_permission', + request: { + sessionId: 'sess-demo', + toolCall: { + toolCallId: 'tc-demo', + title: '执行 bash 命令: npm install', + kind: 'execute', + }, + options: [ + { optionId: 'opt-1', kind: 'allow_once', name: '允许一次' }, + { optionId: 'opt-2', kind: 'allow_always', name: '始终允许' }, + { optionId: 'opt-3', kind: 'reject_once', name: '拒绝一次' }, + { optionId: 'opt-4', kind: 'reject_always', name: '始终拒绝' }, + ], + }, + }, + createdAt: Date.now(), + }, + responseStatus: 'pending', + ...overrides, + }; +} + +/** 文件编辑类 ACP 权限场景 */ +function createAcpFileEditInteraction( + interventionId: string, + toolCallId: string, +): AcpPermissionInteraction { + const base = createAcpInteraction(); + return { + ...base, + intervention: { + ...base.intervention, + id: interventionId, + acp: { + method: 'session/request_permission', + request: { + sessionId: 'sess-demo', + toolCall: { + toolCallId, + title: '编辑文件 src/utils/upload.ts', + kind: 'edit', + }, + options: [ + { optionId: 'opt-a', kind: 'allow_once', name: '允许一次' }, + { optionId: 'opt-r', kind: 'reject_once', name: '拒绝' }, + ], + }, + }, + }, + }; +} + +/** 示例页初始 / 重置用的 ACP 列表 */ +export function createAcpDemoList(reset = false): AcpPermissionInteraction[] { + const stamp = reset ? `-${Date.now()}` : ''; + return [ + createAcpInteraction(), + createAcpFileEditInteraction( + reset ? `itv-reset${stamp}` : 'itv-file-edit', + reset ? `tc-reset${stamp}` : 'tc-file-edit', + ), + ]; +} + +/** 创建 MCP Ask 结构化提问 Mock 交互数据 */ +export function createMcpInteraction( + title: string, + schema: Record, + uiSchema?: Record, + description?: string, +): McpAskInteraction { + return { + toolCallId: `tc-ask-${Date.now()}`, + responseStatus: 'pending', + input: { + toolName: 'nuwax_ask_question', + schemaVersion: 'nuwaclaw.mcp_ask.v1', + requestId: `ask-${Date.now()}`, + revision: 1, + sessionId: 'sess-demo', + title, + description, + ui: { + version: 'nuwaclaw.interaction.v1', + presentation: 'inline', + title, + description, + schema: { + type: 'object', + properties: schema, + required: Object.keys(schema).slice(0, 1), + }, + uiSchema, + submitLabel: '提交', + cancelLabel: '取消', + }, + }, + }; +} + +/** Wizard 分步表单场景 */ +function createMcpWizardInteraction(): McpAskInteraction { + const stamp = Date.now(); + return { + toolCallId: `tc-ask-wizard-${stamp}`, + responseStatus: 'pending', + input: { + toolName: 'nuwax_ask_question', + schemaVersion: 'nuwaclaw.mcp_ask.v1', + requestId: `ask-wizard-${stamp}`, + revision: 1, + sessionId: 'sess-demo', + title: '项目初始化向导', + description: '请按步骤填写项目配置信息。', + ui: { + version: 'nuwaclaw.interaction.v1', + presentation: 'wizard', + title: '项目初始化向导', + description: '请按步骤填写项目配置信息。', + schema: { + type: 'object', + properties: { + projectName: { type: 'string', title: '项目名称' }, + description: { + type: 'string', + title: '项目描述', + maxLength: 500, + }, + framework: { + type: 'string', + title: '前端框架', + enum: ['react', 'vue', 'angular', 'svelte'], + }, + features: { + type: 'array', + title: '功能模块', + items: { + type: 'string', + enum: ['auth', 'api', 'database', 'testing', 'ci'], + }, + }, + deployTarget: { + type: 'string', + title: '部署目标', + enum: ['vercel', 'netlify', 'docker', 'aws', 'gcp'], + }, + notes: { type: 'string', title: '备注' }, + }, + required: ['projectName', 'framework', 'deployTarget'], + }, + uiSchema: { + description: { 'ui:widget': 'textarea' }, + framework: { + 'ui:widget': 'list', + 'ui:options': { + enumNames: ['React', 'Vue', 'Angular', 'Svelte'], + }, + }, + features: { + 'ui:widget': 'checkboxes', + 'ui:options': { + enumNames: [ + '用户认证', + 'API 接口', + '数据库', + '自动化测试', + 'CI/CD', + ], + }, + }, + deployTarget: { + 'ui:widget': 'list', + 'ui:options': { + enumNames: ['Vercel', 'Netlify', 'Docker', 'AWS', 'GCP'], + }, + }, + notes: { 'ui:widget': 'textarea' }, + }, + steps: [ + { + id: 'basic', + title: '基本信息', + description: '填写项目名称和描述', + fields: ['projectName', 'description'], + }, + { + id: 'tech', + title: '技术选型', + description: '选择框架和功能模块', + fields: ['framework', 'features'], + }, + { + id: 'deploy', + title: '部署配置', + description: '选择部署目标和备注', + fields: ['deployTarget', 'notes'], + }, + ], + submitLabel: '完成配置', + cancelLabel: '取消', + nextStepLabel: '下一步', + prevStepLabel: '上一步', + }, + }, + }; +} + +/** 示例页初始 / 重置用的 MCP 列表 */ +export function createMcpDemoList(): McpAskInteraction[] { + return [ + createMcpInteraction( + '请选择继续方式', + { + choice: { + type: 'string', + title: '选项', + enum: ['deploy', 'test', 'cancel'], + }, + notes: { type: 'string', title: '补充说明' }, + checks: { + type: 'array', + title: '检查项', + items: { type: 'string', enum: ['lint', 'unit', 'e2e'] }, + }, + }, + { + choice: { + 'ui:widget': 'radio', + 'ui:options': { enumNames: ['直接部署', '先跑测试', '取消任务'] }, + }, + notes: { 'ui:widget': 'textarea' }, + checks: { + 'ui:widget': 'checkboxes', + 'ui:options': { enumNames: ['代码检查', '单元测试', '端到端测试'] }, + }, + }, + 'Agent 需要你确认下一步操作。', + ), + createMcpInteraction( + '选择技术栈', + { + framework: { + type: 'string', + title: '前端框架', + enum: [ + 'react', + 'vue', + 'angular', + 'svelte', + 'solid', + 'preact', + 'qwik', + 'astro', + ], + }, + language: { + type: 'string', + title: '编程语言', + enum: ['typescript', 'javascript', 'python', 'go', 'rust', 'java'], + }, + }, + { + framework: { + 'ui:widget': 'list', + 'ui:options': { + enumNames: [ + 'React', + 'Vue', + 'Angular', + 'Svelte', + 'SolidJS', + 'Preact', + 'Qwik', + 'Astro', + ], + }, + }, + language: { + 'ui:widget': 'list', + 'ui:options': { + enumNames: [ + 'TypeScript', + 'JavaScript', + 'Python', + 'Go', + 'Rust', + 'Java', + ], + }, + }, + }, + '请选择项目使用的主要技术栈。', + ), + createMcpInteraction( + '提交问题截图', + { + description: { type: 'string', title: '问题描述', maxLength: 500 }, + screenshot: { type: 'string', title: '截图', format: 'data-url' }, + attachments: { + type: 'array', + title: '相关附件', + items: { type: 'string', format: 'data-url' }, + }, + }, + { + description: { 'ui:widget': 'textarea' }, + screenshot: { + 'ui:widget': 'file', + 'ui:options': { accept: 'image/*' }, + }, + attachments: { 'ui:widget': 'file', 'ui:options': { multiple: true } }, + }, + '请上传问题的截图或相关文件,帮助 Agent 更好地理解问题。', + ), + createMcpInteraction( + '部署配置', + { + environment: { + type: 'string', + title: '部署环境', + enum: ['dev', 'staging', 'production'], + }, + region: { + type: 'string', + title: '服务区域', + enum: ['cn-east', 'cn-north', 'us-west', 'eu-central'], + }, + }, + { + environment: { + 'ui:widget': 'select', + 'ui:options': { enumNames: ['开发环境', '预发布环境', '生产环境'] }, + }, + region: { + 'ui:widget': 'select', + 'ui:options': { enumNames: ['华东', '华北', '美西', '欧洲中部'] }, + }, + }, + '请选择部署目标环境和区域。', + ), + createMcpWizardInteraction(), + ]; +} diff --git a/qiming/src/examples/McpAskDuplicateDemo/index.less b/qiming/src/examples/McpAskDuplicateDemo/index.less new file mode 100644 index 00000000..f8ef5f91 --- /dev/null +++ b/qiming/src/examples/McpAskDuplicateDemo/index.less @@ -0,0 +1,68 @@ +.demo-wrap { + max-width: 1200px; + margin: 0 auto; + padding: 16px; +} + +.demo-title { + margin: 12px 0 4px; +} + +.demo-desc { + margin-bottom: 16px; +} + +.session { + height: 660px; + display: flex; + flex-direction: column; + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; + background: #fafafa; +} + +.msg-list { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.msg { + max-width: 80%; + padding: 8px 12px; + border-radius: 12px; + margin-bottom: 8px; + word-break: break-word; + font-size: 14px; + line-height: 1.5; + box-shadow: 0 1px 2px rgba(0, 0, 0, 4%); +} + +.msg-user { + margin-left: auto; + background: #1677ff; + color: #fff; +} + +.msg-assistant { + margin-right: auto; + background: #fff; + border: 1px solid #f0f0f0; +} + +.msg-meta { + margin-top: 4px; + opacity: 0.85; +} + +.intervention-dock { + position: relative; + z-index: 30; + flex-shrink: 0; +} + +.console-card { + position: sticky; + top: 16px; +} diff --git a/qiming/src/examples/McpAskDuplicateDemo/index.tsx b/qiming/src/examples/McpAskDuplicateDemo/index.tsx new file mode 100644 index 00000000..06cdbdb3 --- /dev/null +++ b/qiming/src/examples/McpAskDuplicateDemo/index.tsx @@ -0,0 +1,396 @@ +/** + * MCP Ask 重复询问本地示例页 + * + * 用用户提供的真实 ASK_QUESTION SSE 载荷,模拟两轮同 title 询问, + * 验证第二次 Ask 在第一次 resume 后仍能弹出 Dock 卡片。 + * 访问:/examples/mcp-ask-duplicate-demo + */ +import { AgentInterventionChatLayer } from '@/components/business-component/AgentIntervention'; +import { useActiveInterventionQueue } from '@/components/business-component/AgentIntervention/hooks/useActiveInterventionQueue'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '@/components/business-component/AgentIntervention/types/mcpAskIntervention'; +import { + buildMcpAskResumeMessage, + hasMcpAskResumeMessage, + stripMcpAskResumeDisplayArtifacts, +} from '@/components/business-component/AgentIntervention/utils/mcpAskResumeMessage'; +import { extractMcpAskFormAttachments } from '@/components/business-component/AgentIntervention/utils/normalizeMcpAskFormData'; +import { processInterventionSsePatch } from '@/components/business-component/AgentIntervention/utils/processInterventionSsePatch'; +import { reconcileMcpAskHydratedMessageList } from '@/components/business-component/AgentIntervention/utils/reconcileMcpAskHydratedStatus'; +import { AssistantRoleEnum } from '@/types/enums/agent'; +import { MessageStatusEnum } from '@/types/enums/common'; +import type { + ConversationChatResponse, + MessageInfo, +} from '@/types/interfaces/conversationInfo'; +import { + Button, + Card, + Col, + Descriptions, + Row, + Space, + Tag, + Typography, +} from 'antd'; +import classNames from 'classnames'; +import React, { useCallback, useMemo, useState } from 'react'; +import { Link } from 'umi'; +import styles from './index.less'; +import { + FIRST_EXECUTE_ID, + FIRST_REQUEST_ID, + MCP_ASK_FIRST_SSE, + MCP_ASK_SECOND_SSE, + SECOND_EXECUTE_ID, + SECOND_REQUEST_ID, +} from './mockSsePayloads'; + +const cx = classNames.bind(styles); +const { Title, Paragraph, Text } = Typography; + +let messageSeq = 0; + +function createWelcomeMessage(): MessageInfo { + return { + id: `msg-welcome-${messageSeq}`, + index: messageSeq++, + role: AssistantRoleEnum.ASSISTANT, + text: '点击下方「推送第 1 次 AskQuestion」开始模拟。两次询问 title 均为「补充回复」,但 requestId / executeId 不同。', + status: MessageStatusEnum.Complete, + } as MessageInfo; +} + +function getNextIndex(messages: MessageInfo[]): number { + if (!messages.length) { + return 0; + } + return Math.max(...messages.map((item) => item.index ?? 0)) + 1; +} + +/** 从消息列表收集所有 MCP Ask 交互及所在消息下标 */ +function collectMcpAskRows(messages: MessageInfo[]) { + const rows: Array<{ + messageIndex: number; + interaction: McpAskInteraction; + }> = []; + + messages.forEach((message, messageIndex) => { + message.mcpAskInteractions?.forEach((interaction) => { + rows.push({ messageIndex, interaction }); + }); + }); + + return rows; +} + +const McpAskDuplicateDemo: React.FC = () => { + const [messages, setMessages] = useState([ + createWelcomeMessage(), + ]); + const [firstAskPushed, setFirstAskPushed] = useState(false); + const [firstAskResponded, setFirstAskResponded] = useState(false); + const [secondAskPushed, setSecondAskPushed] = useState(false); + + const activeQueue = useActiveInterventionQueue(messages); + const mcpAskRows = useMemo(() => collectMcpAskRows(messages), [messages]); + + const pushAskSse = useCallback( + (ssePayload: ConversationChatResponse, assistantText: string) => { + setMessages((prev) => { + const nextIndex = getNextIndex(prev); + const assistantMessage = { + id: `msg-assistant-${++messageSeq}`, + index: nextIndex, + role: AssistantRoleEnum.ASSISTANT, + text: assistantText, + status: MessageStatusEnum.Loading, + } as MessageInfo; + + const patched = processInterventionSsePatch( + ssePayload, + assistantMessage, + prev, + ); + + if (!patched) { + return prev; + } + + return [...prev, patched]; + }); + }, + [], + ); + + const handlePushFirst = useCallback(() => { + pushAskSse(MCP_ASK_FIRST_SSE, '(Agent)第 1 次 AskQuestion:补充回复'); + setFirstAskPushed(true); + }, [pushAskSse]); + + const handlePushSecond = useCallback(() => { + pushAskSse(MCP_ASK_SECOND_SSE, '(Agent)第 2 次 AskQuestion:补充回复'); + setSecondAskPushed(true); + }, [pushAskSse]); + + const handleReset = useCallback(() => { + messageSeq = 0; + setMessages([createWelcomeMessage()]); + setFirstAskPushed(false); + setFirstAskResponded(false); + setSecondAskPushed(false); + }, []); + + const handleRespondMcpAsk = useCallback( + (interaction: McpAskInteraction, payload: McpAskRespondPayload) => { + const requestId = interaction.input.requestId; + const resumeText = buildMcpAskResumeMessage(interaction, payload); + const uploadFiles = + payload.files ?? + extractMcpAskFormAttachments( + payload.formData ?? {}, + interaction.input.ui, + ); + const attachments = uploadFiles.map((file) => ({ + fileKey: file.key || file.uid || '', + fileUrl: file.url || '', + fileName: file.name || '', + mimeType: file.type || '', + })); + const resolveStatus = (): McpAskInteraction['responseStatus'] => { + if (payload.action === 'cancel') return 'cancelled'; + if (payload.action === 'skip') return 'skipped'; + return 'submitted'; + }; + + setMessages((prev) => { + const updated = prev.map((message) => { + if ( + !message.mcpAskInteractions?.some( + (item) => item.input.requestId === requestId, + ) + ) { + return message; + } + + return { + ...message, + status: MessageStatusEnum.Complete, + mcpAskInteractions: message.mcpAskInteractions.map((item) => + item.input.requestId === requestId + ? { + ...item, + responseStatus: resolveStatus(), + formData: payload.formData, + } + : item, + ), + }; + }); + + const userMessage = { + id: `msg-user-resume-${requestId}`, + index: getNextIndex(updated), + role: AssistantRoleEnum.USER, + text: resumeText, + attachments: attachments.length ? attachments : undefined, + status: MessageStatusEnum.Complete, + } as MessageInfo; + + const merged = [...updated, userMessage]; + return reconcileMcpAskHydratedMessageList(merged, merged); + }); + + if (requestId === FIRST_REQUEST_ID) { + setFirstAskResponded(true); + } + }, + [], + ); + + return ( +
+ ← 返回示例索引 + + MCP Ask 重复询问 Demo + + + 复现同 title「补充回复」的两次 ASK_QUESTION SSE。第一次提交 resume 后, + 第二次应仍能弹出 Dock 表单。修复前会因 legacy title 匹配误判为已回答。 + + + + +
+
+ {messages.map((message) => { + const isUser = message.role === AssistantRoleEnum.USER; + const displayText = isUser + ? stripMcpAskResumeDisplayArtifacts(message.text) + : message.text; + + return ( +
+ {displayText} + {message.mcpAskInteractions?.length ? ( +
+ + mcpAsk × {message.mcpAskInteractions.length} + +
+ ) : null} +
+ ); + })} +
+ +
+ {}} + onRespondMcpAsk={handleRespondMcpAsk} + /> +
+
+ + + + + + + + 第 1 次已推送 + + + 第 1 次已回复 + + + 第 2 次已推送 + + + Dock 队列 {activeQueue.length} + + + + + + + + + + + 流程:推送第 1 次 → Dock 填写提交 → 推送第 2 次 → Dock + 应再次弹出。 + + + + + + + + + {FIRST_REQUEST_ID} + + + + {FIRST_EXECUTE_ID} + + + + {SECOND_REQUEST_ID} + + + + {SECOND_EXECUTE_ID} + + + + {mcpAskRows.length === 0 ? ( + + 暂无 mcpAskInteractions + + ) : ( + + {mcpAskRows.map(({ messageIndex, interaction }) => { + const resumeMatched = hasMcpAskResumeMessage( + messages, + interaction, + { containingMessageIndex: messageIndex }, + ); + const inQueue = activeQueue.some( + (item) => + item.kind === 'mcp_ask' && + item.interaction.input.requestId === + interaction.input.requestId, + ); + + return ( + + + {interaction.input.title} + + requestId: {interaction.input.requestId} + + + executeId: {interaction.executeId ?? '—'} + + + responseStatus:{' '} + {interaction.responseStatus ?? 'pending'} + + + + hasMcpAskResumeMessage: {String(resumeMatched)} + + + in Dock queue: {String(inQueue)} + + + + + ); + })} + + )} + + + + 更多静态卡片形态见{' '} + + Agent 干预交互卡片 Demo + + + +
+
+ ); +}; + +export default McpAskDuplicateDemo; diff --git a/qiming/src/examples/McpAskDuplicateDemo/mockSsePayloads.ts b/qiming/src/examples/McpAskDuplicateDemo/mockSsePayloads.ts new file mode 100644 index 00000000..d089922f --- /dev/null +++ b/qiming/src/examples/McpAskDuplicateDemo/mockSsePayloads.ts @@ -0,0 +1,133 @@ +/** + * MCP Ask 重复询问场景 — 真实后端 ASK_QUESTION SSE 载荷 + * 两次 title 均为「补充回复」,但 requestId / executeId 不同。 + */ +import { ConversationEventTypeEnum } from '@/types/enums/agent'; +import type { ConversationChatResponse } from '@/types/interfaces/conversationInfo'; + +export const FIRST_REQUEST_ID = '13f030d0c07547fe83fd6d43b624f0e0'; +export const SECOND_REQUEST_ID = 'ed325c9eec724bce95ca6a05974b42d6'; +export const FIRST_EXECUTE_ID = '733e52ee48a8406b8f148de386092f47'; +export const SECOND_EXECUTE_ID = '449db35f985747bf8cd1645627bc2d8c'; + +const sharedFields = [ + { + widget: 'file', + name: 'file', + type: 'string', + title: '补充内容', + required: false, + }, + { + widget: 'text', + name: 'name', + type: 'string', + title: '你的名字', + required: false, + }, + { + widget: 'select', + name: 'hh', + options: [ + { label: '说晚安', value: '说晚安', children: null }, + { label: 'sds', value: 'sds', children: null }, + ], + type: 'string', + title: '调度', + required: false, + }, + { + widget: 'radio', + name: 'ddw', + options: [ + { label: '1', value: '1', children: null }, + { label: '2', value: '2', children: null }, + { label: '3', value: '3', children: null }, + ], + type: 'string', + title: '单选', + required: false, + }, + { + widget: 'checkboxes', + name: 'dx', + options: [ + { label: '1', value: '1', children: null }, + { label: '2', value: '2', children: null }, + { label: '3', value: '3', children: null }, + ], + type: 'string', + title: '多选', + required: false, + }, + { + widget: 'number', + name: 'sz', + type: 'integer', + title: 's z', + required: false, + }, +] as const; + +function buildAskQuestionSsePayload( + requestId: string, + executeId: string, +): ConversationChatResponse { + return { + requestId, + eventType: ConversationEventTypeEnum.PROCESSING, + error: null, + data: { + targetId: null, + name: 'AskQuestion', + originalTitle: null, + type: 'Event', + status: 'EXECUTING', + executingMessage: null, + result: { + id: null, + name: '补充回复', + icon: null, + type: 'Event', + success: true, + error: null, + data: { + schemaVersion: 'nuwax.mcp_ask.v1', + ui: { + presentation: 'inline', + title: '请选择\n\n', + fields: [...sharedFields], + }, + requestId, + description: '请选择\n\n', + title: '补充回复', + revision: 1, + }, + innerExecuteInfo: null, + startTime: null, + endTime: null, + input: null, + executeId, + kind: null, + locations: null, + }, + cardBindConfig: null, + cardData: null, + pageArgConfig: null, + subEventType: 'ASK_QUESTION', + }, + completed: false, + } as ConversationChatResponse; +} + +/** 第 1 次 AskQuestion SSE(用户原始数据) */ +export const MCP_ASK_FIRST_SSE = buildAskQuestionSsePayload( + FIRST_REQUEST_ID, + FIRST_EXECUTE_ID, +); + +/** 第 2 次 AskQuestion SSE(用户原始数据,executeId 不同) */ +export const MCP_ASK_SECOND_SSE = buildAskQuestionSsePayload( + SECOND_REQUEST_ID, + SECOND_EXECUTE_ID, +); diff --git a/qiming/src/examples/MessageQueueDemo.less b/qiming/src/examples/MessageQueueDemo.less new file mode 100644 index 00000000..2bd8587f --- /dev/null +++ b/qiming/src/examples/MessageQueueDemo.less @@ -0,0 +1,127 @@ +.demo-wrap { + max-width: 1200px; + margin: 0 auto; + padding: 16px; +} + +.demo-title { + margin: 12px 0 4px; +} + +.demo-desc { + margin-bottom: 16px; +} + +// ===== 会话容器(复刻 UnifiedChatSession 层级结构)===== +.session { + height: 660px; + display: flex; + flex-direction: column; + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; + background: #fafafa; +} + +.msg-list { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.msg { + max-width: 80%; + padding: 8px 12px; + border-radius: 12px; + margin-bottom: 8px; + word-break: break-word; + font-size: 14px; + line-height: 1.5; + box-shadow: 0 1px 2px rgba(0, 0, 0, 4%); +} + +.msg-user { + margin-left: auto; + background: #1677ff; + color: #fff; +} + +.msg-assistant { + margin-right: auto; + background: #fff; + border: 1px solid #f0f0f0; +} + +// intervention 层:z-index 30 + position,盖过输入区 +.intervention-dock { + position: relative; + z-index: 30; + flex-shrink: 0; +} + +// 输入区:z-index 10(低于 intervention) +.input-area { + position: relative; + z-index: 10; + flex-shrink: 0; + border-top: 1px solid #f0f0f0; + background: #fff; + padding: 8px 12px 12px; +} + +.input-row { + display: flex; + align-items: flex-end; + gap: 8px; + max-width: 800px; + margin: 0 auto; +} + +.send-btn { + flex-shrink: 0; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + color: #1677ff; + border: 1px solid transparent; + transition: all 0.15s ease; + + &:hover { + background: color-mix(in srgb, #1677ff 10%, transparent); + } + + &.stop { + color: #ff4d4f; + + &:hover { + background: color-mix(in srgb, #ff4d4f 10%, transparent); + } + } + + &.disabled { + color: #d9d9d9; + cursor: not-allowed; + } +} + +.queue-wrap { + max-width: 800px; + margin: 0 auto 8px; +} + +// ===== 右侧控制台 ===== +.console-card { + position: sticky; + top: 16px; +} + +.log-item { + padding: 3px 0; + font-size: 12px; + border: none !important; +} diff --git a/qiming/src/examples/MessageQueueDemo.tsx b/qiming/src/examples/MessageQueueDemo.tsx new file mode 100644 index 00000000..9bda1d74 --- /dev/null +++ b/qiming/src/examples/MessageQueueDemo.tsx @@ -0,0 +1,433 @@ +/** + * MessageQueue 消息队列示例页 + * + * 真实会话状态机 + 权限审批 / Ask·Question 卡片 + 待发送队列,全 Mock 驱动。 + * 访问:/examples/message-queue-demo + * + * 状态模型: + * - active(会话活跃 = isConversationActive):由 sendMessage 自动驱动 + * 发送消息 → active=true(AI 处理中)→ 回复完成 → active=false(触发消费下一条) + * - 「模拟阻塞」开关 / 审批 / Ask 卡片:手动让 active=true(模拟会话被占用) + * + * 注:demo 用单信号简化。真实页面(useUnifiedChatQueue)入队判定用合并信号 + * isActive(isConversationActive || taskStatus===EXECUTING,与发送按钮一致), + * 而 auto-consume 触发用纯 isConversationActive,避免 taskStatus 状态机切换的中间空白误消费。 + */ +import { AgentInterventionChatLayer } from '@/components/business-component/AgentIntervention'; +import type { + AcpPermissionInteraction, + AcpRequestPermissionResponse, +} from '@/components/business-component/AgentIntervention/types/acpIntervention'; +import type { + McpAskInteraction, + McpAskRespondPayload, +} from '@/components/business-component/AgentIntervention/types/mcpAskIntervention'; +import MessageQueuePanel, { + useChatMessageQueue, + type QueuedMessage, +} from '@/components/business-component/MessageQueue'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { PlusOutlined, SendOutlined, StopOutlined } from '@ant-design/icons'; +import { + Button, + Card, + Col, + Input, + InputNumber, + List, + Row, + Space, + Switch, + Tag, + Typography, +} from 'antd'; +import classNames from 'classnames'; +import React, { useCallback, useRef, useState } from 'react'; +import { Link } from 'umi'; +import { + createAcpInteraction, + createMcpInteraction, +} from './AgentInterventionDemo/mockData'; +import styles from './MessageQueueDemo.less'; + +const cx = classNames.bind(styles); +const { Title, Paragraph, Text } = Typography; + +interface LogRecord { + key: string; + text: string; +} + +let msgSeq = 0; +const makeUserMessage = (text: string): MessageInfo => + ({ + id: `m-${++msgSeq}`, + role: 'user', + demoText: text, + status: 'complete', + } as any); +const makeAssistantMessage = ( + text: string, + status: any = 'complete', +): MessageInfo => + ({ id: `m-${++msgSeq}`, role: 'assistant', demoText: text, status } as any); + +const REPLY_DELAY = 1200; + +const MessageQueueDemo: React.FC = () => { + // 会话活跃(= isConversationActive):sendMessage 自动驱动,开关/审批可手动占据 + const [active, setActive] = useState(false); + // 队列消费最小间隔(ms),可调,对应会话框的 queueMinConsumeInterval + const [minInterval, setMinInterval] = useState(1200); + const [input, setInput] = useState(''); + const [messages, setMessages] = useState([ + makeAssistantMessage( + '你好!打开「模拟阻塞」开关或点「添加审批 / Ask」让会话进入活跃,连发多条消息即可入队;关闭阻塞后队列会逐条自动发送(每条发送→活跃→回复完成→发下一条)。', + ), + ]); + const [logs, setLogs] = useState([]); + const replyTimerRef = useRef(null); + + const log = useCallback((text: string) => { + setLogs((prev) => + [{ key: `${Date.now()}-${Math.random()}`, text }, ...prev].slice(0, 60), + ); + }, []); + + // mock:真正发送 → 会话活跃 → REPLY_DELAY 后回复完成转空闲(触发消费下一条) + const sendMessage = useCallback( + (messageInfo: string) => { + setMessages((prev) => [...prev, makeUserMessage(messageInfo)]); + setActive(true); + log(`📤 发送:"${messageInfo}" → 会话活跃`); + if (replyTimerRef.current) window.clearTimeout(replyTimerRef.current); + replyTimerRef.current = window.setTimeout(() => { + setMessages((prev) => [ + ...prev, + makeAssistantMessage(`(回复)收到:"${messageInfo}"`), + ]); + setActive(false); + log(`✅ 回复完成 → 会话空闲(触发下一条消费)`); + }, REPLY_DELAY); + }, + [log], + ); + + const runStopConversation = useCallback(() => { + if (replyTimerRef.current) { + window.clearTimeout(replyTimerRef.current); + replyTimerRef.current = null; + } + setActive(false); + log('⏹ 停止会话 → 立即空闲'); + }, [log]); + + const messageQueue = useChatMessageQueue({ + isConversationActive: active, + messageList: messages, + conversationId: 'demo-conv', + sendMessage, + runStopConversation, + minConsumeInterval: minInterval, + }); + + const handleEdit = useCallback( + (qMsg: QueuedMessage) => { + const item = messageQueue.editQueued(qMsg); + if (item) { + setInput(item.text); + log(`✏️ 编辑回填:${item.text}`); + } + }, + [messageQueue, log], + ); + + const handleSendClick = () => { + const value = input.trim(); + if (!value) return; + if (active) log(`📥 入队:"${value}"`); + messageQueue.trySend(value); + setInput(''); + }; + + const showStop = active && !input.trim(); + + const addAcp = () => { + setMessages((prev) => [ + ...prev, + { + ...makeAssistantMessage('我需要执行一个操作,需要你授权:', 'loading'), + acpPermissionInteractions: [createAcpInteraction()], + } as any, + ]); + setActive(true); + log('➕ 添加 ACP 权限审批(会话占用)'); + }; + const addMcp = () => { + setMessages((prev) => [ + ...prev, + { + ...makeAssistantMessage('请回答下面的问题:', 'loading'), + mcpAskInteractions: [ + createMcpInteraction('请填写项目信息', { + projectName: { type: 'string', title: '项目名称' }, + framework: { + type: 'string', + title: '前端框架', + enum: ['React', 'Vue', 'Angular'], + }, + }), + ], + } as any, + ]); + setActive(true); + log('➕ 添加 Ask/Question(会话占用)'); + }; + + const onRespondAcpPermission = useCallback( + ( + interaction: AcpPermissionInteraction, + response: AcpRequestPermissionResponse, + ) => { + const outcomeDesc = + response.outcome.outcome === 'selected' + ? response.outcome.optionId + : 'cancelled'; + log(`✅ 审批响应:${outcomeDesc} → 释放占用`); + setActive(false); + setMessages((prev) => + prev.map((m: any) => + m.acpPermissionInteractions + ? { + ...m, + acpPermissionInteractions: m.acpPermissionInteractions.map( + (i: AcpPermissionInteraction) => + i.intervention.id === interaction.intervention.id + ? { ...i, responseStatus: 'submitted' as const } + : i, + ), + } + : m, + ), + ); + }, + [log], + ); + + const onRespondMcpAsk = useCallback( + (interaction: McpAskInteraction, payload: McpAskRespondPayload) => { + log(`✏️ Ask 响应:${payload.action} → 释放占用`); + setActive(false); + setMessages((prev) => + prev.map((m: any) => + m.mcpAskInteractions + ? { + ...m, + mcpAskInteractions: m.mcpAskInteractions.map( + (i: McpAskInteraction) => + i.input.requestId === interaction.input.requestId + ? { + ...i, + responseStatus: + payload.action === 'cancel' + ? ('cancelled' as any) + : ('submitted' as any), + hasMcpAskResumeMessage: true as any, + } + : i, + ), + } + : m, + ), + ); + }, + [log], + ); + + React.useEffect( + () => () => { + if (replyTimerRef.current) window.clearTimeout(replyTimerRef.current); + }, + [], + ); + + return ( +
+ ← 返回示例索引 + + MessageQueue 消息队列 Demo + + + 真实会话状态机:发送消息后会话自动活跃,AI + 回复完成后转空闲并触发队列消费下一条。「模拟阻塞」/ 审批 / Ask + 卡片会让会话被占用,此时发送的消息进入队列。 + + + + +
+
+ {messages.map((m: any) => ( +
+ {m.demoText} +
+ ))} +
+ +
+ +
+ +
+
+ { + messageQueue.sendNow(m); + log(`⚡ 立即发送:${m.text}`); + }} + onDelete={(id) => { + messageQueue.deleteQueued(id); + log(`❌ 删除:${id}`); + }} + onEdit={handleEdit} + onClear={() => { + messageQueue.clearQueue(); + log('🗑 清空'); + }} + onReorder={(from, to) => { + messageQueue.reorder(from, to); + log(`↕️ 排序:${from} → ${to}`); + }} + /> +
+
+ setInput(e.target.value)} + placeholder={ + active ? '会话活跃中,发送将加入队列…' : '输入消息…' + } + onPressEnter={handleSendClick} + /> + { + if (showStop) { + runStopConversation(); + } else { + handleSendClick(); + } + }} + > + {showStop ? : } + +
+
+
+ + + + + + + + {active ? '会话活跃' : '会话空闲'} + + + 队列 {messageQueue.queue.length} + + + + 模拟阻塞: + { + setActive(v); + log( + v ? '🔒 开启占用(会话活跃)' : '🔓 关闭占用 → 触发消费', + ); + }} + /> + + + 消费间隔(ms): + setMinInterval(v ?? 500)} + /> + + + + + + + + 测试连续消费: + 开「模拟阻塞」→ 连发 3 条(入队)→ 关阻塞 → + 队列逐条发送:每条发送后会话活跃(开关自动 + ON),回复完成后(开关自动 OFF)发下一条。 + + + + + + ( + + {item.text} + + )} + /> + + +
+
+ ); +}; + +export default MessageQueueDemo; diff --git a/qiming/src/examples/VoiceInputDemo/index.less b/qiming/src/examples/VoiceInputDemo/index.less new file mode 100644 index 00000000..e78b6973 --- /dev/null +++ b/qiming/src/examples/VoiceInputDemo/index.less @@ -0,0 +1,35 @@ +@import '@/styles/color'; +@import '@/styles/token'; + +.voice-input-demo { + display: flex; + flex-direction: column; + height: 100vh; + background-color: @colorBgLayout; + overflow: hidden; +} + +.voice-input-demo-header { + flex-shrink: 0; + padding: @paddingMd @paddingLg; + border-bottom: 1px solid @colorSplit; + background-color: @colorBgContainer; +} + +.voice-input-demo-session { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: @paddingMd @paddingLg @paddingLg; +} + +.voice-input-demo-session-inner { + flex: 1; + min-height: 0; + max-width: 960px; + width: 100%; + margin: 0 auto; + display: flex; + flex-direction: column; +} diff --git a/qiming/src/examples/VoiceInputDemo/index.tsx b/qiming/src/examples/VoiceInputDemo/index.tsx new file mode 100644 index 00000000..335f52bc --- /dev/null +++ b/qiming/src/examples/VoiceInputDemo/index.tsx @@ -0,0 +1,134 @@ +/** + * Agent 会话框语音交互示例页 + * + * 使用真实 UnifiedChatSession + ChatInputHomeIndependent(与 Agent 开发页一致), + * 消息发送为本地 Mock,语音录制/转写亦为模拟,无需麦克风与后端。 + * + * 访问:/examples/voice-input-demo + */ +import { UnifiedChatSession } from '@/components/business-component'; +import { DefaultSelectedEnum } from '@/types/enums/agent'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { Alert, Button, message, Space, Switch, Typography } from 'antd'; +import React, { useCallback, useState } from 'react'; +import { Link } from 'umi'; +import styles from './index.less'; +import { + createAssistantReply, + createUserMessage, + DEMO_AGENT_INFO, + DEMO_CONVERSATION_ID, + DEMO_SUGGEST_LIST, +} from './mockData'; + +const { Title, Paragraph, Text } = Typography; + +const VoiceInputDemoPage: React.FC = () => { + const [messageList, setMessageList] = useState([]); + const [selectedModelId, setSelectedModelId] = useState(); + // 真实麦克风模式:录音与波形走真实 PCM 音量(需授权麦克风;转写走真实 STT 接口) + const [useRealMic, setUseRealMic] = useState(false); + + const handleSendMessage = useCallback((text: string) => { + const trimmed = text?.trim(); + if (!trimmed) { + return; + } + + const userMsg = createUserMessage(trimmed); + const assistantMsg = createAssistantReply(trimmed); + + setMessageList((prev) => [...prev, userMsg, assistantMsg]); + message.success('已发送(演示 Mock)'); + }, []); + + const handleClear = useCallback(async () => { + setMessageList([]); + message.info('已清空演示会话'); + }, []); + + return ( +
+
+ + ← 返回示例中心 + + +
+ + Agent 会话框 · 语音交互演示 + + + 路由:/examples/voice-input-demo + +
+ + + 真实麦克风 + + + + +
+ +
  • 麦克风在发送按钮左侧,点击开始录音
  • +
  • + 录音时底栏仅保留左侧「+」「清空」,中间为波形动效,右侧为停止/发送 +
  • +
  • + {useRealMic + ? '当前为真实麦克风模式:波形随你的声音起伏,转写走真实 STT 接口(消息发送仍为本地模拟)' + : '当前为模拟模式:波形为伪随机动效、不访问麦克风;打开右上角「真实麦克风」可体验真实音量驱动'} +
  • + + } + /> +
    + +
    +
    + +
    +
    +
    + ); +}; + +export default VoiceInputDemoPage; diff --git a/qiming/src/examples/VoiceInputDemo/mockData.ts b/qiming/src/examples/VoiceInputDemo/mockData.ts new file mode 100644 index 00000000..6274be20 --- /dev/null +++ b/qiming/src/examples/VoiceInputDemo/mockData.ts @@ -0,0 +1,71 @@ +import type { UnifiedAgentInfo } from '@/components/business-component/UnifiedChatSession/types'; +import { + AssistantRoleEnum, + DefaultSelectedEnum, + MessageTypeEnum, +} from '@/types/enums/agent'; +import { MessageStatusEnum } from '@/types/enums/common'; +import { AgentTypeEnum } from '@/types/enums/space'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; + +/** 演示用会话 ID */ +export const DEMO_CONVERSATION_ID = 900001; + +/** 演示用 Agent 信息(与真实 Agent 会话框配置字段对齐) */ +export const DEMO_AGENT_INFO: UnifiedAgentInfo = { + id: DEMO_CONVERSATION_ID, + name: '语音交互演示 Agent', + type: AgentTypeEnum.ConversationAgent, + allowChooseMode: DefaultSelectedEnum.Yes, + hasPermission: true, + openingChatMsg: + '这是真实 Agent 会话输入框的演示环境。点击发送按钮左侧的麦克风开始录音;录音结束后可选择「停止回填」或「停止发送」。', +}; + +/** 开场推荐问题 */ +export const DEMO_SUGGEST_LIST = [ + '语音回填和自动发送有什么区别?', + '帮我用一句话说明当前需求', +]; + +let messageSeq = 1; + +/** 创建一条演示用用户消息 */ +export const createUserMessage = (text: string): MessageInfo => { + const id = messageSeq++; + return { + id, + index: id, + role: AssistantRoleEnum.USER, + messageType: MessageTypeEnum.USER, + text, + time: new Date().toISOString(), + componentExecutedList: [], + tenantId: 1, + senderType: 'USER', + senderId: 'demo-user', + userId: 1, + agentId: DEMO_CONVERSATION_ID, + status: MessageStatusEnum.Complete, + }; +}; + +/** 创建一条演示用助手回复(Mock,不请求后端) */ +export const createAssistantReply = (userText: string): MessageInfo => { + const id = messageSeq++; + return { + id, + index: id, + role: AssistantRoleEnum.ASSISTANT, + messageType: MessageTypeEnum.ASSISTANT, + text: `(演示回复)已收到你的消息:「${userText}」。真实环境中这里会由 Agent 流式返回。`, + time: new Date().toISOString(), + componentExecutedList: [], + tenantId: 1, + senderType: 'AGENT', + senderId: 'demo-agent', + userId: 1, + agentId: DEMO_CONVERSATION_ID, + status: MessageStatusEnum.Complete, + }; +}; diff --git a/qiming/src/hooks/useAppDevChat.ts b/qiming/src/hooks/useAppDevChat.ts index 0da942e0..9f412d43 100644 --- a/qiming/src/hooks/useAppDevChat.ts +++ b/qiming/src/hooks/useAppDevChat.ts @@ -77,6 +77,41 @@ interface UseAppDevChatProps { onRestartDevServer?: () => Promise; // 新增:重启开发服务器回调 } +/** + * 显式发送消息时的覆盖参数 + * 用于新建项目页跳转到 AppDev 后自动发送首条消息,避免 setChatInput 后立即发送的状态竞态。 + */ +interface SendMessageOverrides { + /** 本次发送使用的提示词,不传则使用当前输入框内容 */ + prompt?: string; + /** 本次发送使用的编码模型 ID,不传则使用当前选择器模型 */ + selectedModelId?: number | null; + /** 本次发送使用的多模态模型 ID,不传则使用当前选择器模型 */ + multiModelId?: number | null; + /** 本次发送使用的数据源列表,不传则使用当前勾选的数据源 */ + selectedDataResources?: DataResource[]; +} + +/** + * 从外部携带完整上下文直接发送消息的参数 + */ +interface SendMessageWithPromptParams extends SendMessageOverrides { + /** 用户提示词 */ + prompt: string; + /** 展示层附件 */ + attachments?: Attachment[]; + /** ai-chat 文件附件 */ + attachmentFiles?: FileStreamAttachment[]; + /** ai-chat 原型图片附件 */ + attachmentPrototypeImages?: FileStreamAttachment[]; + /** @ 提及的文件、目录或数据源 */ + selectedMentions?: MentionItem[]; + /** 技能 ID 列表 */ + skillIds?: number[]; + /** 请求 ID,用于关联 SSE 流 */ + requestId?: string; +} + export const useAppDevChat = ({ projectId, selectedModelId, // 新增 @@ -690,6 +725,14 @@ export const useAppDevChat = ({ params.attachments, params.attachment_files, params.attachment_prototype_images, + generateRequestId(), + undefined, + params.skill_ids, + { + prompt: params.prompt, + selectedModelId: params.chat_model_id, + multiModelId: params.multi_model_id, + }, ); //继续发送消息 }); } else { @@ -742,12 +785,20 @@ export const useAppDevChat = ({ requestId: string = generateRequestId(), // 生成临时request_id selectedMentions?: MentionItem[], // 新增:@ 提及的项(包含通过 @ 选择的数据源) skillIds?: number[], // 新增:@ 提及的技能 ID 列表 + overrides?: SendMessageOverrides, ) => { try { + const promptText = overrides?.prompt ?? chatInput; + const currentSelectedModelId = + overrides?.selectedModelId ?? selectedModelId; + const currentMultiModelId = overrides?.multiModelId ?? multiModelId; + const currentSelectedDataResources = + overrides?.selectedDataResources ?? selectedDataResources; + // 数据源数据结构提取 // 1. 从 props 传入的 selectedDataResources 中提取 const propsDataSources: DataSourceSelection[] = - selectedDataResources + currentSelectedDataResources .filter((item) => item.isSelected) ?.map((resource) => { return { @@ -780,10 +831,10 @@ export const useAppDevChat = ({ ); const aiChatParams = { - prompt: chatInput, + prompt: promptText, project_id: projectId, - chat_model_id: selectedModelId, // 编码模型ID - multi_model_id: multiModelId, // 多模态模型ID(视觉模型ID,可选) + chat_model_id: currentSelectedModelId, // 编码模型ID + multi_model_id: currentMultiModelId, // 多模态模型ID(视觉模型ID,可选) attachments, // 附件文件列表 attachment_files: attachmentFiles, @@ -795,7 +846,7 @@ export const useAppDevChat = ({ // 添加用户消息(包含附件和数据源) const userMessage = createUserMessage( - chatInput, + promptText, requestId, attachments, attachmentPrototypeImages, @@ -812,7 +863,15 @@ export const useAppDevChat = ({ } catch (error) { if (error && (error as any).code === AGENT_SERVICE_RUNNING) { showStopAgentServiceModal(projectId, () => { - sendMessageAndConnectSSE(); //继续发送消息 + sendMessageAndConnectSSE( + attachments, + attachmentFiles, + attachmentPrototypeImages, + generateRequestId(), + selectedMentions, + skillIds, + overrides, + ); //继续发送消息 }); } else { setIsChatLoading(false); @@ -823,6 +882,7 @@ export const useAppDevChat = ({ chatInput, projectId, selectedModelId, // 新增:添加 selectedModelId 依赖 + multiModelId, initializeAppDevSSEConnection, showStopAgentServiceModal, selectedDataResources, @@ -875,6 +935,46 @@ export const useAppDevChat = ({ [chatInput, sendMessageAndConnectSSE], ); + /** + * 使用外部传入的完整上下文直接发送消息 + * 主要用于从新建项目页进入 AppDev 后自动发送首条需求,避免依赖输入框异步状态。 + */ + const sendMessageWithPrompt = useCallback( + async ({ + prompt, + attachments, + attachmentFiles, + attachmentPrototypeImages, + requestId, + selectedMentions, + skillIds, + selectedModelId: overrideSelectedModelId, + multiModelId: overrideMultiModelId, + selectedDataResources: overrideSelectedDataResources, + }: SendMessageWithPromptParams) => { + if (!prompt.trim()) { + message.warning(t('PC.Pages.AppDevChat.inputMessageRequired')); + return; + } + + sendMessageAndConnectSSE( + attachments, + attachmentFiles, + attachmentPrototypeImages, + requestId, + selectedMentions, + skillIds, + { + prompt, + selectedModelId: overrideSelectedModelId, + multiModelId: overrideMultiModelId, + selectedDataResources: overrideSelectedDataResources, + }, + ); + }, + [sendMessageAndConnectSSE], + ); + /** * 清理 AppDev SSE 连接 */ @@ -1020,6 +1120,7 @@ export const useAppDevChat = ({ setChatMessages, // 新增:设置聊天消息的方法 sendChat, sendMessage, // 新增:支持附件的发送消息方法 + sendMessageWithPrompt, cancelChat, cleanupAppDevSSE, // loadHistorySession, diff --git a/qiming/src/hooks/useAppDevFileManagement.ts b/qiming/src/hooks/useAppDevFileManagement.ts index 24c20029..c44831ca 100644 --- a/qiming/src/hooks/useAppDevFileManagement.ts +++ b/qiming/src/hooks/useAppDevFileManagement.ts @@ -2,16 +2,13 @@ * AppDev 文件管理相关 Hook */ -import { - ERROR_MESSAGES, - SUCCESS_MESSAGES, - UI_CONSTANTS, -} from '@/constants/appDevConstants'; +import { ERROR_MESSAGES, UI_CONSTANTS } from '@/constants/appDevConstants'; import { getFileContent, getProjectContent, keepAlive, submitFilesUpdate, + submitSpecifiedFilesUpdate, uploadSingleFile, } from '@/services/appDev'; import { dict } from '@/services/i18nRuntime'; @@ -21,7 +18,6 @@ import type { FileTreeState, } from '@/types/interfaces/appDev'; import { - debounce, findFileNode, isFileModified as isContentModified, isPreviewableFile, @@ -29,13 +25,17 @@ import { treeToFlatList, } from '@/utils/appDevUtils'; import { message } from 'antd'; +import debounce from 'lodash/debounce'; import { useCallback, useEffect, useRef, useState } from 'react'; interface UseAppDevFileManagementProps { projectId: string; onFileSelect?: (fileId: string) => void; onFileContentChange?: (fileId: string, content: string) => void; - isChatLoading?: boolean; // 新增:是否正在AI聊天加载中 + /** 文件保存到服务端成功后的回调(如刷新 Git status 列表) */ + onSaveSuccess?: (fileId: string) => void | Promise; + /** 文件树写操作成功后的回调(如刷新 Git status 列表) */ + onFileMutationSuccess?: () => void | Promise; hasPermission?: boolean; // 新增:是否有权限访问项目 } @@ -50,8 +50,8 @@ export const useAppDevFileManagement = ({ projectId, onFileSelect, onFileContentChange, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isChatLoading = false, + onSaveSuccess, + onFileMutationSuccess, hasPermission = true, }: UseAppDevFileManagementProps) => { // 文件树状态 @@ -148,7 +148,8 @@ export const useAppDevFileManagement = ({ // 检查是否是新的扁平格式 if (Array.isArray(files) && files.length > 0 && files[0].name) { - treeData = transformFlatListToTree(files); + // 不做前端过滤,按后端返回的完整文件列表构建树(含 .gitignore 等点文件) + treeData = transformFlatListToTree(files, false); } else if (Array.isArray(files)) { // 如果是原有的树形格式,直接使用 treeData = files as FileNode[]; @@ -250,6 +251,16 @@ export const useAppDevFileManagement = ({ // 检查文件是否已经有content数据,如果有则不需要调用API const fileNode = findFileNode(fileId, fileTreeState.data); + // 选中文件夹时仅更新选中态,不加载文件内容 + if (fileNode?.type === 'folder') { + setFileContentState((prev) => ({ + ...prev, + isLoadingFileContent: false, + fileContentError: null, + })); + return; + } + // 检查文件是否已经有内容数据 const hasContent = fileNode && @@ -394,81 +405,49 @@ export const useAppDevFileManagement = ({ ); /** - * 保存文件 + * 保存文件(防抖,仅提交指定文件) + * @param options.fileId 文件路径 ID + * @param options.content 文件内容 */ - const saveFile = useCallback(async (): Promise => { - const { selectedFile, fileContent } = fileContentState; + const saveFile = useCallback( + debounce(async (options: { fileId: string; content: string }) => { + const { fileId: selectedFile, content: fileContent } = options; - if (!selectedFile || !projectId) return false; + if (!selectedFile || !projectId) return; - try { - setFileContentState((prev) => ({ ...prev, isSavingFile: true })); + try { + setFileContentState((prev) => ({ ...prev, isSavingFile: true })); - // 首先获取最新的项目内容 - const projectResponse = await getProjectContent(projectId); - - if ( - !projectResponse || - projectResponse.code !== '0000' || - !projectResponse.data - ) { - return false; - } - - // 将项目数据转换为扁平列表格式 - let filesList: any[] = []; - const files = projectResponse.data.files; - - if (Array.isArray(files) && files.length > 0 && files[0].name) { - // 如果是扁平格式,直接使用 - filesList = [...files]; - } else if (Array.isArray(files)) { - // 如果是树形格式,转换为扁平列表 - filesList = treeToFlatList(files as FileNode[]); - } - - // 更新要保存的文件内容 - const updatedFilesList = filesList.map((file) => { - if (file.name === selectedFile) { - return { - ...file, + const response = await submitSpecifiedFilesUpdate(projectId, [ + { + name: selectedFile, contents: fileContent, binary: false, sizeExceeded: false, - }; + operation: 'modify', + }, + ]); + + if (response.success && response.code === '0000') { + keepAlive(projectId); + setFileContentState((prev) => ({ + ...prev, + originalFileContent: fileContent, + isFileModified: false, + isSavingFile: false, + })); + await onSaveSuccess?.(selectedFile); + return; } - return file; - }); - // 保存文件 - const response = await submitFilesUpdate(projectId, updatedFilesList); - - if (response.success && response.code === '0000') { - keepAlive(projectId); - // 保存成功后更新状态 - setFileContentState((prev) => ({ - ...prev, - originalFileContent: fileContent, - isFileModified: false, - isSavingFile: false, - })); - - message.success(SUCCESS_MESSAGES.FILE_SAVED); - // 文件保存成功 - return true; - } else { - return false; + setFileContentState((prev) => ({ ...prev, isSavingFile: false })); + } catch (error) { + console.error('Failed to save file', error); + setFileContentState((prev) => ({ ...prev, isSavingFile: false })); } - } catch (error) { - // 保存文件失败 - // message.error( - // `Failed to save file: ${error instanceof Error ? error.message : 'Unknown error'}`, - // ); - console.error('Failed to save file', error); - setFileContentState((prev) => ({ ...prev, isSavingFile: false })); - return false; - } - }, [fileContentState, projectId]); + }, 500), + [projectId, onSaveSuccess], + ); /** * 取消编辑 @@ -509,7 +488,6 @@ export const useAppDevFileManagement = ({ try { // 上传文件 - const result = await uploadSingleFile({ file, projectId, @@ -517,10 +495,9 @@ export const useAppDevFileManagement = ({ }); if (result?.success) { - // message.success(`Uploaded to ${filePath.trim()}`); - // 上传成功后重新加载文件树(与删除文件逻辑保持一致) await loadFileTree(true, true); + await onFileMutationSuccess?.(); // 文件上传成功后不自动选中,让用户自己选择要查看的文件 // 文件上传成功,文件树已更新 @@ -536,7 +513,7 @@ export const useAppDevFileManagement = ({ return false; } }, - [projectId, loadFileTree], + [projectId, loadFileTree, onFileMutationSuccess], ); /** @@ -554,6 +531,16 @@ export const useAppDevFileManagement = ({ }); }, []); + /** + * 折叠全部文件夹 + */ + const collapseAllFolders = useCallback(() => { + setFileTreeState((prev) => ({ + ...prev, + expandedFolders: new Set(), + })); + }, []); + /** * 切换文件树折叠状态 */ @@ -571,7 +558,7 @@ export const useAppDevFileManagement = ({ ); /** - * 删除文件或文件夹(通过全量更新方式) + * 删除文件或文件夹(通过指定文件修改方式,只提交被删除的文件) */ const deleteFileItem = useCallback( async (fileId: string): Promise => { @@ -587,37 +574,23 @@ export const useAppDevFileManagement = ({ return false; } - // 删除文件 - - // 获取当前完整文件列表 - const flatFileList = treeToFlatList(fileTreeState.data); - - // 过滤掉要删除的文件及其所有子文件(如果是文件夹) - const filteredList = flatFileList.filter((file) => { - // 如果是文件本身,直接删除 - if (file.name === fileId) { - // 从列表中移除文件 - return false; - } - // 如果是文件夹,删除其所有子文件 - if (fileNode.type === 'folder') { - const shouldRemove = file.name.startsWith(fileNode.path + '/'); - if (shouldRemove) { - // 从列表中移除子文件 - } - return !shouldRemove; - } - return true; - }); - - // 提交更新后的文件列表 - const result = await submitFilesUpdate(projectId, filteredList); + // 只提交被删除的文件/文件夹本身 + const result = await submitSpecifiedFilesUpdate(projectId, [ + { + name: fileNode.path, + binary: false, + sizeExceeded: false, + operation: 'delete', + isDir: fileNode.type === 'folder', + }, + ]); if (result?.success) { // 文件删除成功 // 删除成功后重新加载文件树 await loadFileTree(true, true); keepAlive(projectId); + await onFileMutationSuccess?.(); // 如果删除的是当前选中的文件,清空选择 if (fileContentState.selectedFile === fileId) { @@ -640,6 +613,7 @@ export const useAppDevFileManagement = ({ fileContentState.selectedFile, loadFileTree, setSelectedFile, + onFileMutationSuccess, ], ); @@ -690,6 +664,144 @@ export const useAppDevFileManagement = ({ [], ); + /** + * 在文件树中插入临时节点并进入重命名(新建文件/文件夹) + */ + const insertTempNodeForCreate = useCallback( + (parentNode: FileNode | null, type: 'file' | 'folder'): FileNode => { + const parentPath = parentNode?.path || null; + const tempIdSuffix = `__new__${Date.now()}`; + const fullPath = parentPath + ? `${parentPath}/${tempIdSuffix}` + : tempIdSuffix; + + const newNode: FileNode = { + id: fullPath, + name: '', + type, + path: fullPath, + children: type === 'folder' ? [] : undefined, + parentPath, + content: type === 'file' ? '' : undefined, + lastModified: Date.now(), + status: 'create', + }; + + const insertNodeAtTop = ( + nodes: FileNode[], + targetParentId: string | null, + ): FileNode[] => { + if (!targetParentId) { + return [newNode, ...nodes]; + } + + return nodes.map((node) => { + if (node.id === targetParentId) { + const children = node.children || []; + return { + ...node, + children: [newNode, ...children], + }; + } + + if (node.children?.length) { + return { + ...node, + children: insertNodeAtTop(node.children, targetParentId), + }; + } + + return node; + }); + }; + + setFileTreeState((prev) => { + const nextExpandedFolders = new Set(prev.expandedFolders); + if (parentNode?.id) { + nextExpandedFolders.add(parentNode.id); + } + + return { + ...prev, + data: insertNodeAtTop(prev.data, parentNode?.id || null), + expandedFolders: nextExpandedFolders, + }; + }); + + return newNode; + }, + [], + ); + + /** + * 移除新建流程中的临时节点(取消重命名时) + */ + const removeTempNode = useCallback((nodeId: string) => { + if (!nodeId.includes('__new__')) { + return; + } + + const removeNodeFromTree = ( + nodes: FileNode[], + targetId: string, + ): FileNode[] => + nodes + .filter((node) => node.id !== targetId) + .map((node) => + node.children?.length + ? { ...node, children: removeNodeFromTree(node.children, targetId) } + : node, + ); + + setFileTreeState((prev) => ({ + ...prev, + data: removeNodeFromTree(prev.data, nodeId), + })); + }, []); + + /** + * 创建文件或文件夹(确认临时节点名称后,通过 specified-files-update 提交) + */ + const createFileItem = useCallback( + async (node: FileNode, newName: string): Promise => { + if (!projectId) { + removeTempNode(node.id); + return false; + } + + const trimmedName = newName.trim(); + if (!trimmedName) { + removeTempNode(node.id); + return false; + } + + const parentPath = node.parentPath || ''; + const newPath = parentPath ? `${parentPath}/${trimmedName}` : trimmedName; + + try { + await submitSpecifiedFilesUpdate(projectId, [ + { + name: newPath, + contents: node.type === 'file' ? node.content || '' : '', + binary: false, + sizeExceeded: false, + operation: 'create', + isDir: node.type === 'folder', + }, + ]); + + await loadFileTree(true, true); + keepAlive(projectId); + await onFileMutationSuccess?.(); + return true; + } catch (error) { + removeTempNode(node.id); + return false; + } + }, + [projectId, loadFileTree, removeTempNode, onFileMutationSuccess], + ); + /** * 重命名文件或文件夹 */ @@ -798,6 +910,7 @@ export const useAppDevFileManagement = ({ await loadFileTree(true, true); keepAlive(projectId); + await onFileMutationSuccess?.(); // 如果重命名的是当前选中的文件,更新选中状态 if (fileContentState.selectedFile === fileId) { @@ -808,25 +921,15 @@ export const useAppDevFileManagement = ({ } } - // message.success(`Rename succeeded: ${fileNode.name} -> ${newName.trim()}`); return true; } else { // 重命名文件失败,重新加载文件树以恢复原状态 await loadFileTree(true, true); - message.error(dict('PC.Hooks.UseAppDevFileManagement.renameFailed')); return false; } } catch (error) { // 重命名文件异常,重新加载文件树以恢复原状态 await loadFileTree(true, true); - message.error( - dict( - 'PC.Hooks.UseAppDevFileManagement.renameFailedWithError', - error instanceof Error - ? error.message - : dict('PC.Common.Global.unknownError'), - ), - ); return false; } }, @@ -836,6 +939,7 @@ export const useAppDevFileManagement = ({ fileContentState.selectedFile, loadFileTree, setSelectedFile, + onFileMutationSuccess, ], ); @@ -852,6 +956,7 @@ export const useAppDevFileManagement = ({ fileTreeState, loadFileTree, toggleFolder, + collapseAllFolders, toggleFileTreeCollapse, // 文件内容相关 @@ -861,7 +966,6 @@ export const useAppDevFileManagement = ({ switchToFile, saveFile, cancelEdit, - // updateFileClassName, // 文件上传相关 uploadSingleFileToServer, @@ -869,6 +973,9 @@ export const useAppDevFileManagement = ({ // 文件操作相关 deleteFileItem, renameFileItem, + insertTempNodeForCreate, + removeTempNode, + createFileItem, // 文件树初始化 loading 状态 isFileTreeInitializing, diff --git a/qiming/src/hooks/useAppDevInitialAutoSend.ts b/qiming/src/hooks/useAppDevInitialAutoSend.ts new file mode 100644 index 00000000..9c6f9e1b --- /dev/null +++ b/qiming/src/hooks/useAppDevInitialAutoSend.ts @@ -0,0 +1,283 @@ +/** + * AppDev 初始自动发送 Hook + * 用于承接“新建项目 -> 网页应用 -> 详细开发页”的首条需求透传。 + */ +import { createPageHandoffKey } from '@/models/pageHandoffContext'; +import type { + Attachment, + FileStreamAttachment, +} from '@/types/interfaces/appDev'; +import type { UploadFileInfo } from '@/types/interfaces/common'; +import { + DataResourceStatus, + DataResourceType, + type DataResource, +} from '@/types/interfaces/dataResource'; +import { generateRequestId } from '@/utils/chatUtils'; +import { useEffect, useMemo, useRef } from 'react'; +import { useModel } from 'umi'; + +const APP_DEV_INITIAL_PAYLOAD_SCOPE = 'appDevInitialPayload'; + +/** + * 生成 AppDev 首条消息透传上下文 key + * @param projectId AppDev 项目 ID + * @returns 页面间透传上下文 key + */ +export const createAppDevInitialPayloadKey = ( + projectId?: string | number, +): string => { + return createPageHandoffKey(APP_DEV_INITIAL_PAYLOAD_SCOPE, projectId); +}; + +/** + * 新建项目页透传给 AppDev 的首条消息上下文 + * 该状态仅保存在 SPA 内存中,刷新页面后会自然丢失,避免重复自动发送。 + */ +export interface AppDevInitialPayload { + /** 用户在新建项目页输入的提示词 */ + message?: string; + /** 用户在新建项目页上传的附件 */ + files?: UploadFileInfo[]; + /** 用户通过 @ 或组件选择带来的技能 ID */ + skillIds?: number[]; + /** 用户在新建项目页选择的模型 ID */ + modelId?: number; + /** 用户在新建项目页选择的工具/组件信息 */ + infos?: any[]; + /** 用户在新建项目页选择的电脑 ID;当前 AppDev ai-chat 请求体暂未消费,仅保留类型说明 */ + selectedComputerId?: string; + /** 用户在新建项目页选择的 Agent 模式;当前 AppDev ai-chat 请求体暂未消费,仅保留类型说明 */ + agentMode?: string; +} + +interface UseAppDevInitialAutoSendParams { + /** 当前页面项目 ID */ + projectId?: string; + /** 当前项目 ID 是否有效 */ + hasValidProjectId: boolean; + /** 当前用户是否拥有项目权限 */ + hasPermission?: boolean; + /** AppDev 聊天 Hook 返回值 */ + chat: any; + /** AppDev 模型选择器 Hook 返回值 */ + modelSelector: any; +} + +/** + * 判断路由 state 中是否包含值得自动发送的首条内容 + * @param state 新建项目页透传过来的路由状态 + * @returns 是否存在提示词、附件或技能 + */ +const hasInitialMessagePayload = (state?: AppDevInitialPayload | null) => { + return Boolean( + state && + (state.message?.trim() || state.files?.length || state.skillIds?.length), + ); +}; + +/** + * 将新建项目输入框上传的文件转换为 AppDev 聊天展示附件 + * @param files 新建项目页上传文件列表 + * @returns AppDev 消息列表可展示的附件列表 + */ +const toChatAttachments = (files?: UploadFileInfo[]): Attachment[] => { + return (files || []) + .filter((file) => file?.url) + .map((file) => { + const baseContent = { + id: file.uid || file.key || file.url, + filename: file.name, + mime_type: file.type, + source: { + source_type: 'Url', + data: { + url: file.url, + mime_type: file.type, + }, + }, + }; + + if (file.type?.startsWith('image')) { + return { + type: 'Image', + content: { + dimensions: { + width: file.width || 0, + height: file.height || 0, + }, + ...baseContent, + }, + } as Attachment; + } + + return { + type: 'Document', + content: { + size: file.size, + ...baseContent, + }, + } as Attachment; + }); +}; + +/** + * 将新建项目输入框上传的文件转换为 ai-chat 接口附件 + * @param files 新建项目页上传文件列表 + * @returns ai-chat 接口需要的文件流附件列表 + */ +const toFileStreamAttachments = ( + files?: UploadFileInfo[], +): FileStreamAttachment[] => { + return (files || []) + .filter((file) => file?.url) + .map((file) => ({ + url: file.url, + mimeType: file.type, + fileName: file.name, + fileKey: file.key || file.uid, + })); +}; + +/** + * 尝试将新建项目页选择的工具组件转换为 AppDev 数据源 + * @param infos 新建项目页透传的工具/组件信息 + * @returns 可传给 ai-chat 的已选数据源 + */ +const toSelectedDataResources = (infos?: any[]): DataResource[] => { + return (infos || []) + .map((item): DataResource | null => { + const id = item?.targetId ?? item?.id; + const name = item?.name || item?.title; + if (!id || !name) { + return null; + } + + const rawType = `${item?.targetType || item?.type || ''}`.toLowerCase(); + const type = rawType.includes('plugin') + ? DataResourceType.PLUGIN + : DataResourceType.WORKFLOW; + + return { + id, + name, + type, + isSelected: true, + status: DataResourceStatus.ACTIVE, + description: item?.description, + icon: item?.icon, + }; + }) + .filter((item): item is DataResource => Boolean(item)); +}; + +/** + * 承接新建项目页透传的首条消息并自动发送一次 + * @param params Hook 参数 + * @returns void + */ +export const useAppDevInitialAutoSend = ({ + projectId, + hasValidProjectId, + hasPermission, + chat, + modelSelector, +}: UseAppDevInitialAutoSendParams) => { + const { getContext, clearContext } = useModel('pageHandoffContext'); + const hasAutoSentRef = useRef(false); + + const handoffKey = useMemo( + () => createAppDevInitialPayloadKey(projectId), + [projectId], + ); + + const initialState = useMemo(() => { + return getContext(handoffKey) as AppDevInitialPayload | undefined; + }, [getContext, handoffKey]); + + const initialDataResources = useMemo( + () => toSelectedDataResources(initialState?.infos), + [initialState?.infos], + ); + + useEffect(() => { + if ( + !hasInitialMessagePayload(initialState) || + !initialState?.modelId || + !modelSelector?.models?.chatModelList?.length + ) { + return; + } + + const exists = modelSelector.models.chatModelList.some( + (model: { id: number }) => model.id === initialState.modelId, + ); + if (exists && modelSelector.selectedModelId !== initialState.modelId) { + modelSelector.selectModel(initialState.modelId); + } + }, [ + initialState?.modelId, + initialState, + modelSelector?.models, + modelSelector?.selectedModelId, + ]); + + useEffect(() => { + if (hasAutoSentRef.current) { + return; + } + + if ( + !hasValidProjectId || + !projectId || + !hasPermission || + chat.isChatLoading || + !hasInitialMessagePayload(initialState) + ) { + return; + } + + const routeModelId = initialState?.modelId; + const chatModelList = modelSelector?.models?.chatModelList || []; + const isRouteModelAvailable = + routeModelId && + chatModelList.some((model: { id: number }) => model.id === routeModelId); + const selectedModelId = isRouteModelAvailable + ? routeModelId + : modelSelector?.selectedModelId; + if (!selectedModelId) { + return; + } + + hasAutoSentRef.current = true; + + const files = initialState?.files || []; + const requestId = generateRequestId(); + const prompt = initialState?.message || ''; + + // 首条消息直接带完整上下文发送,避免先写入输入框再触发发送造成状态竞态。 + chat.sendMessageWithPrompt({ + prompt, + requestId, + selectedModelId, + attachments: toChatAttachments(files), + attachmentFiles: toFileStreamAttachments(files), + skillIds: initialState?.skillIds || [], + selectedDataResources: initialDataResources, + }); + + // 首条消息上下文只消费一次;刷新页面后 model 本身也会重置,不会重复发送。 + clearContext(handoffKey); + }, [ + hasValidProjectId, + projectId, + hasPermission, + chat.isChatLoading, + chat.sendMessageWithPrompt, + initialState, + initialDataResources, + clearContext, + handoffKey, + modelSelector?.selectedModelId, + ]); +}; diff --git a/qiming/src/hooks/useAppDevModelSelector.ts b/qiming/src/hooks/useAppDevModelSelector.ts index fedf192f..d63da891 100644 --- a/qiming/src/hooks/useAppDevModelSelector.ts +++ b/qiming/src/hooks/useAppDevModelSelector.ts @@ -16,7 +16,7 @@ import { useCallback, useEffect, useState } from 'react'; export const useAppDevModelSelector = ( spaceId: number, - projectId: string, + projectId?: string, hasPermission: boolean = true, ) => { // 模型对象(包含编码模型列表、视觉模型列表) @@ -34,7 +34,7 @@ export const useAppDevModelSelector = ( * 加载模型列表 */ const loadModels = useCallback(async () => { - if (!projectId) return; + if (!spaceId) return; setIsLoadingModels(true); try { @@ -86,7 +86,7 @@ export const useAppDevModelSelector = ( } finally { setIsLoadingModels(false); } - }, [projectId, spaceId]); + }, [spaceId]); /** * 选择编码模型 @@ -110,10 +110,10 @@ export const useAppDevModelSelector = ( // 组件初始化时加载模型列表 useEffect(() => { - if (projectId && hasPermission) { + if (spaceId && hasPermission) { loadModels(); } - }, [projectId, hasPermission]); // 移除 loadModels 依赖,避免重复执行 + }, [spaceId, hasPermission]); // 移除 loadModels 依赖,避免重复执行 return { models, diff --git a/qiming/src/hooks/useAppDevProjectInfo.ts b/qiming/src/hooks/useAppDevProjectInfo.ts index 33122f59..779f054c 100644 --- a/qiming/src/hooks/useAppDevProjectInfo.ts +++ b/qiming/src/hooks/useAppDevProjectInfo.ts @@ -4,12 +4,8 @@ */ import { getProjectInfo } from '@/services/appDev'; -import { dict, getCurrentLang } from '@/services/i18nRuntime'; -import type { - ProjectDetailData, - VersionInfoItem, -} from '@/types/interfaces/appDev'; -import { getJsLocale } from '@/utils/i18nAdapters'; +import { dict } from '@/services/i18nRuntime'; +import type { ProjectDetailData } from '@/types/interfaces/appDev'; import { useCallback, useEffect, useMemo, useState } from 'react'; /** @@ -38,14 +34,6 @@ export interface UseAppDevProjectInfoReturn { refreshProjectInfo: () => Promise; /** 检查是否有更新未部署 */ hasUpdates: boolean; - /** 版本列表 */ - versionList: VersionInfoItem[]; - /** 获取操作类型文本 */ - getActionText: (action: string) => string; - /** 获取操作类型颜色 */ - getActionColor: (action: string) => string; - /** 格式化版本时间 */ - formatVersionTime: (time: string) => string; /** 是否有权限访问项目 */ hasPermission: boolean; } @@ -143,88 +131,6 @@ export const useAppDevProjectInfo = ( projectInfoState.projectInfo?.buildVersion, ]); - /** - * 版本列表(按版本号降序排列) - */ - const versionList = useMemo(() => { - if (!projectInfoState.projectInfo?.versionInfo) { - return []; - } - - return [...projectInfoState.projectInfo.versionInfo].sort( - (a, b) => b.version - a.version, - ); - }, [projectInfoState.projectInfo]); - - /** - * 获取操作类型文本 - * @param action 操作类型 - * @returns 操作类型文本 - */ - const getActionText = useCallback((action: string): string => { - switch (action) { - case 'chat': - return dict('PC.Hooks.UseAppDevProjectInfo.actionChat'); - case 'submit_files_update': - return dict('PC.Hooks.UseAppDevProjectInfo.actionFileUpdate'); - case 'upload_single_file': - return dict('PC.Hooks.UseAppDevProjectInfo.actionUploadSingleFile'); - case 'create_project': - return dict('PC.Hooks.UseAppDevProjectInfo.actionCreateProject'); - case 'build': - return dict('PC.Hooks.UseAppDevProjectInfo.actionBuild'); - case 'deploy': - return dict('PC.Hooks.UseAppDevProjectInfo.actionDeploy'); - case 'upload': - return dict('PC.Hooks.UseAppDevProjectInfo.actionUploadProject'); - case 'rollback_version': - return dict('PC.Hooks.UseAppDevProjectInfo.actionVersionRollback'); - default: - return dict('PC.Hooks.UseAppDevProjectInfo.actionUnknown'); - } - }, []); - - /** - * 获取操作类型颜色 - * @param action 操作类型 - * @returns 操作类型颜色 - */ - const getActionColor = useCallback((action: string): string => { - switch (action) { - case 'chat': - return 'blue'; - case 'submit_files_update': - return 'green'; - case 'build': - return 'orange'; - case 'deploy': - return 'purple'; - default: - return 'default'; - } - }, []); - - /** - * 格式化版本时间 - * @param time 时间字符串 - * @returns 格式化后的时间 - */ - const formatVersionTime = useCallback((time: string): string => { - try { - const date = new Date(time); - return date.toLocaleString(getJsLocale(getCurrentLang()), { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); - } catch (error) { - return time; - } - }, []); - // 当项目ID变化时,自动获取项目详情 useEffect(() => { if (projectId) { @@ -236,10 +142,6 @@ export const useAppDevProjectInfo = ( projectInfoState, refreshProjectInfo, hasUpdates, - versionList, - getActionText, - getActionColor, - formatVersionTime, hasPermission: projectInfoState.hasPermission, }; }; diff --git a/qiming/src/hooks/useAuthProtectedImageSrc.ts b/qiming/src/hooks/useAuthProtectedImageSrc.ts new file mode 100644 index 00000000..e7b7a65e --- /dev/null +++ b/qiming/src/hooks/useAuthProtectedImageSrc.ts @@ -0,0 +1,72 @@ +import { + fetchAuthProtectedFileBlobUrl, + isAuthProtectedFileUrl, +} from '@/utils/authProtectedFileUrl'; +import { useEffect, useState } from 'react'; + +interface UseAuthProtectedImageSrcResult { + /** 可直接用于 img / antd Image 的地址 */ + displaySrc?: string; + loading: boolean; + error: boolean; +} + +/** + * 展示受保护文件图片:公开 URL 原样返回,/api/f/ 等需鉴权地址走 Bearer fetch + blob。 + */ +export function useAuthProtectedImageSrc( + remoteUrl: string | undefined, +): UseAuthProtectedImageSrcResult { + const [displaySrc, setDisplaySrc] = useState(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + if (!remoteUrl?.trim()) { + setDisplaySrc(undefined); + setLoading(false); + setError(false); + return; + } + + if (!isAuthProtectedFileUrl(remoteUrl)) { + setDisplaySrc(remoteUrl); + setLoading(false); + setError(false); + return; + } + + let cancelled = false; + let blobUrl: string | undefined; + + setLoading(true); + setError(false); + setDisplaySrc(undefined); + + fetchAuthProtectedFileBlobUrl(remoteUrl) + .then((objectUrl) => { + if (cancelled) { + URL.revokeObjectURL(objectUrl); + return; + } + blobUrl = objectUrl; + setDisplaySrc(objectUrl); + setLoading(false); + }) + .catch(() => { + if (!cancelled) { + setError(true); + setLoading(false); + } + }); + + return () => { + cancelled = true; + if (blobUrl) { + URL.revokeObjectURL(blobUrl); + } + }; + }, [remoteUrl]); + + return { displaySrc, loading, error }; +} diff --git a/qiming/src/hooks/useChatFinishedWhenListExecuting.ts b/qiming/src/hooks/useChatFinishedWhenListExecuting.ts new file mode 100644 index 00000000..c0fece70 --- /dev/null +++ b/qiming/src/hooks/useChatFinishedWhenListExecuting.ts @@ -0,0 +1,40 @@ +import { TaskStatus } from '@/types/enums/agent'; +import { + hasExecutingTaskInList, + subscribeChatFinished, + type ChatFinishedPayload, +} from '@/utils/conversationTaskStatusSync'; +import { useEffect } from 'react'; + +export type { ChatFinishedPayload }; + +export interface UseChatFinishedWhenListExecutingOptions { + /** + * 会话列表(侧栏历史、OpenApp 等) + * 仅当列表中存在 taskStatus === EXECUTING 时才订阅 ChatFinished + */ + conversationList?: Array<{ taskStatus?: TaskStatus }> | null; + /** ChatFinished 回调,通常来自 conversationHistory.handleConversationUpdate */ + onChatFinished: (data: ChatFinishedPayload) => void; + /** 额外开关,默认 true */ + enabled?: boolean; +} + +/** + * 会话列表侧栏:存在执行中任务时订阅 ChatFinished,任务结束后刷新列表项状态 + * + * 用于 HomeSection、OpenApp BaseTemplate 等展示历史会话列表的场景, + * 与 subscribeChatFinishedTaskSync(单会话 taskStatus 同步)互补。 + */ +export function useChatFinishedWhenListExecuting({ + conversationList, + onChatFinished, + enabled = true, +}: UseChatFinishedWhenListExecutingOptions): void { + useEffect(() => { + if (!enabled || !hasExecutingTaskInList(conversationList)) { + return; + } + return subscribeChatFinished(onChatFinished); + }, [conversationList, onChatFinished, enabled]); +} diff --git a/qiming/src/hooks/useConversation.ts b/qiming/src/hooks/useConversation.ts index 29ae35b8..68f7ac92 100644 --- a/qiming/src/hooks/useConversation.ts +++ b/qiming/src/hooks/useConversation.ts @@ -1,3 +1,4 @@ +import type { AgentMode } from '@/components/business-component/AgentIntervention'; import { apiAgentConversationCreate } from '@/services/agentConfig'; import { AgentDetailDto, @@ -27,6 +28,7 @@ const useConversation = () => { message: string; files?: UploadFileInfo[]; infos?: AgentSelectedComponentInfo[]; + selectedComputerId?: string; // 默认智能体详情 defaultAgentDetail?: AgentDetailDto; // 变量参数 @@ -37,6 +39,8 @@ const useConversation = () => { skillIds?: number[]; // 模型 ID modelId?: number; + // 智能体模式 + agentMode?: AgentMode; }, ) => { const variableParams = attach?.variableParams; diff --git a/qiming/src/hooks/useConversationScrollDetection.ts b/qiming/src/hooks/useConversationScrollDetection.ts index 854abf4c..9010ebb2 100644 --- a/qiming/src/hooks/useConversationScrollDetection.ts +++ b/qiming/src/hooks/useConversationScrollDetection.ts @@ -87,7 +87,9 @@ export const useConversationScrollDetection = ( // 增加 1px 的阈值补偿,提高触控板滚动的灵敏度 const isScrollingUp = scrollTop < lastScrollTopRef.current - 1; - if (isScrollingUp) { + // 仅在平滑滚动 (smooth) 过程中允许用户向上滑动打断自动滚动。 + // 瞬间滚动 (instant) 是一步到位的,其间产生的 scrollTop 减小多为浏览器排版或重绘抖动,忽略打断判定。 + if (isScrollingUp && isProgrammatic === 'smooth') { // 用户试图向上滚动,立即禁用自动滚动 allowAutoScrollRef.current = false; // 清除滚动定时器 @@ -142,13 +144,30 @@ export const useConversationScrollDetection = ( handleScrollThrottled(); }; + // 原生滚轮事件监听,一旦滚轮向上滚动,说明用户显式向上操作,立即打断自动置底 + const wheelHandler = (e: WheelEvent) => { + if (e.deltaY < 0) { + allowAutoScrollRef.current = false; + // 清除滚动定时器 + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = null; + } + setShowScrollBtn(true); + } + }; + messageView.addEventListener('scroll', scrollHandler, { passive: true, }); + messageView.addEventListener('wheel', wheelHandler, { + passive: true, + }); // 组件卸载时移除滚动事件监听器 return () => { messageView.removeEventListener('scroll', scrollHandler); + messageView.removeEventListener('wheel', wheelHandler); }; }, [ messageViewTarget, diff --git a/qiming/src/hooks/useEventPolling.ts b/qiming/src/hooks/useEventPolling.ts index cd838c14..57ba6da1 100644 --- a/qiming/src/hooks/useEventPolling.ts +++ b/qiming/src/hooks/useEventPolling.ts @@ -1,4 +1,7 @@ -import { APP_VERSION } from '@/constants/home.constants'; +import { + APP_VERSION, + GLOBAL_POLLING_INTERVAL, +} from '@/constants/home.constants'; import { apiClearEvent, apiCollectEvent, @@ -104,7 +107,7 @@ export default function useEventPolling(): React.ReactElement | null { apiCollectEvent, { loading: false, - pollingInterval: 5000, // 轮询间隔,单位ms + pollingInterval: GLOBAL_POLLING_INTERVAL, // 全局轮询间隔 // 在屏幕不可见时,暂时暂停定时任务。 pollingWhenHidden: false, // 轮询错误重试次数。如果设置为 -1,则无限次 diff --git a/qiming/src/hooks/useExecutingTaskStatusPoll.ts b/qiming/src/hooks/useExecutingTaskStatusPoll.ts new file mode 100644 index 00000000..bd63b74b --- /dev/null +++ b/qiming/src/hooks/useExecutingTaskStatusPoll.ts @@ -0,0 +1,50 @@ +import { MessageStatusEnum, ProcessingEnum } from '@/types/enums/common'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; + +/** 默认检查最近 N 条消息中的 processing 执行态 */ +const DEFAULT_RECENT_MESSAGE_COUNT = 5; + +/** 消息列表末尾是否仍在流式输出(Loading / Incomplete) */ +export function hasActiveStreamingInMessages( + messageList: MessageInfo[] | undefined | null, +): boolean { + if (!messageList?.length) { + return false; + } + const lastMessage = messageList[messageList.length - 1]; + return ( + lastMessage.status === MessageStatusEnum.Loading || + lastMessage.status === MessageStatusEnum.Incomplete + ); +} + +/** + * 最近若干条消息中是否存在 processingList 仍在 EXECUTING 的块。 + * 流式 chunk 间 message.status 可能短暂为 null,但工具/页面调用仍在执行。 + */ +export function hasExecutingProcessingInRecentMessages( + messageList: MessageInfo[] | undefined | null, + recentCount = DEFAULT_RECENT_MESSAGE_COUNT, +): boolean { + if (!messageList?.length) { + return false; + } + const recentMessages = messageList.slice(-recentCount); + return recentMessages.some((message) => + message.processingList?.some( + (item) => item.status === ProcessingEnum.EXECUTING, + ), + ); +} + +/** + * 消息列表是否表明会话仍在流式/处理中(与 model isConversationActive 对齐的兜底信号) + */ +export function isSessionStreamBusy( + messageList: MessageInfo[] | undefined | null, +): boolean { + return ( + hasActiveStreamingInMessages(messageList) || + hasExecutingProcessingInRecentMessages(messageList) + ); +} diff --git a/qiming/src/hooks/useInitProjectMetadata.ts b/qiming/src/hooks/useInitProjectMetadata.ts new file mode 100644 index 00000000..656dd089 --- /dev/null +++ b/qiming/src/hooks/useInitProjectMetadata.ts @@ -0,0 +1,56 @@ +import { AgentComponentTypeEnum } from '@/types/enums/agent'; +import { applyGeneratedIcon } from '@/utils/applyGeneratedIcon'; +import { fetchGeneratedMetadata } from '@/utils/generatedMetadata'; +import { useEffect, useRef } from 'react'; +import { history, useLocation } from 'umi'; + +interface UseInitProjectMetadataProps { + targetType: AgentComponentTypeEnum; + targetId: number; + onSuccess?: () => void; +} + +/** + * Prompt 创建跳转后:generate-info 仅写入 icon(描述空时回填),不覆盖 name + */ +export const useInitProjectMetadata = ({ + targetType, + targetId, + onSuccess, +}: UseInitProjectMetadataProps) => { + const location = useLocation(); + const hasInitRef = useRef(false); + + useEffect(() => { + const state = (location.state || history.location.state) as + | { message?: string } + | undefined; + const prompt = state?.message?.trim(); + + if ( + history.action === 'PUSH' && + prompt && + targetId && + !hasInitRef.current + ) { + hasInitRef.current = true; + + const initMetadata = async () => { + try { + const meta = await fetchGeneratedMetadata(prompt); + if (meta) { + await applyGeneratedIcon(targetType, targetId, meta); + onSuccess?.(); + } + } catch (error) { + console.error( + `Failed to initialize metadata for ${targetType}:`, + error, + ); + } + }; + + initMetadata(); + } + }, [targetType, targetId, location.state, onSuccess]); +}; diff --git a/qiming/src/hooks/useMcp.tsx b/qiming/src/hooks/useMcp.tsx index f3ad4f8c..0447560c 100644 --- a/qiming/src/hooks/useMcp.tsx +++ b/qiming/src/hooks/useMcp.tsx @@ -1,4 +1,5 @@ import McpCollapseComponentList from '@/components/McpCollapseComponentList'; +import { useGuardedSubmit } from '@/components/business-component/GuardedFormModal'; import TooltipIcon from '@/components/custom/TooltipIcon'; import { MCP_COLLAPSE_COMPONENT_LIST } from '@/constants/mcp.constants'; import { dict } from '@/services/i18nRuntime'; @@ -34,9 +35,15 @@ const useMcp = () => { const [openAddComponent, setOpenAddComponent] = useState(false); // 是否部署 const withDeployRef = useRef(false); + const { beginSubmit, abortSubmit, isSubmitting } = useGuardedSubmit( + saveLoading || saveDeployLoading, + ); // 保存MCP服务 const handleSave = (withDeploy: boolean = false) => { + if (!beginSubmit()) { + return; + } withDeployRef.current = withDeploy; form.submit(); }; @@ -181,6 +188,8 @@ const useMcp = () => { handleSave, withDeployRef, collapseList, + abortSubmit, + isSubmitting, }; }; diff --git a/qiming/src/hooks/useMessageEventDelegate.ts b/qiming/src/hooks/useMessageEventDelegate.ts index aac9069c..4406545a 100644 --- a/qiming/src/hooks/useMessageEventDelegate.ts +++ b/qiming/src/hooks/useMessageEventDelegate.ts @@ -52,10 +52,6 @@ export const useMessageEventDelegate = ({ handledEventsRef.current.delete(eventKey); }, 3000); - console.log('[Event Delegate] Triggering event:', { - eventType, - data: dataStr, - }); // 解析 data let parsedData: Record = {}; @@ -78,7 +74,6 @@ export const useMessageEventDelegate = ({ return; } - console.log('[Event Delegate] Event configuration found:', eventConfig); // 根据配置类型执行相应动作 switch (eventConfig.type) { @@ -114,11 +109,6 @@ export const useMessageEventDelegate = ({ ? `${eventConfig.basePath}${pageUrl}` : `${process.env.BASE_URL}${pageUrl}`; - console.log('[Event Delegate] Opening page:', { - uri: fullUri, - params, - name: eventConfig.name, - }); // 调用页面预览 showPagePreview({ @@ -146,10 +136,6 @@ export const useMessageEventDelegate = ({ return; } - console.log( - '[Event Delegate] Opening external link:', - eventConfig.url, - ); window.open(eventConfig.url, '_blank'); break; } @@ -201,12 +187,10 @@ export const useMessageEventDelegate = ({ // 添加事件监听器 container.addEventListener('click', handleClick); - console.log('[Event Delegate] Event delegate initialized'); // 清理函数 return () => { container.removeEventListener('click', handleClick); - console.log('[Event Delegate] Event delegate cleaned up'); }; }, [containerRef, handleEventClick]); diff --git a/qiming/src/hooks/useResumeStreamHandlers.ts b/qiming/src/hooks/useResumeStreamHandlers.ts new file mode 100644 index 00000000..cd1a6050 --- /dev/null +++ b/qiming/src/hooks/useResumeStreamHandlers.ts @@ -0,0 +1,170 @@ +import { CONVERSATION_CHAT_SUB_URL } from '@/constants/common.constants'; +import { ACCESS_TOKEN } from '@/constants/home.constants'; +import { + AssistantRoleEnum, + ConversationEventTypeEnum, + MessageModeEnum, + MessageTypeEnum, +} from '@/types/enums/agent'; +import { MessageStatusEnum } from '@/types/enums/common'; +import type { + ConversationChatParams, + ConversationChatResponse, + MessageInfo, +} from '@/types/interfaces/conversationInfo'; +import { createSSEConnection } from '@/utils/fetchEventSourceConversationInfo'; +import dayjs from 'dayjs'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { useCallback, useRef } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * 会话流式恢复(sub)的 SSE 订阅 handlers(共享) + * + * conversationInfo / conversationAgent 两个 model 的状态是隔离的,但「订阅 sub 流、 + * 用 handleChangeMessageList 重建执行中的助手消息」这段逻辑完全相同,集中在此避免双份维护漂移。 + * + * 各 model 只需把自身的 setMessageList / handleChangeMessageList / messageViewRef / + * allowAutoScrollRef / resetResumeMessageState 传入即可。refs(abort 句柄、占位 id)由本 hook 持有。 + */ +export interface UseResumeStreamHandlersDeps { + setMessageList: Dispatch>; + handleChangeMessageList: ( + params: ConversationChatParams, + res: ConversationChatResponse, + currentMessageId: string, + ) => void; + messageViewRef: MutableRefObject; + allowAutoScrollRef: MutableRefObject; + /** + * 重置 model 内会被 handleChangeMessageList 写入的流式状态(如 messageIdRef)。 + * 在 sub 开/关时调用,避免恢复流的工作流多步骤输出因上次残留 id 误插重复行。 + */ + resetResumeMessageState?: () => void; +} + +export function useResumeStreamHandlers(deps: UseResumeStreamHandlersDeps) { + const { + setMessageList, + messageViewRef, + allowAutoScrollRef, + resetResumeMessageState, + } = deps; + + // sub 专用 abort 句柄:独立于各 model 的 abortConnectionRef,避免与 live 发送互相覆盖 + const resumeAbortRef = useRef<(() => void) | null>(null); + // 本次恢复已追加的占位 id(防重复追加) + const resumeMessageIdRef = useRef(null); + // 用 ref 持有最新的 handleChangeMessageList:onMessage 是异步回调,若直接闭包捕获会拿到 + // 首次渲染的旧版本(其读取的 conversationInfo?.agent?.hideDesktop 等会过期)。改读 ref.current。 + const handleChangeMessageListRef = useRef(deps.handleChangeMessageList); + handleChangeMessageListRef.current = deps.handleChangeMessageList; + + // 中断会话流式恢复(sub)连接,并重置占位记忆 + const abortResumeStream = useCallback(() => { + if (resumeAbortRef.current) { + resumeAbortRef.current(); + resumeAbortRef.current = null; + } + resumeMessageIdRef.current = null; + }, []); + + // 追加一条空白 assistant 占位消息用于接收 sub 流式重建,返回其 id。 + // 仅复用「本次恢复已追加的占位」(resumeMessageIdRef 记忆),【不】复用历史里的任何消息—— + // 否则会把别的(崩溃/旧)任务残留的 Incomplete 气泡当成占位,把本任务输出合并进去。 + // 按从头重放语义,历史不含执行中消息,故始终追加新占位。 + const ensureResumeAssistantPlaceholder = useCallback( + (currentList: MessageInfo[]): string => { + if ( + resumeMessageIdRef.current && + currentList.some((m) => m.id === resumeMessageIdRef.current) + ) { + return resumeMessageIdRef.current; + } + const placeholderId = uuidv4(); + resumeMessageIdRef.current = placeholderId; + const placeholder = { + role: AssistantRoleEnum.ASSISTANT, + type: MessageModeEnum.CHAT, + text: '', + think: '', + time: dayjs().toString(), + id: placeholderId, + messageType: MessageTypeEnum.ASSISTANT, + status: MessageStatusEnum.Loading, + } as MessageInfo; + setMessageList((prev) => [...(prev || []), placeholder]); + return placeholderId; + }, + [], + ); + + // 订阅 EXECUTING 会话的流式恢复(sub)接口:用与 live chat 相同的 handleChangeMessageList 重建执行中的助手消息。 + const resumeConversationStream = useCallback( + ( + conversationId: number | string, + currentList: MessageInfo[], + onClose?: () => void, + ) => { + abortResumeStream(); + // 开流前重置 model 的流式状态(messageIdRef 等),保证重放从头正确,不受上次残留影响 + resetResumeMessageState?.(); + const currentMessageId = ensureResumeAssistantPlaceholder(currentList); + const token = localStorage.getItem(ACCESS_TOKEN) ?? ''; + resumeAbortRef.current = createSSEConnection({ + url: `${CONVERSATION_CHAT_SUB_URL}/${conversationId}`, + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json, text/plain, */*', + }, + onMessage: (res: ConversationChatResponse) => { + // 读 ref.current 取最新的 handleChangeMessageList,避免 stale 闭包 + handleChangeMessageListRef.current( + { conversationId } as ConversationChatParams, + res, + currentMessageId, + ); + // 会话执行失败(ERROR):主动断开 sub,使轮询恢复——FAILED 不属于「执行中」, + // 也要继续轮询检测后续重试等状态变化(轮询只在「执行中 / document.hidden」时暂停) + if (res?.eventType === ConversationEventTypeEnum.ERROR) { + abortResumeStream(); + } + // 流式恢复期间跟随自动滚动 + if (allowAutoScrollRef.current) { + requestAnimationFrame(() => { + const element = messageViewRef?.current; + if (element) { + // 标记程序滚动,避免被 useConversationScrollDetection 误判为用户滚动 + // 而把 allowAutoScrollRef 置 false,导致恢复流「滚一半就停」 + // (与 useUnifiedChatScroll.performScroll 的标志写法保持一致) + (element as any).__isProgrammaticScroll = true; + element.scrollTo({ + top: element.scrollHeight, + behavior: 'instant', + }); + setTimeout(() => { + (element as any).__isProgrammaticScroll = false; + }, 100); + } + }); + } + }, + onClose: () => { + resumeAbortRef.current = null; + // 关闭时再次重置,避免恢复流的残留 id 影响后续 live 发送 + resetResumeMessageState?.(); + onClose?.(); + }, + }); + }, + [ + abortResumeStream, + ensureResumeAssistantPlaceholder, + resetResumeMessageState, + ], + ); + + // 仅暴露外部需要的两个 action;ensureResumeAssistantPlaceholder 仅供内部使用(避免死公共接口) + return { resumeConversationStream, abortResumeStream }; +} diff --git a/qiming/src/hooks/useSubscription.ts b/qiming/src/hooks/useSubscription.ts index 7f5a3acb..196ab5a5 100644 --- a/qiming/src/hooks/useSubscription.ts +++ b/qiming/src/hooks/useSubscription.ts @@ -1,8 +1,5 @@ -import { - apiCreateAgentSubscriptionOrder, - apiGetAgentSubscriptionOrderCashier, - apiGetAgentSubscriptionPlanList, -} from '@/pages/EditAgent/services/agent-subscription-plan'; +import { apiGetAgentSubscriptionPlanList } from '@/pages/EditAgent/services/agent-subscription-plan'; +import { useSubscriptionPurchase } from '@/pages/MorePage/MySubscriptions/hooks/useSubscriptionPurchase'; import { apiQueryToolPricing } from '@/pages/SpaceResource/services/resource'; import { ResourcePricingConfigInfo, @@ -17,12 +14,14 @@ import { apiGetMySubscription } from '@/services/subscriptionService'; import { BizTypeEnum } from '@/types/interfaces/subscription'; import { message } from 'antd'; import { useCallback, useState } from 'react'; -import { useRequest } from 'umi'; +import useRequestPromiseBridge from './useRequestPromiseBridge'; /** * 智能体订阅(套餐列表、「我的订阅」当前智能体数据) */ const useSubscription = () => { + const { handlePaySubscription } = useSubscriptionPurchase(); + // 智能体订阅计划列表 const [agentSubscriptionPlans, setAgentSubscriptionPlans] = useState< SubscriptionPlanInfo[] @@ -34,22 +33,20 @@ const useSubscription = () => { >([]); // 查询目标对象定价配置 - const { run: loadTargetPricing, loading: loadingTargetPricing } = useRequest( - apiQueryToolPricing, - { + const { runWithPromise: loadTargetPricing, loading: loadingTargetPricing } = + useRequestPromiseBridge(apiQueryToolPricing, { manual: true, loadingDelay: 300, onSuccess: (data: ResourcePricingConfigInfo) => { setTargetSubscriptionPlans(data?.plans || []); }, - }, - ); + }); // 查询订阅计划列表 const { - run: loadAgentSubscriptionPlans, + runWithPromise: loadAgentSubscriptionPlans, loading: loadingAgentSubscriptionPlans, - } = useRequest(apiGetAgentSubscriptionPlanList, { + } = useRequestPromiseBridge(apiGetAgentSubscriptionPlanList, { manual: true, loadingDelay: 500, onSuccess: (data: SubscriptionPlanInfo[]) => @@ -59,9 +56,9 @@ const useSubscription = () => { /** 当前智能体、技能套餐维度「我的订阅」 */ const { data: mySubscriptionInfo, - run: loadMySubscription, + runWithPromise: loadMySubscription, loading: loadingMySubscription, - } = useRequest(apiGetMySubscription, { + } = useRequestPromiseBridge(apiGetMySubscription, { manual: true, loadingDelay: 500, }); @@ -81,36 +78,8 @@ const useSubscription = () => { return; } - // 创建订阅订单 - try { - const orderResponse = await apiCreateAgentSubscriptionOrder(plan.id); - const orderId = orderResponse?.data?.id; - if (!orderId) { - message.error('创建订阅订单失败'); - return; - } - - // 获取收银台地址 - const cashierResponse = await apiGetAgentSubscriptionOrderCashier( - orderId, - ); - if (!cashierResponse?.data?.cashierUrl) { - message.error('获取收银台地址失败'); - return; - } - - // 打开收银台 - if (cashierResponse?.data?.cashierUrl) { - const returnUrl = encodeURIComponent(window.location.href); - const separator = cashierResponse.data.cashierUrl.includes('?') - ? '&' - : '?'; - const url = `${cashierResponse.data.cashierUrl}${separator}returnUrl=${returnUrl}`; - window.location.href = url; - } - } catch (error) { - console.error('点击套餐卡片失败:', error); - } + // 统一走支付 Hook + handlePaySubscription(plan.id); }; // 查询智能体订阅计划列表以及当前智能体我的订阅信息 diff --git a/qiming/src/hooks/useTerminalWsUrl.ts b/qiming/src/hooks/useTerminalWsUrl.ts new file mode 100644 index 00000000..b8785d22 --- /dev/null +++ b/qiming/src/hooks/useTerminalWsUrl.ts @@ -0,0 +1,15 @@ +import { buildTtydTerminalWsUrl } from '@/utils/terminalWsUrl'; +import { useMemo } from 'react'; + +/** + * 获取 ttyd 终端 WebSocket 地址 + * @param conversationId 会话 ID + */ +export function useTerminalWsUrl(conversationId?: number): string { + return useMemo( + () => buildTtydTerminalWsUrl(conversationId), + [conversationId], + ); +} + +export default useTerminalWsUrl; diff --git a/qiming/src/hooks/useUnifiedTheme.ts b/qiming/src/hooks/useUnifiedTheme.ts index 085ac43c..8be82b88 100644 --- a/qiming/src/hooks/useUnifiedTheme.ts +++ b/qiming/src/hooks/useUnifiedTheme.ts @@ -84,9 +84,9 @@ interface UseUnifiedThemeReturn { primaryColor: string; antdTheme: 'light' | 'dark'; navigationStyle: ThemeNavigationStyleType; + layoutStyle: ThemeLayoutColorStyle; menuLayout: ThemeMenuLayoutType; menuStyle: ThemeMenuStyleType; - layoutStyle: ThemeLayoutColorStyle; backgroundId: string; language: 'zh-CN' | 'en-US'; basicConfig: UnifiedThemeBasicConfig; @@ -250,9 +250,9 @@ export const useUnifiedTheme = (): UseUnifiedThemeReturn => { primaryColor: data.primaryColor, antdTheme: data.antdTheme, navigationStyle: data.navigationStyle, + layoutStyle: data.layoutStyle, menuLayout: data.menuLayout, menuStyle: data.menuStyle, - layoutStyle: data.layoutStyle, backgroundId: data.backgroundId, language: data.language, basicConfig: data.basicConfig, diff --git a/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/ConversationItem/index.less b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/ConversationItem/index.less new file mode 100644 index 00000000..e512da5c --- /dev/null +++ b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/ConversationItem/index.less @@ -0,0 +1,146 @@ +@import '@/styles/token.less'; + +@statusTagFontSize: 11px; // 运行中标签专用字号 +@statusTagPadding: 0 6px; // 运行中标签内边距 +@statusTagLineHeight: 16px; // 运行中标签行高 +@avatarTextWeight: 600; +@itemContentGap: 2px; + +.conversation-item { + display: flex; + align-items: center; + gap: @paddingSm; + padding: @paddingXs @paddingXs; + border-radius: @borderRadius; + cursor: pointer; + transition: all 0.2s ease; + color: @navSecondMenuAdaptiveTextSecondary; + + // 头像部分 + .avatar-container { + width: @controlHeight; + height: @controlHeight; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + position: relative; + + .avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-text { + font-size: @fontSize; + font-weight: @avatarTextWeight; + text-transform: uppercase; + } + } + + // 会话详情部分 + .conversation-item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: @itemContentGap; + overflow: hidden; + + .conversation-topic-row { + display: flex; + align-items: center; + gap: @paddingXs; + width: 100%; + + .conversation-topic { + flex: 1; + min-width: 0; + font-size: @fontSize; + line-height: @lineHeight; + color: @navSecondMenuAdaptiveText; + } + + .status-tag { + font-size: @statusTagFontSize; + color: @colorPrimary; + background-color: @colorPrimaryBg; + padding: @statusTagPadding; + border-radius: @borderRadiusXs; + line-height: @statusTagLineHeight; + flex-shrink: 0; + } + } + + .conversation-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: @paddingXs; + + .conversation-agent-name { + flex: 1; + min-width: 0; + font-size: @fontSizeSm; + line-height: @lineHeightSm; + color: @navSecondMenuAdaptiveTextSecondary; + } + + .conversation-date { + font-size: @fontSizeSm; + color: @navSecondMenuAdaptiveTextTertiary; + white-space: nowrap; + flex-shrink: 0; + } + } + } + + &:hover { + background-color: @colorBgContainer; + box-shadow: @boxShadowTertiary; + + .conversation-item-content { + .conversation-topic-row { + .conversation-topic { + color: @colorText; + } + } + + .conversation-meta { + .conversation-agent-name { + color: @colorTextSecondary; + } + + .conversation-date { + color: @colorTextTertiary; + } + } + } + } + + &.active { + background-color: @colorBgContainer; + box-shadow: @boxShadowTertiary; + + .conversation-item-content { + .conversation-topic-row { + .conversation-topic { + color: @colorPrimaryText; + } + } + + .conversation-meta { + .conversation-agent-name { + color: @colorTextSecondary; + } + + .conversation-date { + color: @colorTextTertiary; + } + } + } + } +} diff --git a/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/ConversationItem/index.tsx b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/ConversationItem/index.tsx new file mode 100644 index 00000000..15e1d4ba --- /dev/null +++ b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/ConversationItem/index.tsx @@ -0,0 +1,84 @@ +import agentImage from '@/assets/images/agent_image.png'; +import { dict } from '@/services/i18nRuntime'; +import { TaskStatus } from '@/types/enums/agent'; +import { ConversationInfo } from '@/types/interfaces/conversationInfo'; +import { Typography } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import { formatModifiedTime } from '../../utils'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +interface ConversationItemProps { + item: ConversationInfo; + isActive: boolean; + onClick: () => void; +} + +const ConversationItem: React.FC = ({ + item, + isActive, + onClick, +}) => { + const executingText = dict( + 'PC.Layouts.DynamicMenusLayout.ConversationItem.executing', + ); + + // 渲染智能体圆形头像,缺失或加载失败时使用默认图片兜底 + const renderAvatar = () => { + const icon = item.icon || item.agent?.icon; + const name = item.topic || item.agent?.name || ''; + + return ( +
    + {name} { + e.currentTarget.onerror = null; + e.currentTarget.src = agentImage; + }} + /> +
    + ); + }; + + return ( +
    + {renderAvatar()} +
    +
    + + {item.topic} + + {item.taskStatus === TaskStatus.EXECUTING && ( + {executingText} + )} +
    +
    + + {item.agent?.name} + + + {formatModifiedTime(item.modified)} + +
    +
    +
    + ); +}; + +export default ConversationItem; diff --git a/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/EmptyState/index.less b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/EmptyState/index.less new file mode 100644 index 00000000..800a06fa --- /dev/null +++ b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/EmptyState/index.less @@ -0,0 +1,45 @@ +@import '@/styles/token.less'; + +@emptyStateImgSize: 80px; +@emptyStateTextMaxWidth: 140px; + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + padding: @paddingLg @paddingXs; + text-align: center; + line-height: @lineHeight; + font-size: @fontSize; + color: @navSecondMenuAdaptiveTextTertiary; + + .empty-state-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: @paddingXs; + + .empty-state-img { + width: @emptyStateImgSize; + height: @emptyStateImgSize; + } + + .empty-state-text { + font-size: @fontSizeSm; + line-height: @lineHeightSm; + color: @navSecondMenuAdaptiveTextTertiary; + max-width: @emptyStateTextMaxWidth; + display: inline-block; + } + } + + // 如果有子级 typography + :global { + .ant-typography { + color: @navSecondMenuAdaptiveTextTertiary !important; + } + } +} diff --git a/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/EmptyState/index.tsx b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/EmptyState/index.tsx new file mode 100644 index 00000000..c58c8346 --- /dev/null +++ b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/EmptyState/index.tsx @@ -0,0 +1,39 @@ +import emptyStateNoFile from '@/assets/images/empty_state_no_file.svg'; +import { dict } from '@/services/i18nRuntime'; +import { Typography } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +interface EmptyStateProps { + keyword: string; +} + +const EmptyState: React.FC = ({ keyword }) => { + const noSearchResultText = dict( + 'PC.Components.HistoryConversationList.noSearchResult', + ); + + return ( +
    + {keyword ? ( + {noSearchResultText} + ) : ( +
    + empty + + {dict('PC.Components.HistoryConversationList.emptySession')} + +
    + )} +
    + ); +}; + +export default EmptyState; diff --git a/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/SearchHeader/index.less b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/SearchHeader/index.less new file mode 100644 index 00000000..49a2503e --- /dev/null +++ b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/SearchHeader/index.less @@ -0,0 +1,72 @@ +@import '@/styles/token.less'; + +@searchWrapperMarginLeft: 3px; +@newChatIconSize: 18px; + +.header-search-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 @paddingXs; + box-sizing: border-box; + flex-shrink: 0; + margin: 17px 0 10px -3px; + gap: @paddingXs; + + .search-input-wrapper { + flex: 1; + min-width: 0; + margin-left: @searchWrapperMarginLeft; + + .search-input { + font-size: @fontSize; + border-radius: @borderRadius; + background-color: @colorFillSecondary; + border: none; + color: @navSecondMenuAdaptiveText; + + :global { + .ant-input { + font-size: @fontSize !important; + color: @navSecondMenuAdaptiveText !important; + background-color: transparent !important; + + &::placeholder { + color: @navSecondMenuAdaptiveTextTertiary !important; + } + } + } + + .search-icon-inner { + color: @navSecondMenuAdaptiveTextTertiary; + } + + &:hover, + &:focus { + background-color: @colorFillSecondary; + } + } + } + + .new-chat-btn { + width: @controlHeight; + height: @controlHeight; + display: flex; + align-items: center; + justify-content: center; + border-radius: @borderRadiusSm; + cursor: pointer; + color: @navSecondMenuAdaptiveTextSecondary; + transition: all 0.2s ease; + + &:hover { + color: @navSecondMenuAdaptiveText; + background-color: @colorBgTextHover; + } + + .new-chat-svg { + width: @newChatIconSize; + height: @newChatIconSize; + } + } +} diff --git a/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/SearchHeader/index.tsx b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/SearchHeader/index.tsx new file mode 100644 index 00000000..cebfa27c --- /dev/null +++ b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/components/SearchHeader/index.tsx @@ -0,0 +1,61 @@ +import SvgIcon from '@/components/base/SvgIcon'; +import { dict } from '@/services/i18nRuntime'; +import { SearchOutlined } from '@ant-design/icons'; +import { Input, Tooltip } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +interface SearchHeaderProps { + keyword: string; + onSearchChange: (e: React.ChangeEvent) => void; + onNewChat: () => void; + showNewChatButton?: boolean; +} + +const SearchHeader: React.FC = ({ + keyword, + onSearchChange, + onNewChat, + showNewChatButton = true, +}) => { + const searchPlaceholder = dict( + 'PC.Components.HistoryConversationList.searchPlaceholder', + ); + + return ( +
    +
    + + } + placeholder={searchPlaceholder} + value={keyword} + onChange={onSearchChange} + allowClear + className={cx(styles['search-input'])} + /> +
    + {showNewChatButton && ( + +
    + +
    +
    + )} +
    + ); +}; + +export default SearchHeader; diff --git a/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/index.less b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/index.less new file mode 100644 index 00000000..f9c2f140 --- /dev/null +++ b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/index.less @@ -0,0 +1,55 @@ +@import '@/styles/token.less'; + +@sectionMarginLeft: -16px; +@sectionMarginTop: -9px; +@listItemGap: 4px; +@listMarginRight: -8px; + +.new-home-section { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + margin-left: @sectionMarginLeft; + margin-top: @sectionMarginTop; + + // 会话列表容器 - 可滚动区域 + .conversation-list-wrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: hidden; // 默认隐藏原生滚动条 + padding: 0; + scrollbar-gutter: stable; // 始终保留滚动条通道,防止 layout shift + + &:hover { + overflow-y: auto; // 鼠标移入时开启滚动,展现与全局一致的原生滚动条样式 + } + + // 会话列表 + .conversation-list { + display: flex; + flex-direction: column; + gap: @listItemGap; + padding: 0 @paddingXs; + margin-right: @listMarginRight; + + // 加载更多提示与无更多提示 + .load-more, + .no-more { + padding: @paddingSm 0; + text-align: center; + font-size: @fontSizeSm; + color: @navSecondMenuAdaptiveTextTertiary; + + :global { + .ant-typography { + color: @navSecondMenuAdaptiveTextTertiary !important; + } + } + } + } + } +} diff --git a/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/index.tsx b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/index.tsx new file mode 100644 index 00000000..424c8517 --- /dev/null +++ b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/index.tsx @@ -0,0 +1,366 @@ +import { useDebounceFn } from 'ahooks'; +import { Spin } from 'antd'; +import classNames from 'classnames'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { history, useLocation, useModel, useParams } from 'umi'; + +import ConversationItem from './components/ConversationItem'; +import EmptyState from './components/EmptyState'; +import SearchHeader from './components/SearchHeader'; + +import { EVENT_TYPE } from '@/constants/event.constants'; +import { apiAgentConversationList } from '@/services/agentConfig'; +import { TaskStatus } from '@/types/enums/agent'; +import { ConversationInfo } from '@/types/interfaces/conversationInfo'; +import eventBus from '@/utils/eventBus'; +import styles from './index.less'; + +const cx = classNames.bind(styles); + +const ITEM_HEIGHT = 58; // 列表项重构后高度增加 + +const componentCache = { + list: null as ConversationInfo[] | null, + hasMore: true, + keyword: '', + searchKeyword: '', + scrollTop: 0, +}; + +const NewHomeSection: React.FC<{ + style?: React.CSSProperties; +}> = ({ style }) => { + const { id: chatIdParam } = useParams(); + const location = useLocation(); + const chatId = + chatIdParam || location.pathname.match(/\/home\/chat\/([^/]+)/)?.[1]; + + const { handleCloseMobileMenu } = useModel('layout'); + const { firstLevelMenus } = useModel('menuModel'); + + const [localList, setLocalList] = useState( + componentCache.list || [], + ); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState( + componentCache.list ? componentCache.hasMore : true, + ); + const [keyword, setKeyword] = useState(componentCache.keyword); + const [searchKeyword, setSearchKeyword] = useState( + componentCache.searchKeyword, + ); + const scrollContainerRef = useRef(null); + const listInnerRef = useRef(null); + const initializedRef = useRef(false); + const pageSizeRef = useRef(20); + const loadingRef = useRef(false); + + const calcPageSize = useCallback(() => { + const height = scrollContainerRef.current?.clientHeight ?? 0; + if (!height) return 20; + const count = Math.ceil(height / ITEM_HEIGHT); + return Math.max(count, 10); + }, []); + + const loadList = useCallback( + async (isRefresh = false, options?: { silent?: boolean }) => { + if (loadingRef.current || (!hasMore && !isRefresh)) return; + loadingRef.current = true; + if (!options?.silent) { + setLoading(true); + } + + const pageSize = isRefresh ? calcPageSize() : pageSizeRef.current; + if (isRefresh) pageSizeRef.current = pageSize; + const lastId = isRefresh + ? null + : localList.length > 0 + ? localList[localList.length - 1].id + : null; + + try { + const res = await apiAgentConversationList({ + agentId: null, + lastId, + limit: pageSize, + topic: searchKeyword || undefined, + }); + + const data = res.data ?? []; + if (isRefresh) { + setLocalList(data); + } else { + setLocalList((prev) => { + const merged = [...prev, ...data]; + const unique: ConversationInfo[] = []; + const seen = new Set(); + for (const item of merged) { + if (item && item.id !== undefined && item.id !== null) { + if (!seen.has(item.id)) { + seen.add(item.id); + unique.push(item); + } + } else { + unique.push(item); + } + } + return unique; + }); + } + setHasMore(data.length >= pageSize); + } finally { + loadingRef.current = false; + if (!options?.silent) { + setLoading(false); + } + } + }, + [hasMore, localList, calcPageSize, searchKeyword], + ); + + const loadListRef = useRef(loadList); + useEffect(() => { + loadListRef.current = loadList; + }, [loadList]); + + const stateRef = useRef({ localList, hasMore, keyword, searchKeyword }); + stateRef.current = { localList, hasMore, keyword, searchKeyword }; + + useEffect(() => { + if (!initializedRef.current) { + initializedRef.current = true; + if (componentCache.list) { + loadList(true, { silent: true }); + setTimeout(() => { + if (scrollContainerRef.current && componentCache.scrollTop) { + scrollContainerRef.current.scrollTop = componentCache.scrollTop; + } + }, 0); + } else { + loadList(true); + } + } + + return () => { + componentCache.list = stateRef.current.localList; + componentCache.hasMore = stateRef.current.hasMore; + componentCache.keyword = stateRef.current.keyword; + componentCache.searchKeyword = stateRef.current.searchKeyword; + if (scrollContainerRef.current) { + componentCache.scrollTop = scrollContainerRef.current.scrollTop; + } + }; + }, []); + + const prevPathnameRef = useRef(location.pathname); + useEffect(() => { + if (!initializedRef.current) return; + + const isHomeRoute = location.pathname.startsWith('/home'); + const wasHomeRoute = prevPathnameRef.current.startsWith('/home'); + + if (location.pathname === '/home') { + // 点击菜单回到首页,静默更新并回到顶部 + loadListRef.current(true, { silent: true }); + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + } else if (isHomeRoute && !wasHomeRoute) { + // 从其他页面(如 /space)切回到 /home/chat 页面,即使组件未销毁也应当静默更新一次 + loadListRef.current(true, { silent: true }); + } + + prevPathnameRef.current = location.pathname; + }, [location.pathname, location.state]); + + useEffect(() => { + const handleConversationUpdated = (e: Event) => { + const customEvent = e as CustomEvent<{ + id: number; + topic: string; + icon?: string; + }>; + if (!customEvent.detail) return; + const { id, topic, icon } = customEvent.detail; + setLocalList((prev) => + prev.map((item) => { + if (item.id === id) { + return { + ...item, + topic, + icon, + }; + } + return item; + }), + ); + }; + + const handleConversationDeleted = (e: Event) => { + const customEvent = e as CustomEvent<{ id: number }>; + if (!customEvent.detail) return; + const { id } = customEvent.detail; + setLocalList((prev) => prev.filter((item) => item.id !== id)); + }; + + const handleRefreshConversationList = () => { + loadListRef.current(true, { silent: true }); + }; + + const handleUpdateConversationListTaskStatus = ({ + conversationId, + taskStatus, + }: { + conversationId: number | string; + taskStatus: TaskStatus; + }) => { + setLocalList((prev) => + prev.map((item) => + item.id?.toString() === conversationId.toString() + ? { ...item, taskStatus } + : item, + ), + ); + }; + + window.addEventListener('conversation-updated', handleConversationUpdated); + window.addEventListener('conversation-deleted', handleConversationDeleted); + eventBus.on( + EVENT_TYPE.RefreshConversationList, + handleRefreshConversationList, + ); + eventBus.on( + EVENT_TYPE.UpdateConversationListTaskStatus, + handleUpdateConversationListTaskStatus, + ); + + return () => { + window.removeEventListener( + 'conversation-updated', + handleConversationUpdated, + ); + window.removeEventListener( + 'conversation-deleted', + handleConversationDeleted, + ); + eventBus.off( + EVENT_TYPE.RefreshConversationList, + handleRefreshConversationList, + ); + eventBus.off( + EVENT_TYPE.UpdateConversationListTaskStatus, + handleUpdateConversationListTaskStatus, + ); + }; + }, []); + + const isFirstSearchKeywordEffect = useRef(true); + useEffect(() => { + if (isFirstSearchKeywordEffect.current) { + isFirstSearchKeywordEffect.current = false; + return; + } + if (!initializedRef.current) return; + setHasMore(true); + setLocalList([]); + loadList(true); + }, [searchKeyword]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleScroll = () => { + if (loading || !hasMore) return; + const { scrollTop, scrollHeight, clientHeight } = container; + if (scrollTop + clientHeight >= scrollHeight - 30) { + loadList(); + } + }; + + container.addEventListener('scroll', handleScroll, { passive: true }); + return () => container.removeEventListener('scroll', handleScroll); + }, [loading, hasMore, loadList]); + + const { run: debouncedSearch } = useDebounceFn( + (val: string) => { + setSearchKeyword(val); + }, + { wait: 500 }, + ); + + const handleSearchChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setKeyword(val); + debouncedSearch(val); + }; + + const handleConversationClick = (item: ConversationInfo) => { + handleCloseMobileMenu(); + const { id, agentId, devTargetType, devTargetId, devSpaceId } = item; + + if (devTargetType === 'Agent' && devSpaceId && id) { + history.push( + `/space/${devSpaceId}/agent-dev?agentId=${devTargetId}&conversationId=${id}`, + ); + } else if (devTargetType === 'PageApp' && devSpaceId && devTargetId) { + history.push(`/space/${devSpaceId}/app-dev/${devTargetId}`); + } else { + history.push('/home/chat/' + id + '/' + agentId); + } + }; + + const handleNewConversation = () => { + handleCloseMobileMenu(); + history.push('/home'); + }; + + const showNewChatButton = firstLevelMenus?.some( + (menu: any) => menu?.code === 'new_conversation', + ); + + // const noMoreText = dict('PC.Components.HistoryConversationList.noMore'); + + return ( +
    + + + {/* 会话记录列表 */} +
    + {!loading && localList.length === 0 && } + +
    + {localList.map((item) => ( + handleConversationClick(item)} + /> + ))} + + {loading && ( +
    + +
    + )} + {/* {!loading && !hasMore && localList.length > 0 && ( +
    + {noMoreText} +
    + )} */} +
    +
    +
    + ); +}; + +export default NewHomeSection; diff --git a/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/utils.ts b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/utils.ts new file mode 100644 index 00000000..be7903ab --- /dev/null +++ b/qiming/src/layouts/DynamicMenusLayout/NewHomeSection/utils.ts @@ -0,0 +1,26 @@ +import { dict, getCurrentLang } from '@/services/i18nRuntime'; +import dayjs from 'dayjs'; + +/** + * 格式化会话更新时间,符合设计图逻辑 + * @param timeStr ISO格式时间字符串 + */ +export const formatModifiedTime = (timeStr?: string) => { + if (!timeStr) return ''; + const d = dayjs(timeStr); + if (!d.isValid()) return ''; + + const now = dayjs(); + if (d.isSame(now, 'day')) { + return d.format('HH:mm'); + } + if (d.isSame(now.subtract(1, 'day'), 'day')) { + return dict('PC.Utils.Common.yesterday'); + } + + const lang = getCurrentLang(); + if (lang.startsWith('zh')) { + return d.format('M月D日'); + } + return d.format('MMM D'); +}; diff --git a/qiming/src/locales/i18n/en-US.ts b/qiming/src/locales/i18n/en-US.ts index f9f9e5bc..23c6389d 100644 --- a/qiming/src/locales/i18n/en-US.ts +++ b/qiming/src/locales/i18n/en-US.ts @@ -2627,6 +2627,8 @@ export const EN_US: SystemLangMap = { "PC.Pages.Login.captchaTitle": "Please complete the following verification to continue", "PC.Pages.Login.captchaVerifying": "Verifying...", "PC.Pages.Login.codeLoginOrRegister": "Sign In / Sign Up with Code", + "PC.Pages.Login.inputAccountPlaceholder": "Please enter username or email", + "PC.Pages.Login.inputAccountRequired": "Please input username or email", "PC.Pages.Login.inputEmailPlaceholder": "Please enter your email", "PC.Pages.Login.inputEmailRequired": "Please input email", "PC.Pages.Login.inputPasswordPlaceholder": "Please enter a password with at least 6 characters", @@ -5441,4 +5443,519 @@ export const EN_US: SystemLangMap = { "PC.Pages.SpaceKnowledge.Index.loadGraphListFailed": "Failed to load knowledge graph list", "PC.Pages.SpaceKnowledge.Index.viewAllGraphsFailed": "Failed to view all knowledge graphs", "PC.Pages.SpaceKnowledge.KnowledgeHeader.graph": "Knowledge Graph", + "PC.Common.Global.uploadFileSizeExceed": "Total upload file size cannot exceed {0}MB", + "PC.Common.Global.times": "use", + "PC.Components.AcpPermissionCard.allowAlways": "Always allow", + "PC.Components.AcpPermissionCard.allowOnce": "Allow once", + "PC.Components.AcpPermissionCard.cancelShortcutHint": "Cancel (Esc)", + "PC.Components.AcpPermissionCard.defaultTitle": "Permission confirmation required", + "PC.Components.AcpPermissionCard.eyebrow": "Approval Request", + "PC.Components.AcpPermissionCard.rejectOnce": "Reject", + "PC.Components.AcpPermissionCard.shortcutHint": "{0} ({1})", + "PC.Components.AcpPermissionCard.submitted": "Permission response submitted", + "PC.Components.ConversationBottomConsole.collapsePanel": "Collapse to bottom", + "PC.Components.ConversationBottomConsole.containerKeepaliveError": "Service keepalive request failed. Please check your network and try again.", + "PC.Components.ConversationBottomConsole.containerKeepaliveFailed": "Service keepalive failed. Please restart the service.", + "PC.Components.ConversationBottomConsole.containerStartError": "Failed to start service. Please try again later.", + "PC.Components.ConversationBottomConsole.containerStarting": "Starting service, please wait…", + "PC.Components.ConversationBottomConsole.expandHeight": "Expand to full height", + "PC.Components.ConversationBottomConsole.logsEmpty": "No logs yet", + "PC.Components.ConversationBottomConsole.restoreHeight": "Restore default height", + "PC.Components.ConversationBottomConsole.retryStartContainer": "Restart service", + "PC.Components.ConversationBottomConsole.tabLogs": "Logs", + "PC.Components.ConversationBottomConsole.tabTerminal": "Terminal", + "PC.Components.ConversationBottomConsole.terminalEmpty": "No terminal output yet", + "PC.Components.ConversationBottomConsole.terminalThemeDark": "Switch to dark terminal", + "PC.Components.ConversationBottomConsole.terminalThemeLight": "Switch to light terminal", + "PC.Components.ChatInputHome.agentMode": "Agent mode", + "PC.Components.ChatInputHome.agentModeAuto": "Auto", + "PC.Components.ChatInputHome.agentModeAutoDesc": "Tools will not ask the user before execution", + "PC.Components.ChatInputHome.agentModeApproval": "Approval", + "PC.Components.ChatInputHome.agentModeApprovalDesc": "The user will be asked to approve some tool calls before execution", + "PC.Components.VoiceInput.startTooltip": "Voice input", + "PC.Components.VoiceInput.connecting": "Connecting microphone…", + "PC.Components.VoiceInput.stopTooltip": "Stop recording and transcribe", + "PC.Components.VoiceInput.stopFillTooltip": "Stop and fill input", + "PC.Components.VoiceInput.stopSendTooltip": "Stop and send", + "PC.Components.VoiceInput.transcribing": "Transcribing…", + "PC.Components.VoiceInput.permissionDenied": "Microphone access was denied. Please allow it in your browser settings and try again.", + "PC.Components.VoiceInput.notSupported": "Voice input isn't supported in this environment. Please use the latest Chrome/Edge/Firefox/Safari.", + "PC.Components.VoiceInput.tooShort": "Recording is too short", + "PC.Components.VoiceInput.tooLarge": "Recording file is too large. Please record a shorter clip.", + "PC.Components.VoiceInput.recognizeFailed": "Voice recognition failed, please try again", + "PC.Components.ChatInputHome.pastedFileName": "Pasted File-{0}-{1}", + "PC.Components.ChatInputHome.dropFilesHint": "Release to upload files", + "PC.Components.ChatInputHomeSpaceSelector.noAvailableSpace": "No available spaces", + "PC.Components.CollapseComponentList.groupAgentDesc": "Group agents are member agents in the group that collaborate on tasks.", + "PC.Components.CreateAgent.typeAgentFlow": "AgentFlow", + "PC.Components.CreateAgent.typeAgentGroup": "AgentGroup", + "PC.Components.CreateWorkflow.createAgentFlow": "Create AgentFlow", + "PC.Components.CreateWorkflow.placeholderAgentFlowName": "Enter AgentFlow name", + "PC.Components.CreateWorkflow.placeholderAgentFlowDesc": "Enter description to help the model understand when to use this AgentFlow", + "PC.Components.CreateWorkflow.skip": "Skip", + "PC.Components.CreateWorkflow.startEditing": "Start Editing", + "PC.Components.FileContextMenu.uploadFolder": "Upload Folder", + "PC.Components.FileContextMenu.uploadFolderEmpty": "No uploadable files in the selected folder", + "PC.Components.FileContextMenu.uploadFileEmpty": "No uploadable files selected", + "PC.Components.FileContextMenu.uploadTargetInvalid": "Cannot upload to this directory", + "PC.Components.HistoryConversationList.emptySession": "You have no conversations yet. Go start your first task!", + "PC.Components.HistoryConversationList.noMore": "No more", + "PC.Components.HistoryConversationList.noSearchResult": "No results found", + "PC.Components.MessageQueue.clearAll": "Clear all", + "PC.Components.MessageQueue.editMessageTooltip": "Edit message", + "PC.Components.MessageQueue.sendNowTooltip": "Send now", + "PC.Components.MessageQueue.title": "Pending", + "PC.Components.McpAskQuestionCard.cancelShortcutHint": "Cancel (Esc)", + "PC.Components.McpAskQuestionCard.cancelled": "Cancelled", + "PC.Components.McpAskQuestionCard.customInputLabel": "Custom value", + "PC.Components.McpAskQuestionCard.customInputPlaceholder": "Enter value", + "PC.Components.McpAskQuestionCard.customOption": "Custom", + "PC.Components.McpAskQuestionCard.eyebrow": "Additional Reply", + "PC.Components.McpAskQuestionCard.fieldRequired": "This field is required", + "PC.Components.McpAskQuestionCard.multiSelectMin": "Select at least one option", + "PC.Components.McpAskQuestionCard.nextStep": "Next", + "PC.Components.McpAskQuestionCard.prevStep": "Previous", + "PC.Components.McpAskQuestionCard.skip": "Skip", + "PC.Components.McpAskQuestionCard.skipped": "Skipped", + "PC.Components.McpAskQuestionCard.stepOf": "Step {0} / {1}", + "PC.Components.McpAskQuestionCard.submitted": "Answer submitted", + "PC.Components.McpAskQuestionCard.uploadDragText": "Click or drag files here to upload", + "PC.Components.McpAskQuestionCard.defaultTitle": "This question", + "PC.Components.McpAskQuestionCard.notFilled": "Not provided", + "PC.Components.McpAskQuestionCard.unknownFile": "Unknown file", + "PC.Components.McpAskQuestionCard.file": "File", + "PC.Components.McpAskQuestionCard.emptyFormContent": "(No form content)", + "PC.Components.McpAskQuestionCard.resumeCancelled": "I cancelled \"{0}\".", + "PC.Components.McpAskQuestionCard.resumeSkipped": "I skipped \"{0}\".", + "PC.Components.McpAskQuestionCard.resumeTimeout": "\"{0}\" timed out. No form answer was received.", + "PC.Components.McpAskQuestionCard.resumeSubmitted": "I answered \"{0}\". Form details:", + "PC.Components.PaymentSubscriptionModal.titleSelectPlan": "Choose a subscription plan", + "PC.Components.PaymentSubscriptionModal.titleWithTrialCount": "Choose a subscription plan (Trials: {0}/{1})", + "PC.Components.PaymentSubscriptionModal.emptyNoPlans": "No plans available", + "PC.Components.PaymentSubscriptionModal.monthlyCredits": "{0} credits per month", + "PC.Components.PaymentSubscriptionModal.callLimitUnlimited": "Unlimited", + "PC.Components.PaymentSubscriptionModal.callLimitPerMonth": "{0} uses/month", + "PC.Components.PaymentSubscriptionModal.originalPrice": "Original {0}{1}/{2}", + "PC.Components.PaymentSubscriptionModal.callCountLabel": "Call limit: {0}", + "PC.Components.PaymentSubscriptionModal.periodMonth": "month", + "PC.Components.PaymentSubscriptionModal.periodQuarter": "quarter", + "PC.Components.PaymentSubscriptionModal.periodYear": "year", + "PC.Components.PaymentSubscriptionModal.periodForever": "forever", + "PC.Components.PaymentSubscriptionModal.btnSubscribePlan": "Subscribe", + "PC.Components.PaymentSubscriptionModal.btnSubscribeBuyoutPlan": "Subscribe (buyout)", + "PC.Components.PaymentSubscriptionModal.btnForeverBoughtOut": "Purchased", + "PC.Components.PaymentSubscriptionModal.btnExpiredRenew": "Expired (renew)", + "PC.Components.PaymentSubscriptionModal.btnCurrent": "Current Plan", + "PC.Components.PaymentSubscriptionModal.btnCurrentRenew": "Current plan (renew)", + "PC.Components.PaymentSubscriptionModal.btnUpgrade": "Upgrade", + "PC.Constants.Library.audioProcess": "Audio Processing", + "PC.Constants.Library.chatMultimodal": "Chat - Multimodal", + "PC.Constants.Library.chatText": "Chat - Text Only", + "PC.Constants.Library.imageProcess": "Image Processing", + "PC.Constants.Library.supportFuncCall": "Supports Function Calling", + "PC.Constants.Library.textCompletion": "Text Completion", + "PC.Constants.Library.textEdit": "Text Editing", + "PC.Constants.Library.vectorEmbedding": "Vector Embedding", + "PC.Constants.Library.externalApi": "External API calls", + "PC.Constants.Space.callApproval": "Call Approval", + "PC.Constants.Space.agentFlowType": "AgentFlow", + "PC.Constants.Space.agentFlowTypeTooltip": "Multi-agent collaborative workflow for complex task orchestration, branching decisions, human-in-the-loop steps, and result evaluation.", + "PC.Constants.Space.agentGroupType": "AgentGroup", + "PC.Constants.Space.agentGroupTypeTooltip": "A multi-agent collaboration group suitable for complex tasks that require multiple agents to work together.", + "PC.Constants.Space.group": "Group", + "PC.Pages.SpaceResource.LeftGroupList.createGroupTitle": "Create Group", + "PC.Pages.SpaceResource.LeftGroupList.editGroupTitle": "Edit Group", + "PC.Pages.SpaceResource.LeftGroupList.groupNameLabel": "Group Name", + "PC.Pages.SpaceResource.LeftGroupList.groupNamePlaceholder": "Please enter group name", + "PC.Pages.SpaceResource.LeftGroupList.groupNameRequired": "Group name is required", + "PC.Pages.SpaceResource.LeftGroupList.deleteConfirmContent": "Are you sure you want to delete the group \"{0}\"?", + "PC.Pages.SpaceResource.LeftGroupList.createSuccess": "Created successfully", + "PC.Pages.SpaceResource.LeftGroupList.editSuccess": "Updated successfully", + "PC.Pages.SpaceResource.LeftGroupList.deleteSuccess": "Deleted successfully", + "PC.Pages.SpaceResource.LeftGroupList.groupIconLabel": "Group Icon", + "PC.Pages.SpaceResource.LeftGroupList.groupNameNoWhitespace": "Name cannot be empty", + "PC.Pages.SpaceResource.LeftGroupList.groupNameMaxLength": "Name cannot exceed 30 characters", + "PC.Pages.SpaceResource.LeftGroupList.groupDescLabel": "Group Description", + "PC.Pages.SpaceResource.LeftGroupList.groupDescMaxLength": "Description cannot exceed 100 characters", + "PC.Pages.SpaceResource.LeftGroupList.groupDescPlaceholder": "Please enter group description", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeLabel": "Group Type", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeRequired": "Please select group type", + "PC.Pages.SpaceResource.LeftGroupList.unitCount": "{0} items", + "PC.Pages.SpaceResource.LeftGroupList.moveToGroup": "Move to Group", + "PC.Pages.SpaceResource.LeftGroupList.moveToOtherGroup": "Move to other group", + "PC.Pages.SpaceResource.LeftGroupList.removeFromGroup": "Remove from Group", + "PC.Pages.SpaceResource.LeftGroupList.confirmMoveToGroup": "Confirm Move to Group", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveFromGroup": "Confirm Remove from Group", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveDesc": "Are you sure you want to remove \"{0}\" from the current group?", + "PC.Pages.SpaceResource.LeftGroupList.moveSuccess": "Moved successfully", + "PC.Pages.SpaceResource.LeftGroupList.removeSuccess": "Removed successfully", + "PC.Pages.SpaceResource.LeftGroupList.selectGroup": "Select Group", + "PC.Pages.SpaceResource.LeftGroupList.selectGroupPlaceholder": "Please select a group to move to", + "PC.Modal.chunkLoadErrorContent": "Failed to load page resources. Please refresh and try again.", + "PC.Modal.chunkLoadErrorRefresh": "Refresh Now", + "PC.Modal.chunkLoadErrorTitle": "Page Load Error", + "PC.Hooks.UseSubscription.createOrderFailed": "Failed to create subscription order", + "PC.Hooks.UseSubscription.getCashierUrlFailed": "Failed to get cashier address", + "PC.Layouts.DynamicMenusLayout.SpaceSection.searchSpacePlaceholder": "Search spaces", + "PC.Layouts.DynamicMenusLayout.SpaceSection.noSpaceMatch": "No matching spaces", + "PC.Layouts.DynamicMenusLayout.newProject": "New Project", + "PC.Models.ConversationInfo.askResponseFailed": "Failed to submit answer", + "PC.Models.ConversationInfo.permissionResponseFailed": "Failed to submit permission response", + "PC.Pages.AgentArrangeConfig.addGroupAgent": "Add Group Agent", + "PC.Pages.AgentArrangeConfig.allowChooseMode": "Allow users to choose mode in the dialog", + "PC.Pages.AgentArrangeConfig.allowChooseModeDesc": "When enabled, users can choose Auto or Approval below the dialog. When disabled, Auto mode is used by default.", + "PC.Pages.AgentArrangeConfig.versionControl": "Version Control", + "PC.Pages.AgentArrangeConfig.versionControlDesc": "When enabled, the agent supports version control to view code changes and commit history.", + "PC.Pages.AgentArrangeConfig.groupAgent": "Group Agent", + "PC.Pages.AgentArrangeConfig.groupMembers": "Members", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategy": "Parameter Update Strategy", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyAppend": "Append", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyPlaceholder": "Select parameter update strategy", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyReplace": "Replace", + "PC.Pages.AgentArrangeCreateHookModal.config": "Config JSON", + "PC.Pages.AgentArrangeCreateHookModal.configInvalid": "Please enter valid JSON config", + "PC.Pages.AgentArrangeCreateHookModal.event": "Event", + "PC.Pages.AgentArrangeCreateHookModal.eventRequired": "Please select an event", + "PC.Pages.AgentArrangeCreateHookModal.matcher": "Match Rule", + "PC.Pages.AgentArrangeCreateHookModal.matcherPlaceholder": "Tool name, e.g. Bash, Edit|Write, mcp...", + "PC.Pages.AgentArrangeCreateHookModal.matcherNotSupported": "Match rules are not supported", + "PC.Pages.AgentArrangeCreateHookModal.matcherStopFailurePlaceholder": "Error type, e.g. rate_limit, overloaded", + "PC.Pages.AgentArrangeCreateHookModal.matcherSessionStartPlaceholder": "Start mode, e.g. startup, resume, clear, compact", + "PC.Pages.AgentArrangeCreateHookModal.matcherSubagentStartPlaceholder": "Agent type, e.g. general-purpose, Explore, Plan", + "PC.Pages.AgentArrangeCreateHookModal.name": "Name", + "PC.Pages.AgentArrangeCreateHookModal.namePlaceholder": "e.g. Code Formatting", + "PC.Pages.AgentArrangeCreateHookModal.nameRequired": "Please enter a name", + "PC.Pages.AgentArrangeCreateHookModal.titleCreate": "Create Hook", + "PC.Pages.AgentArrangeCreateHookModal.titleEdit": "Edit Hook", + "PC.Pages.AgentArrangeCreateHookModal.type": "Type", + "PC.Pages.AgentArrangeCreateHookModal.typeRequired": "Please select a type", + "PC.Pages.AgentArrangeCreateHooks.add": "New", + "PC.Pages.AgentArrangeCreateHooks.columnAction": "Actions", + "PC.Pages.AgentArrangeCreateHooks.columnEvent": "Event", + "PC.Pages.AgentArrangeCreateHooks.columnMatcher": "Match Rule", + "PC.Pages.AgentArrangeCreateHooks.columnName": "Name", + "PC.Pages.AgentArrangeCreateHooks.columnStatus": "Status", + "PC.Pages.AgentArrangeCreateHooks.columnType": "Type", + "PC.Pages.AgentArrangeCreateHooks.deleteSuccess": "Deleted successfully", + "PC.Pages.AgentArrangeCreateHooks.searchPlaceholder": "Search...", + "PC.Pages.AgentArrangeCreateHooks.title": "Hook Settings", + "PC.Pages.AgentArrangeCreateHooks.titleTooltip": "Events supported by OpenAI protocol models: PreToolUse, PostToolUse, PostToolUseFailure, SessionStart, SessionEnd, Stop", + "PC.Pages.AgentArrangeCreateHooks.updateSuccess": "Updated successfully", + "PC.Pages.AgentArrangeConfig.addHook": "Add Hook", + "PC.Pages.AgentArrangeConfig.hook": "Hook Settings", + "PC.Pages.AgentArrangeConfig.hookUnavailable": "Hook component is not ready, please try again later", + "PC.Pages.AgentArrangeConfig.enableAskQuestion": "Allow asking user", + "PC.Pages.AgentArrangeConfig.enableAskQuestionDesc": "When enabled, the agent can pop up a form to ask the user for additional information when needed.", + "PC.Components.HookList.description": "Hooks let you inject custom logic at key points during agent execution.", + "PC.Pages.AgentArrangeCallApproval.optionNo": "No", + "PC.Pages.AgentArrangeCallApproval.optionYes": "Yes", + "PC.Pages.AgentArrangeCallApproval.title": "Approval Required", + "PC.Pages.AgentArrangeCallApproval.tooltip": "When enabled, the model sends an approval notification before calling a tool. Execution continues after the user approves.", + "PC.Pages.AntvX6Header.autoArrange": "Auto Arrange", + "PC.Pages.AntvX6Header.run": "Run", + "PC.Pages.AntvX6Header.save": "Save", + "PC.Pages.AgentFlow.loading": "Loading...", + "PC.Pages.AgentFlow.chipApprove": "Approve", + "PC.Pages.AgentFlow.chipFail": "Fail", + "PC.Pages.AgentFlow.chipPass": "Pass", + "PC.Pages.AgentFlow.chipReject": "Reject", + "PC.Pages.AgentFlow.dragToConnect": "Drag to connect", + "PC.Pages.AgentFlowNode.routeDecisionExtraPromptPlaceholder": "Enter extra routing instructions for AI...", + "PC.Pages.AgentFlowNode.askQuestionLabel": "Question", + "PC.Pages.AgentFlowNode.askQuestionPlaceholder": "Enter the question to ask the user...", + "PC.Pages.AgentFlowNode.routeDecisionRouteDescriptionPlaceholder": "Describe when this route should be chosen...", + "PC.Pages.AgentFlowNode.routeDecisionItemFallback": "Route {0}", + "PC.Pages.AgentFlowNode.askOptionIndex": "Option {0}", + "PC.Pages.AgentFlowNode.extraPromptLabel": "Extra Prompt", + "PC.Pages.AgentFlowNode.agentInputLabel": "Input Parameters", + "PC.Pages.AgentFlowNode.contextWriteKeyLabel": "Context Write", + "PC.Pages.AgentFlowNode.contextWriteKeyPlaceholder": "e.g. user_reply", + "PC.Pages.AgentFlowNode.endEmptyHint": "The end node requires no extra configuration; the flow ends automatically here.", + "PC.Pages.AgentFlowNode.inputRefVarsLabel": "Referenced Variables", + "PC.Pages.AgentFlowNode.replyModeFormReply": "Form Reply", + "PC.Pages.AgentFlowNode.replyModeOptionsReply": "Options Reply", + "PC.Pages.AgentFlowNode.replyModeTextReply": "Text Reply", + "PC.Pages.AgentFlowNode.routeAddBranch": "Add Branch", + "PC.Pages.AgentFlowNode.routeAddCondition": "Add Condition", + "PC.Pages.AgentFlowNode.routeBranchesTitle": "Route Branches", + "PC.Pages.AgentFlowNode.routeOtherIntent": "Other Intent", + "PC.Pages.AgentFlowNode.routeConditionMatchLabel": "Condition Match", + "PC.Pages.AgentFlowNode.routeConditionValue": "Value", + "PC.Pages.AgentFlowNode.routeConditionVariable": "Variable", + "PC.Pages.AgentFlowNode.routeExtraPromptLabel": "Extra Prompt", + "PC.Pages.AgentFlowNode.routeInputLabel": "Input", + "PC.Pages.AgentFlowNode.routeSelectVariablePlaceholder": "Select variable...", + "PC.Pages.AgentFlowNode.startEmptyHint": "The start node requires no extra configuration; the flow starts from this node.", + "PC.Pages.AgentFlowNode.startSingleOutgoingHint": "The start node can connect to only one next node", + "PC.Pages.AgentFlowNode.extraPromptPlaceholder1": "Enter supplementary prompt to further constrain agent behavior", + "PC.Pages.AgentFlowNode.reminderPromptLabel": "Loop Reminder Prompt", + "PC.Pages.AgentFlowNode.reminderPromptPlaceholder": "Loop reminder prompt...", + "PC.Pages.AgentFlowNode.selfLoopTimesLabel": "Self Loop Count", + "PC.Pages.AgentFlowNode.selfLoopTimesTooltip": "Number of times the agent self-loops; 0 means no loop", + "PC.Pages.AgentFlowNode.routeDecisionRouteNamePlaceholder": "e.g. complaint_handling", + "PC.Pages.AgentFlowNode.askOptionsTitle": "Options", + "PC.Pages.AgentFlowNode.contextWriteKeyHint": "Context key for output, e.g. evalResult", + "PC.Pages.AgentFlowNode.formFieldLabel": "Field Label", + "PC.Pages.AgentFlowNode.formFieldOptionsPlaceholder": "Option1,Option2,Option3", + "PC.Pages.AgentFlowNode.formFieldRequired": "Required", + "PC.Pages.AgentFlowNode.formFieldsTitle": "Form Fields", + "PC.Pages.AgentFlowNode.formFieldsHint": "Define the form fields the user needs to fill in", + "PC.Pages.AgentFlowNode.formFieldDescriptionPlaceholder": "Fill-in hint (tells the user what to enter)", + "PC.Pages.AgentFlowNode.formFieldAddBtn": "Add Field", + "PC.Pages.AgentFlowNode.formTypeCheckbox": "Multi-select", + "PC.Pages.AgentFlowNode.formTypeFile": "File Upload", + "PC.Pages.AgentFlowNode.formTypeInput": "Text Input", + "PC.Pages.AgentFlowNode.formTypeNumber": "Number", + "PC.Pages.AgentFlowNode.formTypeRadio": "Single Select", + "PC.Pages.AgentFlowNode.formTypeSelect": "Dropdown", + "PC.Pages.AgentFlowNode.formTypeTextarea": "Textarea", + "PC.Pages.AgentFlowNode.replyModeLabel": "Reply Mode", + "PC.Pages.AgentFlowParams.groupAgentFlowProcess": "Process Nodes", + "PC.Pages.AgentFlowParams.groupAgentFlowAdvanced": "Advanced Nodes", + "PC.Pages.AgentFlowParams.nodeRouteDecisionName": "Route Decision", + "PC.Pages.AgentFlowParams.nodeRouteDecisionDescription": "AI decides which branch to follow", + "PC.Pages.AgentFlowParams.nodeAgentName": "Agent", + "PC.Pages.AgentFlowParams.nodeAgentDescription": "Invoke agent to execute tasks", + "PC.Pages.AgentFlowParams.nodeHumanApproveName": "Human Approval", + "PC.Pages.AgentFlowParams.nodeHumanApproveDescription": "Human approval confirmation node", + "PC.Pages.AgentFlowParams.nodeHumanAskName": "Ask User", + "PC.Pages.AgentFlowParams.nodeHumanAskDescription": "Ask user a question and get response", + "PC.Pages.AgentFlowParams.nodeEvalGateName": "Result Evaluation", + "PC.Pages.AgentFlowParams.nodeEvalGateDescription": "Evaluate agent output results", + "PC.Pages.AgentFlowParams.nodeExternalConnectorName": "External Connector", + "PC.Pages.AgentFlowParams.nodeExternalConnectorDescription": "Connect to external platform services", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertName": "Knowledge Insert", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertDescription": "Write content into the selected knowledge bases", + "PC.Pages.AntvX6Stencil.searchPlaceholder": "Search nodes...", + "PC.Pages.AppDevEditorHeaderRight.viewTerminal": "View terminal", + "PC.Pages.AppDevEditorHeaderRight.gitVersionHistory": "Version history", + "PC.Pages.Chat.editConversationInfo": "Edit Conversation Info", + "PC.Pages.Chat.conversationIcon": "Conversation Icon", + "PC.Pages.Chat.conversationName": "Conversation Name", + "PC.Pages.ConversationAgent.devServerReady": "Dev server ready", + "PC.Pages.ConversationAgent.gitPush.defaultMessage": "Update files", + "PC.Pages.ConversationAgent.gitPush.noConversation": "No active conversation, cannot perform Git push", + "PC.Pages.ConversationAgent.gitPush.success": "Git commit and push successful", + "PC.Pages.ConversationAgent.ArrangePanel.tabConfig": "Config", + "PC.Pages.ConversationAgent.ArrangePanel.tabDebug": "Debug", + "PC.Pages.ConversationAgent.ArrangePanel.tabVersion": "Version", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.title": "Version History", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.subtitle": "{0} commits · {1} branch", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollback": "Rollback", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollbackSuccess": "Rollback successful", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.commitsOn": "Commits on {0}", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.empty": "No commits yet", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.noConversation": "No version records", + "PC.Components.FileTreePanel.FileTreeToolbar.projects": "Projects", + "PC.Components.FileTreePanel.FileTreeToolbar.exportProject": "Export project", + "PC.Components.FileTreePanel.FileTreeToolbar.importProject": "Import project", + "PC.Components.FileTreePanel.FileTreeToolbar.newFile": "New file", + "PC.Components.FileTreePanel.FileTreeToolbar.newFolder": "New folder", + "PC.Components.FileTreePanel.FileTreeToolbar.upload": "Upload", + "PC.Components.FileTreePanel.FileTreeToolbar.uploadFolder": "Upload Folder", + "PC.Components.FileTreePanel.FileTreeToolbar.collapseAll": "Collapse all", + "PC.Components.FileTreePanel.GitVersionRecord.noProject": "Select a project to view version history", + "PC.Components.FileTreePanel.GitVersionRecord.backToList": "Back to list", + "PC.Components.FileTreePanel.GitVersionRecord.changedFileCount": "Changed files: {0}", + "PC.Components.FileTreePanel.GitVersionRecord.searchPlaceholder": "Search", + "PC.Components.FileTreePanel.GitVersionRecord.diffUnified": "Unified", + "PC.Components.FileTreePanel.GitVersionRecord.diffSplit": "Split", + "PC.Components.FileTreePanel.GitVersionRecord.revertToVersion": "Revert to this version", + "PC.Components.FileTreePanel.GitVersionRecord.statusModified": "Modified", + "PC.Components.FileTreePanel.GitVersionRecord.statusAdded": "Added", + "PC.Components.FileTreePanel.GitVersionRecord.statusDeleted": "Deleted", + "PC.Components.FileTreePanel.GitVersionRecord.statusRenamed": "Renamed", + "PC.Components.FileTreePanel.GitVersionRecord.noChangedFiles": "No changed files in this commit", + "PC.Components.FileTreePanel.GitVersionRecord.autoSaveBeforeRestoreMessage": "auto saved your changes before restore", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackModalTitle": "Rollback to version {0}", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackVersionLabel": "Rollback version", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectTitle": "This operation will:", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectAutoSave": "Uncommitted files in the workspace and staging area will be saved automatically", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectBranchSync": "Other conversations under this branch will also be rolled back synchronously", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectWorkspace": "Restore the workspace to the selected checkpoint state", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectUndoChanges": "Undo all changes after this version", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectHistory": "History will be preserved; you can roll back to other versions at any time", + "PC.Pages.ConversationAgent.prototypeTitle": "Agent Dev Prototype", + "PC.Pages.ConversationAgentMiddlePanel.files": "Files", + "PC.Pages.ConversationAgentMiddlePanel.sourceControl": "Source Control", + "PC.Pages.ConversationAgentSourceControl.changes": "Changes", + "PC.Pages.ConversationAgentSourceControl.commit": "Commit", + "PC.Pages.ConversationAgentSourceControl.commitAndPush": "Push", + "PC.Pages.ConversationAgentSourceControl.commitMessage": "Commit message", + "PC.Pages.ConversationAgentSourceControl.gitPushTooltip": "Git commit and push to remote repository", + "PC.Pages.ConversationAgentSourceControl.noChanges": "No modified files", + "PC.Pages.ConversationAgentSourceControl.save": "Save", + "PC.Pages.ConversationAgentSourceControl.saveTooltip": "Save changes to sandbox", + "PC.Pages.ConversationAgentSourceControl.title": "Source Control", + "PC.Pages.ConversationAgentSourceControl.openChanges": "Open Changes", + "PC.Pages.ConversationAgentSourceControl.openFile": "Open File", + "PC.Pages.ConversationAgentSourceControl.discardChanges": "Discard Changes", + "PC.Pages.ConversationAgentSourceControl.discardChangesConfirmTitle": "Discard changes?", + "PC.Pages.ConversationAgentSourceControl.discardAllChanges": "Discard All Changes", + "PC.Pages.ConversationAgentSourceControl.discardAllChangesConfirmTitle": "Discard all changes?", + "PC.Pages.ConversationAgentSourceControl.stageAllChanges": "Stage All Changes", + "PC.Pages.ConversationAgentSourceControl.unstageAllChanges": "Unstage All Changes", + "PC.Pages.ConversationAgentSourceControl.stageChanges": "Stage Changes", + "PC.Pages.ConversationAgentSourceControl.unstageChanges": "Unstage Changes", + "PC.Pages.ConversationAgentSourceControl.addToGitignore": "Add to .gitignore", + "PC.Pages.ConversationAgentSourceControl.alreadyInGitignore": "File is already in .gitignore", + "PC.Pages.ConversationAgentSourceControl.gitignoreSuccess": "Added to .gitignore", + "PC.Pages.ConversationAgentSourceControl.gitignoreFailed": "Failed to add to .gitignore", + "PC.Pages.ConversationAgentSourceControl.stagedChanges": "Staged Changes", + "PC.Pages.ConversationAgentSourceControl.refresh": "Refresh", + "PC.Pages.ConversationAgentSourceControl.viewAsList": "View as list", + "PC.Pages.ConversationAgentSourceControl.viewAsTree": "View as tree", + "PC.Pages.ConversationAgentPreviewTabBar.addTab": "New Tab", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.close": "Close", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeOthers": "Close Others", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeAll": "Close All", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.pin": "Pin", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.unpin": "Unpin", + "PC.Pages.ConversationAgentPreviewTabBar.closeTab": "Close Tab", + "PC.Pages.ConversationAgentTabPicker.devTools": "Development Tools", + "PC.Pages.ConversationAgentTabPicker.devToolsDesc": "Code editing, live preview and debugging for the full development workflow.", + "PC.Pages.ConversationAgentTabPicker.preview": "Preview", + "PC.Pages.ConversationAgentTabPicker.previewDesc": "Preview running results and debug code in real time.", + "PC.Pages.ConversationAgentTabPicker.arrange": "Arrange", + "PC.Pages.ConversationAgentTabPicker.arrangeDesc": "Configure model, prompts, variables, tools and other agent settings.", + "PC.Pages.ConversationAgentTabPicker.terminal": "Terminal", + "PC.Pages.ConversationAgentTabPicker.terminalDesc": "Access the system shell to run scripts or install dependencies.", + "PC.Pages.ConversationAgentTabPicker.versionControl": "Version Control", + "PC.Pages.ConversationAgentTabPicker.versionControlDesc": "Manage code versions, view changes and commit history.", + "PC.Pages.ConversationAgentTabPicker.subscription": "Subscription", + "PC.Pages.ConversationAgentTabPicker.subscriptionDesc": "Configure paid plans and trials, and view subscription metrics.", + "PC.Pages.ConversationAgentTabPicker.subscriptionSetting": "Subscription Settings", + "PC.Pages.ConversationAgentTabPicker.subscriptionSettingDesc": "Enable paid mode, manage plans and default trial count.", + "PC.Pages.ConversationAgentTabPicker.subscriptionStats": "Subscription Stats", + "PC.Pages.ConversationAgentTabPicker.subscriptionStatsDesc": "View total subscriptions, revenue and subscriber list.", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPrompt": "Supplementary Prompt", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPromptPlaceholder": "Enter a supplementary prompt to add extra constraints for the group agent", + "PC.Pages.MorePage.ApiKeyPermission.apiPermission": "API Permissions", + "PC.Pages.MorePage.ApiKeyPermission.modelPermission": "System Models", + "PC.Pages.MorePage.ApiKeyPermission.noModelDefs": "No models defined", + "PC.Pages.MorePage.ApiKeyPermission.spaceModelPermission": "Personal Space Models", + "PC.Pages.MorePage.ApiKeyPermission.noSpaceModelDefs": "No space models defined", + "PC.Pages.MorePage.MySubscriptions.paySuccess": "Payment successful", + "PC.Pages.MorePage.MySubscriptions.payFailed": "Payment failed, please try again", + "PC.Pages.MorePage.MySubscriptions.payPending": "Payment pending confirmation, please check back later", + "PC.Pages.OpenApp.subscription": "Subscribe", + "PC.Pages.SpaceDevelop.ApplicationItem.agentFlowType": "AgentFlow", + "PC.Pages.SpaceDevelop.ApplicationItem.customType": "Custom", + "PC.Pages.SpaceDevelop.ApplicationItem.agentGroupType": "AgentGroup", + "PC.Pages.SpaceLibrary.CreateModel.inputModelIdentifier": "Enter model identifier", + "PC.Pages.SpacePluginWorkflow.pluginPageTitle": "Plugins", + "PC.Pages.SpacePluginWorkflow.workflowPageTitle": "Workflows", + "PC.Pages.SpaceResourcePricing.periodSecond": "second", + "PC.Pages.SpaceResourcePricing.periodMillionToken": "million tokens", + "PC.Pages.SpaceResourcePricing.pricingTypeOneTime": "One-time", + "PC.Pages.SpaceResourcePricing.pricingTypeMonthly": "Monthly", + "PC.Pages.SpaceResourcePricing.pricingTypeTiered": "Tiered billing", + "PC.Pages.UserManage.Index.delete": "Delete", + "PC.Pages.UserManage.Index.deleteSuccess": "Deleted successfully", + "PC.Pages.UserManage.Index.resetPassword": "Reset Password", + "PC.Pages.UserManage.Index.userId": "User ID", + "PC.Pages.UserManage.Index.placeholderUserId": "Please enter User ID, integers only", + "PC.Pages.UserManage.ResetPasswordModal.title": "Reset Password", + "PC.Pages.UserManage.ResetPasswordModal.newPassword": "New Password", + "PC.Pages.UserManage.ResetPasswordModal.inputNewPassword": "Please enter new password", + "PC.Pages.UserManage.ResetPasswordModal.confirmPassword": "Confirm Password", + "PC.Pages.UserManage.ResetPasswordModal.inputConfirmPassword": "Please enter the new password again", + "PC.Pages.UserManage.ResetPasswordModal.newPasswordRequired": "Please enter new password", + "PC.Pages.UserManage.ResetPasswordModal.confirmPasswordRequired": "Please confirm the new password", + "PC.Pages.UserManage.ResetPasswordModal.passwordMismatch": "The two passwords do not match", + "PC.Pages.UserManage.ResetPasswordModal.resetSuccess": "Password modified successfully", + "PC.Pages.UserManage.UserAuthModal.subscriptionUserGroup": "Subscription User Groups", + "PC.Routes.modelPermissions": "Model Permissions", + "PC.Pages.ModelPermissions.tabSystem": "System Models", + "PC.Pages.ModelPermissions.tabSpace": "Space Models", + "PC.Pages.ModelPermissions.colModelName": "Model Name", + "PC.Pages.ModelPermissions.colModelId": "Model ID", + "PC.Pages.ModelPermissions.colModelIdTip": "Use this value when calling from external systems", + "PC.Pages.ModelPermissions.colModalities": "Modalities", + "PC.Pages.ModelPermissions.colExternalCall": "External API", + "PC.Pages.ModelPermissions.colExternalCallTip": "Whether the model can be called from other external systems", + "PC.Pages.ModelPermissions.colApiProtocol": "API Protocol", + "PC.Pages.ModelPermissions.colPrice": "Price", + "PC.Pages.ModelPermissions.notSupported": "Not supported", + "PC.Pages.ModelPermissions.interfaceAddress": "API URL", + "PC.Pages.ModelPermissions.copyInterface": "Copy API", + "PC.Pages.ModelPermissions.interfaceAddressCopied": "API URL copied", + "PC.Pages.ModelPermissions.modelIdCopied": "Model ID copied", + "PC.Routes.recommendManage": "Recommendation Management", + "PC.Routes.homeRecommend": "Home Recommendations", + "PC.Routes.officialRecommend": "Official Recommendations", + "PC.Routes.chatboxRecommend": "Dialog Agent", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDesc": "Conversion description", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDescPlaceholder": "Enter a user-facing credit conversion note, e.g. exchange ratio and eligible usage. Shown next to the credit balance.", + "PC.Pages.SystemCreditPackages.periodMonthUnit": "months", + "PC.Pages.SystemUserCredits.colUserNamePhoneOrEmail": "Username / Phone / Email", + "PC.Pages.SystemUserCredits.grantCredit": "Grant credits", + "PC.Pages.SystemUserCredits.deductCredit": "Deduct credits", + "PC.Pages.SystemUserCredits.grantModalTitle": "Grant credits", + "PC.Pages.SystemUserCredits.deductModalTitle": "Deduct credits", + "PC.Pages.SystemUserCredits.fieldCreditType": "Credit type", + "PC.Pages.SystemUserCredits.fieldAmount": "Amount", + "PC.Pages.SystemUserCredits.fieldAmountPlaceholder": "Enter credit amount", + "PC.Pages.SystemUserCredits.fieldAmountMin": "Amount must be greater than 0", + "PC.Pages.SystemUserCredits.fieldBizNo": "Business No.", + "PC.Pages.SystemUserCredits.fieldBizNoPlaceholder": "Enter business no. (optional)", + "PC.Pages.SystemUserCredits.fieldRemark": "Remark", + "PC.Pages.SystemUserCredits.fieldRemarkPlaceholder": "Enter remark (optional)", + "PC.Pages.SystemUserCredits.fieldExpireTime": "Expire time", + "PC.Pages.SystemUserCredits.fieldExpireTimePlaceholder": "Select expire time (never expires if empty)", + "PC.Pages.SystemUserCredits.fieldAllowNegative": "Allow overdraft", + "PC.Pages.SystemUserCredits.grantSuccess": "Credits granted successfully", + "PC.Pages.SystemUserCredits.deductSuccess": "Credits deducted successfully", + "PC.Pages.SystemRecommendManage.addTitle": "Add Recommendation", + "PC.Pages.SystemRecommendManage.createSuccess": "Created successfully", + "PC.Pages.SystemRecommendManage.colAction": "Actions", + "PC.Pages.SystemRecommendManage.colCreated": "Created At", + "PC.Pages.SystemRecommendManage.colModified": "Modified At", + "PC.Pages.SystemRecommendManage.colLabel": "Name", + "PC.Pages.SystemRecommendManage.dialogHintLabel": "Dialog prompt message", + "PC.Pages.SystemRecommendManage.dialogHintPlaceholder": "Please enter the dialog prompt information", + "PC.Pages.SystemRecommendManage.colTargetId": "Target ID", + "PC.Pages.SystemRecommendManage.colTargetType": "Type", + "PC.Pages.SystemRecommendManage.colSubType": "Sub Type", + "PC.Pages.SystemRecommendManage.confirmDeleteContent": "This action cannot be undone. Continue?", + "PC.Pages.SystemRecommendManage.confirmDeleteTitle": "Confirm Delete", + "PC.Pages.SystemRecommendManage.editTitle": "Edit Recommendation", + "PC.Pages.SystemRecommendManage.updateSuccess": "Updated successfully", + "PC.Pages.SystemRecommendManage.functionType.agentDev": "Agent Development", + "PC.Pages.SystemRecommendManage.functionType.chat": "Agent", + "PC.Pages.SystemRecommendManage.functionType.pageAppDev": "Web App Development", + "PC.Pages.SystemRecommendManage.functionType.pluginDev": "Plugin Development", + "PC.Pages.SystemRecommendManage.functionType.skillDev": "Skill Development", + "PC.Pages.SystemRecommendManage.searchName": "Enter name", + "PC.Pages.SystemRecommendManage.selectTarget": "Select target", + "PC.Pages.SystemRecommendManage.selectTargetRequired": "Please select {0}", + "PC.Pages.SystemRecommendManage.clickToAddAgent": "Click to add agent", + "PC.Pages.SystemRecommendManage.sortUpdated": "Sort updated", + "PC.Pages.SystemRecommendManage.targetType.Agent": "Agent", + "PC.Pages.SystemRecommendManage.targetType.PageApp": "Application", + "PC.Pages.SystemRecommendManage.targetType.Plugin": "Plugin", + "PC.Pages.SystemRecommendManage.targetType.Skill": "Skill", + "PC.Pages.SystemRecommendManage.targetType.Workflow": "Workflow", + "PC.Pages.SystemPlans.fieldNamePlaceholderExample": "e.g. Basic", + "PC.Pages.SystemPlans.fieldCreditAmountMonthly": "Credits included (monthly)", + "PC.Pages.SystemPlans.fieldPriceLabel": "Plan price (¥)", + "PC.Pages.SystemPlans.fieldPricePlaceholderExample": "e.g. 99", + "PC.Pages.SystemPlans.fieldValidityPeriod": "Validity period", + "PC.Pages.SystemPlans.periodOption1Month": "1 month", + "PC.Pages.SystemPlans.periodOption3Months": "3 months", + "PC.Pages.SystemPlans.periodOption12Months": "12 months", + "PC.Pages.SystemPlans.fieldHotLabel": "Featured tag", + "PC.Pages.SystemPlans.developerPermissionTitle": "Developer permissions", + "PC.Pages.SystemPlans.originalPrice": "Original ¥{0}/{1}", + "PC.Pages.SpaceCreateProject.greetingTitle": "Hi, {0}, give me a task to start now?", + "PC.Pages.SpaceCreateProject.tabAgent": "Agent", + "PC.Pages.SpaceCreateProject.placeholderAgent": "Describe the agent you want, e.g., help me create a code review assistant that automatically detects issues and provides optimization suggestions.", + "PC.Pages.SpaceCreateProject.tabPageApp": "Web App", + "PC.Pages.SpaceCreateProject.placeholderPageApp": "Describe the web app you want, e.g., help me develop a beauty rating website that supports uploading photos to intelligently evaluate beauty and skin quality.", + "PC.Pages.SpaceCreateProject.tabSkill": "Skill", + "PC.Pages.SpaceCreateProject.placeholderSkill": "Describe the custom skill you want, e.g., help me write an API endpoint to query the current weather based on coordinates.", + "PC.Pages.SpaceCreateProject.tabPlugin": "Plugin", + "PC.Pages.SpaceCreateProject.placeholderPlugin": "Describe the plugin tool you want, e.g., help me integrate a third-party image conversion HTTP plugin.", + "PC.Pages.SpaceCreateProject.inputRequiredWarning": "Please input your task description!", + "PC.Pages.SpaceKnowledge.KnowledgeHeader.accuracytest": "Accuracy Test", }; diff --git a/qiming/src/locales/i18n/ja-JP.ts b/qiming/src/locales/i18n/ja-JP.ts index 5c9dd1d1..a975499e 100644 --- a/qiming/src/locales/i18n/ja-JP.ts +++ b/qiming/src/locales/i18n/ja-JP.ts @@ -2644,6 +2644,8 @@ export const JA_JP: SystemLangMap = { "PC.Pages.Login.captchaTitle": "続行するには次の確認を完了してください", "PC.Pages.Login.captchaVerifying": "確認中...", "PC.Pages.Login.codeLoginOrRegister": "コードでサインイン / サインアップ", + "PC.Pages.Login.inputAccountPlaceholder": "ユーザー名またはメールアドレスを入力してください", + "PC.Pages.Login.inputAccountRequired": "ユーザー名またはメールアドレスを入力してください", "PC.Pages.Login.inputEmailPlaceholder": "メールアドレスを入力してください", "PC.Pages.Login.inputEmailRequired": "メールアドレスを入力してください", "PC.Pages.Login.inputPasswordPlaceholder": "6文字以上のパスワードを入力してください", @@ -5358,4 +5360,524 @@ export const JA_JP: SystemLangMap = { "PC.Pages.SystemSubsOrders.detailDeveloperInfo": "Developer Info", "PC.Pages.SystemSubsOrders.detailSubscriptionInfo": "Subscription Info", "PC.Pages.SystemSubsOrders.drawerTitle": "Subscription Details", + "PC.Common.Global.times": "回", + "PC.Common.Global.uploadFileSizeExceed": "アップロード ファイルの合計サイズは {0}MB を超えることはできません", + "PC.Components.ConversationBottomConsole.collapsePanel": "下部に折りたたむ", + "PC.Components.ConversationBottomConsole.containerKeepaliveError": "サービスのキープアライブ リクエストに失敗しました。ネットワークを確認して再試行してください。", + "PC.Components.ConversationBottomConsole.containerKeepaliveFailed": "サービスのキープアライブに失敗しました。サービスを再起動してください。", + "PC.Components.ConversationBottomConsole.containerStartError": "サービスの起動に失敗しました。しばらくしてから再試行してください。", + "PC.Components.ConversationBottomConsole.containerStarting": "サービスを起動中です。しばらくお待ちください…", + "PC.Components.ConversationBottomConsole.expandHeight": "全画面に拡大", + "PC.Components.ConversationBottomConsole.logsEmpty": "ログはまだありません", + "PC.Components.ConversationBottomConsole.restoreHeight": "デフォルトの高さに戻す", + "PC.Components.ConversationBottomConsole.retryStartContainer": "サービスを再起動", + "PC.Components.ConversationBottomConsole.tabLogs": "ログ", + "PC.Components.ConversationBottomConsole.tabTerminal": "ターミナル", + "PC.Components.ConversationBottomConsole.terminalEmpty": "ターミナル出力はまだありません", + "PC.Components.ConversationBottomConsole.terminalThemeDark": "ダークターミナルに切り替え", + "PC.Components.ConversationBottomConsole.terminalThemeLight": "ライトターミナルに切り替え", + "PC.Components.VoiceInput.startTooltip": "音声入力", + "PC.Components.VoiceInput.connecting": "マイクに接続中…", + "PC.Components.VoiceInput.stopTooltip": "録音を停止して認識", + "PC.Components.VoiceInput.stopFillTooltip": "録音を停止して入力欄に反映", + "PC.Components.VoiceInput.stopSendTooltip": "録音を停止して送信", + "PC.Components.VoiceInput.transcribing": "音声認識中…", + "PC.Components.VoiceInput.permissionDenied": "マイクへのアクセスが拒否されました。ブラウザの設定で許可して再試行してください", + "PC.Components.VoiceInput.notSupported": "現在の環境では音声入力に対応していません。最新の Chrome/Edge/Firefox/Safari をご利用ください", + "PC.Components.VoiceInput.tooShort": "録音時間が短すぎます", + "PC.Components.VoiceInput.tooLarge": "録音ファイルが大きすぎます。録音時間を短くして再試行してください", + "PC.Components.VoiceInput.recognizeFailed": "音声認識に失敗しました。再試行してください", + "PC.Components.ChatInputHome.pastedFileName": "貼り付けられたファイル-{0}-{1}", + "PC.Components.ChatInputHome.dropFilesHint": "離してファイルをアップロード", + "PC.Components.ChatInputHome.agentMode": "エージェントモード", + "PC.Components.ChatInputHome.agentModeAuto": "自動", + "PC.Components.ChatInputHome.agentModeAutoDesc": "ツール実行前にユーザーに確認しません", + "PC.Components.ChatInputHome.agentModeApproval": "承認", + "PC.Components.ChatInputHome.agentModeApprovalDesc": "一部のツール実行前にユーザーの承認を求めます", + "PC.Components.ChatInputHomeSpaceSelector.noAvailableSpace": "利用可能なスペースはありません", + "PC.Components.CollapseComponentList.groupAgentDesc": "グループエージェントはグループ内のメンバーエージェントで、協力してタスクを実行します。", + "PC.Components.CreateAgent.typeAgentFlow": "AgentFlow", + "PC.Components.CreateAgent.typeAgentGroup": "AgentGroup", + "PC.Components.CreateWorkflow.createAgentFlow": "AgentFlowを作成", + "PC.Components.CreateWorkflow.placeholderAgentFlowName": "AgentFlow名を入力", + "PC.Components.CreateWorkflow.placeholderAgentFlowDesc": "このAgentFlowをいつ使用するかをモデルが理解できるように説明を入力", + "PC.Components.CreateWorkflow.skip": "スキップ", + "PC.Components.CreateWorkflow.startEditing": "編集開始", + "PC.Components.FileContextMenu.uploadFolder": "フォルダをアップロード", + "PC.Components.FileContextMenu.uploadFolderEmpty": "アップロード可能なファイルがフォルダにありません", + "PC.Components.FileContextMenu.uploadFileEmpty": "アップロード可能なファイルがありません", + "PC.Components.FileContextMenu.uploadTargetInvalid": "このディレクトリにはアップロードできません", + "PC.Components.HistoryConversationList.emptySession": "まだ会話がありません。最初のタスクを始めましょう!", + "PC.Components.HistoryConversationList.noMore": "これ以上ありません", + "PC.Components.HistoryConversationList.noSearchResult": "検索結果がありません", + "PC.Components.McpAskQuestionCard.cancelShortcutHint": "キャンセル(Esc)", + "PC.Components.McpAskQuestionCard.cancelled": "キャンセルされました", + "PC.Components.McpAskQuestionCard.customInputLabel": "カスタム値", + "PC.Components.McpAskQuestionCard.customInputPlaceholder": "値を入力", + "PC.Components.McpAskQuestionCard.customOption": "カスタム", + "PC.Components.MessageQueue.clearAll": "すべてクリア", + "PC.Components.MessageQueue.editMessageTooltip": "メッセージを編集", + "PC.Components.MessageQueue.sendNowTooltip": "今すぐ送信", + "PC.Components.MessageQueue.title": "送信待ち", + "PC.Components.McpAskQuestionCard.defaultTitle": "この質問", + "PC.Components.McpAskQuestionCard.notFilled": "未入力", + "PC.Components.McpAskQuestionCard.unknownFile": "不明なファイル", + "PC.Components.McpAskQuestionCard.uploadDragText": "ここをクリックまたはファイルをドラッグしてアップロード", + "PC.Components.McpAskQuestionCard.file": "ファイル", + "PC.Components.McpAskQuestionCard.emptyFormContent": "(フォーム内容なし)", + "PC.Components.McpAskQuestionCard.eyebrow": "追加返答", + "PC.Components.McpAskQuestionCard.fieldRequired": "この項目は必須です", + "PC.Components.McpAskQuestionCard.multiSelectMin": "少なくとも1つ選択してください", + "PC.Components.McpAskQuestionCard.nextStep": "次へ", + "PC.Components.McpAskQuestionCard.prevStep": "前へ", + "PC.Components.McpAskQuestionCard.resumeCancelled": "「{0}」をキャンセルしました。", + "PC.Components.McpAskQuestionCard.resumeSkipped": "「{0}」をスキップしました。", + "PC.Components.McpAskQuestionCard.resumeTimeout": "「{0}」はタイムアウトしました。フォームの回答は受信されませんでした。", + "PC.Components.McpAskQuestionCard.skip": "スキップ", + "PC.Components.McpAskQuestionCard.skipped": "スキップ済み", + "PC.Components.McpAskQuestionCard.stepOf": "ステップ {0} / {1}", + "PC.Components.McpAskQuestionCard.submitted": "回答を送信しました", + "PC.Components.McpAskQuestionCard.resumeSubmitted": "「{0}」に回答しました。フォーム内容は以下のとおりです:", + "PC.Components.PaymentSubscriptionModal.titleSelectPlan": "サブスクリプションプランを選択", + "PC.Components.PaymentSubscriptionModal.titleWithTrialCount": "サブスクリプションプランを選択(試用回数:{0}/{1})", + "PC.Components.PaymentSubscriptionModal.emptyNoPlans": "利用可能なプランがありません", + "PC.Components.PaymentSubscriptionModal.monthlyCredits": "毎月 {0} クレジット", + "PC.Components.PaymentSubscriptionModal.callLimitUnlimited": "制限なし", + "PC.Components.PaymentSubscriptionModal.callLimitPerMonth": "月 {0} 回", + "PC.Components.PaymentSubscriptionModal.originalPrice": "定価 {0}{1}/{2}", + "PC.Components.PaymentSubscriptionModal.callCountLabel": "呼び出し回数:{0}", + "PC.Components.PaymentSubscriptionModal.periodMonth": "月", + "PC.Components.PaymentSubscriptionModal.periodQuarter": "四半期", + "PC.Components.PaymentSubscriptionModal.periodYear": "年", + "PC.Components.PaymentSubscriptionModal.periodForever": "永久", + "PC.Components.PaymentSubscriptionModal.btnSubscribePlan": "プランを購読", + "PC.Components.PaymentSubscriptionModal.btnSubscribeBuyoutPlan": "買い切りプランを購読", + "PC.Components.PaymentSubscriptionModal.btnForeverBoughtOut": "買い切り済み", + "PC.Components.PaymentSubscriptionModal.btnExpiredRenew": "期限切れ(更新)", + "PC.Components.PaymentSubscriptionModal.btnCurrent": "現在のプラン", + "PC.Components.PaymentSubscriptionModal.btnCurrentRenew": "現在のプラン(更新)", + "PC.Components.PaymentSubscriptionModal.btnUpgrade": "アップグレード", + "PC.Constants.Library.audioProcess": "音声処理", + "PC.Constants.Library.chatMultimodal": "チャット - マルチモーダル", + "PC.Constants.Library.chatText": "チャット - テキストのみ", + "PC.Constants.Library.imageProcess": "画像処理", + "PC.Constants.Library.supportFuncCall": "関数呼び出し対応", + "PC.Constants.Library.textCompletion": "テキスト補完", + "PC.Constants.Library.textEdit": "テキスト編集", + "PC.Constants.Library.vectorEmbedding": "ベクトル埋め込み", + "PC.Constants.Library.externalApi": "外部API呼び出し", + "PC.Constants.Space.callApproval": "呼び出し承認", + "PC.Constants.Space.agentFlowType": "AgentFlow", + "PC.Constants.Space.agentFlowTypeTooltip": "複数エージェントの協調編成に基づくワークフロー。複雑なタスク分解、分岐判断、人間の介入、結果評価に適しています。", + "PC.Constants.Space.agentGroupType": "AgentGroup", + "PC.Constants.Space.agentGroupTypeTooltip": "複数のエージェントが協力して複雑なタスクを分担するためのマルチエージェント協調グループ。", + "PC.Constants.Space.group": "グループ", + "PC.Pages.SpaceResource.LeftGroupList.createGroupTitle": "グループの作成", + "PC.Pages.SpaceResource.LeftGroupList.editGroupTitle": "グループの編集", + "PC.Pages.SpaceResource.LeftGroupList.groupNameLabel": "グループ名", + "PC.Pages.SpaceResource.LeftGroupList.groupNameMaxLength": "名前は30文字以内で入力してください", + "PC.Pages.SpaceResource.LeftGroupList.groupNameNoWhitespace": "名前を空白にすることはできません", + "PC.Pages.SpaceResource.LeftGroupList.groupNamePlaceholder": "グループ名を入力してください", + "PC.Pages.SpaceResource.LeftGroupList.groupNameRequired": "グループ名を入力してください", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeLabel": "グループタイプ", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeRequired": "グループタイプを選択してください", + "PC.Pages.SpaceResource.LeftGroupList.moveSuccess": "移動しました", + "PC.Pages.SpaceResource.LeftGroupList.moveToGroup": "グループへ移動", + "PC.Pages.SpaceResource.LeftGroupList.moveToOtherGroup": "別のグループへ移動", + "PC.Pages.SpaceResource.LeftGroupList.removeFromGroup": "グループから削除", + "PC.Pages.SpaceResource.LeftGroupList.removeSuccess": "削除しました", + "PC.Pages.SpaceResource.LeftGroupList.selectGroup": "グループを選択", + "PC.Pages.SpaceResource.LeftGroupList.selectGroupPlaceholder": "移動先のグループを選択してください", + "PC.Pages.SpaceResource.LeftGroupList.unitCount": "{0} 件", + "PC.Pages.SpaceResource.LeftGroupList.deleteConfirmContent": "グループ「{0}」を削除してもよろしいですか?", + "PC.Pages.SpaceResource.LeftGroupList.createSuccess": "作成に成功しました", + "PC.Pages.SpaceResource.LeftGroupList.editSuccess": "変更に成功しました", + "PC.Pages.SpaceResource.LeftGroupList.groupDescLabel": "グループの説明", + "PC.Pages.SpaceResource.LeftGroupList.groupDescMaxLength": "説明は100文字以内で入力してください", + "PC.Pages.SpaceResource.LeftGroupList.groupDescPlaceholder": "グループの説明を入力してください", + "PC.Pages.SpaceResource.LeftGroupList.groupIconLabel": "グループアイコン", + "PC.Pages.SpaceResource.LeftGroupList.deleteSuccess": "削除に成功しました", + "PC.Modal.chunkLoadErrorContent": "ページリソースの読み込みに失敗しました。ページを更新して再試行してください。", + "PC.Modal.chunkLoadErrorRefresh": "今すぐ更新", + "PC.Modal.chunkLoadErrorTitle": "ページ読み込みエラー", + "PC.Models.ConversationInfo.askResponseFailed": "回答の送信に失敗しました", + "PC.Hooks.UseSubscription.createOrderFailed": "サブスクリプション注文の作成に失敗しました", + "PC.Hooks.UseSubscription.getCashierUrlFailed": "レジURLの取得に失敗しました", + "PC.Layouts.DynamicMenusLayout.newProject": "新規プロジェクト", + "PC.Layouts.DynamicMenusLayout.SpaceSection.searchSpacePlaceholder": "スペースを検索", + "PC.Layouts.DynamicMenusLayout.SpaceSection.noSpaceMatch": "一致するスペースがありません", + "PC.Models.ConversationInfo.permissionResponseFailed": "権限応答の送信に失敗しました", + "PC.Pages.AgentArrangeConfig.addGroupAgent": "グループエージェントを追加", + "PC.Pages.AgentArrangeConfig.allowChooseMode": "ユーザーがダイアログでモードを選択できるようにする", + "PC.Pages.AgentArrangeConfig.allowChooseModeDesc": "有効にすると、ユーザーはダイアログ下部で Auto または Approval を選択できます。無効の場合は、デフォルトで Auto モードが使用されます。", + "PC.Pages.AgentArrangeConfig.versionControl": "バージョン管理", + "PC.Pages.AgentArrangeConfig.versionControlDesc": "有効にすると、エージェントはバージョン管理をサポートし、コード変更とコミット履歴を確認できます。", + "PC.Pages.AgentArrangeConfig.groupAgent": "グループエージェント", + "PC.Pages.AgentArrangeConfig.groupMembers": "メンバー", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategy": "パラメータ更新戦略", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyAppend": "追加", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyPlaceholder": "パラメータ更新戦略を選択してください", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyReplace": "置換", + "PC.Pages.AgentArrangeCreateHookModal.config": "設定 JSON", + "PC.Pages.AgentArrangeCreateHookModal.configInvalid": "有効な JSON 設定を入力してください", + "PC.Pages.AgentArrangeCreateHookModal.event": "イベント", + "PC.Pages.AgentArrangeCreateHookModal.eventRequired": "イベントを選択してください", + "PC.Pages.AgentArrangeCreateHookModal.matcher": "マッチ規則", + "PC.Pages.AgentArrangeCreateHookModal.matcherPlaceholder": "ツール名(例:Bash、Edit|Write、mcp...)", + "PC.Pages.AgentArrangeCreateHookModal.matcherNotSupported": "マッチ規則はサポートされていません", + "PC.Pages.AgentArrangeCreateHookModal.matcherStopFailurePlaceholder": "エラータイプ(例:rate_limit、overloaded)", + "PC.Pages.AgentArrangeCreateHookModal.matcherSessionStartPlaceholder": "起動方式(例:startup、resume、clear、compact)", + "PC.Pages.AgentArrangeCreateHookModal.matcherSubagentStartPlaceholder": "Agent タイプ(例:general-purpose、Explore、Plan)", + "PC.Pages.AgentArrangeCreateHookModal.name": "名称", + "PC.Pages.AgentArrangeCreateHookModal.namePlaceholder": "例:コードフォーマット", + "PC.Pages.AgentArrangeCreateHookModal.nameRequired": "名称を入力してください", + "PC.Pages.AgentArrangeCreateHookModal.titleCreate": "Hook を作成", + "PC.Pages.AgentArrangeCreateHookModal.titleEdit": "Hook を編集", + "PC.Pages.AgentArrangeCreateHookModal.type": "タイプ", + "PC.Pages.AgentArrangeCreateHookModal.typeRequired": "タイプを選択してください", + "PC.Pages.AgentArrangeCreateHooks.add": "新規", + "PC.Pages.AgentArrangeCreateHooks.columnAction": "操作", + "PC.Pages.AgentArrangeCreateHooks.columnEvent": "イベント", + "PC.Pages.AgentArrangeCreateHooks.columnMatcher": "マッチ規則", + "PC.Pages.AgentArrangeCreateHooks.columnName": "名称", + "PC.Pages.AgentArrangeCreateHooks.columnStatus": "状態", + "PC.Pages.AgentArrangeCreateHooks.columnType": "タイプ", + "PC.Pages.AgentArrangeCreateHooks.deleteSuccess": "削除しました", + "PC.Pages.AgentArrangeCreateHooks.searchPlaceholder": "検索...", + "PC.Pages.AgentArrangeCreateHooks.title": "Hook 設定", + "PC.Pages.AgentArrangeCreateHooks.titleTooltip": "OpenAI プロトコルモデルがサポートするイベント:PreToolUse、PostToolUse、PostToolUseFailure、SessionStart、SessionEnd、Stop", + "PC.Pages.AgentArrangeCreateHooks.updateSuccess": "更新しました", + "PC.Pages.AgentArrangeConfig.addHook": "Hook を追加", + "PC.Pages.AgentArrangeConfig.hook": "Hook 設定", + "PC.Pages.AgentArrangeConfig.hookUnavailable": "Hook コンポーネントの準備ができていません。後でもう一度お試しください", + "PC.Pages.AgentArrangeConfig.enableAskQuestion": "ユーザーへの質問を許可", + "PC.Pages.AgentArrangeConfig.enableAskQuestionDesc": "有効にすると、エージェントが追加情報が必要な場合にフォームを表示してユーザーに質問できます。", + "PC.Components.HookList.description": "Hook を使用すると、エージェント実行の重要なポイントにカスタムロジックを挿入できます。", + "PC.Pages.AgentArrangeCallApproval.optionNo": "いいえ", + "PC.Pages.AgentArrangeCallApproval.optionYes": "はい", + "PC.Pages.AgentArrangeCallApproval.title": "承認が必要か", + "PC.Pages.AgentArrangeCallApproval.tooltip": "有効にすると、モデルはツールを呼び出す前にユーザーへ承認通知を送信し、同意後に実行を続行します", + "PC.Pages.AntvX6Header.autoArrange": "自動整列", + "PC.Pages.AntvX6Header.run": "実行", + "PC.Pages.AntvX6Header.save": "保存", + "PC.Pages.AgentFlow.loading": "読み込み中...", + "PC.Pages.AgentFlow.chipApprove": "承認", + "PC.Pages.AgentFlow.chipFail": "不合格", + "PC.Pages.AgentFlow.chipPass": "合格", + "PC.Pages.AgentFlow.chipReject": "却下", + "PC.Pages.AgentFlow.dragToConnect": "ドラッグで接続", + "PC.Pages.AgentFlowNode.routeDecisionExtraPromptPlaceholder": "AIへの追加ルーティング指示を入力...", + "PC.Pages.AgentFlowNode.askQuestionLabel": "質問内容", + "PC.Pages.AgentFlowNode.askQuestionPlaceholder": "ユーザーに尋ねる質問を入力...", + "PC.Pages.AgentFlowNode.routeDecisionRouteDescriptionPlaceholder": "このルートを選択すべきタイミングを記述...", + "PC.Pages.AgentFlowNode.routeDecisionItemFallback": "ルート {0}", + "PC.Pages.AgentFlowNode.askOptionIndex": "選択肢 {0}", + "PC.Pages.AgentFlowNode.extraPromptLabel": "追加プロンプト", + "PC.Pages.AgentFlowNode.agentInputLabel": "入力パラメータ", + "PC.Pages.AgentFlowNode.contextWriteKeyLabel": "コンテキスト書き込み", + "PC.Pages.AgentFlowNode.contextWriteKeyPlaceholder": "例:user_reply", + "PC.Pages.AgentFlowNode.endEmptyHint": "終了ノードは追加設定が不要で、フローはここで自動的に終了します。", + "PC.Pages.AgentFlowNode.inputRefVarsLabel": "参照変数", + "PC.Pages.AgentFlowNode.replyModeFormReply": "フォーム返信", + "PC.Pages.AgentFlowNode.replyModeOptionsReply": "選択肢返信", + "PC.Pages.AgentFlowNode.replyModeTextReply": "テキスト返信", + "PC.Pages.AgentFlowNode.routeAddBranch": "分岐を追加", + "PC.Pages.AgentFlowNode.routeAddCondition": "条件を追加", + "PC.Pages.AgentFlowNode.routeBranchesTitle": "ルート分岐", + "PC.Pages.AgentFlowNode.routeOtherIntent": "その他", + "PC.Pages.AgentFlowNode.routeConditionMatchLabel": "条件マッチング", + "PC.Pages.AgentFlowNode.routeConditionValue": "値", + "PC.Pages.AgentFlowNode.routeConditionVariable": "変数", + "PC.Pages.AgentFlowNode.routeExtraPromptLabel": "追加プロンプト", + "PC.Pages.AgentFlowNode.routeInputLabel": "入力", + "PC.Pages.AgentFlowNode.routeSelectVariablePlaceholder": "変数を選択...", + "PC.Pages.AgentFlowNode.startEmptyHint": "開始ノードは追加設定が不要で、フローはこのノードから開始します。", + "PC.Pages.AgentFlowNode.startSingleOutgoingHint": "開始ノードは1つの後続ノードにのみ接続できます", + "PC.Pages.AgentFlowNode.extraPromptPlaceholder1": "補足プロンプトを入力し、エージェントの動作をさらに制約してください", + "PC.Pages.AgentFlowNode.reminderPromptLabel": "ループリマインダープロンプト", + "PC.Pages.AgentFlowNode.reminderPromptPlaceholder": "ループリマインダープロンプト...", + "PC.Pages.AgentFlowNode.selfLoopTimesLabel": "自己ループ回数", + "PC.Pages.AgentFlowNode.selfLoopTimesTooltip": "エージェントが自己ループする回数。0はループしないことを示す", + "PC.Pages.AgentFlowNode.routeDecisionRouteNamePlaceholder": "例: complaint_handling", + "PC.Pages.AgentFlowNode.askOptionsTitle": "選択肢", + "PC.Pages.AgentFlowNode.contextWriteKeyHint": "出力を書き込むコンテキストキー名(例: evalResult)", + "PC.Pages.AgentFlowNode.formFieldLabel": "フィールドラベル", + "PC.Pages.AgentFlowNode.formFieldOptionsPlaceholder": "オプション1,オプション2,オプション3", + "PC.Pages.AgentFlowNode.formFieldRequired": "必須", + "PC.Pages.AgentFlowNode.formFieldsTitle": "フォームフィールド", + "PC.Pages.AgentFlowNode.formFieldsHint": "ユーザーが入力するフォームフィールドを定義", + "PC.Pages.AgentFlowNode.formFieldDescriptionPlaceholder": "入力説明(ユーザーに入力内容を提示)", + "PC.Pages.AgentFlowNode.formFieldAddBtn": "フィールドを追加", + "PC.Pages.AgentFlowNode.formTypeCheckbox": "複数選択", + "PC.Pages.AgentFlowNode.formTypeFile": "ファイルアップロード", + "PC.Pages.AgentFlowNode.formTypeInput": "テキスト入力", + "PC.Pages.AgentFlowNode.formTypeNumber": "数値", + "PC.Pages.AgentFlowNode.formTypeRadio": "単一選択", + "PC.Pages.AgentFlowNode.formTypeSelect": "ドロップダウン", + "PC.Pages.AgentFlowNode.formTypeTextarea": "テキストエリア", + "PC.Pages.AgentFlowNode.replyModeLabel": "返信モード", + "PC.Pages.AgentFlowParams.groupAgentFlowProcess": "処理ノード", + "PC.Pages.AgentFlowParams.groupAgentFlowAdvanced": "高度ノード", + "PC.Pages.AgentFlowParams.nodeRouteDecisionName": "ルート決定", + "PC.Pages.AgentFlowParams.nodeRouteDecisionDescription": "AIがどのブランチに進むかを決定", + "PC.Pages.AgentFlowParams.nodeAgentName": "エージェント", + "PC.Pages.AgentFlowParams.nodeAgentDescription": "エージェントを呼び出してタスクを実行", + "PC.Pages.AgentFlowParams.nodeHumanApproveName": "承認", + "PC.Pages.AgentFlowParams.nodeHumanApproveDescription": "人間の承認確認ノード", + "PC.Pages.AgentFlowParams.nodeHumanAskName": "ユーザーに質問", + "PC.Pages.AgentFlowParams.nodeHumanAskDescription": "ユーザーに質問して回答を取得", + "PC.Pages.AgentFlowParams.nodeEvalGateName": "結果評価", + "PC.Pages.AgentFlowParams.nodeEvalGateDescription": "エージェントの出力結果を評価", + "PC.Pages.AgentFlowParams.nodeExternalConnectorName": "外部コネクタ", + "PC.Pages.AgentFlowParams.nodeExternalConnectorDescription": "外部プラットフォームサービスに接続", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertName": "ナレッジ挿入", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertDescription": "選択したナレッジベースにコンテンツを書き込みます", + "PC.Pages.AntvX6Stencil.searchPlaceholder": "ノードを検索...", + "PC.Pages.AppDevEditorHeaderRight.viewTerminal": "ターミナルを表示する", + "PC.Pages.AppDevEditorHeaderRight.gitVersionHistory": "バージョン履歴", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.title": "バージョン履歴", + "PC.Pages.ConversationAgent.ArrangePanel.tabConfig": "設定", + "PC.Pages.ConversationAgent.ArrangePanel.tabDebug": "デバッグ", + "PC.Pages.ConversationAgent.ArrangePanel.tabVersion": "バージョン", + "PC.Pages.ConversationAgent.devServerReady": "開発サーバーの準備ができました", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.subtitle": "{0} 件のコミット · {1} ブランチ", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollback": "ロールバック", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollbackSuccess": "ロールバックに成功しました", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.commitsOn": "{0} のコミット", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.empty": "コミットはまだありません", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.noConversation": "バージョン記録がありません", + "PC.Components.FileTreePanel.FileTreeToolbar.projects": "プロジェクト", + "PC.Components.FileTreePanel.FileTreeToolbar.exportProject": "プロジェクトをエクスポート", + "PC.Components.FileTreePanel.FileTreeToolbar.importProject": "プロジェクトをインポート", + "PC.Components.FileTreePanel.FileTreeToolbar.newFile": "新規ファイル", + "PC.Components.FileTreePanel.FileTreeToolbar.newFolder": "新規フォルダ", + "PC.Components.FileTreePanel.FileTreeToolbar.upload": "アップロード", + "PC.Components.FileTreePanel.FileTreeToolbar.uploadFolder": "フォルダをアップロード", + "PC.Components.FileTreePanel.FileTreeToolbar.collapseAll": "すべて折りたたむ", + "PC.Components.FileTreePanel.GitVersionRecord.noProject": "プロジェクトを選択してからバージョン履歴を表示してください", + "PC.Components.FileTreePanel.GitVersionRecord.backToList": "一覧に戻る", + "PC.Components.FileTreePanel.GitVersionRecord.changedFileCount": "変更ファイル数:{0}", + "PC.Components.FileTreePanel.GitVersionRecord.searchPlaceholder": "検索", + "PC.Components.FileTreePanel.GitVersionRecord.diffUnified": "統合", + "PC.Components.FileTreePanel.GitVersionRecord.diffSplit": "分割", + "PC.Components.FileTreePanel.GitVersionRecord.revertToVersion": "このバージョンに戻す", + "PC.Components.FileTreePanel.GitVersionRecord.statusModified": "変更", + "PC.Components.FileTreePanel.GitVersionRecord.statusAdded": "追加", + "PC.Components.FileTreePanel.GitVersionRecord.statusDeleted": "削除", + "PC.Components.FileTreePanel.GitVersionRecord.statusRenamed": "名前変更", + "PC.Components.FileTreePanel.GitVersionRecord.noChangedFiles": "このコミットに変更ファイルはありません", + "PC.Components.FileTreePanel.GitVersionRecord.autoSaveBeforeRestoreMessage": "復元前に変更を自動保存しました", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackModalTitle": "バージョン {0} にロールバック", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackVersionLabel": "ロールバックバージョン", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectTitle": "この操作により:", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectAutoSave": "ワークスペースおよびステージングエリアの未コミットファイルが自動保存されます", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectBranchSync": "このブランチ上の他の会話も同期してロールバックされます", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectWorkspace": "ワークスペースが選択した checkpoint の状態に復元されます", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectUndoChanges": "このバージョン以降のすべての変更が取り消されます", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectHistory": "履歴は保持され、いつでも他のバージョンにロールバックできます", + "PC.Pages.Chat.conversationIcon": "会話アイコン", + "PC.Pages.Chat.conversationName": "会話名", + "PC.Pages.Chat.editConversationInfo": "会話情報を編集", + "PC.Pages.ConversationAgent.gitPush.defaultMessage": "ファイルを更新", + "PC.Pages.ConversationAgent.gitPush.noConversation": "アクティブな会話がありません。Git プッシュを実行できません", + "PC.Pages.ConversationAgent.gitPush.success": "Git コミットとプッシュに成功しました", + "PC.Pages.ConversationAgent.prototypeTitle": "エージェント開発プロトタイプ", + "PC.Pages.ConversationAgentMiddlePanel.files": "ファイル", + "PC.Pages.ConversationAgentMiddlePanel.sourceControl": "ソース管理", + "PC.Pages.ConversationAgentSourceControl.addToGitignore": ".gitignore に追加", + "PC.Pages.ConversationAgentSourceControl.alreadyInGitignore": "ファイルは既に .gitignore にあります", + "PC.Pages.ConversationAgentSourceControl.changes": "変更", + "PC.Pages.ConversationAgentSourceControl.commit": "コミット", + "PC.Pages.ConversationAgentSourceControl.commitAndPush": "プッシュ", + "PC.Pages.ConversationAgentSourceControl.discardChanges": "変更を破棄", + "PC.Pages.ConversationAgentSourceControl.discardChangesConfirmTitle": "変更を破棄しますか?", + "PC.Pages.ConversationAgentSourceControl.gitPushTooltip": "Git をコミットしてリモートリポジトリにプッシュ", + "PC.Pages.ConversationAgentSourceControl.gitignoreFailed": ".gitignore への追加に失敗しました", + "PC.Pages.ConversationAgentSourceControl.gitignoreSuccess": ".gitignore に追加しました", + "PC.Pages.ConversationAgentSourceControl.noChanges": "変更されたファイルはありません", + "PC.Pages.ConversationAgentSourceControl.openChanges": "変更を開く", + "PC.Pages.ConversationAgentSourceControl.openFile": "ファイルを開く", + "PC.Pages.ConversationAgentSourceControl.stageChanges": "変更をステージ", + "PC.Pages.ConversationAgentSourceControl.title": "ソース管理", + "PC.Pages.ConversationAgentSourceControl.unstageChanges": "ステージを解除", + "PC.Pages.ConversationAgentSourceControl.commitMessage": "コミットメッセージ", + "PC.Pages.ConversationAgentSourceControl.stagedChanges": "ステージされた変更", + "PC.Pages.ConversationAgentSourceControl.discardAllChanges": "すべての変更を破棄", + "PC.Pages.ConversationAgentSourceControl.discardAllChangesConfirmTitle": "すべての変更を破棄しますか?", + "PC.Pages.ConversationAgentSourceControl.stageAllChanges": "すべての変更をステージ", + "PC.Pages.ConversationAgentSourceControl.unstageAllChanges": "すべてのステージを解除", + "PC.Pages.ConversationAgentSourceControl.refresh": "リフレッシュ", + "PC.Pages.ConversationAgentSourceControl.save": "保存", + "PC.Pages.ConversationAgentSourceControl.saveTooltip": "変更をサンドボックスに保存", + "PC.Pages.ConversationAgentSourceControl.viewAsList": "リスト表示", + "PC.Pages.ConversationAgentSourceControl.viewAsTree": "ツリー表示", + "PC.Pages.ConversationAgentPreviewTabBar.addTab": "新しいタブ", + "PC.Pages.ConversationAgentPreviewTabBar.closeTab": "タブを閉じる", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.close": "閉じる", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeOthers": "その他を閉じる", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeAll": "すべて閉じる", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.pin": "固定", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.unpin": "固定解除", + "PC.Pages.ConversationAgentTabPicker.subscription": "サブスクリプション", + "PC.Pages.ConversationAgentTabPicker.subscriptionDesc": "有料プランとトライアルを設定し、サブスクリプション指標と購読者状況を確認します。", + "PC.Pages.ConversationAgentTabPicker.subscriptionSetting": "サブスクリプション設定", + "PC.Pages.ConversationAgentTabPicker.subscriptionSettingDesc": "有料モードの有効化、プラン一覧とデフォルトの試用回数の管理。", + "PC.Pages.ConversationAgentTabPicker.subscriptionStats": "サブスクリプション統計", + "PC.Pages.ConversationAgentTabPicker.subscriptionStatsDesc": "総購読数、収益、購読者リストなどの統計データを表示します。", + "PC.Pages.ConversationAgentTabPicker.preview": "プレビュー", + "PC.Pages.ConversationAgentTabPicker.arrange": "編成", + "PC.Pages.ConversationAgentTabPicker.terminal": "ターミナル", + "PC.Pages.ConversationAgentTabPicker.versionControl": "バージョン管理", + "PC.Pages.ConversationAgentTabPicker.previewDesc": "実行結果をリアルタイムでプレビューし、コードをデバッグします。", + "PC.Pages.ConversationAgentTabPicker.arrangeDesc": "モデル、プロンプト、変数、ツールなどのエージェント編成設定を行います。", + "PC.Pages.ConversationAgentTabPicker.terminalDesc": "システムシェルにアクセスしてスクリプトを実行したり、依存関係をインストールします。", + "PC.Pages.ConversationAgentTabPicker.versionControlDesc": "コードのバージョンを管理し、変更内容とコミット履歴を確認します。", + "PC.Pages.ConversationAgentTabPicker.devTools": "開発ツール", + "PC.Pages.ConversationAgentTabPicker.devToolsDesc": "コード編集、リアルタイムプレビュー、デバッグ機能を提供し、開発とバージョン管理の全フローをサポートします。", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPrompt": "補足プロンプト", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPromptPlaceholder": "補足プロンプトを入力してグループエージェントに追加制約を設定します", + "PC.Pages.MorePage.ApiKeyPermission.apiPermission": "API権限", + "PC.Pages.MorePage.ApiKeyPermission.modelPermission": "システムモデル", + "PC.Pages.MorePage.ApiKeyPermission.noModelDefs": "モデルの定義がありません", + "PC.Pages.MorePage.ApiKeyPermission.spaceModelPermission": "個人スペースモデル", + "PC.Pages.MorePage.ApiKeyPermission.noSpaceModelDefs": "スペースモデルの定義がありません", + "PC.Pages.MorePage.MySubscriptions.paySuccess": "支払い成功", + "PC.Pages.MorePage.MySubscriptions.payFailed": "支払いに失敗しました。再試行してください", + "PC.Pages.MorePage.MySubscriptions.payPending": "支払い確認中、後で確認してください", + "PC.Pages.MorePage.MySubscriptions.continuousMonthly": "月額サブスク", + "PC.Pages.MorePage.MySubscriptions.continuousQuarterly": "四半期サブスク", + "PC.Pages.MorePage.MySubscriptions.continuousYearly": "年額サブスク", + "PC.Pages.OpenApp.subscription": "購読", + "PC.Pages.SpaceDevelop.ApplicationItem.agentFlowType": "AgentFlow", + "PC.Pages.SpaceDevelop.ApplicationItem.customType": "カスタム", + "PC.Pages.SpaceDevelop.ApplicationItem.agentGroupType": "AgentGroup", + "PC.Pages.SpaceLibrary.CreateModel.inputModelIdentifier": "モデル識別子を入力", + "PC.Pages.SpacePluginWorkflow.pluginPageTitle": "プラグイン", + "PC.Pages.SpacePluginWorkflow.workflowPageTitle": "ワークフロー", + "PC.Pages.SpaceResource.LeftGroupList.confirmMoveToGroup": "グループへの移動を確認", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveDesc": "「{0}」を現在のグループから移動しますか?", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveFromGroup": "グループからの削除を確認", + "PC.Pages.SpaceResourcePricing.periodSecond": "秒", + "PC.Pages.SpaceResourcePricing.periodMillionToken": "百万Token", + "PC.Pages.SpaceCreateProject.greetingTitle": "こんにちは、{0}。タスクを教えてください。今すぐ始めますか?", + "PC.Pages.SpaceCreateProject.inputRequiredWarning": "タスクの内容を入力してください!", + "PC.Pages.SpaceCreateProject.placeholderAgent": "必要なエージェントを説明してください。例:コードレビューアシスタントを作成し、コードの問題を自動検出して最適化の提案を行うもの", + "PC.Pages.SpaceCreateProject.placeholderPageApp": "必要なウェブアプリを説明してください。例:写真をアップロードして顔の評価や肌質を判定するビューティー管理サイトを開発して", + "PC.Pages.SpaceCreateProject.placeholderPlugin": "必要なプラグインを説明してください。例:サードパーティの画像変換 HTTP インターフェースと連携するプラグインを作成して", + "PC.Pages.SpaceCreateProject.placeholderSkill": "必要なカスタムスキルを説明してください。例:緯度・経度から現在の天気を取得する API エンドポイントを作成して", + "PC.Pages.SpaceCreateProject.tabAgent": "エージェント", + "PC.Pages.SpaceCreateProject.tabPageApp": "ウェブアプリ", + "PC.Pages.SpaceCreateProject.tabPlugin": "プラグイン", + "PC.Pages.SpaceCreateProject.tabSkill": "スキル", + "PC.Pages.UserManage.Index.delete": "削除", + "PC.Pages.UserManage.Index.deleteSuccess": "削除しました", + "PC.Pages.UserManage.Index.resetPassword": "パスワードリセット", + "PC.Pages.UserManage.Index.placeholderUserId": "ユーザーIDを入力してください。整数のみ入力可能です", + "PC.Pages.UserManage.Index.userId": "ユーザーID", + "PC.Pages.UserManage.ResetPasswordModal.title": "パスワードリセット", + "PC.Pages.UserManage.ResetPasswordModal.newPassword": "新しいパスワード", + "PC.Pages.UserManage.ResetPasswordModal.inputNewPassword": "新しいパスワードを入力してください", + "PC.Pages.UserManage.ResetPasswordModal.confirmPassword": "パスワードの確認", + "PC.Pages.UserManage.ResetPasswordModal.inputConfirmPassword": "新しいパスワードをもう一度入力してください", + "PC.Pages.UserManage.ResetPasswordModal.newPasswordRequired": "新しいパスワードを入力してください", + "PC.Pages.UserManage.ResetPasswordModal.confirmPasswordRequired": "パスワードを確認してください", + "PC.Pages.UserManage.ResetPasswordModal.passwordMismatch": "入力されたパスワードが一致しません", + "PC.Pages.UserManage.ResetPasswordModal.resetSuccess": "パスワードが正常に変更されました", + "PC.Pages.UserManage.UserAuthModal.subscriptionUserGroup": "サブスクリプション関連ユーザーグループ", + "PC.Routes.modelPermissions": "モデル権限", + "PC.Pages.ModelPermissions.tabSystem": "システムモデル", + "PC.Pages.ModelPermissions.tabSpace": "個人スペースモデル", + "PC.Pages.ModelPermissions.colModelName": "モデル名", + "PC.Pages.ModelPermissions.colModelId": "モデルID", + "PC.Pages.ModelPermissions.colModelIdTip": "外部呼び出し時にこの値を指定してください", + "PC.Pages.ModelPermissions.colModalities": "対応モダリティ", + "PC.Pages.ModelPermissions.colExternalCall": "外部呼び出し", + "PC.Pages.ModelPermissions.colExternalCallTip": "他の外部システムからこのモデルを呼び出せるかどうか", + "PC.Pages.ModelPermissions.colApiProtocol": "APIプロトコル", + "PC.Pages.ModelPermissions.colPrice": "価格", + "PC.Pages.ModelPermissions.notSupported": "非対応", + "PC.Pages.ModelPermissions.interfaceAddress": "API URL", + "PC.Pages.ModelPermissions.copyInterface": "APIをコピー", + "PC.Pages.ModelPermissions.interfaceAddressCopied": "API URLをコピーしました", + "PC.Pages.ModelPermissions.modelIdCopied": "モデルIDをコピーしました", + "PC.Routes.recommendManage": "おすすめ管理", + "PC.Routes.homeRecommend": "ホームおすすめ", + "PC.Routes.officialRecommend": "公式おすすめ", + "PC.Routes.chatboxRecommend": "ダイアログエージェント", + "PC.Pages.SystemRecommendManage.addTitle": "おすすめを追加", + "PC.Pages.SystemRecommendManage.createSuccess": "作成に成功しました", + "PC.Pages.SystemRecommendManage.colAction": "操作", + "PC.Pages.SystemRecommendManage.colCreated": "作成日時", + "PC.Pages.SystemRecommendManage.colModified": "更新日時", + "PC.Pages.SystemRecommendManage.colLabel": "名称", + "PC.Pages.SystemRecommendManage.dialogHintLabel": "ダイアログヒント", + "PC.Pages.SystemRecommendManage.dialogHintPlaceholder": "ダイアログヒントを入力してください", + "PC.Pages.SystemRecommendManage.colTargetId": "ターゲットID", + "PC.Pages.SystemRecommendManage.colTargetType": "タイプ", + "PC.Pages.SystemRecommendManage.colSubType": "サブタイプ", + "PC.Pages.SystemRecommendManage.confirmDeleteContent": "削除後は元に戻せません。続行しますか?", + "PC.Pages.SystemRecommendManage.confirmDeleteTitle": "削除の確認", + "PC.Pages.SystemRecommendManage.editTitle": "おすすめを編集", + "PC.Pages.SystemRecommendManage.updateSuccess": "更新に成功しました", + "PC.Pages.SystemRecommendManage.functionType.agentDev": "エージェント開発", + "PC.Pages.SystemRecommendManage.functionType.chat": "エージェント", + "PC.Pages.SystemRecommendManage.functionType.pageAppDev": "Webアプリ開発", + "PC.Pages.SystemRecommendManage.functionType.pluginDev": "プラグイン開発", + "PC.Pages.SystemRecommendManage.functionType.skillDev": "スキル開発", + "PC.Pages.SystemRecommendManage.searchName": "名称を入力", + "PC.Pages.SystemRecommendManage.selectTarget": "おすすめ対象を選択", + "PC.Pages.SystemRecommendManage.selectTargetRequired": "{0}を選択してください", + "PC.Pages.SystemRecommendManage.clickToAddAgent": "クリックしてエージェントを追加", + "PC.Pages.SystemRecommendManage.sortUpdated": "並び順を更新しました", + "PC.Pages.SystemRecommendManage.targetType.Agent": "エージェント", + "PC.Pages.SystemRecommendManage.targetType.PageApp": "アプリケーション", + "PC.Pages.SystemRecommendManage.targetType.Plugin": "プラグイン", + "PC.Pages.SystemRecommendManage.targetType.Skill": "スキル", + "PC.Pages.SystemRecommendManage.targetType.Workflow": "ワークフロー", + "PC.Pages.SystemPlans.fieldNamePlaceholderExample": "例:ベーシック", + "PC.Pages.SystemPlans.fieldCreditAmountMonthly": "プランに含まれるクレジット(月額)", + "PC.Pages.SystemPlans.fieldPriceLabel": "プラン価格 (¥)", + "PC.Pages.SystemPlans.fieldPricePlaceholderExample": "例:99", + "PC.Pages.SystemPlans.fieldValidityPeriod": "有効期間", + "PC.Pages.SystemPlans.periodOption1Month": "1か月", + "PC.Pages.SystemPlans.periodOption3Months": "3か月", + "PC.Pages.SystemPlans.periodOption12Months": "12か月", + "PC.Pages.SystemPlans.fieldHotLabel": "人気タグ", + "PC.Pages.SystemPlans.developerPermissionTitle": "開発者権限", + "PC.Pages.SystemPlans.originalPrice": "定価 ¥{0}/{1}", + "PC.Pages.SystemUserCredits.colUserNamePhoneOrEmail": "ユーザー名/携帯番号/メール", + "PC.Pages.SystemUserCredits.grantCredit": "クレジット付与", + "PC.Pages.SystemUserCredits.deductCredit": "クレジット控除", + "PC.Pages.SystemUserCredits.grantModalTitle": "クレジット付与", + "PC.Pages.SystemUserCredits.deductModalTitle": "クレジット控除", + "PC.Pages.SystemUserCredits.fieldCreditType": "クレジット種別", + "PC.Pages.SystemUserCredits.fieldAmount": "数量", + "PC.Pages.SystemUserCredits.fieldAmountPlaceholder": "クレジット数量を入力", + "PC.Pages.SystemUserCredits.fieldAmountMin": "数量は 0 より大きい必要があります", + "PC.Pages.SystemUserCredits.fieldBizNo": "業務番号", + "PC.Pages.SystemUserCredits.fieldBizNoPlaceholder": "業務番号を入力(任意)", + "PC.Pages.SystemUserCredits.fieldRemark": "備考", + "PC.Pages.SystemUserCredits.fieldRemarkPlaceholder": "備考を入力(任意)", + "PC.Pages.SystemUserCredits.fieldExpireTime": "有効期限", + "PC.Pages.SystemUserCredits.fieldExpireTimePlaceholder": "有効期限を選択(未入力は無期限)", + "PC.Pages.SystemUserCredits.fieldAllowNegative": "マイナス残高を許可", + "PC.Pages.SystemUserCredits.grantSuccess": "クレジットを付与しました", + "PC.Pages.SystemUserCredits.deductSuccess": "クレジットを控除しました", + "PC.Components.AcpPermissionCard.allowAlways": "常に許可", + "PC.Components.AcpPermissionCard.allowOnce": "一度だけ許可", + "PC.Components.AcpPermissionCard.cancelShortcutHint": "キャンセル(Esc)", + "PC.Components.AcpPermissionCard.defaultTitle": "権限の確認が必要です", + "PC.Components.AcpPermissionCard.eyebrow": "承認リクエスト", + "PC.Components.AcpPermissionCard.rejectOnce": "拒否", + "PC.Components.AcpPermissionCard.shortcutHint": "{0} ({1})", + "PC.Components.AcpPermissionCard.submitted": "権限応答を送信しました", + "PC.Pages.MorePage.MySubscriptions.currentPlanButton": "現在のプラン", + "PC.Pages.MorePage.MySubscriptions.upgradeTo": "にアップグレード", + "PC.Pages.SpaceResourcePricing.pricingTypeOneTime": "都度", + "PC.Pages.SpaceResourcePricing.pricingTypeMonthly": "月額", + "PC.Pages.SpaceResourcePricing.pricingTypeTiered": "段階課金", + "PC.Pages.SystemCreditPackages.periodMonthUnit": "か月", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDesc": "換算説明", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDescPlaceholder": "ユーザー向けのクレジット換算説明を入力してください(交換レート、利用範囲など)。クレジット残高の横に表示されます", + "PC.Pages.SpaceKnowledge.KnowledgeHeader.accuracytest": "精度テスト", }; diff --git a/qiming/src/locales/i18n/zh-CN.ts b/qiming/src/locales/i18n/zh-CN.ts index 2cae6eb3..aa6b94b6 100644 --- a/qiming/src/locales/i18n/zh-CN.ts +++ b/qiming/src/locales/i18n/zh-CN.ts @@ -2627,6 +2627,8 @@ export const ZH_CN: SystemLangMap = { "PC.Pages.Login.captchaTitle": "请完成下列验证后继续", "PC.Pages.Login.captchaVerifying": "验证中...", "PC.Pages.Login.codeLoginOrRegister": "验证码登录/注册", + "PC.Pages.Login.inputAccountPlaceholder": "请输入用户名/邮箱", + "PC.Pages.Login.inputAccountRequired": "请输入用户名/邮箱", "PC.Pages.Login.inputEmailPlaceholder": "请输入邮箱", "PC.Pages.Login.inputEmailRequired": "请输入邮箱", "PC.Pages.Login.inputPasswordPlaceholder": "请输入不少于 6 位的密码", @@ -5460,4 +5462,520 @@ export const ZH_CN: SystemLangMap = { "PC.Pages.SpaceKnowledge.Index.loadGraphListFailed": "加载知识图谱列表失败", "PC.Pages.SpaceKnowledge.Index.viewAllGraphsFailed": "查看全部知识图谱失败", "PC.Pages.SpaceKnowledge.KnowledgeHeader.graph": "知识图谱", + "PC.Common.Global.uploadFileSizeExceed": "上传文件总大小不能超过{0}MB", + "PC.Common.Global.times": "次", + "PC.Components.AcpPermissionCard.allowAlways": "始终允许", + "PC.Components.AcpPermissionCard.allowOnce": "允许一次", + "PC.Components.AcpPermissionCard.cancelShortcutHint": "取消(Esc)", + "PC.Components.AcpPermissionCard.defaultTitle": "需要权限确认", + "PC.Components.AcpPermissionCard.eyebrow": "审批请求", + "PC.Components.AcpPermissionCard.rejectOnce": "拒绝", + "PC.Components.AcpPermissionCard.shortcutHint": "{0}({1})", + "PC.Components.AcpPermissionCard.submitted": "已提交审批结果", + "PC.Components.ConversationBottomConsole.collapsePanel": "折叠到底部", + "PC.Components.ConversationBottomConsole.containerKeepaliveError": "服务保活请求异常,请检查网络后重试", + "PC.Components.ConversationBottomConsole.containerKeepaliveFailed": "服务保活失败,请重新启动服务", + "PC.Components.ConversationBottomConsole.containerStartError": "服务启动异常,请稍后重试", + "PC.Components.ConversationBottomConsole.containerStarting": "服务启动中,请稍候…", + "PC.Components.ConversationBottomConsole.expandHeight": "放大至全屏", + "PC.Components.ConversationBottomConsole.logsEmpty": "暂无日志", + "PC.Components.ConversationBottomConsole.restoreHeight": "恢复默认高度", + "PC.Components.ConversationBottomConsole.retryStartContainer": "重启服务", + "PC.Components.ConversationBottomConsole.tabLogs": "日志", + "PC.Components.ConversationBottomConsole.tabTerminal": "终端", + "PC.Components.ConversationBottomConsole.terminalEmpty": "暂无终端输出", + "PC.Components.ConversationBottomConsole.terminalThemeDark": "切换为深色终端", + "PC.Components.ConversationBottomConsole.terminalThemeLight": "切换为浅色终端", + "PC.Components.ChatInputHome.agentMode": "Agent 模式", + "PC.Components.ChatInputHome.agentModeAuto": "自动", + "PC.Components.ChatInputHome.agentModeAutoDesc": "工具调用前将不会询问用户", + "PC.Components.ChatInputHome.agentModeApproval": "审批", + "PC.Components.ChatInputHome.agentModeApprovalDesc": "部分工具调用前将询问用户是否同意", + "PC.Components.VoiceInput.startTooltip": "语音输入", + "PC.Components.VoiceInput.connecting": "正在连接麦克风…", + "PC.Components.VoiceInput.stopTooltip": "停止录音并识别", + "PC.Components.VoiceInput.stopFillTooltip": "停止录音并回填", + "PC.Components.VoiceInput.stopSendTooltip": "停止录音并发送", + "PC.Components.VoiceInput.transcribing": "语音识别中…", + "PC.Components.VoiceInput.permissionDenied": "麦克风权限被拒绝,请在浏览器设置中允许后重试", + "PC.Components.VoiceInput.notSupported": "当前环境不支持语音输入,请使用最新版 Chrome/Edge/Firefox/Safari", + "PC.Components.VoiceInput.tooShort": "录音时长过短", + "PC.Components.VoiceInput.tooLarge": "录音文件过大,请缩短录音时长后重试", + "PC.Components.VoiceInput.recognizeFailed": "语音识别失败,请重试", + "PC.Components.ChatInputHome.pastedFileName": "粘贴文件-{0}-{1}", + "PC.Components.ChatInputHome.dropFilesHint": "释放以上传文件", + "PC.Components.ChatInputHomeSpaceSelector.noAvailableSpace": "暂无可用空间", + "PC.Components.CollapseComponentList.groupAgentDesc": "群组智能体是组内的成员智能体,可协同完成任务。", + "PC.Components.CreateAgent.typeAgentFlow": "AgentFlow", + "PC.Components.CreateAgent.typeAgentGroup": "AgentGroup", + "PC.Components.CreateWorkflow.createAgentFlow": "新建 AgentFlow", + "PC.Components.CreateWorkflow.placeholderAgentFlowName": "输入 AgentFlow 名称", + "PC.Components.CreateWorkflow.placeholderAgentFlowDesc": "输入描述,让大模型理解什么情况下应该使用此 AgentFlow", + "PC.Components.CreateWorkflow.skip": "跳过", + "PC.Components.CreateWorkflow.startEditing": "开始编辑", + "PC.Components.FileContextMenu.uploadFolder": "上传文件夹", + "PC.Components.FileContextMenu.uploadFolderEmpty": "文件夹中没有可上传的文件", + "PC.Components.FileContextMenu.uploadFileEmpty": "没有可上传的文件", + "PC.Components.FileContextMenu.uploadTargetInvalid": "无法上传到该目录", + "PC.Components.HistoryConversationList.emptySession": "你还没有对话哟,快去开始你的一个任务吧", + "PC.Components.HistoryConversationList.noMore": "没有更多了", + "PC.Components.HistoryConversationList.noSearchResult": "无搜索结果", + "PC.Components.MessageQueue.clearAll": "清空全部", + "PC.Components.MessageQueue.editMessageTooltip": "编辑消息", + "PC.Components.MessageQueue.sendNowTooltip": "立即发送", + "PC.Components.MessageQueue.title": "待发送", + "PC.Components.McpAskQuestionCard.cancelShortcutHint": "取消(Esc)", + "PC.Components.McpAskQuestionCard.cancelled": "已取消", + "PC.Components.McpAskQuestionCard.customInputLabel": "自定义内容", + "PC.Components.McpAskQuestionCard.customInputPlaceholder": "请输入", + "PC.Components.McpAskQuestionCard.customOption": "自定义", + "PC.Components.McpAskQuestionCard.eyebrow": "补充回复", + "PC.Components.McpAskQuestionCard.fieldRequired": "请填写此项", + "PC.Components.McpAskQuestionCard.multiSelectMin": "请至少选择一项", + "PC.Components.McpAskQuestionCard.nextStep": "下一步", + "PC.Components.McpAskQuestionCard.prevStep": "上一步", + "PC.Components.McpAskQuestionCard.skip": "跳过", + "PC.Components.McpAskQuestionCard.skipped": "已跳过", + "PC.Components.McpAskQuestionCard.stepOf": "步骤 {0} / {1}", + "PC.Components.McpAskQuestionCard.submitted": "已提交回答", + "PC.Components.McpAskQuestionCard.uploadDragText": "点击或拖拽文件到此区域上传", + "PC.Components.McpAskQuestionCard.defaultTitle": "这次提问", + "PC.Components.McpAskQuestionCard.notFilled": "未填写", + "PC.Components.McpAskQuestionCard.unknownFile": "未知文件", + "PC.Components.McpAskQuestionCard.file": "文件", + "PC.Components.McpAskQuestionCard.emptyFormContent": "(无表单内容)", + "PC.Components.McpAskQuestionCard.resumeCancelled": "我取消了「{0}」。", + "PC.Components.McpAskQuestionCard.resumeSkipped": "我跳过了「{0}」。", + "PC.Components.McpAskQuestionCard.resumeTimeout": "「{0}」已超时,没有收到表单答案。", + "PC.Components.McpAskQuestionCard.resumeSubmitted": "我已填写「{0}」,表单内容如下:", + "PC.Components.PaymentSubscriptionModal.titleSelectPlan": "选择订阅套餐", + "PC.Components.PaymentSubscriptionModal.titleWithTrialCount": "选择订阅套餐(试用次数:{0}/{1})", + "PC.Components.PaymentSubscriptionModal.emptyNoPlans": "暂无可用套餐", + "PC.Components.PaymentSubscriptionModal.monthlyCredits": "每月 {0} 积分", + "PC.Components.PaymentSubscriptionModal.callLimitUnlimited": "不限制", + "PC.Components.PaymentSubscriptionModal.callLimitPerMonth": "{0} 次/月", + "PC.Components.PaymentSubscriptionModal.originalPrice": "原价{0}{1}/{2}", + "PC.Components.PaymentSubscriptionModal.callCountLabel": "可调用次数:{0}", + "PC.Components.PaymentSubscriptionModal.periodMonth": "月", + "PC.Components.PaymentSubscriptionModal.periodQuarter": "季度", + "PC.Components.PaymentSubscriptionModal.periodYear": "年", + "PC.Components.PaymentSubscriptionModal.periodForever": "永久", + "PC.Components.PaymentSubscriptionModal.btnSubscribePlan": "订阅套餐", + "PC.Components.PaymentSubscriptionModal.btnSubscribeBuyoutPlan": "订阅买断套餐", + "PC.Components.PaymentSubscriptionModal.btnForeverBoughtOut": "已买断套餐", + "PC.Components.PaymentSubscriptionModal.btnExpiredRenew": "套餐已过期(续订)", + "PC.Components.PaymentSubscriptionModal.btnCurrent": "当前套餐", + "PC.Components.PaymentSubscriptionModal.btnCurrentRenew": "当前套餐(续订)", + "PC.Components.PaymentSubscriptionModal.btnUpgrade": "升级", + "PC.Constants.Library.audioProcess": "语音处理", + "PC.Constants.Library.chatMultimodal": "聊天对话-多模态", + "PC.Constants.Library.chatText": "聊天对话-纯文本", + "PC.Constants.Library.imageProcess": "图像处理", + "PC.Constants.Library.supportFuncCall": "支持普通函数调用", + "PC.Constants.Library.textCompletion": "文本补全", + "PC.Constants.Library.textEdit": "文本编辑", + "PC.Constants.Library.vectorEmbedding": "向量嵌入", + "PC.Constants.Library.externalApi": "外部API调用", + "PC.Constants.Space.callApproval": "调用审批", + "PC.Constants.Space.agentFlowType": "AgentFlow", + "PC.Constants.Space.agentFlowTypeTooltip": "基于多智能体协作编排的工作流,适合复杂任务拆解、分支决策、人工介入和结果评估。", + "PC.Constants.Space.agentGroupType": "AgentGroup", + "PC.Constants.Space.agentGroupTypeTooltip": "多智能体协作组,适合需要多个智能体分工协作的复杂任务场景。", + "PC.Constants.Space.group": "分组", + "PC.Pages.SpaceResource.LeftGroupList.createGroupTitle": "新建分组", + "PC.Pages.SpaceResource.LeftGroupList.editGroupTitle": "编辑分组", + "PC.Pages.SpaceResource.LeftGroupList.groupNameLabel": "分组名称", + "PC.Pages.SpaceResource.LeftGroupList.groupNamePlaceholder": "请输入分组名称", + "PC.Pages.SpaceResource.LeftGroupList.groupNameRequired": "请输入分组名称", + "PC.Pages.SpaceResource.LeftGroupList.deleteConfirmContent": "确定要删除分组“{0}”吗?", + "PC.Pages.SpaceResource.LeftGroupList.createSuccess": "新建成功", + "PC.Pages.SpaceResource.LeftGroupList.editSuccess": "修改成功", + "PC.Pages.SpaceResource.LeftGroupList.deleteSuccess": "删除成功", + "PC.Pages.SpaceResource.LeftGroupList.groupIconLabel": "分组图标", + "PC.Pages.SpaceResource.LeftGroupList.groupNameNoWhitespace": "名称不能为空白字符", + "PC.Pages.SpaceResource.LeftGroupList.groupNameMaxLength": "名称长度不能超过30个字符", + "PC.Pages.SpaceResource.LeftGroupList.groupDescLabel": "分组描述", + "PC.Pages.SpaceResource.LeftGroupList.groupDescMaxLength": "描述长度不能超过100个字符", + "PC.Pages.SpaceResource.LeftGroupList.groupDescPlaceholder": "请输入分组描述", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeLabel": "分组类型", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeRequired": "请选择分组类型", + "PC.Pages.SpaceResource.LeftGroupList.unitCount": "{0} 个", + "PC.Pages.SpaceResource.LeftGroupList.moveToGroup": "移入分组", + "PC.Pages.SpaceResource.LeftGroupList.moveToOtherGroup": "移动至其它分组", + "PC.Pages.SpaceResource.LeftGroupList.removeFromGroup": "移出分组", + "PC.Pages.SpaceResource.LeftGroupList.confirmMoveToGroup": "确认移入分组", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveFromGroup": "确认移出分组", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveDesc": "确认将“{0}”从当前分组移出吗?", + "PC.Pages.SpaceResource.LeftGroupList.moveSuccess": "移入成功", + "PC.Pages.SpaceResource.LeftGroupList.removeSuccess": "移出成功", + "PC.Pages.SpaceResource.LeftGroupList.selectGroup": "选择分组", + "PC.Pages.SpaceResource.LeftGroupList.selectGroupPlaceholder": "请选择要移入的分组", + "PC.Modal.chunkLoadErrorContent": "页面资源加载失败,请刷新页面后重试", + "PC.Modal.chunkLoadErrorRefresh": "立即刷新", + "PC.Modal.chunkLoadErrorTitle": "页面加载异常", + "PC.Hooks.UseSubscription.createOrderFailed": "创建订阅订单失败", + "PC.Hooks.UseSubscription.getCashierUrlFailed": "获取收银台地址失败", + "PC.Layouts.DynamicMenusLayout.SpaceSection.searchSpacePlaceholder": "搜索空间", + "PC.Layouts.DynamicMenusLayout.SpaceSection.noSpaceMatch": "未找到匹配的空间", + "PC.Layouts.DynamicMenusLayout.newProject": "新建项目", + "PC.Models.ConversationInfo.askResponseFailed": "问题回答提交失败", + "PC.Models.ConversationInfo.permissionResponseFailed": "权限审批结果提交失败", + "PC.Pages.AgentArrangeConfig.addGroupAgent": "添加群组智能体", + "PC.Pages.AgentArrangeConfig.allowChooseMode": "允许用户在对话框中选择模式", + "PC.Pages.AgentArrangeConfig.allowChooseModeDesc": "开启后,用户在对话框下可以选择:自动(Auto)、审批(Approval),关闭后默认使用自动模式。", + "PC.Pages.AgentArrangeConfig.versionControl": "版本管理", + "PC.Pages.AgentArrangeConfig.versionControlDesc": "开启后,智能体支持版本管理,可查看代码变更和提交历史。", + "PC.Pages.AgentArrangeConfig.groupAgent": "群组智能体", + "PC.Pages.AgentArrangeConfig.groupMembers": "组员", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategy": "参数更新策略", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyAppend": "追加", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyPlaceholder": "请选择参数更新策略", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyReplace": "替换", + "PC.Pages.AgentArrangeCreateHookModal.config": "配置 JSON", + "PC.Pages.AgentArrangeCreateHookModal.configInvalid": "请输入合法的 JSON 配置", + "PC.Pages.AgentArrangeCreateHookModal.event": "事件", + "PC.Pages.AgentArrangeCreateHookModal.eventRequired": "请选择事件", + "PC.Pages.AgentArrangeCreateHookModal.matcher": "匹配规则", + "PC.Pages.AgentArrangeCreateHookModal.matcherPlaceholder": "工具名称,如 Bash、Edit|Write、mcp...", + "PC.Pages.AgentArrangeCreateHookModal.matcherNotSupported": "不支持匹配规则", + "PC.Pages.AgentArrangeCreateHookModal.matcherStopFailurePlaceholder": "错误类型,如 rate_limit、overloaded", + "PC.Pages.AgentArrangeCreateHookModal.matcherSessionStartPlaceholder": "启动方式,如 startup、resume、clear、compact", + "PC.Pages.AgentArrangeCreateHookModal.matcherSubagentStartPlaceholder": "Agent 类型,如 general-purpose、Explore、Plan", + "PC.Pages.AgentArrangeCreateHookModal.name": "名称", + "PC.Pages.AgentArrangeCreateHookModal.namePlaceholder": "如:代码格式化", + "PC.Pages.AgentArrangeCreateHookModal.nameRequired": "请输入名称", + "PC.Pages.AgentArrangeCreateHookModal.titleCreate": "新建 Hook", + "PC.Pages.AgentArrangeCreateHookModal.titleEdit": "编辑 Hook", + "PC.Pages.AgentArrangeCreateHookModal.type": "类型", + "PC.Pages.AgentArrangeCreateHookModal.typeRequired": "请选择类型", + "PC.Pages.AgentArrangeCreateHooks.add": "新建", + "PC.Pages.AgentArrangeCreateHooks.columnAction": "操作", + "PC.Pages.AgentArrangeCreateHooks.columnEvent": "事件", + "PC.Pages.AgentArrangeCreateHooks.columnMatcher": "匹配规则", + "PC.Pages.AgentArrangeCreateHooks.columnName": "名称", + "PC.Pages.AgentArrangeCreateHooks.columnStatus": "状态", + "PC.Pages.AgentArrangeCreateHooks.columnType": "类型", + "PC.Pages.AgentArrangeCreateHooks.deleteSuccess": "删除成功", + "PC.Pages.AgentArrangeCreateHooks.searchPlaceholder": "搜索...", + "PC.Pages.AgentArrangeCreateHooks.title": "Hook 设置", + "PC.Pages.AgentArrangeCreateHooks.titleTooltip": "OpenAI协议模型支持的事件范围:PreToolUse、PostToolUse、PostToolUseFailure、SessionStart、SessionEnd、Stop", + "PC.Pages.AgentArrangeCreateHooks.updateSuccess": "更新成功", + "PC.Pages.AgentArrangeConfig.addHook": "添加 Hook", + "PC.Pages.AgentArrangeConfig.hook": "Hook 设置", + "PC.Pages.AgentArrangeConfig.hookUnavailable": "Hook 组件未就绪,请稍后重试", + "PC.Pages.AgentArrangeConfig.enableAskQuestion": "允许询问用户", + "PC.Pages.AgentArrangeConfig.enableAskQuestionDesc": "开启后,智能体需要额外信息时可以弹出表单向用户询问。", + "PC.Components.HookList.description": "通过 Hook 可在智能体执行的关键节点插入自定义逻辑。", + "PC.Pages.AgentArrangeCallApproval.optionNo": "否", + "PC.Pages.AgentArrangeCallApproval.optionYes": "是", + "PC.Pages.AgentArrangeCallApproval.title": "是否需要审批", + "PC.Pages.AgentArrangeCallApproval.tooltip": "开启后,模型在调用工具前会向用户发送审批通知,同意后可继续执行", + "PC.Pages.AntvX6Header.autoArrange": "自动排列", + "PC.Pages.AntvX6Header.run": "运行", + "PC.Pages.AntvX6Header.save": "保存", + "PC.Pages.AgentFlow.loading": "加载中...", + "PC.Pages.AgentFlow.chipApprove": "通过", + "PC.Pages.AgentFlow.chipFail": "不达标", + "PC.Pages.AgentFlow.chipPass": "通过", + "PC.Pages.AgentFlow.chipReject": "拒绝", + "PC.Pages.AgentFlow.dragToConnect": "拖拽连线", + "PC.Pages.AgentFlowNode.routeDecisionExtraPromptPlaceholder": "请输入给 AI 的额外路由决策指令...", + "PC.Pages.AgentFlowNode.askQuestionLabel": "提问内容", + "PC.Pages.AgentFlowNode.askQuestionPlaceholder": "请输入要向用户提出的问题...", + "PC.Pages.AgentFlowNode.routeDecisionRouteDescriptionPlaceholder": "描述何时应选择此路由...", + "PC.Pages.AgentFlowNode.routeDecisionItemFallback": "路由 {0}", + "PC.Pages.AgentFlowNode.askOptionIndex": "选项 {0}", + "PC.Pages.AgentFlowNode.extraPromptLabel": "补充提示词", + "PC.Pages.AgentFlowNode.extraPromptPlaceholder1": "请输入补充提示词,进一步对智能体行为进行约束", + "PC.Pages.AgentFlowNode.reminderPromptLabel": "循环提醒提示词", + "PC.Pages.AgentFlowNode.reminderPromptPlaceholder": "循环提醒提示词...", + "PC.Pages.AgentFlowNode.selfLoopTimesLabel": "自身循环次数", + "PC.Pages.AgentFlowNode.selfLoopTimesTooltip": "智能体自循环执行的次数,0 表示不循环", + "PC.Pages.AgentFlowNode.routeDecisionRouteNamePlaceholder": "如 complaint_handling", + "PC.Pages.AgentFlowNode.askOptionsTitle": "选项内容", + "PC.Pages.AgentFlowNode.contextWriteKeyHint": "输出写入的上下文键名,如 evalResult", + "PC.Pages.AgentFlowNode.formFieldLabel": "字段标签", + "PC.Pages.AgentFlowNode.formFieldOptionsPlaceholder": "选项1,选项2,选项3", + "PC.Pages.AgentFlowNode.formFieldRequired": "是否必填", + "PC.Pages.AgentFlowNode.formFieldsTitle": "表单字段", + "PC.Pages.AgentFlowNode.formFieldsHint": "定义用户需要填写的表单字段", + "PC.Pages.AgentFlowNode.formFieldDescriptionPlaceholder": "填写说明(提示用户输入什么内容)", + "PC.Pages.AgentFlowNode.formFieldAddBtn": "添加字段", + "PC.Pages.AgentFlowNode.formTypeCheckbox": "多选", + "PC.Pages.AgentFlowNode.formTypeFile": "文件上传", + "PC.Pages.AgentFlowNode.formTypeInput": "单行文本", + "PC.Pages.AgentFlowNode.formTypeNumber": "数字", + "PC.Pages.AgentFlowNode.formTypeRadio": "单选", + "PC.Pages.AgentFlowNode.formTypeSelect": "下拉单选", + "PC.Pages.AgentFlowNode.formTypeTextarea": "多行文本", + "PC.Pages.AgentFlowNode.replyModeLabel": "回复模式", + "PC.Pages.AgentFlowNode.replyModeTextReply": "文本回复", + "PC.Pages.AgentFlowNode.replyModeOptionsReply": "选项回复", + "PC.Pages.AgentFlowNode.replyModeFormReply": "表单回复", + "PC.Pages.AgentFlowNode.startEmptyHint": "开始节点无需额外配置,流程从此节点启动。", + "PC.Pages.AgentFlowNode.startSingleOutgoingHint": "开始节点只能连接一个后续节点", + "PC.Pages.AgentFlowNode.endEmptyHint": "结束节点无需额外配置,流程执行至此自动结束。", + "PC.Pages.AgentFlowNode.inputRefVarsLabel": "引用变量", + "PC.Pages.AgentFlowNode.contextWriteKeyLabel": "上下文写入", + "PC.Pages.AgentFlowNode.contextWriteKeyPlaceholder": "如 user_reply", + "PC.Pages.AgentFlowNode.agentInputLabel": "入参", + "PC.Pages.AgentFlowNode.routeInputLabel": "输入", + "PC.Pages.AgentFlowNode.routeExtraPromptLabel": "补充提示词", + "PC.Pages.AgentFlowNode.routeBranchesTitle": "路由分支", + "PC.Pages.AgentFlowNode.routeOtherIntent": "其他意图", + "PC.Pages.AgentFlowNode.routeAddBranch": "添加分支", + "PC.Pages.AgentFlowNode.routeAddCondition": "添加条件", + "PC.Pages.AgentFlowNode.routeConditionMatchLabel": "条件匹配", + "PC.Pages.AgentFlowNode.routeSelectVariablePlaceholder": "选择变量...", + "PC.Pages.AgentFlowNode.routeConditionValue": "值", + "PC.Pages.AgentFlowNode.routeConditionVariable": "变量", + "PC.Pages.AgentFlowParams.groupAgentFlowProcess": "处理节点", + "PC.Pages.AgentFlowParams.groupAgentFlowAdvanced": "高级节点", + "PC.Pages.AgentFlowParams.nodeRouteDecisionName": "路由决策", + "PC.Pages.AgentFlowParams.nodeRouteDecisionDescription": "AI 决策走哪条分支", + "PC.Pages.AgentFlowParams.nodeAgentName": "智能体", + "PC.Pages.AgentFlowParams.nodeAgentDescription": "调用智能体执行任务", + "PC.Pages.AgentFlowParams.nodeHumanApproveName": "人工确认", + "PC.Pages.AgentFlowParams.nodeHumanApproveDescription": "人工审批确认节点", + "PC.Pages.AgentFlowParams.nodeHumanAskName": "询问用户", + "PC.Pages.AgentFlowParams.nodeHumanAskDescription": "向用户提问并获取回复", + "PC.Pages.AgentFlowParams.nodeEvalGateName": "结果评估", + "PC.Pages.AgentFlowParams.nodeEvalGateDescription": "评估智能体输出结果", + "PC.Pages.AgentFlowParams.nodeExternalConnectorName": "三方平台连接器", + "PC.Pages.AgentFlowParams.nodeExternalConnectorDescription": "连接外部平台服务", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertName": "知识库写入", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertDescription": "将内容写入选定的知识库", + "PC.Pages.AntvX6Stencil.searchPlaceholder": "搜索节点...", + "PC.Pages.AppDevEditorHeaderRight.viewTerminal": "查看终端", + "PC.Pages.AppDevEditorHeaderRight.gitVersionHistory": "版本记录", + "PC.Pages.Chat.editConversationInfo": "编辑会话信息", + "PC.Pages.Chat.conversationIcon": "会话图标", + "PC.Pages.Chat.conversationName": "会话名称", + "PC.Pages.ConversationAgent.devServerReady": "开发服务器已就绪", + "PC.Pages.ConversationAgent.gitPush.defaultMessage": "更新文件", + "PC.Pages.ConversationAgent.gitPush.noConversation": "无活跃会话,无法执行 Git 推送", + "PC.Pages.ConversationAgent.gitPush.success": "Git 提交并推送成功", + "PC.Pages.ConversationAgent.ArrangePanel.tabConfig": "配置", + "PC.Pages.ConversationAgent.ArrangePanel.tabDebug": "调试", + "PC.Pages.ConversationAgent.ArrangePanel.tabVersion": "版本", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.title": "版本记录", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.subtitle": "共 {0} 次提交 · {1} 分支", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollback": "回滚", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollbackSuccess": "回滚成功", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.commitsOn": "提交于 {0}", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.empty": "暂无提交记录", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.noConversation": "暂无版本记录", + "PC.Components.FileTreePanel.FileTreeToolbar.projects": "项目", + "PC.Components.FileTreePanel.FileTreeToolbar.exportProject": "导出项目", + "PC.Components.FileTreePanel.FileTreeToolbar.importProject": "导入项目", + "PC.Components.FileTreePanel.FileTreeToolbar.newFile": "新建文件", + "PC.Components.FileTreePanel.FileTreeToolbar.newFolder": "新建文件夹", + "PC.Components.FileTreePanel.FileTreeToolbar.upload": "上传", + "PC.Components.FileTreePanel.FileTreeToolbar.uploadFolder": "上传文件夹", + "PC.Components.FileTreePanel.FileTreeToolbar.collapseAll": "折叠全部", + "PC.Components.FileTreePanel.GitVersionRecord.noProject": "请先选择项目后再查看版本记录", + "PC.Components.FileTreePanel.GitVersionRecord.backToList": "返回列表", + "PC.Components.FileTreePanel.GitVersionRecord.changedFileCount": "变更文件数:{0}", + "PC.Components.FileTreePanel.GitVersionRecord.searchPlaceholder": "搜索内容", + "PC.Components.FileTreePanel.GitVersionRecord.diffUnified": "统一", + "PC.Components.FileTreePanel.GitVersionRecord.diffSplit": "分屏", + "PC.Components.FileTreePanel.GitVersionRecord.revertToVersion": "回到该版本", + "PC.Components.FileTreePanel.GitVersionRecord.statusModified": "修改", + "PC.Components.FileTreePanel.GitVersionRecord.statusAdded": "新增", + "PC.Components.FileTreePanel.GitVersionRecord.statusDeleted": "删除", + "PC.Components.FileTreePanel.GitVersionRecord.statusRenamed": "重命名", + "PC.Components.FileTreePanel.GitVersionRecord.noChangedFiles": "该提交暂无变更文件", + "PC.Components.FileTreePanel.GitVersionRecord.autoSaveBeforeRestoreMessage": "回滚前自动保存您的更改", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackModalTitle": "回滚至 {0} 版本", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackVersionLabel": "回滚版本", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectTitle": "此操作会:", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectAutoSave": "会自动保存工作区、暂存区未提交的文件", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectBranchSync": "该分支下的其余对话也同步回滚", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectWorkspace": "工作区恢复至选中的 checkpoint 状态", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectUndoChanges": "撤销该版本之后的所有更改", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectHistory": "历史记录会保留,可随时回滚到其他版本", + "PC.Pages.ConversationAgent.prototypeTitle": "Agent开发原型", + "PC.Pages.ConversationAgentMiddlePanel.files": "文件", + "PC.Pages.ConversationAgentMiddlePanel.sourceControl": "源代码管理", + "PC.Pages.ConversationAgentSourceControl.changes": "更改", + "PC.Pages.ConversationAgentSourceControl.commit": "提交", + "PC.Pages.ConversationAgentSourceControl.commitAndPush": "推送", + "PC.Pages.ConversationAgentSourceControl.commitMessage": "提交信息", + "PC.Pages.ConversationAgentSourceControl.gitPushTooltip": "Git 提交并推送到远程仓库", + "PC.Pages.ConversationAgentSourceControl.noChanges": "暂无修改的文件", + "PC.Pages.ConversationAgentSourceControl.save": "保存", + "PC.Pages.ConversationAgentSourceControl.saveTooltip": "保存更改到沙箱", + "PC.Pages.ConversationAgentSourceControl.title": "源代码管理", + "PC.Pages.ConversationAgentSourceControl.openChanges": "打开更改", + "PC.Pages.ConversationAgentSourceControl.openFile": "打开文件", + "PC.Pages.ConversationAgentSourceControl.discardChanges": "放弃更改", + "PC.Pages.ConversationAgentSourceControl.discardChangesConfirmTitle": "确定要放弃更改吗?", + "PC.Pages.ConversationAgentSourceControl.discardAllChanges": "放弃所有更改", + "PC.Pages.ConversationAgentSourceControl.discardAllChangesConfirmTitle": "确定要放弃所有更改吗?", + "PC.Pages.ConversationAgentSourceControl.stageAllChanges": "暂存所有更改", + "PC.Pages.ConversationAgentSourceControl.unstageAllChanges": "取消所有暂存", + "PC.Pages.ConversationAgentSourceControl.stageChanges": "暂存更改", + "PC.Pages.ConversationAgentSourceControl.unstageChanges": "取消暂存", + "PC.Pages.ConversationAgentSourceControl.addToGitignore": "添加到 .gitignore", + "PC.Pages.ConversationAgentSourceControl.alreadyInGitignore": "该文件已在 .gitignore 中", + "PC.Pages.ConversationAgentSourceControl.gitignoreSuccess": "已添加到 .gitignore", + "PC.Pages.ConversationAgentSourceControl.gitignoreFailed": "添加到 .gitignore 失败", + "PC.Pages.ConversationAgentSourceControl.stagedChanges": "暂存的更改", + "PC.Pages.ConversationAgentSourceControl.refresh": "刷新", + "PC.Pages.ConversationAgentSourceControl.viewAsList": "以列表形式查看", + "PC.Pages.ConversationAgentSourceControl.viewAsTree": "以树形结构查看", + "PC.Pages.ConversationAgentPreviewTabBar.addTab": "新标签页", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.close": "关闭", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeOthers": "关闭其他", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeAll": "关闭所有", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.pin": "固定", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.unpin": "取消固定", + "PC.Pages.ConversationAgentPreviewTabBar.closeTab": "关闭标签页", + "PC.Pages.ConversationAgentTabPicker.devTools": "开发工具", + "PC.Pages.ConversationAgentTabPicker.devToolsDesc": "提供代码编辑、实时预览和调试能力,支持开发与版本管理的全流程。", + "PC.Pages.ConversationAgentTabPicker.preview": "预览", + "PC.Pages.ConversationAgentTabPicker.previewDesc": "实时预览运行效果,进行代码调试。", + "PC.Pages.ConversationAgentTabPicker.arrange": "编排", + "PC.Pages.ConversationAgentTabPicker.arrangeDesc": "配置模型、提示词、变量与工具等智能体编排能力。", + "PC.Pages.ConversationAgentTabPicker.terminal": "终端", + "PC.Pages.ConversationAgentTabPicker.terminalDesc": "访问系统命令行,执行脚本或安装依赖。", + "PC.Pages.ConversationAgentTabPicker.versionControl": "版本控制", + "PC.Pages.ConversationAgentTabPicker.versionControlDesc": "管理代码版本,查看具体代码变更和提交历史。", + "PC.Pages.ConversationAgentTabPicker.subscription": "订阅", + "PC.Pages.ConversationAgentTabPicker.subscriptionDesc": "配置付费套餐与试用策略,查看订阅数据与订阅者情况。", + "PC.Pages.ConversationAgentTabPicker.subscriptionSetting": "订阅设置", + "PC.Pages.ConversationAgentTabPicker.subscriptionSettingDesc": "开启付费模式、管理套餐列表与默认可试用次数。", + "PC.Pages.ConversationAgentTabPicker.subscriptionStats": "订阅统计", + "PC.Pages.ConversationAgentTabPicker.subscriptionStatsDesc": "查看总订阅数、收入与订阅者列表等统计数据。", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPrompt": "补充提示词", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPromptPlaceholder": "输入补充提示词,对群组智能体进行额外约束", + "PC.Pages.MorePage.ApiKeyPermission.apiPermission": "接口权限", + "PC.Pages.MorePage.ApiKeyPermission.modelPermission": "系统模型", + "PC.Pages.MorePage.ApiKeyPermission.noModelDefs": "暂无模型定义", + "PC.Pages.MorePage.ApiKeyPermission.spaceModelPermission": "个人空间模型", + "PC.Pages.MorePage.ApiKeyPermission.noSpaceModelDefs": "暂无空间模型定义", + "PC.Pages.MorePage.MySubscriptions.paySuccess": "支付成功", + "PC.Pages.MorePage.MySubscriptions.payFailed": "支付失败,请重新尝试", + "PC.Pages.MorePage.MySubscriptions.payPending": "支付确认中,请稍后查看", + "PC.Pages.OpenApp.subscription": "订阅", + "PC.Pages.SpaceDevelop.ApplicationItem.agentFlowType": "AgentFlow", + "PC.Pages.SpaceDevelop.ApplicationItem.customType": "自定义", + "PC.Pages.SpaceDevelop.ApplicationItem.agentGroupType": "AgentGroup", + "PC.Pages.SpaceLibrary.CreateModel.inputModelIdentifier": "输入模型标识", + "PC.Pages.SpacePluginWorkflow.pluginPageTitle": "插件", + "PC.Pages.SpacePluginWorkflow.workflowPageTitle": "工作流", + "PC.Pages.SpaceResourcePricing.periodSecond": "秒", + "PC.Pages.SpaceResourcePricing.periodMillionToken": "百万Token", + "PC.Pages.SpaceResourcePricing.pricingTypeOneTime": "单次", + "PC.Pages.SpaceResourcePricing.pricingTypeMonthly": "包月", + "PC.Pages.SpaceResourcePricing.pricingTypeTiered": "阶梯计费", + "PC.Pages.UserManage.Index.delete": "删除", + "PC.Pages.UserManage.Index.deleteSuccess": "删除成功", + "PC.Pages.UserManage.Index.resetPassword": "密码重置", + "PC.Pages.UserManage.Index.userId": "用户ID", + "PC.Pages.UserManage.Index.placeholderUserId": "请输入用户ID,仅支持输入整数", + "PC.Pages.UserManage.ResetPasswordModal.title": "重置密码", + "PC.Pages.UserManage.ResetPasswordModal.newPassword": "新密码", + "PC.Pages.UserManage.ResetPasswordModal.inputNewPassword": "请输入新密码", + "PC.Pages.UserManage.ResetPasswordModal.confirmPassword": "确认密码", + "PC.Pages.UserManage.ResetPasswordModal.inputConfirmPassword": "请再次输入新密码", + "PC.Pages.UserManage.ResetPasswordModal.newPasswordRequired": "请输入新密码", + "PC.Pages.UserManage.ResetPasswordModal.confirmPasswordRequired": "请确认新密码", + "PC.Pages.UserManage.ResetPasswordModal.passwordMismatch": "两次输入的密码不一致", + "PC.Pages.UserManage.ResetPasswordModal.resetSuccess": "密码修改成功", + "PC.Pages.UserManage.UserAuthModal.subscriptionUserGroup": "订阅关联用户组", + "PC.Routes.recommendManage": "推荐管理", + "PC.Routes.homeRecommend": "首页推荐", + "PC.Routes.officialRecommend": "官方推荐", + "PC.Routes.chatboxRecommend": "对话框智能体", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDesc": "换算描述", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDescPlaceholder": "请输入面向用户的积分换算说明,例如兑换比例、可抵扣范围等,将展示在积分余额旁", + "PC.Pages.SystemCreditPackages.periodMonthUnit": "个月", + "PC.Pages.SystemUserCredits.colUserNamePhoneOrEmail": "用户名称/手机号/邮箱", + "PC.Pages.SystemUserCredits.grantCredit": "积分发放", + "PC.Pages.SystemUserCredits.deductCredit": "积分扣减", + "PC.Pages.SystemUserCredits.grantModalTitle": "积分发放", + "PC.Pages.SystemUserCredits.deductModalTitle": "积分扣减", + "PC.Pages.SystemUserCredits.fieldCreditType": "积分类型", + "PC.Pages.SystemUserCredits.fieldAmount": "积分数量", + "PC.Pages.SystemUserCredits.fieldAmountPlaceholder": "请输入积分数量", + "PC.Pages.SystemUserCredits.fieldAmountMin": "积分数量须大于 0", + "PC.Pages.SystemUserCredits.fieldBizNo": "业务单号", + "PC.Pages.SystemUserCredits.fieldBizNoPlaceholder": "请输入业务单号(选填)", + "PC.Pages.SystemUserCredits.fieldRemark": "备注", + "PC.Pages.SystemUserCredits.fieldRemarkPlaceholder": "请输入备注(选填)", + "PC.Pages.SystemUserCredits.fieldExpireTime": "过期时间", + "PC.Pages.SystemUserCredits.fieldExpireTimePlaceholder": "请选择过期时间(不填则永不过期)", + "PC.Pages.SystemUserCredits.fieldAllowNegative": "允许透支", + "PC.Pages.SystemUserCredits.grantSuccess": "积分发放成功", + "PC.Pages.SystemUserCredits.deductSuccess": "积分扣减成功", + "PC.Pages.SystemRecommendManage.addTitle": "新增推荐", + "PC.Pages.SystemRecommendManage.createSuccess": "创建成功", + "PC.Pages.SystemRecommendManage.colAction": "操作", + "PC.Pages.SystemRecommendManage.colCreated": "创建时间", + "PC.Pages.SystemRecommendManage.colModified": "修改时间", + "PC.Pages.SystemRecommendManage.colLabel": "名称", + "PC.Pages.SystemRecommendManage.dialogHintLabel": "对话框提示信息", + "PC.Pages.SystemRecommendManage.dialogHintPlaceholder": "请输入对话框提示信息", + "PC.Pages.SystemRecommendManage.colTargetId": "目标ID", + "PC.Pages.SystemRecommendManage.colTargetType": "类型", + "PC.Pages.SystemRecommendManage.colSubType": "子类型", + "PC.Pages.SystemRecommendManage.confirmDeleteContent": "删除后无法恢复,确定继续吗?", + "PC.Pages.SystemRecommendManage.confirmDeleteTitle": "确认删除", + "PC.Pages.SystemRecommendManage.editTitle": "编辑推荐", + "PC.Pages.SystemRecommendManage.updateSuccess": "更新成功", + "PC.Pages.SystemRecommendManage.functionType.agentDev": "智能体开发", + "PC.Pages.SystemRecommendManage.functionType.chat": "智能体", + "PC.Pages.SystemRecommendManage.functionType.pageAppDev": "网页应用开发", + "PC.Pages.SystemRecommendManage.functionType.pluginDev": "插件开发", + "PC.Pages.SystemRecommendManage.functionType.skillDev": "技能开发", + "PC.Pages.SystemRecommendManage.searchName": "请输入名称", + "PC.Pages.SystemRecommendManage.selectTarget": "选择推荐目标", + "PC.Pages.SystemRecommendManage.selectTargetRequired": "请选择{0}", + "PC.Pages.SystemRecommendManage.clickToAddAgent": "点击添加智能体", + "PC.Pages.SystemRecommendManage.sortUpdated": "排序已更新", + "PC.Pages.SystemRecommendManage.targetType.Agent": "智能体", + "PC.Pages.SystemRecommendManage.targetType.PageApp": "应用", + "PC.Pages.SystemRecommendManage.targetType.Plugin": "插件", + "PC.Pages.SystemRecommendManage.targetType.Skill": "技能", + "PC.Pages.SystemRecommendManage.targetType.Workflow": "工作流", + "PC.Pages.SystemPlans.fieldNamePlaceholderExample": "例如:基础版", + "PC.Pages.SystemPlans.fieldCreditAmountMonthly": "套餐包含积分(每月)", + "PC.Pages.SystemPlans.fieldPriceLabel": "套餐价格 (¥)", + "PC.Pages.SystemPlans.fieldPricePlaceholderExample": "例如:99", + "PC.Pages.SystemPlans.fieldValidityPeriod": "套餐有效期", + "PC.Pages.SystemPlans.periodOption1Month": "1个月", + "PC.Pages.SystemPlans.periodOption3Months": "3个月", + "PC.Pages.SystemPlans.periodOption12Months": "12个月", + "PC.Pages.SystemPlans.fieldHotLabel": "热门标签", + "PC.Pages.SystemPlans.fieldDescPlaceholder": "描述套餐的定位和适用场景", + "PC.Pages.SystemPlans.developerPermissionTitle": "开发者权限", + "PC.Pages.SystemPlans.originalPrice": "原价¥{0}/{1}", + "PC.Routes.modelPermissions": "模型权限", + "PC.Pages.ModelPermissions.tabSystem": "系统模型", + "PC.Pages.ModelPermissions.tabSpace": "个人空间模型", + "PC.Pages.ModelPermissions.colModelName": "模型名称", + "PC.Pages.ModelPermissions.colModelId": "模型标识", + "PC.Pages.ModelPermissions.colModelIdTip": "外部调用时填写该值", + "PC.Pages.ModelPermissions.colModalities": "支持模态", + "PC.Pages.ModelPermissions.colExternalCall": "外部调用", + "PC.Pages.ModelPermissions.colExternalCallTip": "是否支持在其他外部系统调用该模型", + "PC.Pages.ModelPermissions.colApiProtocol": "接口协议", + "PC.Pages.ModelPermissions.colPrice": "价格", + "PC.Pages.ModelPermissions.notSupported": "不支持", + "PC.Pages.ModelPermissions.interfaceAddress": "接口地址", + "PC.Pages.ModelPermissions.copyInterface": "复制接口", + "PC.Pages.ModelPermissions.interfaceAddressCopied": "接口地址已复制", + "PC.Pages.ModelPermissions.modelIdCopied": "模型标识已复制", + "PC.Pages.SpaceCreateProject.greetingTitle": "嗨,{0},给我一个任务,现在开始?", + "PC.Pages.SpaceCreateProject.tabAgent": "智能体", + "PC.Pages.SpaceCreateProject.placeholderAgent": "描述你想要的智能体,例如:帮我创建一个代码审查助手,能自动检测代码问题并给出优化建议", + "PC.Pages.SpaceCreateProject.tabPageApp": "网页应用", + "PC.Pages.SpaceCreateProject.placeholderPageApp": "描述你想要的网页应用,例如:帮我开发一个颜值管理网站,支持上传照片智能评估颜值与肤质", + "PC.Pages.SpaceCreateProject.tabSkill": "技能", + "PC.Pages.SpaceCreateProject.placeholderSkill": "描述你想要的自定义技能,例如:帮我写一个根据经纬度查询当前天气状况的API接口", + "PC.Pages.SpaceCreateProject.tabPlugin": "插件", + "PC.Pages.SpaceCreateProject.placeholderPlugin": "描述你想要的插件工具,例如:帮我对接第三方图片转换的HTTP接口插件", + "PC.Pages.SpaceCreateProject.inputRequiredWarning": "请输入您的任务描述!", + "PC.Pages.SpaceKnowledge.KnowledgeHeader.accuracytest": "命中测试", }; diff --git a/qiming/src/locales/i18n/zh-HK.ts b/qiming/src/locales/i18n/zh-HK.ts index 625f1674..d9b007f6 100644 --- a/qiming/src/locales/i18n/zh-HK.ts +++ b/qiming/src/locales/i18n/zh-HK.ts @@ -2624,6 +2624,8 @@ export const ZH_HK: SystemLangMap = { "PC.Pages.Login.captchaTitle": "請完成下列驗證後繼續", "PC.Pages.Login.captchaVerifying": "驗證中...", "PC.Pages.Login.codeLoginOrRegister": "驗證碼登錄/註冊", + "PC.Pages.Login.inputAccountPlaceholder": "請輸入用戶名/郵箱", + "PC.Pages.Login.inputAccountRequired": "請輸入用戶名/郵箱", "PC.Pages.Login.inputEmailPlaceholder": "請輸入郵箱", "PC.Pages.Login.inputEmailRequired": "請輸入郵箱", "PC.Pages.Login.inputPasswordPlaceholder": "請輸入不少於 6 位的密碼", @@ -5355,4 +5357,525 @@ export const ZH_HK: SystemLangMap = { "PC.Pages.SystemSubsOrders.detailDeveloperInfo": "開發者信息", "PC.Pages.SystemSubsOrders.detailSubscriptionInfo": "訂閱信息", "PC.Pages.SystemSubsOrders.drawerTitle": "訂閱詳情", + "PC.Common.Global.times": "次", + "PC.Common.Global.uploadFileSizeExceed": "上傳文件總大小不能超過{0}MB", + "PC.Components.ConversationBottomConsole.collapsePanel": "摺疊到底部", + "PC.Components.ConversationBottomConsole.containerKeepaliveError": "服務保活請求異常,請檢查網絡後重試", + "PC.Components.ConversationBottomConsole.containerKeepaliveFailed": "服務保活失敗,請重新啟動服務", + "PC.Components.ConversationBottomConsole.containerStartError": "服務啟動異常,請稍後重試", + "PC.Components.ConversationBottomConsole.containerStarting": "服務啟動中,請稍候…", + "PC.Components.ConversationBottomConsole.expandHeight": "放大至全螢幕", + "PC.Components.ConversationBottomConsole.logsEmpty": "暫無日誌", + "PC.Components.ConversationBottomConsole.restoreHeight": "恢復預設高度", + "PC.Components.ConversationBottomConsole.retryStartContainer": "重啟服務", + "PC.Components.ConversationBottomConsole.tabLogs": "日誌", + "PC.Components.ConversationBottomConsole.tabTerminal": "終端", + "PC.Components.ConversationBottomConsole.terminalEmpty": "暫無終端輸出", + "PC.Components.ConversationBottomConsole.terminalThemeDark": "切換為深色終端", + "PC.Components.ConversationBottomConsole.terminalThemeLight": "切換為淺色終端", + "PC.Components.VoiceInput.startTooltip": "語音輸入", + "PC.Components.VoiceInput.connecting": "連接麥克風…", + "PC.Components.VoiceInput.stopTooltip": "停止錄音並識別", + "PC.Components.VoiceInput.stopFillTooltip": "停止錄音並回填", + "PC.Components.VoiceInput.stopSendTooltip": "停止錄音並發送", + "PC.Components.VoiceInput.transcribing": "語音識別中…", + "PC.Components.VoiceInput.permissionDenied": "麥克風權限被拒絕,請在瀏覽器設定中允許後重試", + "PC.Components.VoiceInput.notSupported": "目前環境不支援語音輸入,請使用最新版 Chrome/Edge/Firefox/Safari", + "PC.Components.VoiceInput.tooShort": "錄音時間過短", + "PC.Components.VoiceInput.tooLarge": "錄音檔案過大,請縮短錄音時間後重試", + "PC.Components.VoiceInput.recognizeFailed": "語音識別失敗,請重試", + "PC.Components.ChatInputHome.pastedFileName": "粘貼文件-{0}-{1}", + "PC.Components.ChatInputHome.dropFilesHint": "釋放以上傳文件", + "PC.Components.ChatInputHome.agentMode": "Agent 模式", + "PC.Components.ChatInputHome.agentModeAuto": "自動", + "PC.Components.ChatInputHome.agentModeAutoDesc": "工具調用前將不會詢問用戶", + "PC.Components.ChatInputHome.agentModeApproval": "審批", + "PC.Components.ChatInputHome.agentModeApprovalDesc": "部分工具調用前將詢問用戶是否同意", + "PC.Components.ChatInputHomeSpaceSelector.noAvailableSpace": "暫無可用空間", + "PC.Components.CollapseComponentList.groupAgentDesc": "群組智能體是組內的成員智能體,可協同完成任務。", + "PC.Components.CreateAgent.typeAgentFlow": "AgentFlow", + "PC.Components.CreateAgent.typeAgentGroup": "AgentGroup", + "PC.Components.CreateWorkflow.createAgentFlow": "新建 AgentFlow", + "PC.Components.CreateWorkflow.placeholderAgentFlowName": "輸入 AgentFlow 名稱", + "PC.Components.CreateWorkflow.placeholderAgentFlowDesc": "輸入描述,讓大模型理解什麼情況下應該使用此 AgentFlow", + "PC.Components.CreateWorkflow.skip": "跳過", + "PC.Components.CreateWorkflow.startEditing": "開始編輯", + "PC.Components.FileContextMenu.uploadFolder": "上傳文件夾", + "PC.Components.FileContextMenu.uploadFolderEmpty": "文件夾中沒有可上傳的文件", + "PC.Components.FileContextMenu.uploadFileEmpty": "沒有可上傳的文件", + "PC.Components.FileContextMenu.uploadTargetInvalid": "無法上傳到該目錄", + "PC.Components.HistoryConversationList.emptySession": "你還沒有對話喲,快去開始你的第一個任務吧", + "PC.Components.HistoryConversationList.noMore": "沒有更多了", + "PC.Components.HistoryConversationList.noSearchResult": "無搜索結果", + "PC.Components.McpAskQuestionCard.cancelShortcutHint": "取消(Esc)", + "PC.Components.McpAskQuestionCard.cancelled": "已取消", + "PC.Components.McpAskQuestionCard.customInputLabel": "自定義內容", + "PC.Components.McpAskQuestionCard.customInputPlaceholder": "請輸入", + "PC.Components.McpAskQuestionCard.customOption": "自定義", + "PC.Components.MessageQueue.clearAll": "清空全部", + "PC.Components.MessageQueue.editMessageTooltip": "編輯訊息", + "PC.Components.MessageQueue.sendNowTooltip": "立即發送", + "PC.Components.MessageQueue.title": "待發送", + "PC.Components.McpAskQuestionCard.defaultTitle": "這次提問", + "PC.Components.McpAskQuestionCard.notFilled": "未填寫", + "PC.Components.McpAskQuestionCard.unknownFile": "未知檔案", + "PC.Components.McpAskQuestionCard.uploadDragText": "點擊或拖拽檔案到此區域上傳", + "PC.Components.McpAskQuestionCard.file": "檔案", + "PC.Components.McpAskQuestionCard.emptyFormContent": "(無表單內容)", + "PC.Components.McpAskQuestionCard.eyebrow": "補充回覆", + "PC.Components.McpAskQuestionCard.fieldRequired": "請填寫此項", + "PC.Components.McpAskQuestionCard.multiSelectMin": "請至少選擇一項", + "PC.Components.McpAskQuestionCard.nextStep": "下一步", + "PC.Components.McpAskQuestionCard.prevStep": "上一步", + "PC.Components.McpAskQuestionCard.resumeCancelled": "我取消了「{0}」。", + "PC.Components.McpAskQuestionCard.resumeSkipped": "我跳過了「{0}」。", + "PC.Components.McpAskQuestionCard.resumeTimeout": "「{0}」已逾時,沒有收到表單答案。", + "PC.Components.McpAskQuestionCard.skip": "跳過", + "PC.Components.McpAskQuestionCard.skipped": "已跳過", + "PC.Components.McpAskQuestionCard.stepOf": "步驟 {0} / {1}", + "PC.Components.McpAskQuestionCard.submitted": "已提交回答", + "PC.Components.McpAskQuestionCard.resumeSubmitted": "我已填寫「{0}」,表單內容如下:", + "PC.Components.PaymentSubscriptionModal.titleSelectPlan": "選擇訂閱套餐", + "PC.Components.PaymentSubscriptionModal.titleWithTrialCount": "選擇訂閱套餐(試用次數:{0}/{1})", + "PC.Components.PaymentSubscriptionModal.emptyNoPlans": "暫無可用套餐", + "PC.Components.PaymentSubscriptionModal.monthlyCredits": "每月 {0} 積分", + "PC.Components.PaymentSubscriptionModal.callLimitUnlimited": "不限制", + "PC.Components.PaymentSubscriptionModal.callLimitPerMonth": "{0} 次/月", + "PC.Components.PaymentSubscriptionModal.originalPrice": "原價{0}{1}/{2}", + "PC.Components.PaymentSubscriptionModal.callCountLabel": "可調用次數:{0}", + "PC.Components.PaymentSubscriptionModal.periodMonth": "月", + "PC.Components.PaymentSubscriptionModal.periodQuarter": "季度", + "PC.Components.PaymentSubscriptionModal.periodYear": "年", + "PC.Components.PaymentSubscriptionModal.periodForever": "永久", + "PC.Components.PaymentSubscriptionModal.btnSubscribePlan": "訂閱套餐", + "PC.Components.PaymentSubscriptionModal.btnSubscribeBuyoutPlan": "訂閱買斷套餐", + "PC.Components.PaymentSubscriptionModal.btnForeverBoughtOut": "已買斷套餐", + "PC.Components.PaymentSubscriptionModal.btnExpiredRenew": "套餐已過期(續訂)", + "PC.Components.PaymentSubscriptionModal.btnCurrent": "當前套餐", + "PC.Components.PaymentSubscriptionModal.btnCurrentRenew": "當前套餐(續訂)", + "PC.Components.PaymentSubscriptionModal.btnUpgrade": "升級", + "PC.Constants.Library.audioProcess": "語音處理", + "PC.Constants.Library.chatMultimodal": "聊天對話-多模態", + "PC.Constants.Library.chatText": "聊天對話-純文本", + "PC.Constants.Library.imageProcess": "圖像處理", + "PC.Constants.Library.supportFuncCall": "支持普通函數調用", + "PC.Constants.Library.textCompletion": "文本補全", + "PC.Constants.Library.textEdit": "文本編輯", + "PC.Constants.Library.vectorEmbedding": "向量嵌入", + "PC.Constants.Library.externalApi": "外部API調用", + "PC.Constants.Space.callApproval": "調用審批", + "PC.Constants.Space.agentFlowType": "AgentFlow", + "PC.Constants.Space.agentFlowTypeTooltip": "基於多智能體協作編排的工作流,適合複雜任務拆解、分支決策、人工介入和結果評估。", + "PC.Constants.Space.agentGroupType": "AgentGroup", + "PC.Constants.Space.agentGroupTypeTooltip": "多智能體協作組,適合需要多個智能體分工協作的複雜任務場景。", + "PC.Constants.Space.group": "分組", + "PC.Pages.SpaceResource.LeftGroupList.createGroupTitle": "新建分組", + "PC.Pages.SpaceResource.LeftGroupList.editGroupTitle": "編輯分組", + "PC.Pages.SpaceResource.LeftGroupList.groupNameLabel": "分組名稱", + "PC.Pages.SpaceResource.LeftGroupList.groupNameMaxLength": "名稱長度不能超過30個字符", + "PC.Pages.SpaceResource.LeftGroupList.groupNameNoWhitespace": "名稱不能為空白字符", + "PC.Pages.SpaceResource.LeftGroupList.groupNamePlaceholder": "請輸入分組名稱", + "PC.Pages.SpaceResource.LeftGroupList.groupNameRequired": "請輸入分組名稱", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeLabel": "分組類型", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeRequired": "請選擇分組類型", + "PC.Pages.SpaceResource.LeftGroupList.moveSuccess": "移入成功", + "PC.Pages.SpaceResource.LeftGroupList.moveToGroup": "移入分組", + "PC.Pages.SpaceResource.LeftGroupList.moveToOtherGroup": "移動至其它分組", + "PC.Pages.SpaceResource.LeftGroupList.removeFromGroup": "移出分組", + "PC.Pages.SpaceResource.LeftGroupList.removeSuccess": "移出成功", + "PC.Pages.SpaceResource.LeftGroupList.selectGroup": "選擇分組", + "PC.Pages.SpaceResource.LeftGroupList.selectGroupPlaceholder": "請選擇要移入的分組", + "PC.Pages.SpaceResource.LeftGroupList.unitCount": "{0} 個", + "PC.Pages.SpaceResource.LeftGroupList.deleteConfirmContent": "確定要刪除分組「{0}」嗎?", + "PC.Pages.SpaceResource.LeftGroupList.createSuccess": "新建成功", + "PC.Pages.SpaceResource.LeftGroupList.editSuccess": "修改成功", + "PC.Pages.SpaceResource.LeftGroupList.groupDescLabel": "分組描述", + "PC.Pages.SpaceResource.LeftGroupList.groupDescMaxLength": "描述長度不能超過100個字符", + "PC.Pages.SpaceResource.LeftGroupList.groupDescPlaceholder": "請輸入分組描述", + "PC.Pages.SpaceResource.LeftGroupList.groupIconLabel": "分組圖標", + "PC.Pages.SpaceResource.LeftGroupList.deleteSuccess": "刪除成功", + "PC.Modal.chunkLoadErrorContent": "頁面資源加載失敗,請刷新頁面後重試", + "PC.Modal.chunkLoadErrorRefresh": "立即刷新", + "PC.Modal.chunkLoadErrorTitle": "頁面加載異常", + "PC.Models.ConversationInfo.askResponseFailed": "問題回答提交失敗", + "PC.Hooks.UseSubscription.createOrderFailed": "創建訂閱訂單失敗", + "PC.Hooks.UseSubscription.getCashierUrlFailed": "獲取收銀台地址失敗", + "PC.Layouts.DynamicMenusLayout.newProject": "新建項目", + "PC.Layouts.DynamicMenusLayout.SpaceSection.searchSpacePlaceholder": "搜尋空間", + "PC.Layouts.DynamicMenusLayout.SpaceSection.noSpaceMatch": "未找到符合的空間", + "PC.Models.ConversationInfo.permissionResponseFailed": "權限審批結果提交失敗", + "PC.Pages.AgentArrangeConfig.addGroupAgent": "添加群組智能體", + "PC.Pages.AgentArrangeConfig.allowChooseMode": "允許用戶在對話框中選擇模式", + "PC.Pages.AgentArrangeConfig.allowChooseModeDesc": "開啓後,用戶在對話框下可以選擇:自動(Auto)、審批(Approval),關閉後默認使用自動模式。", + "PC.Pages.AgentArrangeConfig.versionControl": "版本管理", + "PC.Pages.AgentArrangeConfig.versionControlDesc": "開啓後,智能體支持版本管理,可查看代碼變更和提交歷史。", + "PC.Pages.AgentArrangeConfig.groupAgent": "群組智能體", + "PC.Pages.AgentArrangeConfig.groupMembers": "組員", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategy": "參數更新策略", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyAppend": "追加", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyPlaceholder": "請選擇參數更新策略", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyReplace": "替換", + "PC.Pages.AgentArrangeCreateHookModal.config": "配置 JSON", + "PC.Pages.AgentArrangeCreateHookModal.configInvalid": "請輸入合法的 JSON 配置", + "PC.Pages.AgentArrangeCreateHookModal.event": "事件", + "PC.Pages.AgentArrangeCreateHookModal.eventRequired": "請選擇事件", + "PC.Pages.AgentArrangeCreateHookModal.matcher": "匹配規則", + "PC.Pages.AgentArrangeCreateHookModal.matcherPlaceholder": "工具名稱,如 Bash、Edit|Write、mcp...", + "PC.Pages.AgentArrangeCreateHookModal.matcherNotSupported": "不支持匹配規則", + "PC.Pages.AgentArrangeCreateHookModal.matcherStopFailurePlaceholder": "錯誤類型,如 rate_limit、overloaded", + "PC.Pages.AgentArrangeCreateHookModal.matcherSessionStartPlaceholder": "啟動方式,如 startup、resume、clear、compact", + "PC.Pages.AgentArrangeCreateHookModal.matcherSubagentStartPlaceholder": "Agent 類型,如 general-purpose、Explore、Plan", + "PC.Pages.AgentArrangeCreateHookModal.name": "名稱", + "PC.Pages.AgentArrangeCreateHookModal.namePlaceholder": "如:代碼格式化", + "PC.Pages.AgentArrangeCreateHookModal.nameRequired": "請輸入名稱", + "PC.Pages.AgentArrangeCreateHookModal.titleCreate": "新建 Hook", + "PC.Pages.AgentArrangeCreateHookModal.titleEdit": "編輯 Hook", + "PC.Pages.AgentArrangeCreateHookModal.type": "類型", + "PC.Pages.AgentArrangeCreateHookModal.typeRequired": "請選擇類型", + "PC.Pages.AgentArrangeCreateHooks.add": "新建", + "PC.Pages.AgentArrangeCreateHooks.columnAction": "操作", + "PC.Pages.AgentArrangeCreateHooks.columnEvent": "事件", + "PC.Pages.AgentArrangeCreateHooks.columnMatcher": "匹配規則", + "PC.Pages.AgentArrangeCreateHooks.columnName": "名稱", + "PC.Pages.AgentArrangeCreateHooks.columnStatus": "狀態", + "PC.Pages.AgentArrangeCreateHooks.columnType": "類型", + "PC.Pages.AgentArrangeCreateHooks.deleteSuccess": "刪除成功", + "PC.Pages.AgentArrangeCreateHooks.searchPlaceholder": "搜索...", + "PC.Pages.AgentArrangeCreateHooks.title": "Hook 設置", + "PC.Pages.AgentArrangeCreateHooks.titleTooltip": "OpenAI 協議模型支援的事件範圍:PreToolUse、PostToolUse、PostToolUseFailure、SessionStart、SessionEnd、Stop", + "PC.Pages.AgentArrangeCreateHooks.updateSuccess": "更新成功", + "PC.Pages.AgentArrangeConfig.addHook": "添加 Hook", + "PC.Pages.AgentArrangeConfig.hook": "Hook 設置", + "PC.Pages.AgentArrangeConfig.hookUnavailable": "Hook 組件未就緒,請稍後重試", + "PC.Pages.AgentArrangeConfig.enableAskQuestion": "允許詢問用戶", + "PC.Pages.AgentArrangeConfig.enableAskQuestionDesc": "開啓後,智能體需要額外信息時可以彈出表單向用戶詢問。", + "PC.Components.HookList.description": "通過 Hook 可在智能體執行的關鍵節點插入自定義邏輯。", + "PC.Pages.AgentArrangeCallApproval.optionNo": "否", + "PC.Pages.AgentArrangeCallApproval.optionYes": "是", + "PC.Pages.AgentArrangeCallApproval.title": "是否需要審批", + "PC.Pages.AgentArrangeCallApproval.tooltip": "開啟後,模型在調用工具前會向用戶發送審批通知,同意後可繼續執行", + "PC.Pages.AntvX6Header.autoArrange": "自動排列", + "PC.Pages.AntvX6Header.run": "執行", + "PC.Pages.AntvX6Header.save": "儲存", + "PC.Pages.AgentFlow.loading": "載入中...", + "PC.Pages.AgentFlow.chipApprove": "通過", + "PC.Pages.AgentFlow.chipFail": "未達標", + "PC.Pages.AgentFlow.chipPass": "通過", + "PC.Pages.AgentFlow.chipReject": "拒絕", + "PC.Pages.AgentFlow.dragToConnect": "拖曳連線", + "PC.Pages.AgentFlowNode.routeDecisionExtraPromptPlaceholder": "請輸入給 AI 的額外路由決策指令...", + "PC.Pages.AgentFlowNode.askQuestionLabel": "提問內容", + "PC.Pages.AgentFlowNode.askQuestionPlaceholder": "請輸入要向用戶提出嘅問題...", + "PC.Pages.AgentFlowNode.routeDecisionRouteDescriptionPlaceholder": "描述幾時應該選擇此路由...", + "PC.Pages.AgentFlowNode.routeDecisionItemFallback": "路由 {0}", + "PC.Pages.AgentFlowNode.askOptionIndex": "選項 {0}", + "PC.Pages.AgentFlowNode.extraPromptLabel": "補充提示詞", + "PC.Pages.AgentFlowNode.agentInputLabel": "入參", + "PC.Pages.AgentFlowNode.contextWriteKeyLabel": "上下文寫入", + "PC.Pages.AgentFlowNode.contextWriteKeyPlaceholder": "如 user_reply", + "PC.Pages.AgentFlowNode.endEmptyHint": "結束節點無需額外配置,流程執行至此自動結束。", + "PC.Pages.AgentFlowNode.inputRefVarsLabel": "引用變數", + "PC.Pages.AgentFlowNode.replyModeFormReply": "表單回覆", + "PC.Pages.AgentFlowNode.replyModeOptionsReply": "選項回覆", + "PC.Pages.AgentFlowNode.replyModeTextReply": "文字回覆", + "PC.Pages.AgentFlowNode.routeAddBranch": "新增分支", + "PC.Pages.AgentFlowNode.routeAddCondition": "新增條件", + "PC.Pages.AgentFlowNode.routeBranchesTitle": "路由分支", + "PC.Pages.AgentFlowNode.routeOtherIntent": "其他意圖", + "PC.Pages.AgentFlowNode.routeConditionMatchLabel": "條件匹配", + "PC.Pages.AgentFlowNode.routeConditionValue": "值", + "PC.Pages.AgentFlowNode.routeConditionVariable": "變數", + "PC.Pages.AgentFlowNode.routeExtraPromptLabel": "補充提示詞", + "PC.Pages.AgentFlowNode.routeInputLabel": "輸入", + "PC.Pages.AgentFlowNode.routeSelectVariablePlaceholder": "選擇變數...", + "PC.Pages.AgentFlowNode.startEmptyHint": "開始節點無需額外配置,流程從此節點啟動。", + "PC.Pages.AgentFlowNode.startSingleOutgoingHint": "開始節點只能連接一個後續節點", + "PC.Pages.AgentFlowNode.extraPromptPlaceholder1": "請輸入補充提示詞,進一步對智能體行為進行約束", + "PC.Pages.AgentFlowNode.reminderPromptLabel": "循環提醒提示詞", + "PC.Pages.AgentFlowNode.reminderPromptPlaceholder": "循環提醒提示詞...", + "PC.Pages.AgentFlowNode.selfLoopTimesLabel": "自身循環次數", + "PC.Pages.AgentFlowNode.selfLoopTimesTooltip": "智能體自循環執行的次數,0 表示不循環", + "PC.Pages.AgentFlowNode.routeDecisionRouteNamePlaceholder": "如 complaint_handling", + "PC.Pages.AgentFlowNode.askOptionsTitle": "選項內容", + "PC.Pages.AgentFlowNode.contextWriteKeyHint": "輸出寫入的上下文鍵名,如 evalResult", + "PC.Pages.AgentFlowNode.formFieldLabel": "欄位標籤", + "PC.Pages.AgentFlowNode.formFieldOptionsPlaceholder": "選項1,選項2,選項3", + "PC.Pages.AgentFlowNode.formFieldRequired": "是否必填", + "PC.Pages.AgentFlowNode.formFieldsTitle": "表單欄位", + "PC.Pages.AgentFlowNode.formFieldsHint": "定義使用者需要填寫的表單欄位", + "PC.Pages.AgentFlowNode.formFieldDescriptionPlaceholder": "填寫說明(提示使用者輸入什麼內容)", + "PC.Pages.AgentFlowNode.formFieldAddBtn": "新增欄位", + "PC.Pages.AgentFlowNode.formTypeCheckbox": "多選", + "PC.Pages.AgentFlowNode.formTypeFile": "檔案上傳", + "PC.Pages.AgentFlowNode.formTypeInput": "單行文字", + "PC.Pages.AgentFlowNode.formTypeNumber": "數字", + "PC.Pages.AgentFlowNode.formTypeRadio": "單選", + "PC.Pages.AgentFlowNode.formTypeSelect": "下拉單選", + "PC.Pages.AgentFlowNode.formTypeTextarea": "多行文字", + "PC.Pages.AgentFlowNode.replyModeLabel": "回覆模式", + "PC.Pages.AgentFlowParams.groupAgentFlowProcess": "處理節點", + "PC.Pages.AgentFlowParams.groupAgentFlowAdvanced": "進階節點", + "PC.Pages.AgentFlowParams.nodeRouteDecisionName": "路由決策", + "PC.Pages.AgentFlowParams.nodeRouteDecisionDescription": "AI 決策走哪條分支", + "PC.Pages.AgentFlowParams.nodeAgentName": "智能體", + "PC.Pages.AgentFlowParams.nodeAgentDescription": "調用智能體執行任務", + "PC.Pages.AgentFlowParams.nodeHumanApproveName": "人工確認", + "PC.Pages.AgentFlowParams.nodeHumanApproveDescription": "人工審批確認節點", + "PC.Pages.AgentFlowParams.nodeHumanAskName": "詢問用戶", + "PC.Pages.AgentFlowParams.nodeHumanAskDescription": "向用戶提問並獲取回覆", + "PC.Pages.AgentFlowParams.nodeEvalGateName": "結果評估", + "PC.Pages.AgentFlowParams.nodeEvalGateDescription": "評估智能體輸出結果", + "PC.Pages.AgentFlowParams.nodeExternalConnectorName": "三方平台連接器", + "PC.Pages.AgentFlowParams.nodeExternalConnectorDescription": "連接外部平台服務", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertName": "知識庫寫入", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertDescription": "將內容寫入選定的知識庫", + "PC.Pages.AntvX6Stencil.searchPlaceholder": "搜尋節點...", + "PC.Pages.AppDevEditorHeaderRight.viewTerminal": "查看終端", + "PC.Pages.AppDevEditorHeaderRight.gitVersionHistory": "版本記錄", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.title": "版本記錄", + "PC.Pages.ConversationAgent.ArrangePanel.tabConfig": "配置", + "PC.Pages.ConversationAgent.ArrangePanel.tabDebug": "調試", + "PC.Pages.ConversationAgent.ArrangePanel.tabVersion": "版本", + "PC.Pages.ConversationAgent.devServerReady": "開發服務器已就緒", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.subtitle": "共 {0} 次提交 · {1} 分支", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollback": "回滾", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollbackSuccess": "回滾成功", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.commitsOn": "提交於 {0}", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.empty": "暫無提交記錄", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.noConversation": "暫無版本記錄", + "PC.Components.FileTreePanel.FileTreeToolbar.projects": "項目", + "PC.Components.FileTreePanel.FileTreeToolbar.exportProject": "導出項目", + "PC.Components.FileTreePanel.FileTreeToolbar.importProject": "導入項目", + "PC.Components.FileTreePanel.FileTreeToolbar.newFile": "新建文件", + "PC.Components.FileTreePanel.FileTreeToolbar.newFolder": "新建文件夾", + "PC.Components.FileTreePanel.FileTreeToolbar.upload": "上傳", + "PC.Components.FileTreePanel.FileTreeToolbar.uploadFolder": "上傳文件夾", + "PC.Components.FileTreePanel.FileTreeToolbar.collapseAll": "摺疊全部", + "PC.Components.FileTreePanel.GitVersionRecord.noProject": "請先選擇項目後再查看版本記錄", + "PC.Components.FileTreePanel.GitVersionRecord.backToList": "返回列表", + "PC.Components.FileTreePanel.GitVersionRecord.changedFileCount": "變更文件數:{0}", + "PC.Components.FileTreePanel.GitVersionRecord.searchPlaceholder": "搜索內容", + "PC.Components.FileTreePanel.GitVersionRecord.diffUnified": "統一", + "PC.Components.FileTreePanel.GitVersionRecord.diffSplit": "分屏", + "PC.Components.FileTreePanel.GitVersionRecord.revertToVersion": "回到該版本", + "PC.Components.FileTreePanel.GitVersionRecord.statusModified": "修改", + "PC.Components.FileTreePanel.GitVersionRecord.statusAdded": "新增", + "PC.Components.FileTreePanel.GitVersionRecord.statusDeleted": "刪除", + "PC.Components.FileTreePanel.GitVersionRecord.statusRenamed": "重命名", + "PC.Components.FileTreePanel.GitVersionRecord.noChangedFiles": "該提交暫無變更文件", + "PC.Components.FileTreePanel.GitVersionRecord.autoSaveBeforeRestoreMessage": "回滾前自動保存您的更改", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackModalTitle": "回滾至 {0} 版本", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackVersionLabel": "回滾版本", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectTitle": "此操作會:", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectAutoSave": "會自動保存工作區、暫存區未提交的文件", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectBranchSync": "該分支下的其餘對話也同步回滾", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectWorkspace": "工作區恢復至選中的 checkpoint 狀態", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectUndoChanges": "撤銷該版本之後的所有更改", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectHistory": "歷史記錄會保留,可隨時回滾到其他版本", + "PC.Pages.Chat.conversationIcon": "會話圖標", + "PC.Pages.Chat.conversationName": "會話名稱", + "PC.Pages.Chat.editConversationInfo": "編輯會話信息", + "PC.Pages.ConversationAgent.gitPush.defaultMessage": "更新文件", + "PC.Pages.ConversationAgent.gitPush.noConversation": "無活躍會話,無法執行 Git 推送", + "PC.Pages.ConversationAgent.gitPush.success": "Git 提交並推送成功", + "PC.Pages.ConversationAgent.prototypeTitle": "Agent 開發原型", + "PC.Pages.ConversationAgentMiddlePanel.files": "文件", + "PC.Pages.ConversationAgentMiddlePanel.sourceControl": "原始碼管理", + "PC.Pages.ConversationAgentSourceControl.addToGitignore": "添加到 .gitignore", + "PC.Pages.ConversationAgentSourceControl.alreadyInGitignore": "該文件已在 .gitignore 中", + "PC.Pages.ConversationAgentSourceControl.changes": "更改", + "PC.Pages.ConversationAgentSourceControl.commit": "提交", + "PC.Pages.ConversationAgentSourceControl.commitAndPush": "推送", + "PC.Pages.ConversationAgentSourceControl.discardChanges": "放棄更改", + "PC.Pages.ConversationAgentSourceControl.discardChangesConfirmTitle": "確定要放棄更改嗎?", + "PC.Pages.ConversationAgentSourceControl.gitPushTooltip": "Git 提交並推送到遠程倉庫", + "PC.Pages.ConversationAgentSourceControl.gitignoreFailed": "添加到 .gitignore 失敗", + "PC.Pages.ConversationAgentSourceControl.gitignoreSuccess": "已添加到 .gitignore", + "PC.Pages.ConversationAgentSourceControl.noChanges": "暫無修改的文件", + "PC.Pages.ConversationAgentSourceControl.openChanges": "打開更改", + "PC.Pages.ConversationAgentSourceControl.openFile": "打開文件", + "PC.Pages.ConversationAgentSourceControl.stageChanges": "暫存更改", + "PC.Pages.ConversationAgentSourceControl.title": "原始碼管理", + "PC.Pages.ConversationAgentSourceControl.unstageChanges": "取消暫存", + "PC.Pages.ConversationAgentSourceControl.commitMessage": "提交資訊", + "PC.Pages.ConversationAgentSourceControl.stagedChanges": "暫存的更改", + "PC.Pages.ConversationAgentSourceControl.discardAllChanges": "放棄所有更改", + "PC.Pages.ConversationAgentSourceControl.discardAllChangesConfirmTitle": "確定要放棄所有更改嗎?", + "PC.Pages.ConversationAgentSourceControl.stageAllChanges": "暫存所有更改", + "PC.Pages.ConversationAgentSourceControl.unstageAllChanges": "取消所有暫存", + "PC.Pages.ConversationAgentSourceControl.refresh": "刷新", + "PC.Pages.ConversationAgentSourceControl.save": "保存", + "PC.Pages.ConversationAgentSourceControl.saveTooltip": "保存更改到沙箱", + "PC.Pages.ConversationAgentSourceControl.viewAsList": "以列表形式查看", + "PC.Pages.ConversationAgentSourceControl.viewAsTree": "以樹形結構查看", + "PC.Pages.ConversationAgentPreviewTabBar.addTab": "新標籤頁", + "PC.Pages.ConversationAgentPreviewTabBar.closeTab": "關閉標籤頁", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.close": "關閉", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeOthers": "關閉其他", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeAll": "關閉所有", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.pin": "固定", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.unpin": "取消固定", + "PC.Pages.ConversationAgentTabPicker.subscription": "訂閱", + "PC.Pages.ConversationAgentTabPicker.subscriptionDesc": "配置付費套餐與試用策略,查看訂閱資料與訂閱者情況。", + "PC.Pages.ConversationAgentTabPicker.subscriptionSetting": "訂閱設置", + "PC.Pages.ConversationAgentTabPicker.subscriptionSettingDesc": "開啟付費模式、管理套餐列表與默認可試用次數。", + "PC.Pages.ConversationAgentTabPicker.subscriptionStats": "訂閱統計", + "PC.Pages.ConversationAgentTabPicker.subscriptionStatsDesc": "查看總訂閱數、收入與訂閱者列表等統計資料。", + "PC.Pages.ConversationAgentTabPicker.preview": "預覽", + "PC.Pages.ConversationAgentTabPicker.arrange": "編排", + "PC.Pages.ConversationAgentTabPicker.terminal": "終端", + "PC.Pages.ConversationAgentTabPicker.versionControl": "版本控制", + "PC.Pages.ConversationAgentTabPicker.previewDesc": "即時預覽運行結果,進行程式碼除錯。", + "PC.Pages.ConversationAgentTabPicker.arrangeDesc": "配置模型、提示詞、變數與工具等智能體編排能力。", + "PC.Pages.ConversationAgentTabPicker.terminalDesc": "存取系統命令列,執行指令碼或安裝依賴。", + "PC.Pages.ConversationAgentTabPicker.versionControlDesc": "管理程式碼版本,查看具體程式碼變更和提交歷史。", + "PC.Pages.ConversationAgentTabPicker.devTools": "開發工具", + "PC.Pages.ConversationAgentTabPicker.devToolsDesc": "提供程式碼編輯、即時預覽和除錯能力,支援開發與版本管理的全流程。", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPrompt": "補充提示詞", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPromptPlaceholder": "輸入補充提示詞,對群組智能體進行額外約束", + "PC.Pages.MorePage.ApiKeyPermission.apiPermission": "接口權限", + "PC.Pages.MorePage.ApiKeyPermission.modelPermission": "系統模型", + "PC.Pages.MorePage.ApiKeyPermission.noModelDefs": "暫無模型定義", + "PC.Pages.MorePage.ApiKeyPermission.spaceModelPermission": "個人空間模型", + "PC.Pages.MorePage.ApiKeyPermission.noSpaceModelDefs": "暫無空間模型定義", + "PC.Pages.MorePage.MySubscriptions.paySuccess": "支付成功", + "PC.Pages.MorePage.MySubscriptions.payFailed": "支付失敗,請重新嘗試", + "PC.Pages.MorePage.MySubscriptions.payPending": "支付確認中,請稍後查看", + "PC.Pages.MorePage.MySubscriptions.continuousMonthly": "連續包月", + "PC.Pages.MorePage.MySubscriptions.continuousQuarterly": "連續包季", + "PC.Pages.MorePage.MySubscriptions.continuousYearly": "連續包年", + "PC.Pages.OpenApp.subscription": "訂閱", + "PC.Pages.SpaceDevelop.ApplicationItem.agentFlowType": "AgentFlow", + "PC.Pages.SpaceDevelop.ApplicationItem.customType": "自定義", + "PC.Pages.SpaceDevelop.ApplicationItem.agentGroupType": "AgentGroup", + "PC.Pages.SpaceLibrary.CreateModel.inputModelIdentifier": "輸入模型標識", + "PC.Pages.SpacePluginWorkflow.pluginPageTitle": "插件", + "PC.Pages.SpacePluginWorkflow.workflowPageTitle": "工作流", + "PC.Pages.SpaceResource.LeftGroupList.confirmMoveToGroup": "確認移入分組", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveDesc": "確認將「{0}」從當前分組移出嗎?", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveFromGroup": "確認移出分組", + "PC.Pages.SpaceResourcePricing.periodSecond": "秒", + "PC.Pages.SpaceResourcePricing.periodMillionToken": "百萬Token", + "PC.Pages.SpaceCreateProject.greetingTitle": "嗨,{0},給我一個任務,現在開始?", + "PC.Pages.SpaceCreateProject.inputRequiredWarning": "請輸入您的任務描述!", + "PC.Pages.SpaceCreateProject.placeholderAgent": "描述你想要的智能體,例如:幫我創建一個代碼審查助手,能自動檢測代碼問題並給出優化建議", + "PC.Pages.SpaceCreateProject.placeholderPageApp": "描述你想要的網頁應用,例如:幫我開發一個顏值管理網站,支持上傳照片智能評估顏值與膚質", + "PC.Pages.SpaceCreateProject.placeholderPlugin": "描述你想要的插件工具,例如:幫我對接第三方圖片轉換的HTTP接口插件", + "PC.Pages.SpaceCreateProject.placeholderSkill": "描述你想要的自定義技能,例如:幫我寫一個根據經緯度查詢當前天氣狀況的API接口", + "PC.Pages.SpaceCreateProject.tabAgent": "智能體", + "PC.Pages.SpaceCreateProject.tabPageApp": "網頁應用", + "PC.Pages.SpaceCreateProject.tabPlugin": "插件", + "PC.Pages.SpaceCreateProject.tabSkill": "技能", + "PC.Pages.UserManage.Index.delete": "刪除", + "PC.Pages.UserManage.Index.deleteSuccess": "刪除成功", + "PC.Pages.UserManage.Index.resetPassword": "密碼重置", + "PC.Pages.UserManage.Index.placeholderUserId": "請輸入用戶ID,僅支持輸入整數", + "PC.Pages.UserManage.Index.userId": "用戶ID", + "PC.Pages.UserManage.ResetPasswordModal.title": "重置密碼", + "PC.Pages.UserManage.ResetPasswordModal.newPassword": "新密碼", + "PC.Pages.UserManage.ResetPasswordModal.inputNewPassword": "請輸入新密碼", + "PC.Pages.UserManage.ResetPasswordModal.confirmPassword": "確認密碼", + "PC.Pages.UserManage.ResetPasswordModal.inputConfirmPassword": "請再次輸入新密碼", + "PC.Pages.UserManage.ResetPasswordModal.newPasswordRequired": "請輸入新密碼", + "PC.Pages.UserManage.ResetPasswordModal.confirmPasswordRequired": "請確認新密碼", + "PC.Pages.UserManage.ResetPasswordModal.passwordMismatch": "兩次輸入的密碼不一致", + "PC.Pages.UserManage.ResetPasswordModal.resetSuccess": "密碼修改成功", + "PC.Pages.UserManage.UserAuthModal.subscriptionUserGroup": "訂閱關聯用戶組", + "PC.Routes.modelPermissions": "模型權限", + "PC.Pages.ModelPermissions.tabSystem": "系統模型", + "PC.Pages.ModelPermissions.tabSpace": "個人空間模型", + "PC.Pages.ModelPermissions.colModelName": "模型名稱", + "PC.Pages.ModelPermissions.colModelId": "模型標識", + "PC.Pages.ModelPermissions.colModelIdTip": "外部調用時填寫該值", + "PC.Pages.ModelPermissions.colModalities": "支援模態", + "PC.Pages.ModelPermissions.colExternalCall": "外部調用", + "PC.Pages.ModelPermissions.colExternalCallTip": "是否支援在其他外部系統調用該模型", + "PC.Pages.ModelPermissions.colApiProtocol": "接口協議", + "PC.Pages.ModelPermissions.colPrice": "價格", + "PC.Pages.ModelPermissions.notSupported": "不支援", + "PC.Pages.ModelPermissions.interfaceAddress": "接口地址", + "PC.Pages.ModelPermissions.copyInterface": "複製接口", + "PC.Pages.ModelPermissions.interfaceAddressCopied": "接口地址已複製", + "PC.Pages.ModelPermissions.modelIdCopied": "模型標識已複製", + "PC.Routes.recommendManage": "推薦管理", + "PC.Routes.homeRecommend": "首頁推薦", + "PC.Routes.officialRecommend": "官方推薦", + "PC.Routes.chatboxRecommend": "對話框智能體", + "PC.Pages.SystemRecommendManage.addTitle": "新增推薦", + "PC.Pages.SystemRecommendManage.createSuccess": "創建成功", + "PC.Pages.SystemRecommendManage.colAction": "操作", + "PC.Pages.SystemRecommendManage.colCreated": "創建時間", + "PC.Pages.SystemRecommendManage.colModified": "修改時間", + "PC.Pages.SystemRecommendManage.colLabel": "名稱", + "PC.Pages.SystemRecommendManage.dialogHintLabel": "對話框提示信息", + "PC.Pages.SystemRecommendManage.dialogHintPlaceholder": "請輸入對話框提示信息", + "PC.Pages.SystemRecommendManage.colTargetId": "目標ID", + "PC.Pages.SystemRecommendManage.colTargetType": "類型", + "PC.Pages.SystemRecommendManage.colSubType": "子類型", + "PC.Pages.SystemRecommendManage.confirmDeleteContent": "刪除後無法恢復,確定繼續嗎?", + "PC.Pages.SystemRecommendManage.confirmDeleteTitle": "確認刪除", + "PC.Pages.SystemRecommendManage.editTitle": "編輯推薦", + "PC.Pages.SystemRecommendManage.updateSuccess": "更新成功", + "PC.Pages.SystemRecommendManage.functionType.agentDev": "智能體開發", + "PC.Pages.SystemRecommendManage.functionType.chat": "智能體", + "PC.Pages.SystemRecommendManage.functionType.pageAppDev": "網頁應用開發", + "PC.Pages.SystemRecommendManage.functionType.pluginDev": "插件開發", + "PC.Pages.SystemRecommendManage.functionType.skillDev": "技能開發", + "PC.Pages.SystemRecommendManage.searchName": "請輸入名稱", + "PC.Pages.SystemRecommendManage.selectTarget": "選擇推薦目標", + "PC.Pages.SystemRecommendManage.selectTargetRequired": "請選擇{0}", + "PC.Pages.SystemRecommendManage.clickToAddAgent": "點擊添加智能體", + "PC.Pages.SystemRecommendManage.sortUpdated": "排序已更新", + "PC.Pages.SystemRecommendManage.targetType.Agent": "智能體", + "PC.Pages.SystemRecommendManage.targetType.PageApp": "應用", + "PC.Pages.SystemRecommendManage.targetType.Plugin": "插件", + "PC.Pages.SystemRecommendManage.targetType.Skill": "技能", + "PC.Pages.SystemRecommendManage.targetType.Workflow": "工作流", + "PC.Pages.SystemPlans.fieldNamePlaceholderExample": "例如:基礎版", + "PC.Pages.SystemPlans.fieldCreditAmountMonthly": "套餐包含積分(每月)", + "PC.Pages.SystemPlans.fieldPriceLabel": "套餐價格 (¥)", + "PC.Pages.SystemPlans.fieldPricePlaceholderExample": "例如:99", + "PC.Pages.SystemPlans.fieldValidityPeriod": "套餐有效期", + "PC.Pages.SystemPlans.periodOption1Month": "1個月", + "PC.Pages.SystemPlans.periodOption3Months": "3個月", + "PC.Pages.SystemPlans.periodOption12Months": "12個月", + "PC.Pages.SystemPlans.fieldHotLabel": "熱門標籤", + "PC.Pages.SystemPlans.fieldDescPlaceholder": "描述套餐的定位和適用場景", + "PC.Pages.SystemPlans.developerPermissionTitle": "開發者權限", + "PC.Pages.SystemPlans.originalPrice": "原價¥{0}/{1}", + "PC.Pages.SystemUserCredits.colUserNamePhoneOrEmail": "用戶名稱/手機號/郵箱", + "PC.Pages.SystemUserCredits.grantCredit": "積分發放", + "PC.Pages.SystemUserCredits.deductCredit": "積分扣減", + "PC.Pages.SystemUserCredits.grantModalTitle": "積分發放", + "PC.Pages.SystemUserCredits.deductModalTitle": "積分扣減", + "PC.Pages.SystemUserCredits.fieldCreditType": "積分類型", + "PC.Pages.SystemUserCredits.fieldAmount": "積分數量", + "PC.Pages.SystemUserCredits.fieldAmountPlaceholder": "請輸入積分數量", + "PC.Pages.SystemUserCredits.fieldAmountMin": "積分數量須大於 0", + "PC.Pages.SystemUserCredits.fieldBizNo": "業務單號", + "PC.Pages.SystemUserCredits.fieldBizNoPlaceholder": "請輸入業務單號(選填)", + "PC.Pages.SystemUserCredits.fieldRemark": "備註", + "PC.Pages.SystemUserCredits.fieldRemarkPlaceholder": "請輸入備註(選填)", + "PC.Pages.SystemUserCredits.fieldExpireTime": "過期時間", + "PC.Pages.SystemUserCredits.fieldExpireTimePlaceholder": "請選擇過期時間(不填則永不過期)", + "PC.Pages.SystemUserCredits.fieldAllowNegative": "允許透支", + "PC.Pages.SystemUserCredits.grantSuccess": "積分發放成功", + "PC.Pages.SystemUserCredits.deductSuccess": "積分扣減成功", + "PC.Components.AcpPermissionCard.allowAlways": "始終允許", + "PC.Components.AcpPermissionCard.allowOnce": "允許一次", + "PC.Components.AcpPermissionCard.cancelShortcutHint": "取消(Esc)", + "PC.Components.AcpPermissionCard.defaultTitle": "需要權限確認", + "PC.Components.AcpPermissionCard.eyebrow": "審批請求", + "PC.Components.AcpPermissionCard.rejectOnce": "拒絕", + "PC.Components.AcpPermissionCard.shortcutHint": "{0}({1})", + "PC.Components.AcpPermissionCard.submitted": "已提交審批結果", + "PC.Pages.MorePage.MySubscriptions.currentPlanButton": "當前套餐", + "PC.Pages.MorePage.MySubscriptions.upgradeTo": "升級為", + "PC.Pages.SpaceResourcePricing.pricingTypeOneTime": "單次", + "PC.Pages.SpaceResourcePricing.pricingTypeMonthly": "包月", + "PC.Pages.SpaceResourcePricing.pricingTypeTiered": "階梯計費", + "PC.Pages.SystemCreditPackages.periodMonthUnit": "個月", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDesc": "換算描述", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDescPlaceholder": "請輸入面向用戶的積分換算說明,例如兌換比例、可抵扣範圍等,將展示在積分餘額旁", + "PC.Pages.SpaceKnowledge.KnowledgeHeader.accuracytest": "命中測試", }; diff --git a/qiming/src/locales/i18n/zh-TW.ts b/qiming/src/locales/i18n/zh-TW.ts index ba8f1c95..c8b9981b 100644 --- a/qiming/src/locales/i18n/zh-TW.ts +++ b/qiming/src/locales/i18n/zh-TW.ts @@ -2624,6 +2624,8 @@ export const ZH_TW: SystemLangMap = { "PC.Pages.Login.captchaTitle": "請完成下列驗證後繼續", "PC.Pages.Login.captchaVerifying": "驗證中...", "PC.Pages.Login.codeLoginOrRegister": "驗證碼登入/註冊", + "PC.Pages.Login.inputAccountPlaceholder": "請輸入使用者名稱/郵箱", + "PC.Pages.Login.inputAccountRequired": "請輸入使用者名稱/郵箱", "PC.Pages.Login.inputEmailPlaceholder": "請輸入郵箱", "PC.Pages.Login.inputEmailRequired": "請輸入郵箱", "PC.Pages.Login.inputPasswordPlaceholder": "請輸入不少於 6 位的密碼", @@ -5389,4 +5391,525 @@ export const ZH_TW: SystemLangMap = { "PC.Pages.SystemSubsOrders.detailDeveloperInfo": "開發者信息", "PC.Pages.SystemSubsOrders.detailSubscriptionInfo": "訂閱信息", "PC.Pages.SystemSubsOrders.drawerTitle": "訂閱詳情", + "PC.Common.Global.times": "次", + "PC.Common.Global.uploadFileSizeExceed": "上傳檔案總大小不能超過{0}MB", + "PC.Components.AcpPermissionCard.allowAlways": "始終允許", + "PC.Components.AcpPermissionCard.allowOnce": "允許一次", + "PC.Components.AcpPermissionCard.cancelShortcutHint": "取消(Esc)", + "PC.Components.AcpPermissionCard.defaultTitle": "需要權限確認", + "PC.Components.AcpPermissionCard.eyebrow": "審批請求", + "PC.Components.AcpPermissionCard.rejectOnce": "拒絕", + "PC.Components.AcpPermissionCard.shortcutHint": "{0}({1})", + "PC.Components.AcpPermissionCard.submitted": "已提交審批結果", + "PC.Components.ConversationBottomConsole.collapsePanel": "摺疊到底部", + "PC.Components.ConversationBottomConsole.containerKeepaliveError": "服務保活請求異常,請檢查網路後重試", + "PC.Components.ConversationBottomConsole.containerKeepaliveFailed": "服務保活失敗,請重新啟動服務", + "PC.Components.ConversationBottomConsole.containerStartError": "服務啟動異常,請稍後重試", + "PC.Components.ConversationBottomConsole.containerStarting": "服務啟動中,請稍候…", + "PC.Components.ConversationBottomConsole.expandHeight": "放大至全螢幕", + "PC.Components.ConversationBottomConsole.logsEmpty": "暫無日誌", + "PC.Components.ConversationBottomConsole.restoreHeight": "恢復預設高度", + "PC.Components.ConversationBottomConsole.retryStartContainer": "重啟服務", + "PC.Components.ConversationBottomConsole.tabLogs": "日誌", + "PC.Components.ConversationBottomConsole.tabTerminal": "終端", + "PC.Components.ConversationBottomConsole.terminalEmpty": "暫無終端輸出", + "PC.Components.ConversationBottomConsole.terminalThemeDark": "切換為深色終端", + "PC.Components.ConversationBottomConsole.terminalThemeLight": "切換為淺色終端", + "PC.Components.VoiceInput.startTooltip": "語音輸入", + "PC.Components.VoiceInput.connecting": "連接麥克風…", + "PC.Components.VoiceInput.stopTooltip": "停止錄音並辨識", + "PC.Components.VoiceInput.stopFillTooltip": "停止錄音並回填", + "PC.Components.VoiceInput.stopSendTooltip": "停止錄音並發送", + "PC.Components.VoiceInput.transcribing": "語音辨識中…", + "PC.Components.VoiceInput.permissionDenied": "麥克風權限被拒絕,請在瀏覽器設定中允許後重試", + "PC.Components.VoiceInput.notSupported": "目前環境不支援語音輸入,請使用最新版 Chrome/Edge/Firefox/Safari", + "PC.Components.VoiceInput.tooShort": "錄音時間過短", + "PC.Components.VoiceInput.tooLarge": "錄音檔案過大,請縮短錄音時間後重試", + "PC.Components.VoiceInput.recognizeFailed": "語音辨識失敗,請重試", + "PC.Components.ChatInputHome.pastedFileName": "貼上檔案-{0}-{1}", + "PC.Components.ChatInputHome.dropFilesHint": "釋放以上傳檔案", + "PC.Components.ChatInputHome.agentMode": "Agent 模式", + "PC.Components.ChatInputHome.agentModeAuto": "自動", + "PC.Components.ChatInputHome.agentModeAutoDesc": "工具呼叫前將不會詢問使用者", + "PC.Components.ChatInputHome.agentModeApproval": "審批", + "PC.Components.ChatInputHome.agentModeApprovalDesc": "部分工具呼叫前將詢問使用者是否同意", + "PC.Components.ChatInputHomeSpaceSelector.noAvailableSpace": "暫無可用空間", + "PC.Components.CollapseComponentList.groupAgentDesc": "群組智慧體是組內的成員智慧體,可協同完成任務。", + "PC.Components.CreateAgent.typeAgentFlow": "AgentFlow", + "PC.Components.CreateAgent.typeAgentGroup": "AgentGroup", + "PC.Components.CreateWorkflow.createAgentFlow": "新建 AgentFlow", + "PC.Components.CreateWorkflow.placeholderAgentFlowName": "輸入 AgentFlow 名稱", + "PC.Components.CreateWorkflow.placeholderAgentFlowDesc": "輸入描述,讓大模型理解什麼情況下應該使用此 AgentFlow", + "PC.Components.CreateWorkflow.skip": "跳過", + "PC.Components.CreateWorkflow.startEditing": "開始編輯", + "PC.Components.FileContextMenu.uploadFolder": "上傳資料夾", + "PC.Components.FileContextMenu.uploadFolderEmpty": "資料夾中沒有可上傳的檔案", + "PC.Components.FileContextMenu.uploadFileEmpty": "沒有可上傳的檔案", + "PC.Components.FileContextMenu.uploadTargetInvalid": "無法上傳到該目錄", + "PC.Components.HistoryConversationList.emptySession": "你還沒有對話喲,快去開始你的一個任務吧", + "PC.Components.HistoryConversationList.noMore": "沒有更多了", + "PC.Components.HistoryConversationList.noSearchResult": "無搜尋結果", + "PC.Components.McpAskQuestionCard.cancelShortcutHint": "取消(Esc)", + "PC.Components.McpAskQuestionCard.cancelled": "已取消", + "PC.Components.McpAskQuestionCard.customInputLabel": "自訂內容", + "PC.Components.McpAskQuestionCard.customInputPlaceholder": "請輸入", + "PC.Components.McpAskQuestionCard.customOption": "自訂", + "PC.Components.MessageQueue.clearAll": "清空全部", + "PC.Components.MessageQueue.editMessageTooltip": "編輯訊息", + "PC.Components.MessageQueue.sendNowTooltip": "立即發送", + "PC.Components.MessageQueue.title": "待發送", + "PC.Components.McpAskQuestionCard.defaultTitle": "這次提問", + "PC.Components.McpAskQuestionCard.notFilled": "未填寫", + "PC.Components.McpAskQuestionCard.unknownFile": "未知檔案", + "PC.Components.McpAskQuestionCard.uploadDragText": "點擊或拖曳檔案到此區域上傳", + "PC.Components.McpAskQuestionCard.file": "檔案", + "PC.Components.McpAskQuestionCard.emptyFormContent": "(無表單內容)", + "PC.Components.McpAskQuestionCard.eyebrow": "補充回覆", + "PC.Components.McpAskQuestionCard.fieldRequired": "請填寫此項", + "PC.Components.McpAskQuestionCard.multiSelectMin": "請至少選擇一項", + "PC.Components.McpAskQuestionCard.nextStep": "下一步", + "PC.Components.McpAskQuestionCard.prevStep": "上一步", + "PC.Components.McpAskQuestionCard.resumeCancelled": "我取消了「{0}」。", + "PC.Components.McpAskQuestionCard.resumeSkipped": "我跳過了「{0}」。", + "PC.Components.McpAskQuestionCard.resumeTimeout": "「{0}」已逾時,沒有收到表單答案。", + "PC.Components.McpAskQuestionCard.skip": "跳過", + "PC.Components.McpAskQuestionCard.skipped": "已跳過", + "PC.Components.McpAskQuestionCard.stepOf": "步驟 {0} / {1}", + "PC.Components.McpAskQuestionCard.submitted": "已提交回答", + "PC.Components.McpAskQuestionCard.resumeSubmitted": "我已填寫「{0}」,表單內容如下:", + "PC.Components.PaymentSubscriptionModal.titleSelectPlan": "選擇訂閱套餐", + "PC.Components.PaymentSubscriptionModal.titleWithTrialCount": "選擇訂閱套餐(試用次數:{0}/{1})", + "PC.Components.PaymentSubscriptionModal.emptyNoPlans": "暫無可用套餐", + "PC.Components.PaymentSubscriptionModal.monthlyCredits": "每月 {0} 積分", + "PC.Components.PaymentSubscriptionModal.callLimitUnlimited": "不限制", + "PC.Components.PaymentSubscriptionModal.callLimitPerMonth": "{0} 次/月", + "PC.Components.PaymentSubscriptionModal.originalPrice": "原價{0}{1}/{2}", + "PC.Components.PaymentSubscriptionModal.callCountLabel": "可調用次數:{0}", + "PC.Components.PaymentSubscriptionModal.periodMonth": "月", + "PC.Components.PaymentSubscriptionModal.periodQuarter": "季度", + "PC.Components.PaymentSubscriptionModal.periodYear": "年", + "PC.Components.PaymentSubscriptionModal.periodForever": "永久", + "PC.Components.PaymentSubscriptionModal.btnSubscribePlan": "訂閱套餐", + "PC.Components.PaymentSubscriptionModal.btnSubscribeBuyoutPlan": "訂閱買斷套餐", + "PC.Components.PaymentSubscriptionModal.btnForeverBoughtOut": "已買斷套餐", + "PC.Components.PaymentSubscriptionModal.btnExpiredRenew": "套餐已過期(續訂)", + "PC.Components.PaymentSubscriptionModal.btnCurrent": "當前套餐", + "PC.Components.PaymentSubscriptionModal.btnCurrentRenew": "當前套餐(續訂)", + "PC.Components.PaymentSubscriptionModal.btnUpgrade": "升級", + "PC.Constants.Library.audioProcess": "語音處理", + "PC.Constants.Library.chatMultimodal": "聊天對話-多模態", + "PC.Constants.Library.chatText": "聊天對話-純文字", + "PC.Constants.Library.imageProcess": "圖像處理", + "PC.Constants.Library.supportFuncCall": "支援一般函式呼叫", + "PC.Constants.Library.textCompletion": "文字補全", + "PC.Constants.Library.textEdit": "文字編輯", + "PC.Constants.Library.vectorEmbedding": "向量嵌入", + "PC.Constants.Library.externalApi": "外部API調用", + "PC.Constants.Space.callApproval": "呼叫審批", + "PC.Constants.Space.agentFlowType": "AgentFlow", + "PC.Constants.Space.agentFlowTypeTooltip": "基於多智慧體協作編排的工作流,適合複雜任務拆解、分支決策、人工介入和結果評估。", + "PC.Constants.Space.agentGroupType": "AgentGroup", + "PC.Constants.Space.agentGroupTypeTooltip": "多智慧體協作組,適合需要多個智慧體分工協作的複雜任務場景。", + "PC.Constants.Space.group": "分組", + "PC.Pages.SpaceResource.LeftGroupList.createGroupTitle": "新建分組", + "PC.Pages.SpaceResource.LeftGroupList.editGroupTitle": "編輯分組", + "PC.Pages.SpaceResource.LeftGroupList.groupNameLabel": "分組名稱", + "PC.Pages.SpaceResource.LeftGroupList.groupNamePlaceholder": "請輸入分組名稱", + "PC.Pages.SpaceResource.LeftGroupList.groupNameRequired": "請輸入分組名稱", + "PC.Pages.SpaceResource.LeftGroupList.deleteConfirmContent": "確定要刪除分組「{0}」嗎?", + "PC.Pages.SpaceResource.LeftGroupList.createSuccess": "新建成功", + "PC.Pages.SpaceResource.LeftGroupList.editSuccess": "修改成功", + "PC.Pages.SpaceResource.LeftGroupList.deleteSuccess": "刪除成功", + "PC.Pages.SpaceResource.LeftGroupList.groupIconLabel": "分組圖示", + "PC.Pages.SpaceResource.LeftGroupList.groupNameNoWhitespace": "名稱不能為空白字元", + "PC.Pages.SpaceResource.LeftGroupList.groupNameMaxLength": "名稱長度不能超過30個字元", + "PC.Pages.SpaceResource.LeftGroupList.groupDescLabel": "分組描述", + "PC.Pages.SpaceResource.LeftGroupList.groupDescMaxLength": "描述長度不能超過100個字元", + "PC.Pages.SpaceResource.LeftGroupList.groupDescPlaceholder": "請輸入分組描述", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeLabel": "分組類型", + "PC.Pages.SpaceResource.LeftGroupList.groupTypeRequired": "請選擇分組類型", + "PC.Pages.SpaceResource.LeftGroupList.unitCount": "{0} 個", + "PC.Pages.SpaceResource.LeftGroupList.moveToGroup": "移入分組", + "PC.Pages.SpaceResource.LeftGroupList.moveToOtherGroup": "移動至其它分組", + "PC.Pages.SpaceResource.LeftGroupList.removeFromGroup": "移出分組", + "PC.Pages.SpaceResource.LeftGroupList.confirmMoveToGroup": "確認移入分組", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveFromGroup": "確認移出分組", + "PC.Pages.SpaceResource.LeftGroupList.confirmRemoveDesc": "確定將「{0}」從當前分組移出嗎?", + "PC.Pages.SpaceResource.LeftGroupList.moveSuccess": "移入成功", + "PC.Pages.SpaceResource.LeftGroupList.removeSuccess": "移出成功", + "PC.Pages.SpaceResource.LeftGroupList.selectGroup": "選擇分組", + "PC.Pages.SpaceResource.LeftGroupList.selectGroupPlaceholder": "請選擇要移入的分組", + "PC.Modal.chunkLoadErrorContent": "頁面資源載入失敗,請重新整理頁面後重試", + "PC.Modal.chunkLoadErrorRefresh": "立即重新整理", + "PC.Modal.chunkLoadErrorTitle": "頁面載入異常", + "PC.Models.ConversationInfo.askResponseFailed": "問題回答提交失敗", + "PC.Hooks.UseSubscription.createOrderFailed": "建立訂閱訂單失敗", + "PC.Hooks.UseSubscription.getCashierUrlFailed": "取得收銀台地址失敗", + "PC.Layouts.DynamicMenusLayout.newProject": "新建專案", + "PC.Layouts.DynamicMenusLayout.SpaceSection.searchSpacePlaceholder": "搜尋空間", + "PC.Layouts.DynamicMenusLayout.SpaceSection.noSpaceMatch": "未找到符合的空間", + "PC.Models.ConversationInfo.permissionResponseFailed": "權限審批結果提交失敗", + "PC.Pages.AgentArrangeConfig.addGroupAgent": "新增群組智慧體", + "PC.Pages.AgentArrangeConfig.allowChooseMode": "允許使用者在對話框中選擇模式", + "PC.Pages.AgentArrangeConfig.allowChooseModeDesc": "開啟後,使用者在對話框下可以選擇:自動(Auto)、審批(Approval),關閉後預設使用自動模式。", + "PC.Pages.AgentArrangeConfig.versionControl": "版本管理", + "PC.Pages.AgentArrangeConfig.versionControlDesc": "開啟後,智慧體支援版本管理,可查看程式碼變更和提交歷史。", + "PC.Pages.AgentArrangeConfig.groupAgent": "群組智慧體", + "PC.Pages.AgentArrangeConfig.groupMembers": "組員", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategy": "參數更新策略", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyAppend": "追加", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyPlaceholder": "請選擇參數更新策略", + "PC.Pages.AgentArrangeCreateVariableModal.updateStrategyReplace": "替換", + "PC.Pages.AgentArrangeCreateHookModal.config": "設定 JSON", + "PC.Pages.AgentArrangeCreateHookModal.configInvalid": "請輸入合法的 JSON 設定", + "PC.Pages.AgentArrangeCreateHookModal.event": "事件", + "PC.Pages.AgentArrangeCreateHookModal.eventRequired": "請選擇事件", + "PC.Pages.AgentArrangeCreateHookModal.matcher": "匹配規則", + "PC.Pages.AgentArrangeCreateHookModal.matcherPlaceholder": "工具名稱,如 Bash、Edit|Write、mcp...", + "PC.Pages.AgentArrangeCreateHookModal.matcherNotSupported": "不支援匹配規則", + "PC.Pages.AgentArrangeCreateHookModal.matcherStopFailurePlaceholder": "錯誤類型,如 rate_limit、overloaded", + "PC.Pages.AgentArrangeCreateHookModal.matcherSessionStartPlaceholder": "啟動方式,如 startup、resume、clear、compact", + "PC.Pages.AgentArrangeCreateHookModal.matcherSubagentStartPlaceholder": "Agent 類型,如 general-purpose、Explore、Plan", + "PC.Pages.AgentArrangeCreateHookModal.name": "名稱", + "PC.Pages.AgentArrangeCreateHookModal.namePlaceholder": "如:程式碼格式化", + "PC.Pages.AgentArrangeCreateHookModal.nameRequired": "請輸入名稱", + "PC.Pages.AgentArrangeCreateHookModal.titleCreate": "新建 Hook", + "PC.Pages.AgentArrangeCreateHookModal.titleEdit": "編輯 Hook", + "PC.Pages.AgentArrangeCreateHookModal.type": "類型", + "PC.Pages.AgentArrangeCreateHookModal.typeRequired": "請選擇類型", + "PC.Pages.AgentArrangeCreateHooks.add": "新建", + "PC.Pages.AgentArrangeCreateHooks.columnAction": "操作", + "PC.Pages.AgentArrangeCreateHooks.columnEvent": "事件", + "PC.Pages.AgentArrangeCreateHooks.columnMatcher": "匹配規則", + "PC.Pages.AgentArrangeCreateHooks.columnName": "名稱", + "PC.Pages.AgentArrangeCreateHooks.columnStatus": "狀態", + "PC.Pages.AgentArrangeCreateHooks.columnType": "類型", + "PC.Pages.AgentArrangeCreateHooks.deleteSuccess": "刪除成功", + "PC.Pages.AgentArrangeCreateHooks.searchPlaceholder": "搜尋...", + "PC.Pages.AgentArrangeCreateHooks.title": "Hook 設定", + "PC.Pages.AgentArrangeCreateHooks.titleTooltip": "OpenAI 協議模型支援的事件範圍:PreToolUse、PostToolUse、PostToolUseFailure、SessionStart、SessionEnd、Stop", + "PC.Pages.AgentArrangeCreateHooks.updateSuccess": "更新成功", + "PC.Pages.AgentArrangeConfig.addHook": "新增 Hook", + "PC.Pages.AgentArrangeConfig.hook": "Hook 設定", + "PC.Pages.AgentArrangeConfig.hookUnavailable": "Hook 元件未就緒,請稍後重試", + "PC.Pages.AgentArrangeConfig.enableAskQuestion": "允許詢問用戶", + "PC.Pages.AgentArrangeConfig.enableAskQuestionDesc": "開啟後,智慧體需要額外資訊時可以彈出表單向使用者詢問。", + "PC.Components.HookList.description": "透過 Hook 可在智慧體執行的關鍵節點插入自訂邏輯。", + "PC.Pages.AgentArrangeCallApproval.optionNo": "否", + "PC.Pages.AgentArrangeCallApproval.optionYes": "是", + "PC.Pages.AgentArrangeCallApproval.title": "是否需要審批", + "PC.Pages.AgentArrangeCallApproval.tooltip": "開啟後,模型在呼叫工具前會向使用者傳送審批通知,同意後可繼續執行", + "PC.Pages.AntvX6Header.autoArrange": "自動排列", + "PC.Pages.AntvX6Header.run": "執行", + "PC.Pages.AntvX6Header.save": "儲存", + "PC.Pages.AgentFlow.loading": "載入中...", + "PC.Pages.AgentFlow.chipApprove": "通過", + "PC.Pages.AgentFlow.chipFail": "未達標", + "PC.Pages.AgentFlow.chipPass": "通過", + "PC.Pages.AgentFlow.chipReject": "拒絕", + "PC.Pages.AgentFlow.dragToConnect": "拖曳連線", + "PC.Pages.AgentFlowNode.routeDecisionExtraPromptPlaceholder": "請輸入給 AI 的額外路由決策指令...", + "PC.Pages.AgentFlowNode.askQuestionLabel": "提問內容", + "PC.Pages.AgentFlowNode.askQuestionPlaceholder": "請輸入要向使用者提出的問題...", + "PC.Pages.AgentFlowNode.routeDecisionRouteDescriptionPlaceholder": "描述何時應選擇此路由...", + "PC.Pages.AgentFlowNode.routeDecisionItemFallback": "路由 {0}", + "PC.Pages.AgentFlowNode.askOptionIndex": "選項 {0}", + "PC.Pages.AgentFlowNode.extraPromptLabel": "補充提示詞", + "PC.Pages.AgentFlowNode.agentInputLabel": "入參", + "PC.Pages.AgentFlowNode.contextWriteKeyLabel": "上下文寫入", + "PC.Pages.AgentFlowNode.contextWriteKeyPlaceholder": "如 user_reply", + "PC.Pages.AgentFlowNode.endEmptyHint": "結束節點無需額外配置,流程執行至此自動結束。", + "PC.Pages.AgentFlowNode.inputRefVarsLabel": "引用變數", + "PC.Pages.AgentFlowNode.replyModeFormReply": "表單回覆", + "PC.Pages.AgentFlowNode.replyModeOptionsReply": "選項回覆", + "PC.Pages.AgentFlowNode.replyModeTextReply": "文字回覆", + "PC.Pages.AgentFlowNode.routeAddBranch": "新增分支", + "PC.Pages.AgentFlowNode.routeAddCondition": "新增條件", + "PC.Pages.AgentFlowNode.routeBranchesTitle": "路由分支", + "PC.Pages.AgentFlowNode.routeOtherIntent": "其他意圖", + "PC.Pages.AgentFlowNode.routeConditionMatchLabel": "條件匹配", + "PC.Pages.AgentFlowNode.routeConditionValue": "值", + "PC.Pages.AgentFlowNode.routeConditionVariable": "變數", + "PC.Pages.AgentFlowNode.routeExtraPromptLabel": "補充提示詞", + "PC.Pages.AgentFlowNode.routeInputLabel": "輸入", + "PC.Pages.AgentFlowNode.routeSelectVariablePlaceholder": "選擇變數...", + "PC.Pages.AgentFlowNode.startEmptyHint": "開始節點無需額外配置,流程從此節點啟動。", + "PC.Pages.AgentFlowNode.startSingleOutgoingHint": "開始節點只能連接一個後續節點", + "PC.Pages.AgentFlowNode.extraPromptPlaceholder1": "請輸入補充提示詞,進一步對智能體行為進行約束", + "PC.Pages.AgentFlowNode.reminderPromptLabel": "循環提醒提示詞", + "PC.Pages.AgentFlowNode.reminderPromptPlaceholder": "循環提醒提示詞...", + "PC.Pages.AgentFlowNode.selfLoopTimesLabel": "自身循環次數", + "PC.Pages.AgentFlowNode.selfLoopTimesTooltip": "智能體自循環執行的次數,0 表示不循環", + "PC.Pages.AgentFlowNode.routeDecisionRouteNamePlaceholder": "如 complaint_handling", + "PC.Pages.AgentFlowNode.askOptionsTitle": "選項內容", + "PC.Pages.AgentFlowNode.contextWriteKeyHint": "輸出寫入的上下文鍵名,如 evalResult", + "PC.Pages.AgentFlowNode.formFieldLabel": "欄位標籤", + "PC.Pages.AgentFlowNode.formFieldOptionsPlaceholder": "選項1,選項2,選項3", + "PC.Pages.AgentFlowNode.formFieldRequired": "是否必填", + "PC.Pages.AgentFlowNode.formFieldsTitle": "表單欄位", + "PC.Pages.AgentFlowNode.formFieldsHint": "定義使用者需要填寫的表單欄位", + "PC.Pages.AgentFlowNode.formFieldDescriptionPlaceholder": "填寫說明(提示使用者輸入什麼內容)", + "PC.Pages.AgentFlowNode.formFieldAddBtn": "新增欄位", + "PC.Pages.AgentFlowNode.formTypeCheckbox": "多選", + "PC.Pages.AgentFlowNode.formTypeFile": "檔案上傳", + "PC.Pages.AgentFlowNode.formTypeInput": "單行文字", + "PC.Pages.AgentFlowNode.formTypeNumber": "數字", + "PC.Pages.AgentFlowNode.formTypeRadio": "單選", + "PC.Pages.AgentFlowNode.formTypeSelect": "下拉單選", + "PC.Pages.AgentFlowNode.formTypeTextarea": "多行文字", + "PC.Pages.AgentFlowNode.replyModeLabel": "回覆模式", + "PC.Pages.AgentFlowParams.groupAgentFlowProcess": "處理節點", + "PC.Pages.AgentFlowParams.groupAgentFlowAdvanced": "進階節點", + "PC.Pages.AgentFlowParams.nodeRouteDecisionName": "路由決策", + "PC.Pages.AgentFlowParams.nodeRouteDecisionDescription": "AI 決策走哪條分支", + "PC.Pages.AgentFlowParams.nodeAgentName": "智能體", + "PC.Pages.AgentFlowParams.nodeAgentDescription": "調用智能體執行任務", + "PC.Pages.AgentFlowParams.nodeHumanApproveName": "人工確認", + "PC.Pages.AgentFlowParams.nodeHumanApproveDescription": "人工審批確認節點", + "PC.Pages.AgentFlowParams.nodeHumanAskName": "詢問用戶", + "PC.Pages.AgentFlowParams.nodeHumanAskDescription": "向用戶提問並獲取回覆", + "PC.Pages.AgentFlowParams.nodeEvalGateName": "結果評估", + "PC.Pages.AgentFlowParams.nodeEvalGateDescription": "評估智能體輸出結果", + "PC.Pages.AgentFlowParams.nodeExternalConnectorName": "三方平台連接器", + "PC.Pages.AgentFlowParams.nodeExternalConnectorDescription": "連接外部平台服務", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertName": "知識庫寫入", + "PC.Pages.AntvX6Params.nodeKnowledgeInsertDescription": "將內容寫入選定的知識庫", + "PC.Pages.AntvX6Stencil.searchPlaceholder": "搜尋節點...", + "PC.Pages.AppDevEditorHeaderRight.viewTerminal": "檢視終端機", + "PC.Pages.AppDevEditorHeaderRight.gitVersionHistory": "版本記錄", + "PC.Pages.Chat.editConversationInfo": "編輯會話資訊", + "PC.Pages.Chat.conversationIcon": "會話圖標", + "PC.Pages.Chat.conversationName": "會話名稱", + "PC.Pages.ConversationAgent.devServerReady": "開發伺服器已就緒", + "PC.Pages.ConversationAgent.gitPush.defaultMessage": "更新檔案", + "PC.Pages.ConversationAgent.gitPush.noConversation": "無活躍會話,無法執行 Git 推送", + "PC.Pages.ConversationAgent.gitPush.success": "Git 提交並推送成功", + "PC.Pages.ConversationAgent.ArrangePanel.tabConfig": "配置", + "PC.Pages.ConversationAgent.ArrangePanel.tabDebug": "除錯", + "PC.Pages.ConversationAgent.ArrangePanel.tabVersion": "版本", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.title": "版本記錄", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.subtitle": "共 {0} 次提交 · {1} 分支", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollback": "回滾", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.rollbackSuccess": "回滾成功", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.commitsOn": "提交於 {0}", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.empty": "暫無提交記錄", + "PC.Pages.ConversationAgent.AgentGitVersionRecord.noConversation": "暫無版本記錄", + "PC.Components.FileTreePanel.FileTreeToolbar.projects": "專案", + "PC.Components.FileTreePanel.FileTreeToolbar.exportProject": "匯出專案", + "PC.Components.FileTreePanel.FileTreeToolbar.importProject": "匯入專案", + "PC.Components.FileTreePanel.FileTreeToolbar.newFile": "新建檔案", + "PC.Components.FileTreePanel.FileTreeToolbar.newFolder": "新建資料夾", + "PC.Components.FileTreePanel.FileTreeToolbar.upload": "上傳", + "PC.Components.FileTreePanel.FileTreeToolbar.uploadFolder": "上傳資料夾", + "PC.Components.FileTreePanel.FileTreeToolbar.collapseAll": "摺疊全部", + "PC.Components.FileTreePanel.GitVersionRecord.noProject": "請先選擇專案後再查看版本記錄", + "PC.Components.FileTreePanel.GitVersionRecord.backToList": "返回列表", + "PC.Components.FileTreePanel.GitVersionRecord.changedFileCount": "變更檔案數:{0}", + "PC.Components.FileTreePanel.GitVersionRecord.searchPlaceholder": "搜尋內容", + "PC.Components.FileTreePanel.GitVersionRecord.diffUnified": "統一", + "PC.Components.FileTreePanel.GitVersionRecord.diffSplit": "分屏", + "PC.Components.FileTreePanel.GitVersionRecord.revertToVersion": "回到該版本", + "PC.Components.FileTreePanel.GitVersionRecord.statusModified": "修改", + "PC.Components.FileTreePanel.GitVersionRecord.statusAdded": "新增", + "PC.Components.FileTreePanel.GitVersionRecord.statusDeleted": "刪除", + "PC.Components.FileTreePanel.GitVersionRecord.statusRenamed": "重新命名", + "PC.Components.FileTreePanel.GitVersionRecord.noChangedFiles": "該提交暫無變更檔案", + "PC.Components.FileTreePanel.GitVersionRecord.autoSaveBeforeRestoreMessage": "回滾前自動儲存您的變更", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackModalTitle": "回滾至 {0} 版本", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackVersionLabel": "回滾版本", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectTitle": "此操作會:", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectAutoSave": "會自動儲存工作區、暫存區未提交的檔案", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectBranchSync": "該分支下的其餘對話也同步回滾", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectWorkspace": "工作區恢復至選中的 checkpoint 狀態", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectUndoChanges": "撤銷該版本之後的所有變更", + "PC.Components.FileTreePanel.GitVersionRecord.rollbackEffectHistory": "歷史記錄會保留,可隨時回滾到其他版本", + "PC.Pages.ConversationAgent.prototypeTitle": "Agent開發原型", + "PC.Pages.ConversationAgentMiddlePanel.files": "檔案", + "PC.Pages.ConversationAgentMiddlePanel.sourceControl": "原始碼管理", + "PC.Pages.ConversationAgentSourceControl.changes": "更改", + "PC.Pages.ConversationAgentSourceControl.commit": "提交", + "PC.Pages.ConversationAgentSourceControl.commitAndPush": "推送", + "PC.Pages.ConversationAgentSourceControl.commitMessage": "提交訊息", + "PC.Pages.ConversationAgentSourceControl.gitPushTooltip": "Git 提交並推送到遠端倉庫", + "PC.Pages.ConversationAgentSourceControl.noChanges": "暫無修改的檔案", + "PC.Pages.ConversationAgentSourceControl.save": "儲存", + "PC.Pages.ConversationAgentSourceControl.saveTooltip": "儲存變更到沙箱", + "PC.Pages.ConversationAgentSourceControl.title": "原始碼管理", + "PC.Pages.ConversationAgentSourceControl.openChanges": "開啟更改", + "PC.Pages.ConversationAgentSourceControl.openFile": "開啟檔案", + "PC.Pages.ConversationAgentSourceControl.discardChanges": "放棄更改", + "PC.Pages.ConversationAgentSourceControl.discardChangesConfirmTitle": "確定要放棄更改嗎?", + "PC.Pages.ConversationAgentSourceControl.discardAllChanges": "放棄所有更改", + "PC.Pages.ConversationAgentSourceControl.discardAllChangesConfirmTitle": "確定要放棄所有更改嗎?", + "PC.Pages.ConversationAgentSourceControl.stageAllChanges": "暫存所有更改", + "PC.Pages.ConversationAgentSourceControl.unstageAllChanges": "取消所有暫存", + "PC.Pages.ConversationAgentSourceControl.stageChanges": "暫存更改", + "PC.Pages.ConversationAgentSourceControl.unstageChanges": "取消暫存", + "PC.Pages.ConversationAgentSourceControl.addToGitignore": "新增到 .gitignore", + "PC.Pages.ConversationAgentSourceControl.alreadyInGitignore": "該檔案已在 .gitignore 中", + "PC.Pages.ConversationAgentSourceControl.gitignoreSuccess": "已新增到 .gitignore", + "PC.Pages.ConversationAgentSourceControl.gitignoreFailed": "新增到 .gitignore 失敗", + "PC.Pages.ConversationAgentSourceControl.stagedChanges": "暫存的更改", + "PC.Pages.ConversationAgentSourceControl.refresh": "重新整理", + "PC.Pages.ConversationAgentSourceControl.viewAsList": "以列表形式查看", + "PC.Pages.ConversationAgentSourceControl.viewAsTree": "以樹形結構查看", + "PC.Pages.ConversationAgentPreviewTabBar.addTab": "新標籤頁", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.close": "關閉", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeOthers": "關閉其他", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.closeAll": "關閉所有", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.pin": "固定", + "PC.Pages.ConversationAgentPreviewTabBar.contextMenu.unpin": "取消固定", + "PC.Pages.ConversationAgentPreviewTabBar.closeTab": "關閉標籤頁", + "PC.Pages.ConversationAgentTabPicker.devTools": "開發工具", + "PC.Pages.ConversationAgentTabPicker.devToolsDesc": "提供程式碼編輯、即時預覽和偵錯能力,支援開發與版本管理的全流程。", + "PC.Pages.ConversationAgentTabPicker.preview": "預覽", + "PC.Pages.ConversationAgentTabPicker.previewDesc": "即時預覽執行效果,進行程式碼偵錯。", + "PC.Pages.ConversationAgentTabPicker.arrange": "編排", + "PC.Pages.ConversationAgentTabPicker.arrangeDesc": "配置模型、提示詞、變數與工具等智能體編排能力。", + "PC.Pages.ConversationAgentTabPicker.terminal": "終端", + "PC.Pages.ConversationAgentTabPicker.terminalDesc": "存取系統命令列,執行腳本或安裝依賴。", + "PC.Pages.ConversationAgentTabPicker.versionControl": "版本控制", + "PC.Pages.ConversationAgentTabPicker.versionControlDesc": "管理程式碼版本,查看具體程式碼變更和提交歷史。", + "PC.Pages.ConversationAgentTabPicker.subscription": "訂閱", + "PC.Pages.ConversationAgentTabPicker.subscriptionDesc": "配置付費套餐與試用策略,查看訂閱資料與訂閱者情況。", + "PC.Pages.ConversationAgentTabPicker.subscriptionSetting": "訂閱設定", + "PC.Pages.ConversationAgentTabPicker.subscriptionSettingDesc": "開啟付費模式、管理套餐列表與預設可試用次數。", + "PC.Pages.ConversationAgentTabPicker.subscriptionStats": "訂閱統計", + "PC.Pages.ConversationAgentTabPicker.subscriptionStatsDesc": "查看總訂閱數、收入與訂閱者列表等統計資料。", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPrompt": "補充提示詞", + "PC.Pages.EditAgent.SystemTipsWord.supplementaryPromptPlaceholder": "輸入補充提示詞,對群組智能體進行額外約束", + "PC.Pages.MorePage.ApiKeyPermission.apiPermission": "接口權限", + "PC.Pages.MorePage.ApiKeyPermission.modelPermission": "系統模型", + "PC.Pages.MorePage.ApiKeyPermission.noModelDefs": "暫無模型定義", + "PC.Pages.MorePage.ApiKeyPermission.spaceModelPermission": "個人空間模型", + "PC.Pages.MorePage.ApiKeyPermission.noSpaceModelDefs": "暫無空間模型定義", + "PC.Pages.MorePage.MySubscriptions.paySuccess": "支付成功", + "PC.Pages.MorePage.MySubscriptions.payFailed": "支付失敗,請重新嘗試", + "PC.Pages.MorePage.MySubscriptions.payPending": "支付確認中,請稍後查看", + "PC.Pages.MorePage.MySubscriptions.continuousMonthly": "連續包月", + "PC.Pages.MorePage.MySubscriptions.continuousQuarterly": "連續包季", + "PC.Pages.MorePage.MySubscriptions.continuousYearly": "連續包年", + "PC.Pages.OpenApp.subscription": "訂閱", + "PC.Pages.SpaceDevelop.ApplicationItem.agentFlowType": "AgentFlow", + "PC.Pages.SpaceDevelop.ApplicationItem.customType": "自定義", + "PC.Pages.SpaceDevelop.ApplicationItem.agentGroupType": "AgentGroup", + "PC.Pages.SpaceLibrary.CreateModel.inputModelIdentifier": "輸入模型標識", + "PC.Pages.SpacePluginWorkflow.pluginPageTitle": "套件", + "PC.Pages.SpacePluginWorkflow.workflowPageTitle": "工作流", + "PC.Pages.SpaceResourcePricing.periodSecond": "秒", + "PC.Pages.SpaceResourcePricing.periodMillionToken": "百萬Token", + "PC.Pages.UserManage.Index.delete": "刪除", + "PC.Pages.UserManage.Index.deleteSuccess": "刪除成功", + "PC.Pages.UserManage.Index.resetPassword": "密碼重置", + "PC.Pages.UserManage.Index.placeholderUserId": "請輸入使用者ID,僅支援輸入整數", + "PC.Pages.UserManage.Index.userId": "使用者ID", + "PC.Pages.UserManage.ResetPasswordModal.title": "重置密碼", + "PC.Pages.UserManage.ResetPasswordModal.newPassword": "新密碼", + "PC.Pages.UserManage.ResetPasswordModal.inputNewPassword": "請輸入新密碼", + "PC.Pages.UserManage.ResetPasswordModal.confirmPassword": "確認密碼", + "PC.Pages.UserManage.ResetPasswordModal.inputConfirmPassword": "請再次輸入新密碼", + "PC.Pages.UserManage.ResetPasswordModal.newPasswordRequired": "請輸入新密碼", + "PC.Pages.UserManage.ResetPasswordModal.confirmPasswordRequired": "請確認新密碼", + "PC.Pages.UserManage.ResetPasswordModal.passwordMismatch": "兩次輸入的密碼不一致", + "PC.Pages.UserManage.ResetPasswordModal.resetSuccess": "密碼修改成功", + "PC.Pages.UserManage.UserAuthModal.subscriptionUserGroup": "訂閱關聯使用者組", + "PC.Routes.modelPermissions": "模型權限", + "PC.Pages.ModelPermissions.tabSystem": "系統模型", + "PC.Pages.ModelPermissions.tabSpace": "個人空間模型", + "PC.Pages.ModelPermissions.colModelName": "模型名稱", + "PC.Pages.ModelPermissions.colModelId": "模型標識", + "PC.Pages.ModelPermissions.colModelIdTip": "外部調用時填寫該值", + "PC.Pages.ModelPermissions.colModalities": "支援模態", + "PC.Pages.ModelPermissions.colExternalCall": "外部調用", + "PC.Pages.ModelPermissions.colExternalCallTip": "是否支援在其他外部系統調用該模型", + "PC.Pages.ModelPermissions.colApiProtocol": "介面協議", + "PC.Pages.ModelPermissions.colPrice": "價格", + "PC.Pages.ModelPermissions.notSupported": "不支援", + "PC.Pages.ModelPermissions.interfaceAddress": "介面地址", + "PC.Pages.ModelPermissions.copyInterface": "複製接口", + "PC.Pages.ModelPermissions.interfaceAddressCopied": "介面地址已複製", + "PC.Pages.ModelPermissions.modelIdCopied": "模型標識已複製", + "PC.Routes.recommendManage": "推薦管理", + "PC.Routes.homeRecommend": "首頁推薦", + "PC.Routes.officialRecommend": "官方推薦", + "PC.Routes.chatboxRecommend": "對話框智能體", + "PC.Pages.SystemRecommendManage.addTitle": "新增推薦", + "PC.Pages.SystemRecommendManage.createSuccess": "創建成功", + "PC.Pages.SystemRecommendManage.colAction": "操作", + "PC.Pages.SystemRecommendManage.colCreated": "建立時間", + "PC.Pages.SystemRecommendManage.colModified": "修改時間", + "PC.Pages.SystemRecommendManage.colLabel": "名稱", + "PC.Pages.SystemRecommendManage.dialogHintLabel": "對話框提示訊息", + "PC.Pages.SystemRecommendManage.dialogHintPlaceholder": "請輸入對話框提示訊息", + "PC.Pages.SystemRecommendManage.colTargetId": "目標ID", + "PC.Pages.SystemRecommendManage.colTargetType": "類型", + "PC.Pages.SystemRecommendManage.colSubType": "子類型", + "PC.Pages.SystemRecommendManage.confirmDeleteContent": "刪除後無法恢復,確定繼續嗎?", + "PC.Pages.SystemRecommendManage.confirmDeleteTitle": "確認刪除", + "PC.Pages.SystemRecommendManage.editTitle": "編輯推薦", + "PC.Pages.SystemRecommendManage.updateSuccess": "更新成功", + "PC.Pages.SystemRecommendManage.functionType.agentDev": "智能體開發", + "PC.Pages.SystemRecommendManage.functionType.chat": "智能體", + "PC.Pages.SystemRecommendManage.functionType.pageAppDev": "網頁應用開發", + "PC.Pages.SystemRecommendManage.functionType.pluginDev": "外掛開發", + "PC.Pages.SystemRecommendManage.functionType.skillDev": "技能開發", + "PC.Pages.SystemRecommendManage.searchName": "請輸入名稱", + "PC.Pages.SystemRecommendManage.selectTarget": "選擇推薦目標", + "PC.Pages.SystemRecommendManage.selectTargetRequired": "請選擇{0}", + "PC.Pages.SystemRecommendManage.clickToAddAgent": "點擊添加智能體", + "PC.Pages.SystemRecommendManage.sortUpdated": "排序已更新", + "PC.Pages.SystemRecommendManage.targetType.Agent": "智能體", + "PC.Pages.SystemRecommendManage.targetType.PageApp": "應用", + "PC.Pages.SystemRecommendManage.targetType.Plugin": "外掛", + "PC.Pages.SystemRecommendManage.targetType.Skill": "技能", + "PC.Pages.SystemRecommendManage.targetType.Workflow": "工作流", + "PC.Pages.SystemPlans.fieldNamePlaceholderExample": "例如:基礎版", + "PC.Pages.SystemPlans.fieldCreditAmountMonthly": "套餐包含積分(每月)", + "PC.Pages.SystemPlans.fieldPriceLabel": "套餐價格 (¥)", + "PC.Pages.SystemPlans.fieldPricePlaceholderExample": "例如:99", + "PC.Pages.SystemPlans.fieldValidityPeriod": "套餐有效期", + "PC.Pages.SystemPlans.periodOption1Month": "1個月", + "PC.Pages.SystemPlans.periodOption3Months": "3個月", + "PC.Pages.SystemPlans.periodOption12Months": "12個月", + "PC.Pages.SystemPlans.fieldHotLabel": "熱門標籤", + "PC.Pages.SystemPlans.fieldDescPlaceholder": "描述套餐的定位和適用場景", + "PC.Pages.SystemPlans.developerPermissionTitle": "開發者權限", + "PC.Pages.SystemPlans.originalPrice": "原價¥{0}/{1}", + "PC.Pages.SystemUserCredits.colUserNamePhoneOrEmail": "用戶名稱/手機號/郵箱", + "PC.Pages.SystemUserCredits.grantCredit": "積分發放", + "PC.Pages.SystemUserCredits.deductCredit": "積分扣減", + "PC.Pages.SystemUserCredits.grantModalTitle": "積分發放", + "PC.Pages.SystemUserCredits.deductModalTitle": "積分扣減", + "PC.Pages.SystemUserCredits.fieldCreditType": "積分類型", + "PC.Pages.SystemUserCredits.fieldAmount": "積分數量", + "PC.Pages.SystemUserCredits.fieldAmountPlaceholder": "請輸入積分數量", + "PC.Pages.SystemUserCredits.fieldAmountMin": "積分數量須大於 0", + "PC.Pages.SystemUserCredits.fieldBizNo": "業務單號", + "PC.Pages.SystemUserCredits.fieldBizNoPlaceholder": "請輸入業務單號(選填)", + "PC.Pages.SystemUserCredits.fieldRemark": "備註", + "PC.Pages.SystemUserCredits.fieldRemarkPlaceholder": "請輸入備註(選填)", + "PC.Pages.SystemUserCredits.fieldExpireTime": "過期時間", + "PC.Pages.SystemUserCredits.fieldExpireTimePlaceholder": "請選擇過期時間(不填則永不過期)", + "PC.Pages.SystemUserCredits.fieldAllowNegative": "允許透支", + "PC.Pages.SystemUserCredits.grantSuccess": "積分發放成功", + "PC.Pages.SystemUserCredits.deductSuccess": "積分扣減成功", + "PC.Pages.MorePage.MySubscriptions.currentPlanButton": "目前方案", + "PC.Pages.MorePage.MySubscriptions.upgradeTo": "升級為", + "PC.Pages.SpaceResourcePricing.pricingTypeOneTime": "單次", + "PC.Pages.SpaceResourcePricing.pricingTypeMonthly": "包月", + "PC.Pages.SpaceResourcePricing.pricingTypeTiered": "階梯計費", + "PC.Pages.SystemCreditPackages.periodMonthUnit": "個月", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDesc": "換算描述", + "PC.Pages.SystemSubscriptionBasicConfig.creditExchangeDescPlaceholder": "請輸入面向使用者的積分換算說明,例如兌換比例、可抵扣範圍等,將展示在積分餘額旁", + "PC.Pages.SpaceCreateProject.greetingTitle": "嗨,{0},給我一個任務,現在開始?", + "PC.Pages.SpaceCreateProject.tabAgent": "智能體", + "PC.Pages.SpaceCreateProject.placeholderAgent": "描述你想要的智能體,例如:幫我創建一個程式碼審查助手,能自動檢測程式碼問題並給出優化建議", + "PC.Pages.SpaceCreateProject.tabPageApp": "網頁應用", + "PC.Pages.SpaceCreateProject.placeholderPageApp": "描述你想要的網頁應用,例如:幫我開發一個顏值管理網站,支援上傳照片智慧評估顏值與膚質", + "PC.Pages.SpaceCreateProject.tabSkill": "技能", + "PC.Pages.SpaceCreateProject.placeholderSkill": "描述你想要的自訂技能,例如:幫我寫一個根據經緯度查詢目前天氣狀況的API介面", + "PC.Pages.SpaceCreateProject.tabPlugin": "外掛程式", + "PC.Pages.SpaceCreateProject.placeholderPlugin": "描述你想要的外掛程式工具,例如:幫我對接第三方圖片轉換的HTTP介面外掛程式", + "PC.Pages.SpaceCreateProject.inputRequiredWarning": "請輸入您的任務描述!", + "PC.Pages.SpaceKnowledge.KnowledgeHeader.accuracytest": "命中測試", }; diff --git a/qiming/src/models/conversationAgent.ts b/qiming/src/models/conversationAgent.ts new file mode 100644 index 00000000..24341135 --- /dev/null +++ b/qiming/src/models/conversationAgent.ts @@ -0,0 +1,1060 @@ +/** + * ConversationAgent 页面专用会话状态 Model + * + * 与 conversationInfo 逻辑一致但状态完全隔离, + * 避免 ConversationAgent 与 Chat / EditAgent 等页面共享同一会话状态。 + * 请勿修改 conversationInfo.ts,本文件独立维护。 + */ +import { + hydrateMcpAskInteractionsInMessageList, + prependAndHydrateMcpAskMessageList, + processInterventionSsePatch, + useAgentInterventionHandlers, +} from '@/components/business-component/AgentIntervention'; +import { reconcileAcpPermissionStatusesInMessageList } from '@/components/business-component/AgentIntervention/utils/reconcileAcpPermissionStatus'; +import { reconcileFinalMessageState } from '@/components/business-component/AgentIntervention/utils/reconcileFinalMessageState'; +import { SUCCESS_CODE } from '@/constants/codes.constants'; +import { + CONVERSATION_CONNECTION_URL, + MESSAGE_PAGE_SIZE, +} from '@/constants/common.constants'; +import { ACCESS_TOKEN } from '@/constants/home.constants'; +import { isSessionStreamBusy } from '@/hooks/useExecutingTaskStatusPoll'; +import { useResumeStreamHandlers } from '@/hooks/useResumeStreamHandlers'; +import { getCustomBlock } from '@/plugins/ds-markdown-process'; +import { + apiAgentConversation, + apiAgentConversationChatStop, + apiAgentConversationChatSuggest, + apiAgentConversationMessageList, +} from '@/services/agentConfig'; +import { dict } from '@/services/i18nRuntime'; +import { + AssistantRoleEnum, + ConversationEventTypeEnum, + MessageModeEnum, + MessageTypeEnum, +} from '@/types/enums/agent'; +import { MessageStatusEnum, ProcessingEnum } from '@/types/enums/common'; +import { AgentTypeEnum, OpenCloseEnum } from '@/types/enums/space'; +import { + AgentManualComponentInfo, + GuidQuestionDto, +} from '@/types/interfaces/agent'; +import type { + AttachmentFile, + ConversationChatParams, + ConversationChatResponse, + ConversationChatSuggestParams, + ConversationInfo, + MessageInfo, + MessageQuestionExtInfo, + ProcessingInfo, + SendMessageParams, +} from '@/types/interfaces/conversationInfo'; +import { RequestResponse } from '@/types/interfaces/request'; +import { modalConfirm } from '@/utils/ant-custom'; +import { + createSyncConversationTaskStatus, + subscribeChatFinishedTaskSync, +} from '@/utils/conversationTaskStatusSync'; +import { createSSEConnection } from '@/utils/fetchEventSourceConversationInfo'; +import { + perfTracker, + type MessagePerfLifecycle, +} from '@/utils/qimingClawBridge/perfTracker'; +import { adjustScrollPositionAfterDOMUpdate } from '@/utils/scrollUtils'; +import { useRequest } from 'ahooks'; +import { message } from 'antd'; +import dayjs from 'dayjs'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useModel } from 'umi'; +import { v4 as uuidv4 } from 'uuid'; + +export default () => { + const { showPagePreview, handleChatProcessingList } = useModel('chat'); + + // 会话信息 + const [conversationInfo, setConversationInfo] = + useState(); + const conversationInfoRef = useRef(null); + useEffect(() => { + conversationInfoRef.current = conversationInfo || null; + }, [conversationInfo]); + + const syncConversationTaskStatus = useCallback( + createSyncConversationTaskStatus(setConversationInfo), + [], + ); + + useEffect(() => { + return subscribeChatFinishedTaskSync( + conversationInfo?.id, + conversationInfo?.taskStatus, + syncConversationTaskStatus, + ); + }, [ + conversationInfo?.id, + conversationInfo?.taskStatus, + syncConversationTaskStatus, + ]); + + // 是否用户问题建议 + // const [isSuggest, setIsSuggest] = useState(false); + const isSuggest = useRef(false); + const setIsSuggest = (suggest: boolean) => { + isSuggest.current = suggest; + }; + // 是否还有更多消息, 默认没有更多消息,这样默认隐藏加载更多按钮 + const [isMoreMessage, setIsMoreMessage] = useState(false); + // 加载更多消息的状态 + const [loadingMore, setLoadingMore] = useState(false); + // 会话信息 + const [messageList, setMessageList] = useState([]); + + // 当前会话 ID(用于停止会话、干预回执等操作) + const [currentConversationId, setCurrentConversationId] = useState< + number | null + >(null); + + const { respondAcpPermission, respondMcpAsk } = useAgentInterventionHandlers({ + setMessageList, + conversationId: currentConversationId, + }); + + // 缓存消息列表,用于消息会话错误时,修改消息状态(将当前会话的loading状态的消息改为Error状态) + const messageListRef = useRef([]); + // 会话问题建议 + const [chatSuggestList, setChatSuggestList] = useState< + string[] | GuidQuestionDto[] + >([]); + /** 发送新消息(含队列自动消费)时递增,用于丢弃过期的 suggest 响应 */ + const suggestGenerationRef = useRef(0); + const pendingSuggestGenerationRef = useRef(0); + const messageViewRef = useRef(null); + const scrollTimeoutRef = useRef | null>(null); + const abortConnectionRef = useRef(); + // 会话消息ID + const messageIdRef = useRef(''); + // 是否正在加载会话 + const [, setIsLoadingConversation] = useState(false); + + // 其它调用接口的情况下判断是否正在加载中用于禁用聊天发送按钮 + const [isLoadingOtherInterface, setIsLoadingOtherInterface] = + useState(false); + + // 会话是否正在进行中(有消息正在流式处理 Loading/Incomplete) + const [isConversationActive, setIsConversationActiveRaw] = + useState(false); + // 发送后 3s 内拒绝置 false,避免 SSE 回流间隙覆盖乐观 true + const lastSendAtRef = useRef(0); + const setIsConversationActive = useCallback((v: boolean) => { + if (!v && Date.now() - lastSendAtRef.current < 3000) { + return; + } + setIsConversationActiveRaw(v); + }, []); + + // 添加一个 ref 来控制是否允许自动滚动 + const allowAutoScrollRef = useRef(true); + // 是否显示点击下滚按钮 + const [showScrollBtn, setShowScrollBtn] = useState(false); + // 可手动选择的组件列表 + const [manualComponents, setManualComponents] = useState< + AgentManualComponentInfo[] + >([]); + + // 当前会话请求 ID(用于停止临时会话等操作) + const [currentConversationRequestId, setCurrentConversationRequestId] = + useState(''); + + // 获取当前会话 ID + const getCurrentConversationId = useCallback(() => { + return currentConversationId; + }, [currentConversationId]); + + // 获取当前会话请求 ID + const getCurrentConversationRequestId = useCallback(() => { + return currentConversationRequestId; + }, [currentConversationRequestId]); + + // 滚动到底部 + const messageViewScrollToBottom = () => { + // 只有在允许自动滚动时才执行滚动 + if (!allowAutoScrollRef.current) { + return; + } + // 滚动到底部 + const element = messageViewRef.current; + if (element) { + // 标记为程序触发的滚动,避免被误判为用户滚动 + // 通过设置一个临时属性来标记 + (element as any).__isProgrammaticScroll = true; + element.scrollTo({ + top: element.scrollHeight, + behavior: 'smooth', + }); + // 在滚动完成后清除标记(smooth 滚动大约需要 500ms) + setTimeout(() => { + (element as any).__isProgrammaticScroll = false; + }, 600); + } + }; + + // 修改 handleScrollBottom 函数,添加自动滚动控制 + const handleScrollBottom = () => { + if (allowAutoScrollRef.current) { + scrollTimeoutRef.current = setTimeout(() => { + // 滚动到底部 + messageViewScrollToBottom(); + }, 400); + } + }; + + // 设置所有的详细信息 + const setChatProcessingList = (messages: MessageInfo[]) => { + const list: any[] = []; + messages + .filter((item) => item.role === AssistantRoleEnum.ASSISTANT) + .forEach((item) => { + const componentExecutedList = item?.componentExecutedList || []; + // 补充执行ID,过滤掉 result 为空或无 executeId 的条目(如 SandboxStart 类型) + const _list = componentExecutedList + .filter((item: any) => item?.result?.executeId) + .map((item: any) => ({ + ...item, + executeId: item.result.executeId, + })); + list.push(..._list); + }); + + handleChatProcessingList(list); + }; + + // 查询会话消息列表 + const { runAsync: runQueryConversationMessageList } = useRequest( + apiAgentConversationMessageList, + { + manual: true, + debounceWait: 300, + }, + ); + + // 加载更多消息 + const handleLoadMoreMessage = async (conversationId: number) => { + if ( + !conversationId || + loadingMore || + !isMoreMessage || + messageList?.length === 0 + ) { + return; + } + + // 使用消息列表第一项的 index 属性值作为查询的起始索引 + // 如果没有第一项或第一项没有 index 属性,则使用 0 作为默认值 + const firstMessage = messageList?.[0] as MessageInfo; + const currentIndex = firstMessage?.index || 0; + + // 记录加载前的滚动高度和位置,用于保持滚动位置 + const messageView = messageViewRef.current; + const oldScrollHeight = messageView?.scrollHeight || 0; + const oldScrollTop = messageView?.scrollTop || 0; + + setLoadingMore(true); + try { + const { code, data } = await runQueryConversationMessageList({ + conversationId, + index: currentIndex, + size: MESSAGE_PAGE_SIZE, + }); + + if (code === SUCCESS_CODE) { + // 如果查询到的消息数量大于0,则表示有更多消息 + if (!!data?.length) { + // 将新消息追加到消息列表前面,并重建历史 MCP Ask 交互状态 + setMessageList((prev) => + prependAndHydrateMcpAskMessageList(data, prev), + ); + + // 如果查询到的消息数量小于20,则表示没有更多消息 + if (data.length < MESSAGE_PAGE_SIZE) { + setIsMoreMessage(false); + } else { + setIsMoreMessage(true); + } + // 保持滚动位置(加载更多后,滚动位置应该保持在原来的位置) + // 使用MutationObserver监听DOM变化,确保所有元素都渲染完成后再调整滚动位置 + adjustScrollPositionAfterDOMUpdate( + messageView, + oldScrollTop, + oldScrollHeight, + ); + } else { + // 如果查询到的消息数量为0,则表示没有更多消息 + setIsMoreMessage(false); + } + } + } catch (error) { + console.error('Failed to load more messages:', error); + } finally { + setLoadingMore(false); + } + }; + + /** 根据最近消息是否含 Loading/Incomplete / processing 执行中 更新流式活跃状态 */ + const checkConversationActive = useCallback((messages: MessageInfo[]) => { + const recentMessages = messages?.slice(-5) || []; + setIsConversationActive(isSessionStreamBusy(recentMessages)); + }, []); + + const disabledConversationActive = () => { + setIsConversationActive(false); + }; + + // 查询会话 + const { + run: runQueryConversation, + runAsync, + loading: loadingConversation, + } = useRequest(apiAgentConversation, { + manual: true, + debounceWait: 300, + loadingDelay: 300, // 300ms内不显示loading + onSuccess: (result: RequestResponse) => { + setIsLoadingConversation(false); + const { data } = result; + // 设置所有的详细信息 + setChatProcessingList(data?.messageList || []); + // 设置会话信息 + setConversationInfo(data); + // 记录当前会话 ID(用于停止会话等操作) + setCurrentConversationId(data?.id ?? null); + + // 是否开启用户问题建议 + setIsSuggest(data?.agent?.openSuggest === OpenCloseEnum.Open); + // 可手动选择的组件列表 + setManualComponents(data?.agent?.manualComponents || []); + // 消息列表:拉取后重建 MCP Ask 交互(与 conversationInfo 对齐) + const _messageList = hydrateMcpAskInteractionsInMessageList( + data?.messageList || [], + ); + const len = _messageList?.length || 0; + if (len) { + setMessageList(() => { + checkConversationActive(_messageList); + messageListRef.current = _messageList; + return _messageList; + }); + // 最后一条消息为"问答"时,获取问题建议 + const lastMessage = _messageList[len - 1]; + if ( + lastMessage.type === MessageModeEnum.QUESTION && + lastMessage.ext?.length + ) { + // 问题建议列表 + const suggestList = lastMessage.ext.map((item) => item.content) || []; + setChatSuggestList(suggestList); + } + // 如果消息列表大于1时,说明已开始会话,就不显示预置问题,反之显示 + else if (len === 1) { + const guidQuestionDtos = data?.agent?.guidQuestionDtos || []; + // 如果存在预置问题,显示预置问题 + setChatSuggestList(guidQuestionDtos); + } + + // 无论初始返回的 messageList 长度多少,都认为可能有历史消息, + // 保证第一次上滑到顶部时始终调用一次列表接口进行确认。 + if (len > 0) { + setIsMoreMessage(true); + } + } + // 不存在会话消息时,才显示开场白预置问题 + else { + setMessageList([]); + const guidQuestionDtos = data?.agent?.guidQuestionDtos || []; + // 如果存在预置问题,显示预置问题 + setChatSuggestList(guidQuestionDtos); + } + + // 通过 requestAnimationFrame 在接下来的 800ms 内持续并在浏览器每次重绘前强制置底 + // 能够完美解决由于聊天气泡、Markdown、图片等异步渲染撑开高度,导致的跳闪和未置底问题 + const startTime = Date.now(); + const forceScrollToBottom = () => { + // 滚动到底部 + const element = messageViewRef?.current; + if (element) { + element.scrollTo({ + top: element.scrollHeight, + behavior: 'instant', + }); + } + if (Date.now() - startTime < 800) { + requestAnimationFrame(forceScrollToBottom); + } + }; + requestAnimationFrame(forceScrollToBottom); + }, + onError: () => { + setIsLoadingConversation(false); + }, + }); + + // 智能体会话问题建议 + const { run: runChatSuggest, loading: loadingSuggest } = useRequest( + apiAgentConversationChatSuggest, + { + manual: true, + debounceWait: 300, + onSuccess: (result: RequestResponse) => { + if ( + pendingSuggestGenerationRef.current !== suggestGenerationRef.current + ) { + return; + } + setChatSuggestList(result.data); + handleScrollBottom(); + }, + }, + ); + + // 停止会话 + const { runAsync: runStopConversation, loading: loadingStopConversation } = + useRequest(apiAgentConversationChatStop, { + manual: true, + debounceWait: 300, + }); + + // 修改消息列表 + const handleChangeMessageList = ( + params: ConversationChatParams, + res: ConversationChatResponse, + // 自定义随机id + currentMessageId: string, + ) => { + const { data, eventType } = res; + + // 立即执行同步更新:React 18 会自动处理批量更新合并,无需手动防抖。 + // 这保证了流式输出中的每一个数据包(Chunk)都能被正确拼接,且不会丢失。 + setMessageList((messageList) => { + if (!messageList?.length) { + return []; + } + // 深拷贝消息列表 + let list: any[] = [...messageList]; + const index = list.findIndex((item) => item.id === currentMessageId); + // 数组splice方法的第二个参数表示删除的数量,这里我们只需要删除一个元素,所以设置为1, 如果为0,则表示不删除元素。 + let arraySpliceAction = 1; + // 当前消息 + const currentMessage = list.find( + (item) => item.id === currentMessageId, + ) as MessageInfo; + // 消息不存在时 + if (!currentMessage) { + return messageList; + } + + let newMessage: any = null; + + // 优先拦截 ACP 权限 / MCP Ask 干预类 SSE,挂载到当前流式消息(DockPanel 数据源) + const interventionPatch = processInterventionSsePatch( + res, + currentMessage, + list, + ); + if (interventionPatch) { + list.splice(index, arraySpliceAction, interventionPatch); + const reconciledList = + reconcileAcpPermissionStatusesInMessageList(list); + checkConversationActive(reconciledList); + return reconciledList; + } + + // 更新UI状态... + if (eventType === ConversationEventTypeEnum.PROCESSING) { + const processingResult = data.result || {}; + // 后端可能仅在 data.result.executeId 提供执行 ID + if (!data.executeId && processingResult.executeId) { + data.executeId = processingResult.executeId; + } + const processingList = [ + ...(currentMessage?.processingList || []), + ] as ProcessingInfo[]; + const existingIndex = processingList.findIndex( + (item) => item.executeId === data.executeId, + ); + if (existingIndex > -1) { + processingList[existingIndex] = data; + } else { + processingList.push(data); + } + + newMessage = { + ...currentMessage, + text: getCustomBlock(currentMessage.text || '', data), + status: MessageStatusEnum.Loading, + processingList, + }; + + // 添加处理扩展页面逻辑 + if (data.status === ProcessingEnum.EXECUTING) { + // 判断页面类型 + if (data.type === 'Page') { + const input = processingResult.input; + // 添加页面类型 后的未返回默认 Page + input.uri_type = processingResult.input.uri_type ?? 'Page'; + + // 显示页面预览 + if (!input.uri_type || input.uri_type === 'Page') { + const previewData = { + uri: input.uri, + params: input.arguments || {}, + executeId: data.executeId || '', + method: input.method, + request_id: input.request_id, + data_type: input.data_type, + }; + // console.log('CHART', previewData); + // 显示页面预览 + showPagePreview(previewData); + } + + // 链接类型 + if (input.uri_type === 'Link') { + const input = processingResult.input; + input.uri_type = processingResult.input.uri_type ?? 'Page'; + // 拼接 query 参数 + const queryString = new URLSearchParams( + input.arguments, + ).toString(); + const pageUrl = `${input.uri}?${queryString}`; + window.open(pageUrl, '_blank'); + } + } + } + + handleChatProcessingList(processingList); + } + // MESSAGE事件 + if (eventType === ConversationEventTypeEnum.MESSAGE) { + const { text, type, ext, id, finished } = data; + // 思考think + if (type === MessageModeEnum.THINK) { + newMessage = { + ...currentMessage, + think: `${currentMessage.think}${text}`, + status: MessageStatusEnum.Incomplete, + }; + } + // 问答 + else if (type === MessageModeEnum.QUESTION) { + newMessage = { + ...currentMessage, + text: `${currentMessage.text}${text}`, + // 如果finished为true,则状态为null,此时不会显示运行状态组件,否则为Incomplete + status: finished ? null : MessageStatusEnum.Incomplete, + }; + if (ext?.length) { + // 问题建议 + setChatSuggestList( + ext.map((extItem: MessageQuestionExtInfo) => extItem.content) || + [], + ); + } + } else { + // 工作流过程输出 + if (messageIdRef.current && messageIdRef.current !== id && finished) { + newMessage = { + ...currentMessage, + id, + text: `${currentMessage.text}${text}`, // 这里需要添加 展示MCP 或者其他工具调用 + status: null, // 隐藏运行状态 + }; + // 插入新的消息 + arraySpliceAction = 0; + } else { + messageIdRef.current = id; + newMessage = { + ...currentMessage, + text: `${currentMessage.text}${text}`, + // 如果finished为true,则状态为Complete,否则为Incomplete + status: finished + ? MessageStatusEnum.Complete + : MessageStatusEnum.Incomplete, + }; + } + } + } + // FINAL_RESULT事件 + if (eventType === ConversationEventTypeEnum.FINAL_RESULT) { + // 重置消息ID + messageIdRef.current = ''; + + /** + * "error":"Agent正在执行任务,请等待当前任务完成后再发送新请求" + */ + if ( + res.error?.includes('正在执行任务') || + (!data?.success && data?.error?.includes('正在执行任务')) + ) { + modalConfirm( + dict('PC.Models.ConversationInfo.taskConflictTitle'), + dict('PC.Models.ConversationInfo.taskConflictContent'), + () => { + if (params?.conversationId) { + runStopConversation(params?.conversationId.toString()); + } + return new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + }, + ); + } + + newMessage = { + ...reconcileFinalMessageState(currentMessage, data), + status: MessageStatusEnum.Complete, + finalResult: data, + requestId: res.requestId, + }; + + // 是否开启问题建议,可用值:Open,Close + if (isSuggest.current) { + pendingSuggestGenerationRef.current = suggestGenerationRef.current; + runChatSuggest(params as ConversationChatSuggestParams); + } + + // TaskAgent:同步后台 taskStatus,驱动「智能体正在执行,请稍等」展示/结束 + if ( + params.conversationId && + conversationInfoRef.current?.agent?.type === AgentTypeEnum.TaskAgent + ) { + void syncConversationTaskStatus(params.conversationId); + } + + // 用户主动取消任务 + if (!data?.success && data?.error?.includes('用户主动取消任务')) { + // 如果没有输出文本,删除最后一条消息,不显示流式输出内容 + if (!newMessage?.text && !data.outputText) { + // 将 newMessage 设置为 null,并保持 arraySpliceAction 为 1 + // 这样会在后续的 splice 操作中删除当前消息而不是替换 + newMessage = null; + // 确保删除操作生效:直接从列表中移除当前消息 + list.splice(index, 1); + // 标记已处理,跳过后续的 splice 逻辑 + // arraySpliceAction = 0; + } + } + } + // ERROR事件 + if (eventType === ConversationEventTypeEnum.ERROR) { + newMessage = { + ...currentMessage, + status: MessageStatusEnum.Error, + }; + } + + // 会话事件兼容处理,防止消息为空时,页面渲染报length错误 + if (newMessage) { + list.splice(index, arraySpliceAction, newMessage as MessageInfo); + } + + const reconciledList = reconcileAcpPermissionStatusesInMessageList(list); + + const latestProcessingList = reconciledList.flatMap((message) => + Array.isArray(message.processingList) ? message.processingList : [], + ); + handleChatProcessingList(latestProcessingList); + + // 同步更新会话活跃状态 + checkConversationActive(reconciledList); + messageListRef.current = reconciledList; + + return reconciledList; + }); + }; + + // 会话处理 + const handleConversation = async ( + params: ConversationChatParams, + currentMessageId: string, + perfLifecycle: MessagePerfLifecycle, + ) => { + const token = localStorage.getItem(ACCESS_TOKEN) ?? ''; + + // 请求即将发起:用于计算前端从发送动作到真正网络发起的耗时。 + perfLifecycle.onHttpStart(); + + // 启动连接(不传 abortController,让 createSSEConnection 内部创建) + abortConnectionRef.current = createSSEConnection({ + url: CONVERSATION_CONNECTION_URL, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json, text/plain, */* ', + }, + body: params, + // 不传 abortController,让函数内部创建新的 + onOpen: () => { + perfLifecycle.onSseConnect(); + }, + onMessage: (res: ConversationChatResponse) => { + // 将 chunk 的实际载荷也传给 perfTracker,避免只依赖 eventType 误判”首包” + // 传入整个响应对象:若其中存在 subType(例如 unified 会话流),perfTracker 可据此判断”真正消息块”。 + perfLifecycle.onFirstChunk(res?.eventType, res); + + // 记录当前会话请求 ID(用于停止会话等操作) + if (res?.requestId) { + setCurrentConversationRequestId(res.requestId); + } + + // 现在逻辑已重构为同步,按序处理所有包,包括带有 finished: true 的结束包。 + handleChangeMessageList(params, res, currentMessageId); + // 滚动到底部:在流式输出期间,使用 'instant' 以避免抖动,且只有在允许自动滚动时才触发 + if (allowAutoScrollRef.current) { + // 使用 raf 确保在 DOM 更新后立即执行,且不带平滑动画以防指令堆积 + requestAnimationFrame(() => { + const element = messageViewRef?.current; + if (element) { + element.scrollTo({ + top: element.scrollHeight, + behavior: 'instant', + }); + } + }); + } + }, + onClose: async () => { + // 明确的流结束信号:打破「发送后 3s 保活」,确保活跃态能落 false(停止/快速结束场景) + lastSendAtRef.current = 0; + // 将当前会话的loading状态的消息改为Stopped状态,并将所有正在执行的 processing 状态更新为 FAILED + setMessageList((list) => { + try { + const copyList = JSON.parse(JSON.stringify(list)); + + // 从后往前遍历消息列表,修复包含有工具调用的前置消息状态 + for (let i = copyList.length - 1; i >= 0; i--) { + const currentMessage = copyList[i]; + + // 1. 仅对列表的最后一条真正的消息,如果处于加载态则强置为 Stopped + if ( + i === copyList.length - 1 && + (currentMessage.status === MessageStatusEnum.Loading || + currentMessage.status === MessageStatusEnum.Incomplete) + ) { + currentMessage.status = MessageStatusEnum.Stopped; + } + + // 2. 遍历所有消息 of processingList,强置其中残余的 EXECUTING 状态为 FAILED + if ( + currentMessage.processingList && + Array.isArray(currentMessage.processingList) + ) { + currentMessage.processingList = + currentMessage.processingList.map((item: ProcessingInfo) => { + if (item.status === ProcessingEnum.EXECUTING) { + return { + ...item, + status: ProcessingEnum.FAILED, + }; + } + return item; + }); + } + + // 3. 将悬挂状态的权限审批与提问弹窗关闭 + // cleanupPendingInteractions(currentMessage); + } + + const latestProcessingList = copyList.flatMap((message: any) => + Array.isArray(message.processingList) + ? message.processingList + : [], + ); + handleChatProcessingList(latestProcessingList); + + // 再次调用 checkConversationActive 确保状态同步 + checkConversationActive(copyList); + messageListRef.current = copyList; + return copyList; + } catch (error) { + console.error('[onClose] ERROR:', error); + return list; + } + }); + + if ( + params.conversationId && + conversationInfoRef.current?.agent?.type === AgentTypeEnum.TaskAgent + ) { + await syncConversationTaskStatus(params.conversationId); + } + + disabledConversationActive(); + + perfLifecycle.onStreamEnd(); + perfLifecycle.onCloseRenderComplete(); + // SSE 关闭时重置会话活跃状态 + disabledConversationActive(); + }, + onError: () => { + message.error(dict('PC.Models.ConversationInfo.networkTimeoutError')); + // 将当前会话的 loading 消息改为 Error,并把其 processingList 中执行中的项更新为 FAILED, + // 否则 isSessionStreamBusy 会因残留 EXECUTING 项持续为 true,导致活跃态/停止按钮/队列消费卡死。 + const list = + messageListRef.current?.map((info: MessageInfo) => { + if (info?.id === currentMessageId) { + const processingList = Array.isArray(info.processingList) + ? info.processingList.map((item: ProcessingInfo) => + item.status === ProcessingEnum.EXECUTING + ? { ...item, status: ProcessingEnum.FAILED } + : item, + ) + : info.processingList; + return { + ...info, + status: MessageStatusEnum.Error, + processingList, + }; + } + return info; + }) || []; + // 明确终止:打破「发送后 3s 保活」,确保活跃态能立即落 false + lastSendAtRef.current = 0; + setMessageList(() => { + const latestProcessingList = list.flatMap((message) => + Array.isArray(message.processingList) ? message.processingList : [], + ); + handleChatProcessingList(latestProcessingList); + disabledConversationActive(); + messageListRef.current = list; + return list; + }); + // setMessageList(list); + checkConversationActive(list); + perfLifecycle.onStreamEnd('error'); + }, + }); + }; + + // ===== 会话流式恢复(sub):刷新页面 / 新开标签时,订阅 EXECUTING 会话的输出流 ===== + // 逻辑收敛到共享 hook(与 conversationInfo model 复用同一份实现,避免双份维护漂移) + // 重置会被 handleChangeMessageList 写入的流式状态:恢复流开/关时调用,避免残留 messageIdRef 误插重复行 + const resetResumeMessageState = useCallback(() => { + messageIdRef.current = ''; + }, []); + const { resumeConversationStream, abortResumeStream } = + useResumeStreamHandlers({ + setMessageList, + handleChangeMessageList, + messageViewRef, + allowAutoScrollRef, + resetResumeMessageState, + }); + + // 清除副作用 + const handleClearSideEffect = () => { + // 中断会话流式恢复(sub)连接(hook 内部同时重置占位记忆),避免离开页面后残留 + abortResumeStream(); + // 重置消息ID + messageIdRef.current = ''; + suggestGenerationRef.current += 1; + // 重置问题建议列表 + setChatSuggestList([]); + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = null; + } + // 主动关闭连接 + if (abortConnectionRef.current) { + // 确保 abortConnectionRef.current 是一个可调用的函数 + if (typeof abortConnectionRef.current === 'function') { + abortConnectionRef.current(); + } + abortConnectionRef.current = null; + } + }; + + // 清除文件面板信息, 并关闭文件面板 + // 文件树相关状态由 conversationInfo model 维护,此处保留空实现以兼容清空会话调用 + const clearFilePanelInfo = useCallback(() => {}, []); + + // 重置初始化 + const resetInit = () => { + handleClearSideEffect(); + // 重置是否还有更多消息 + setIsMoreMessage(false); + // 重置加载更多消息的状态 + setLoadingMore(false); + setManualComponents([]); + allowAutoScrollRef.current = true; + setShowScrollBtn(false); + // 重置消息列表 + setMessageList([]); + // 重置会话信息 + setConversationInfo(null); + // 重置问题建议 + setIsSuggest(false); + // 重置会话活跃状态 + disabledConversationActive(); + // 重置当前会话 ID 和请求 ID + setCurrentConversationId(null); + setCurrentConversationRequestId(''); + + // 清除文件面板信息, 并关闭文件面板 + clearFilePanelInfo(); + }; + + // 发送消息 + const onMessageSend = async (sendParams: SendMessageParams) => { + const { + id, + messageInfo, + files = [], + infos = [], + variableParams, + sandboxId, + debug = false, + skillIds, + modelId, + agentMode = 'yolo', + } = sendParams; + // 清除副作用 + handleClearSideEffect(); + + // 乐观标记流式活跃,保证停止按钮与队列入队判定及时 + setIsConversationActive(true); + lastSendAtRef.current = Date.now(); + + // 附件文件 + const attachments: AttachmentFile[] = + files?.map((file) => ({ + fileKey: file.key || '', + fileUrl: file.url || '', + fileName: file.name || '', + mimeType: file.type || '', + })) || []; + + // 将文件和消息加入会话中 + const chatMessage = { + role: AssistantRoleEnum.USER, + type: MessageModeEnum.CHAT, + text: messageInfo, + time: dayjs().toString(), + attachments, + id: uuidv4(), + messageType: MessageTypeEnum.USER, + }; + + const currentMessageId = uuidv4(); + const perfLifecycle = perfTracker.createLifecycle( + Number(id), + currentMessageId, + ); + // 发送动作时点:从用户动作开始对齐后续网络与流式阶段耗时。 + perfLifecycle.onSendClick(); + + // 当前助手信息 + const currentMessage = { + role: AssistantRoleEnum.ASSISTANT, + type: MessageModeEnum.CHAT, + text: '', + think: '', + time: dayjs().toString(), + id: currentMessageId, + messageType: MessageTypeEnum.ASSISTANT, + status: MessageStatusEnum.Loading, + } as MessageInfo; + + // 将Incomplete状态的消息改为Complete状态 + const completeMessageList = + messageList?.map((item: MessageInfo) => { + if (item.status === MessageStatusEnum.Incomplete) { + item.status = MessageStatusEnum.Complete; + } + return item; + }) || []; + + const newMessageList = [ + ...completeMessageList, + chatMessage, + currentMessage, + ] as MessageInfo[]; + + setMessageList(newMessageList); + // 缓存消息列表 + messageListRef.current = newMessageList; + // 同步更新会话活跃状态(用户发送消息后,新消息带有 Loading 状态) + checkConversationActive(newMessageList); + + // 允许滚动 + allowAutoScrollRef.current = true; + // 隐藏点击下滚按钮 + setShowScrollBtn(false); + // 滚动 + handleScrollBottom(); + // 会话请求参数 + const params: ConversationChatParams = { + conversationId: id, + variableParams, + message: messageInfo, + attachments, + debug, + selectedComponents: infos, + sandboxId, + // 技能ID列表 + skillIds, + // 模型ID + modelId, + agentMode, + }; + + // 处理会话 + handleConversation(params, currentMessageId, perfLifecycle); + }; + + return { + conversationInfo, + manualComponents, + messageList, + setMessageList, + chatSuggestList, + loadingConversation, + runQueryConversation, + runAsync, + setIsLoadingConversation, + loadingSuggest, + onMessageSend, + messageViewRef, + // 是否还有更多消息 + isMoreMessage, + setIsMoreMessage, + // 加载更多消息的状态 + loadingMore, + // 加载更多消息 + handleLoadMoreMessage, + handleClearSideEffect, + showScrollBtn, + setShowScrollBtn, + resetInit, + clearFilePanelInfo, + isLoadingOtherInterface, + setIsLoadingOtherInterface, + // 停止会话相关 + runStopConversation, + loadingStopConversation, + // 会话活跃状态(SSE 流式交互中) + isConversationActive, + disabledConversationActive, + checkConversationActive, + // 当前会话 ID 与请求 ID + getCurrentConversationId, + getCurrentConversationRequestId, + setCurrentConversationRequestId, + respondAcpPermission, + respondMcpAsk, + // 会话流式恢复(sub) + resumeConversationStream, + abortResumeStream, + }; +}; diff --git a/qiming/src/models/conversationInfo.ts b/qiming/src/models/conversationInfo.ts index 1c9a0390..0575d8c1 100644 --- a/qiming/src/models/conversationInfo.ts +++ b/qiming/src/models/conversationInfo.ts @@ -125,6 +125,7 @@ export default () => { >([]); const messageViewRef = useRef(null); const scrollTimeoutRef = useRef | null>(null); + const refreshGitListRef = useRef<(() => Promise) | null>(null); const abortConnectionRef = useRef(); const [showType, setShowType] = useState( EditAgentShowType.Hide, @@ -1461,6 +1462,8 @@ export default () => { setViewMode, // 处理文件列表刷新事件 handleRefreshFileList, + refreshFileListImmediately: handleRefreshFileList, + refreshGitListRef, openDesktopView, openPreviewView, // 重启智能体电脑 diff --git a/qiming/src/models/conversationInfoMessageList.test.ts b/qiming/src/models/conversationInfoMessageList.test.ts new file mode 100644 index 00000000..7f7a00fc --- /dev/null +++ b/qiming/src/models/conversationInfoMessageList.test.ts @@ -0,0 +1,56 @@ +import { MessageStatusEnum } from '@/types/enums/common'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; +import { describe, expect, it } from 'vitest'; +import { appendOutgoingConversationMessages } from './conversationInfoMessageList'; + +describe('appendOutgoingConversationMessages', () => { + it('preserves submitted ask/question state when appending resume messages', () => { + const existing = { + id: 'agent-message', + status: MessageStatusEnum.Complete, + mcpAskInteractions: [ + { + input: { + toolName: 'nuwax_ask_question', + requestId: 'ask-1', + prompt: 'fill form', + schema: {}, + ui: {}, + }, + responseStatus: 'submitted', + formData: { thesisTitle: 'AI Based Thesis Defense PPT Generator' }, + }, + ], + } as unknown as MessageInfo; + + const appended = appendOutgoingConversationMessages( + [existing], + { id: 'user-message', text: 'resume' } as MessageInfo, + { + id: 'assistant-message', + status: MessageStatusEnum.Loading, + } as MessageInfo, + ); + + expect(appended).toHaveLength(3); + expect(appended[0].mcpAskInteractions?.[0].responseStatus).toBe( + 'submitted', + ); + }); + + it('completes incomplete messages without mutating the original item', () => { + const existing = { + id: 'agent-message', + status: MessageStatusEnum.Incomplete, + } as MessageInfo; + + const appended = appendOutgoingConversationMessages( + [existing], + { id: 'user-message' } as MessageInfo, + { id: 'assistant-message' } as MessageInfo, + ); + + expect(appended[0].status).toBe(MessageStatusEnum.Complete); + expect(existing.status).toBe(MessageStatusEnum.Incomplete); + }); +}); diff --git a/qiming/src/models/conversationInfoMessageList.ts b/qiming/src/models/conversationInfoMessageList.ts new file mode 100644 index 00000000..28014fed --- /dev/null +++ b/qiming/src/models/conversationInfoMessageList.ts @@ -0,0 +1,21 @@ +import { MessageStatusEnum } from '@/types/enums/common'; +import type { MessageInfo } from '@/types/interfaces/conversationInfo'; + +export function appendOutgoingConversationMessages( + messageList: MessageInfo[] | undefined, + chatMessage: MessageInfo, + currentMessage: MessageInfo, +): MessageInfo[] { + const completeMessageList = + messageList?.map((item: MessageInfo) => { + if (item.status === MessageStatusEnum.Incomplete) { + return { + ...item, + status: MessageStatusEnum.Complete, + }; + } + return item; + }) || []; + + return [...completeMessageList, chatMessage, currentMessage]; +} diff --git a/qiming/src/models/pageHandoffContext.ts b/qiming/src/models/pageHandoffContext.ts new file mode 100644 index 00000000..a2c609c3 --- /dev/null +++ b/qiming/src/models/pageHandoffContext.ts @@ -0,0 +1,89 @@ +import { useCallback, useState } from 'react'; + +/** + * 生成页面间透传上下文 key + * @param scope 业务作用域,如 appDevInitialPayload + * @param id 业务唯一标识,如 projectId + * @returns 页面间透传上下文 key + */ +export const createPageHandoffKey = ( + scope: string, + id?: string | number, +): string => { + return id === undefined || id === null ? scope : `${scope}:${id}`; +}; + +/** + * 页面间一次性内存透传上下文 Model + * 只保存在 SPA 运行期内存中,不写 URL、history.state、localStorage 或 sessionStorage。 + */ +export default () => { + const [contextMap, setContextMap] = useState>({}); + + /** + * 写入页面间透传上下文 + * @param key 上下文唯一 key + * @param payload 业务上下文数据 + * @returns void + */ + const setContext = useCallback((key: string, payload: T) => { + setContextMap((prev) => ({ + ...prev, + [key]: payload, + })); + }, []); + + /** + * 读取页面间透传上下文,不会清理数据 + * @param key 上下文唯一 key + * @returns 业务上下文数据 + */ + const getContext = useCallback( + (key?: string): T | undefined => { + if (!key) { + return undefined; + } + return contextMap[key] as T | undefined; + }, + [contextMap], + ); + + /** + * 清理页面间透传上下文 + * @param key 上下文唯一 key + * @returns void + */ + const clearContext = useCallback((key?: string) => { + if (!key) { + return; + } + + setContextMap((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + }, []); + + /** + * 读取并清理页面间透传上下文 + * @param key 上下文唯一 key + * @returns 业务上下文数据 + */ + const consumeContext = useCallback( + (key?: string): T | undefined => { + const value = getContext(key); + clearContext(key); + return value; + }, + [clearContext, getContext], + ); + + return { + contextMap, + setContext, + getContext, + clearContext, + consumeContext, + }; +}; diff --git a/qiming/src/pages/Antv-X6/component/graph.tsx b/qiming/src/pages/Antv-X6/component/graph.tsx index 25fa7e20..0048ebd8 100644 --- a/qiming/src/pages/Antv-X6/component/graph.tsx +++ b/qiming/src/pages/Antv-X6/component/graph.tsx @@ -1,12 +1,16 @@ // AntV X6 -import { Cell, Edge, Graph, Node, Shape } from '@antv/x6'; - -import { Clipboard } from '@antv/x6-plugin-clipboard'; -import { History } from '@antv/x6-plugin-history'; -import { Keyboard } from '@antv/x6-plugin-keyboard'; -import { Selection } from '@antv/x6-plugin-selection'; -import { Snapline } from '@antv/x6-plugin-snapline'; -// import { Transform } from '@antv/x6-plugin-transform'; +import { + Cell, + Clipboard, + Edge, + Graph, + History, + Keyboard, + Node, + Selection, + Shape, + Snapline, +} from '@antv/x6'; import PlusIcon from '@/assets/svg/plus_icon.svg'; import { t } from '@/services/i18nRuntime'; import { AnswerTypeEnum, NodeTypeEnum } from '@/types/enums/common'; diff --git a/qiming/src/pages/Antv-X6/component/registerCustomNodes.tsx b/qiming/src/pages/Antv-X6/component/registerCustomNodes.tsx index 81d6bf43..30c5a2a8 100644 --- a/qiming/src/pages/Antv-X6/component/registerCustomNodes.tsx +++ b/qiming/src/pages/Antv-X6/component/registerCustomNodes.tsx @@ -20,7 +20,6 @@ import { ChildNode, NodeProps, RunResultItem } from '@/types/interfaces/graph'; import { ExceptionHandleConfig } from '@/types/interfaces/node'; import { showExceptionHandle } from '@/utils/graph'; import { returnBackgroundColor, returnImg } from '@/utils/workflow'; -import { Path } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; import { Tag } from 'antd'; import React, { useCallback, useEffect, useState } from 'react'; @@ -488,11 +487,8 @@ export const createCurvePath = (s: Point, e: Point) => { const v1 = { x: newStartX + control, y: startY }; const v2 = { x: newEndX - control, y: endY }; - return Path.normalize( - `M ${newStartX} ${startY} - L ${newStartX + (s.x < e.x ? startOffset : -startOffset)} ${startY} - C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${newEndX} ${endY} - L ${newEndX} ${endY} - `, - ); + return `M ${newStartX} ${startY} + L ${newStartX + (s.x < e.x ? startOffset : -startOffset)} ${startY} + C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${newEndX} ${endY} + L ${newEndX} ${endY}`; }; diff --git a/qiming/src/pages/Antv-X6/header.tsx b/qiming/src/pages/Antv-X6/header.tsx index f761c3ab..6e3d2e07 100644 --- a/qiming/src/pages/Antv-X6/header.tsx +++ b/qiming/src/pages/Antv-X6/header.tsx @@ -48,6 +48,7 @@ const Header: React.FC = ({ const { spaceId } = useParams(); const { name, icon, publishStatus, modified, description, publishDate } = info; + const defaultWorkflowIcon = getImg(AgentComponentTypeEnum.Workflow); // Whether publish button is disabled. const disabledBtn = useMemo(() => { @@ -68,9 +69,12 @@ const Header: React.FC = ({ /> { + event.currentTarget.src = defaultWorkflowIcon; + }} />
    diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/createdPicker.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/createdPicker.test.ts new file mode 100644 index 00000000..f3108bff --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/createdPicker.test.ts @@ -0,0 +1,120 @@ +import { AgentComponentTypeEnum } from '@/types/enums/agent'; +import { AgentSubTypeEnum, AgentTypeEnum } from '@/types/enums/space'; +import { describe, expect, it } from 'vitest'; +import { + isAgentFlowSelectableAgent, + normalizePublishedAgentSubType, + resolveAgentFlowCreatedModalTabs, +} from '../createdPicker'; + +describe('createdPicker', () => { + const workflowTabs = [ + { label: 'Plugin', key: AgentComponentTypeEnum.Plugin }, + { label: 'Workflow', key: AgentComponentTypeEnum.Workflow }, + { label: 'Agent', key: AgentComponentTypeEnum.Agent }, + ]; + + it('非 AgentFlow 时保持原 tabs', () => { + expect( + resolveAgentFlowCreatedModalTabs( + false, + AgentComponentTypeEnum.Workflow, + workflowTabs, + ), + ).toBe(workflowTabs); + }); + + it('AgentFlow 添加工作流节点仅展示工作流 Tab', () => { + const tabs = resolveAgentFlowCreatedModalTabs( + true, + AgentComponentTypeEnum.Workflow, + workflowTabs, + ); + expect(tabs).toHaveLength(1); + expect(tabs[0].key).toBe(AgentComponentTypeEnum.Workflow); + }); + + it('AgentFlow 添加智能体节点仅展示智能体 Tab', () => { + const tabs = resolveAgentFlowCreatedModalTabs( + true, + AgentComponentTypeEnum.Agent, + workflowTabs, + ); + expect(tabs).toHaveLength(1); + expect(tabs[0].key).toBe(AgentComponentTypeEnum.Agent); + }); + + describe('normalizePublishedAgentSubType', () => { + it('优先识别合法 subType', () => { + expect( + normalizePublishedAgentSubType({ + subType: AgentSubTypeEnum.Custom, + } as any), + ).toBe(AgentSubTypeEnum.Custom); + }); + + it('无效 subType 时回退 targetSubType / agentType', () => { + expect( + normalizePublishedAgentSubType({ + subType: 'invalid', + targetSubType: 'ChatBot', + } as any), + ).toBe(AgentSubTypeEnum.ChatBot); + expect( + normalizePublishedAgentSubType({ + subType: '', + agentType: 'ChatBot', + } as any), + ).toBe(AgentSubTypeEnum.ChatBot); + expect( + normalizePublishedAgentSubType({ + type: AgentTypeEnum.ChatBot, + } as any), + ).toBe(AgentSubTypeEnum.ChatBot); + }); + + it('TaskAgent 无 subType 时视为 General', () => { + expect( + normalizePublishedAgentSubType({ agentType: 'TaskAgent' } as any), + ).toBe(AgentSubTypeEnum.General); + }); + }); + + it('仅 ChatBot / General / Custom 可被 AgentFlow 智能体节点选中', () => { + expect( + isAgentFlowSelectableAgent({ + targetSubType: 'ChatBot', + } as any), + ).toBe(true); + expect( + isAgentFlowSelectableAgent({ + agentType: 'ChatBot', + } as any), + ).toBe(true); + expect( + isAgentFlowSelectableAgent({ + subType: AgentSubTypeEnum.General, + } as any), + ).toBe(true); + expect( + isAgentFlowSelectableAgent({ + subType: AgentSubTypeEnum.Custom, + } as any), + ).toBe(true); + expect( + isAgentFlowSelectableAgent({ + subType: AgentSubTypeEnum.Group, + } as any), + ).toBe(false); + expect( + isAgentFlowSelectableAgent({ + subType: AgentSubTypeEnum.Flow, + } as any), + ).toBe(false); + expect( + isAgentFlowSelectableAgent({ + agentType: 'AgentFlow', + } as any), + ).toBe(false); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/edgeConnect.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/edgeConnect.test.ts new file mode 100644 index 00000000..60f93d7a --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/edgeConnect.test.ts @@ -0,0 +1,50 @@ +/** + * AgentFlow 分支拖线辅助函数单元测试 + */ +import { NodeTypeEnum } from '@/types/enums/common'; +import type { ChildNode } from '@/types/interfaces/graph'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { extensionRegistry } from '../../extensions/registry'; +import { isAgentFlowBranchEdgeConnect } from '../edgeConnect'; +import { HitlAnswerTypeEnum } from '../enums/hitlAnswerType'; +import { humanInteractionHandler } from '../handlers/humanInteraction'; +import { routeDecisionHandler } from '../handlers/routeDecision'; + +beforeEach(() => { + extensionRegistry.register(humanInteractionHandler); + extensionRegistry.register(routeDecisionHandler); +}); + +describe('isAgentFlowBranchEdgeConnect', () => { + it('RouteDecision 任意出口端口走分支路径', () => { + const node = { + id: 1, + type: NodeTypeEnum.RouteDecision, + nodeConfig: { intentConfigs: [{ uuid: 'r1', nextNodeIds: [] }] }, + } as ChildNode; + expect(isAgentFlowBranchEdgeConnect(node, '1-route-r1-out')).toBe(true); + }); + + it('HumanInteraction options 端口走分支路径', () => { + const node = { + id: 2, + type: NodeTypeEnum.HumanInteraction, + nodeConfig: { + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', nextNodeIds: [] }], + }, + } as ChildNode; + expect(isAgentFlowBranchEdgeConnect(node, '2-hitl-option-o1-out')).toBe( + true, + ); + }); + + it('HumanInteraction 文本模式走普通边路径', () => { + const node = { + id: 3, + type: NodeTypeEnum.HumanInteraction, + nodeConfig: { answerType: 'TEXT' }, + } as ChildNode; + expect(isAgentFlowBranchEdgeConnect(node, '3-out')).toBe(false); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/edgeSync.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/edgeSync.test.ts new file mode 100644 index 00000000..6866fe07 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/edgeSync.test.ts @@ -0,0 +1,88 @@ +import type { Edge, Graph } from '@antv/x6'; +import { vi } from 'vitest'; +import { + findGraphEdgesBetween, + hasGraphEdgeBetween, + purgeEdgeBetween, + purgeNodeIncidentEdges, +} from '../edgeSync'; + +const mockDeleteEdge = vi.fn(); +const mockGetEdges = vi.fn(); + +vi.mock('../../services/workflowProxyV3', () => ({ + workflowProxy: { + deleteEdge: (...args: unknown[]) => mockDeleteEdge(...args), + getEdges: () => mockGetEdges(), + }, +})); + +const makeEdge = (id: string, source: string, target: string): Edge => + ({ + id, + getSourceCellId: () => source, + getTargetCellId: () => target, + getSourcePortId: () => `${source}-out`, + } as unknown as Edge); + +describe('edgeSync', () => { + beforeEach(() => { + mockDeleteEdge.mockClear(); + mockGetEdges.mockReturnValue([]); + }); + + it('findGraphEdgesBetween 按 cellId 匹配', () => { + const graph = { + getEdges: () => [makeEdge('e1', '1', '2'), makeEdge('e2', '1', '3')], + } as unknown as Graph; + expect(findGraphEdgesBetween(graph, '1', '2').map((e) => e.id)).toEqual([ + 'e1', + ]); + }); + + it('hasGraphEdgeBetween', () => { + const graph = { + getEdges: () => [makeEdge('e1', '1', '2')], + } as unknown as Graph; + expect(hasGraphEdgeBetween(graph, '1', '2')).toBe(true); + expect(hasGraphEdgeBetween(graph, '1', '9')).toBe(false); + }); + + it('purgeEdgeBetween 同步删除 proxy 与画布', () => { + const graph = { + getEdges: () => [makeEdge('e1', '1', '2')], + } as unknown as Graph; + const deleted: string[] = []; + + purgeEdgeBetween({ + graph, + sourceCellId: '1', + targetCellId: '2', + graphDeleteEdge: (id) => deleted.push(id), + }); + + expect(mockDeleteEdge).toHaveBeenCalled(); + expect(deleted).toEqual(['e1']); + }); + + it('purgeNodeIncidentEdges 清理 proxy 与画布关联边', () => { + mockGetEdges.mockReturnValue([ + { source: '3', target: '5' }, + { source: '1', target: '2' }, + ]); + const graph = { + getEdges: () => [makeEdge('e1', '3', '5'), makeEdge('e2', '9', '1')], + } as unknown as Graph; + const deleted: string[] = []; + + purgeNodeIncidentEdges({ + graph, + nodeId: '3', + graphDeleteEdge: (id) => deleted.push(id), + }); + + expect(mockDeleteEdge).toHaveBeenCalled(); + expect(deleted).toContain('e1'); + expect(deleted).not.toContain('e2'); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/humanInteraction.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/humanInteraction.test.ts new file mode 100644 index 00000000..ad10ff82 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/humanInteraction.test.ts @@ -0,0 +1,394 @@ +/** + * HumanInteraction 分支处理器单元测试(ask 模式) + * + * 覆盖 ask 模式的 options / text / form 行为。 + */ +import { NodeTypeEnum } from '@/types/enums/common'; +import type { ChildNode } from '@/types/interfaces/graph'; +import type { PortConfig } from '@/types/interfaces/node'; +import { describe, expect, it } from 'vitest'; +import { SpecialPortType } from '../../types/enums'; +import { HitlAnswerTypeEnum } from '../enums/hitlAnswerType'; +import { HitlModeEnum } from '../enums/hitlMode'; +import { humanInteractionHandler } from '../handlers/humanInteraction'; + +// ========== 测试数据 ========== + +const createHitlNode = ( + overrides: Partial<{ + hitlMode: HitlModeEnum; + answerType: HitlAnswerTypeEnum; + options: any[]; + }> = {}, +): ChildNode => ({ + id: 10, + type: NodeTypeEnum.HumanInteraction, + name: 'HumanInteraction', + description: '', + workflowId: 1, + shape: 'custom-react' as any, + icon: '', + nextNodeIds: [], + nodeConfig: { + hitlMode: overrides.hitlMode ?? HitlModeEnum.Ask, + question: '', + answerType: + overrides.answerType ?? + (overrides.options?.length + ? HitlAnswerTypeEnum.SELECT + : HitlAnswerTypeEnum.TEXT), + options: overrides.options ?? [], + } as any, +}); + +const mockGeneratePortConfig = (config: PortConfig) => ({ + id: `test-${config.idSuffix}`, + group: config.group, + zIndex: 99, + magnet: true, + args: { + x: config.xWidth ?? 200, + y: config.yHeight ?? 16, + offsetY: config.offsetY ?? 32, + offsetX: config.offsetX ?? (config.idSuffix === 'in' ? 0 : 200), + }, +}); + +const ctx = { generatePortConfig: mockGeneratePortConfig }; + +// ========== 测试用例 ========== + +describe('HumanInteraction Handler (ask mode)', () => { + describe('generatePorts', () => { + it('should generate a single normal out port in ask text mode', () => { + const node = createHitlNode({ answerType: HitlAnswerTypeEnum.TEXT }); + const result = humanInteractionHandler.generatePorts!(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.inputPorts).toHaveLength(1); + expect(result!.outputPorts).toHaveLength(1); + expect(result!.outputPorts[0].id).toContain('out'); + }); + + it('should generate one option port per option in options mode', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [ + { uuid: 'o1', content: 'A' }, + { uuid: 'o2', content: 'B' }, + ], + }); + const result = humanInteractionHandler.generatePorts!(node, ctx); + + expect(result!.outputPorts).toHaveLength(2); + expect(result!.outputPorts[0].id).toContain('hitl-option-o1-out'); + expect(result!.outputPorts[1].id).toContain('hitl-option-o2-out'); + }); + + it('should fall back to a single out port when options list is empty', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [], + }); + const result = humanInteractionHandler.generatePorts!(node, ctx); + + expect(result!.outputPorts).toHaveLength(1); + expect(result!.outputPorts[0].id).toContain('out'); + }); + + it('should always generate one input port', () => { + const node = createHitlNode({ answerType: HitlAnswerTypeEnum.TEXT }); + const result = humanInteractionHandler.generatePorts!(node, ctx); + expect(result!.inputPorts).toHaveLength(1); + }); + }); + + describe('parseSourcePort', () => { + it('should detect hitl-option-{uuid} port', () => { + const node = createHitlNode({ answerType: HitlAnswerTypeEnum.SELECT }); + const result = humanInteractionHandler.parseSourcePort!( + node, + '10-hitl-option-o1-out', + ); + + expect(result).not.toBeNull(); + expect(result!.type).toBe(SpecialPortType.HitlOption); + expect(result!.uuid).toBe('o1'); + }); + + it('should return null for normal out port', () => { + const node = createHitlNode({ answerType: HitlAnswerTypeEnum.TEXT }); + const result = humanInteractionHandler.parseSourcePort!(node, '10-out'); + + expect(result).toBeNull(); + }); + + it('should return null for an unrelated port pattern', () => { + const node = createHitlNode({ answerType: HitlAnswerTypeEnum.TEXT }); + const result = humanInteractionHandler.parseSourcePort!( + node, + '10-something-else', + ); + + expect(result).toBeNull(); + }); + }); + + describe('updateConnection', () => { + it('should add targetNodeId to the matched option nextNodeIds', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', content: 'A', nextNodeIds: [] }], + }); + const ok = humanInteractionHandler.updateConnection!( + node, + { type: SpecialPortType.HitlOption, uuid: 'o1' }, + 5, + 'add', + ); + + expect(ok).toBe(true); + expect((node.nodeConfig as any).options[0].nextNodeIds).toContain(5); + }); + + it('should remove targetNodeId from the matched option nextNodeIds', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', content: 'A', nextNodeIds: [3, 5] }], + }); + humanInteractionHandler.updateConnection!( + node, + { type: SpecialPortType.HitlOption, uuid: 'o1' }, + 5, + 'remove', + ); + + expect((node.nodeConfig as any).options[0].nextNodeIds).toEqual([3]); + }); + + it('should not add a duplicate targetNodeId', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', content: 'A', nextNodeIds: [5] }], + }); + humanInteractionHandler.updateConnection!( + node, + { type: SpecialPortType.HitlOption, uuid: 'o1' }, + 5, + 'add', + ); + + expect((node.nodeConfig as any).options[0].nextNodeIds).toEqual([5]); + }); + + it('should return false for an unknown option uuid', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [], + }); + const ok = humanInteractionHandler.updateConnection!( + node, + { type: SpecialPortType.HitlOption, uuid: 'missing' }, + 5, + 'add', + ); + + expect(ok).toBe(false); + }); + + it('should return false for a non-HitlOption port type', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [], + }); + const ok = humanInteractionHandler.updateConnection!( + node, + { type: SpecialPortType.Condition }, + 5, + 'add', + ); + + expect(ok).toBe(false); + }); + }); + + describe('cleanupNodeReferences', () => { + it('should remove the deleted node from every option nextNodeIds', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [ + { uuid: 'o1', content: 'A', nextNodeIds: [3, 5] }, + { uuid: 'o2', content: 'B', nextNodeIds: [5, 8] }, + ], + }); + humanInteractionHandler.cleanupNodeReferences!(node, 5); + + expect((node.nodeConfig as any).options[0].nextNodeIds).toEqual([3]); + expect((node.nodeConfig as any).options[1].nextNodeIds).toEqual([8]); + }); + + it('should be a no-op when the deleted node is not referenced', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', content: 'A', nextNodeIds: [3] }], + }); + humanInteractionHandler.cleanupNodeReferences!(node, 9); + + expect((node.nodeConfig as any).options[0].nextNodeIds).toEqual([3]); + }); + + it('should be a no-op when nodeConfig is undefined', () => { + const node = createHitlNode(); + node.nodeConfig = undefined as any; + expect(() => + humanInteractionHandler.cleanupNodeReferences!(node, 5), + ).not.toThrow(); + }); + }); + + describe('initBranchMap', () => { + it('should return null in text mode (not a special branch node)', () => { + const node = createHitlNode({ answerType: HitlAnswerTypeEnum.TEXT }); + const result = humanInteractionHandler.initBranchMap!(node); + expect(result).toBeNull(); + }); + + it('should build a map keyed by hitl-option-{uuid} in options mode', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [ + { uuid: 'o1', content: 'A', nextNodeIds: [3] }, + { uuid: 'o2', content: 'B', nextNodeIds: [5] }, + ], + }); + const result = humanInteractionHandler.initBranchMap!(node); + + expect(result).not.toBeNull(); + expect(result!.get('hitl-option-o1')).toEqual([3]); + expect(result!.get('hitl-option-o2')).toEqual([5]); + }); + }); + + describe('resetBranchData', () => { + it('should clear every option nextNodeIds', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', content: 'A', nextNodeIds: [3, 5] }], + }); + humanInteractionHandler.resetBranchData!(node); + + expect((node.nodeConfig as any).options[0].nextNodeIds).toEqual([]); + }); + }); + + describe('getBranchKey', () => { + it('should return hitl-option-{uuid} for HitlOption', () => { + const key = humanInteractionHandler.getBranchKey!({ + type: SpecialPortType.HitlOption, + uuid: 'o1', + }); + expect(key).toBe('hitl-option-o1'); + }); + + it('should return undefined for an unrelated type', () => { + const key = humanInteractionHandler.getBranchKey!({ + type: SpecialPortType.Normal, + }); + expect(key).toBeUndefined(); + }); + }); + + describe('mergeBranchData', () => { + it('should write option nextNodeIds back from the branch map', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [ + { uuid: 'o1', content: 'A', nextNodeIds: [] }, + { uuid: 'o2', content: 'B', nextNodeIds: [] }, + ], + }); + const branchMap = new Map(); + branchMap.set('hitl-option-o1', [3, 4]); + branchMap.set('hitl-option-o2', [5]); + + humanInteractionHandler.mergeBranchData!(node, branchMap); + + expect((node.nodeConfig as any).options[0].nextNodeIds).toEqual([3, 4]); + expect((node.nodeConfig as any).options[1].nextNodeIds).toEqual([5]); + }); + }); + + describe('isSpecialBranchNode', () => { + it('should return true in options mode', () => { + const node = createHitlNode({ answerType: HitlAnswerTypeEnum.SELECT }); + expect(humanInteractionHandler.isSpecialBranchNode!(node)).toBe(true); + }); + + it('should return false in text mode', () => { + const node = createHitlNode({ answerType: HitlAnswerTypeEnum.TEXT }); + expect(humanInteractionHandler.isSpecialBranchNode!(node)).toBe(false); + }); + }); + + describe('handleSpecialNextIndex', () => { + it('should add newNodeId to the matched option nextNodeIds', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', content: 'A', nextNodeIds: [] }], + }); + const result = humanInteractionHandler.handleSpecialNextIndex!( + node, + '10-hitl-option-o1-out', + 20, + ); + + expect(result).not.toBeNull(); + expect((result!.nodeConfig as any).options[0].nextNodeIds).toContain(20); + }); + + it('should replace an existing targetNode id with newNodeId', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', content: 'A', nextNodeIds: [3] }], + }); + const targetNode = { id: 3 } as ChildNode; + const result = humanInteractionHandler.handleSpecialNextIndex!( + node, + '10-hitl-option-o1-out', + 20, + targetNode, + ); + + expect((result!.nodeConfig as any).options[0].nextNodeIds).toEqual([20]); + }); + + it('should return null for an unknown port pattern', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [], + }); + const result = humanInteractionHandler.handleSpecialNextIndex!( + node, + '10-unknown', + 20, + ); + + expect(result).toBeNull(); + }); + + it('should not mutate the original node', () => { + const node = createHitlNode({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', content: 'A', nextNodeIds: [] }], + }); + const original = [...(node.nodeConfig as any).options[0].nextNodeIds]; + humanInteractionHandler.handleSpecialNextIndex!( + node, + '10-hitl-option-o1-out', + 20, + ); + + expect((node.nodeConfig as any).options[0].nextNodeIds).toEqual(original); + }); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/middleNodeEdgeCleanup.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/middleNodeEdgeCleanup.test.ts new file mode 100644 index 00000000..e14acdf5 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/middleNodeEdgeCleanup.test.ts @@ -0,0 +1,54 @@ +import type { Edge, Graph } from '@antv/x6'; +import { clearNodeIncidentEdges } from '../middleNodeEdgeCleanup'; + +const makeEdge = (id: string, source: string, target: string): Edge => + ({ + id, + getSourceCellId: () => source, + getTargetCellId: () => target, + getSourcePortId: () => `${source}-out`, + } as unknown as Edge); + +describe('clearNodeIncidentEdges', () => { + it('删除与节点相关的入边和出边', async () => { + const deleted: string[] = []; + const graph = { + getEdges: () => [ + makeEdge('e-in', '9', '3'), + makeEdge('e-out', '3', '5'), + makeEdge('e-other', '1', '2'), + ], + } as unknown as Graph; + + await clearNodeIncidentEdges({ + graph, + nodeId: '3', + deleteEdge: async (edge) => { + deleted.push(String(edge.id)); + }, + }); + + expect(deleted.sort()).toEqual(['e-in', 'e-out']); + }); + + it('支持 excludeEdgeIds', async () => { + const deleted: string[] = []; + const graph = { + getEdges: () => [ + makeEdge('e-keep', '1', '3'), + makeEdge('e-drop', '3', '2'), + ], + } as unknown as Graph; + + await clearNodeIncidentEdges({ + graph, + nodeId: '3', + excludeEdgeIds: ['e-keep'], + deleteEdge: async (edge) => { + deleted.push(String(edge.id)); + }, + }); + + expect(deleted).toEqual(['e-drop']); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/nodeTypeMapping.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/nodeTypeMapping.test.ts new file mode 100644 index 00000000..15007677 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/nodeTypeMapping.test.ts @@ -0,0 +1,278 @@ +/** + * 前端节点类型 ↔ 后端 NodeType 映射单测 + * + * 模型:前端 AgentFlow 节点 = 后端类型 + 自己的扩展,nodeConfig 原样透传,仅翻转 type。 + * RouteDecision ↔ IntentRecognition + * HumanInteraction ↔ QA + * - 出参:前端类型 → 后端类型。 + * - 入参:AgentFlow 下后端类型 → 前端类型(按 flow 上下文,不存标记字段); + * Workflow 下保持后端类型不变。 + */ + +import { NodeTypeEnum } from '@/types/enums/common'; +import { describe, expect, it } from 'vitest'; +import { + isFrontendMappedType, + normalizeLoadedNode, + normalizeLoadedNodes, + serializeNodeForBackend, + toBackendNodeType, + type NormalizeDefaultsOpts, +} from '../nodeTypeMapping'; + +const AGENTFLOW = true; +const WORKFLOW = false; + +describe('nodeTypeMapping', () => { + describe('isFrontendMappedType', () => { + it('RouteDecision / HumanInteraction 需要映射', () => { + expect(isFrontendMappedType(NodeTypeEnum.RouteDecision)).toBe(true); + expect(isFrontendMappedType(NodeTypeEnum.HumanInteraction)).toBe(true); + }); + + it('其它类型不需要映射', () => { + expect(isFrontendMappedType(NodeTypeEnum.Agent)).toBe(false); + expect(isFrontendMappedType(NodeTypeEnum.Workflow)).toBe(false); + expect(isFrontendMappedType(NodeTypeEnum.QA)).toBe(false); + expect(isFrontendMappedType(undefined)).toBe(false); + }); + }); + + describe('toBackendNodeType', () => { + it('RouteDecision → IntentRecognition', () => { + expect(toBackendNodeType(NodeTypeEnum.RouteDecision)).toBe( + NodeTypeEnum.IntentRecognition, + ); + }); + + it('HumanInteraction → QA', () => { + expect(toBackendNodeType(NodeTypeEnum.HumanInteraction)).toBe( + NodeTypeEnum.QA, + ); + }); + + it('其它类型原样返回', () => { + expect(toBackendNodeType(NodeTypeEnum.Agent)).toBe(NodeTypeEnum.Agent); + expect(toBackendNodeType(NodeTypeEnum.Workflow)).toBe( + NodeTypeEnum.Workflow, + ); + }); + + it('null/undefined 返回空串', () => { + expect(toBackendNodeType(undefined)).toBe(''); + expect(toBackendNodeType(null)).toBe(''); + }); + }); + + describe('serializeNodeForBackend (出参)', () => { + it('RouteDecision 节点改写为 IntentRecognition,保留 nodeConfig', () => { + const out = serializeNodeForBackend({ + type: NodeTypeEnum.RouteDecision, + nodeConfig: { intentConfigs: [], extraPrompt: 'sys' }, + }); + expect(out.type).toBe(NodeTypeEnum.IntentRecognition); + expect(out.nodeConfig.extraPrompt).toBe('sys'); + }); + + it('HumanInteraction 节点改写为 QA,保留 nodeConfig', () => { + const out = serializeNodeForBackend({ + type: NodeTypeEnum.HumanInteraction, + nodeConfig: { hitlMode: 'ask', askConfig: { question: 'q' } }, + }); + expect(out.type).toBe(NodeTypeEnum.QA); + expect(out.nodeConfig.hitlMode).toBe('ask'); + expect(out.nodeConfig.askConfig.question).toBe('q'); + }); + + it('非映射节点不变(保持引用相等)', () => { + const node = { type: NodeTypeEnum.Agent, nodeConfig: {} }; + expect(serializeNodeForBackend(node)).toBe(node); + }); + }); + + describe('normalizeLoadedNode (入参·按 flow 上下文)', () => { + it('AgentFlow:IntentRecognition → RouteDecision', () => { + const node = { type: NodeTypeEnum.IntentRecognition, nodeConfig: {} }; + expect(normalizeLoadedNode(node, AGENTFLOW).type).toBe( + NodeTypeEnum.RouteDecision, + ); + }); + + it('AgentFlow:QA → HumanInteraction', () => { + const node = { + type: NodeTypeEnum.QA, + nodeConfig: { hitlMode: 'ask', askConfig: { question: 'q' } }, + }; + const out = normalizeLoadedNode(node, AGENTFLOW); + expect(out.type).toBe(NodeTypeEnum.HumanInteraction); + // nodeConfig 原样保留 + expect(out.nodeConfig.hitlMode).toBe('ask'); + }); + + it('Workflow:IntentRecognition / QA 保持不变', () => { + const intent = { + type: NodeTypeEnum.IntentRecognition, + nodeConfig: { intentConfigs: [{ intent: 'x', uuid: 'u' }] }, + }; + expect(normalizeLoadedNode(intent, WORKFLOW).type).toBe( + NodeTypeEnum.IntentRecognition, + ); + const qa = { + type: NodeTypeEnum.QA, + nodeConfig: { answerType: 'SELECT' }, + }; + expect(normalizeLoadedNode(qa, WORKFLOW).type).toBe(NodeTypeEnum.QA); + }); + + it('非映射后端类型在任何 flow 下都不变', () => { + const agent = { type: NodeTypeEnum.Agent, nodeConfig: {} }; + expect(normalizeLoadedNode(agent, AGENTFLOW).type).toBe( + NodeTypeEnum.Agent, + ); + const start = { type: NodeTypeEnum.Start, nodeConfig: {} }; + expect(normalizeLoadedNode(start, AGENTFLOW).type).toBe( + NodeTypeEnum.Start, + ); + }); + }); + + describe('normalizeLoadedNodes (批量)', () => { + it('AgentFlow:IntentRecognition/QA 分别还原为 RouteDecision/HumanInteraction', () => { + const out = normalizeLoadedNodes( + [ + { type: NodeTypeEnum.Start, nodeConfig: {} }, + { type: NodeTypeEnum.IntentRecognition, nodeConfig: {} }, + { type: NodeTypeEnum.QA, nodeConfig: {} }, + { type: NodeTypeEnum.Agent, nodeConfig: {} }, + ], + AGENTFLOW, + ); + expect(out.map((n) => n.type)).toEqual([ + NodeTypeEnum.Start, + NodeTypeEnum.RouteDecision, + NodeTypeEnum.HumanInteraction, + NodeTypeEnum.Agent, + ]); + }); + + it('Workflow:IntentRecognition / QA 全部保持', () => { + const out = normalizeLoadedNodes( + [ + { type: NodeTypeEnum.IntentRecognition, nodeConfig: {} }, + { type: NodeTypeEnum.QA, nodeConfig: {} }, + ], + WORKFLOW, + ); + expect(out.map((n) => n.type)).toEqual([ + NodeTypeEnum.IntentRecognition, + NodeTypeEnum.QA, + ]); + }); + + it('null/undefined 入参返回空数组', () => { + expect(normalizeLoadedNodes(null, AGENTFLOW)).toEqual([]); + expect(normalizeLoadedNodes(undefined, WORKFLOW)).toEqual([]); + }); + }); + + describe('遗留默认文案修正 (defaults)', () => { + const defaults: NormalizeDefaultsOpts = { + [NodeTypeEnum.IntentRecognition]: { + frontendDefaultName: '路由决策', + frontendDefaultDescription: 'AI 决策走哪条分支', + backendDefaultName: '意图识别', + backendDefaultDescription: '意图识别描述', + }, + [NodeTypeEnum.QA]: { + frontendDefaultName: '询问用户', + frontendDefaultDescription: '向用户提问并获取回复', + backendDefaultName: '问答', + backendDefaultDescription: '问答描述', + }, + }; + + it('AgentFlow:意图识别默认文案 → 路由决策默认文案', () => { + const node = { + type: NodeTypeEnum.IntentRecognition, + name: '意图识别', + description: '意图识别描述', + }; + const out = normalizeLoadedNode(node, AGENTFLOW, defaults); + expect(out.type).toBe(NodeTypeEnum.RouteDecision); + expect(out.name).toBe('路由决策'); + expect(out.description).toBe('AI 决策走哪条分支'); + }); + + it('AgentFlow:问答默认文案 → 询问用户默认文案', () => { + const node = { + type: NodeTypeEnum.QA, + name: '问答', + description: '问答描述', + }; + const out = normalizeLoadedNode(node, AGENTFLOW, defaults); + expect(out.type).toBe(NodeTypeEnum.HumanInteraction); + expect(out.name).toBe('询问用户'); + expect(out.description).toBe('向用户提问并获取回复'); + }); + + it('AgentFlow:空名 → 前端默认名', () => { + const intent = { type: NodeTypeEnum.IntentRecognition, name: '' }; + expect(normalizeLoadedNode(intent, AGENTFLOW, defaults).name).toBe( + '路由决策', + ); + const qa = { type: NodeTypeEnum.QA, name: '' }; + expect(normalizeLoadedNode(qa, AGENTFLOW, defaults).name).toBe( + '询问用户', + ); + }); + + it('AgentFlow:用户自定义名/描述不被覆盖', () => { + const node = { + type: NodeTypeEnum.QA, + name: '我的提问', + description: '自定义说明', + }; + const out = normalizeLoadedNode(node, AGENTFLOW, defaults); + expect(out.name).toBe('我的提问'); + expect(out.description).toBe('自定义说明'); + }); + + it('不传 defaults 时只改 type,不动文案', () => { + const node = { + type: NodeTypeEnum.QA, + name: '问答', + description: '问答描述', + }; + const out = normalizeLoadedNode(node, AGENTFLOW); + expect(out.type).toBe(NodeTypeEnum.HumanInteraction); + expect(out.name).toBe('问答'); + expect(out.description).toBe('问答描述'); + }); + }); + + describe('往返一致性', () => { + it('RouteDecision: 出参序列化 → AgentFlow 入参还原 → 类型一致', () => { + const local = { + type: NodeTypeEnum.RouteDecision, + nodeConfig: { extraPrompt: 'sys', intentConfigs: [] }, + }; + const backend = serializeNodeForBackend(local); // 保存到后端 + expect(backend.type).toBe(NodeTypeEnum.IntentRecognition); + const reloaded = normalizeLoadedNode(backend, AGENTFLOW); // AgentFlow 加载 + expect(reloaded.type).toBe(NodeTypeEnum.RouteDecision); + expect(reloaded.nodeConfig.extraPrompt).toBe('sys'); + }); + + it('HumanInteraction: 出参序列化 → AgentFlow 入参还原 → 类型一致', () => { + const local = { + type: NodeTypeEnum.HumanInteraction, + nodeConfig: { hitlMode: 'ask', askConfig: { question: '订单号?' } }, + }; + const backend = serializeNodeForBackend(local); // 保存到后端 + expect(backend.type).toBe(NodeTypeEnum.QA); + const reloaded = normalizeLoadedNode(backend, AGENTFLOW); // AgentFlow 加载 + expect(reloaded.type).toBe(NodeTypeEnum.HumanInteraction); + expect(reloaded.nodeConfig.askConfig.question).toBe('订单号?'); + }); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/qaConfigAdapter.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/qaConfigAdapter.test.ts new file mode 100644 index 00000000..cd4759d6 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/qaConfigAdapter.test.ts @@ -0,0 +1,260 @@ +/** + * qaConfigAdapter 单元测试 + * + * answerType 为权威字段(TEXT / SELECT / FORM) + */ +import { NodeTypeEnum } from '@/types/enums/common'; +import { describe, expect, it } from 'vitest'; +import { + coerceFormArgInputType, + getHitlOptions, + isFormArgChoiceInputType, + isHitlOptionsBranchMode, + normalizeHitlNodeConfig, + prepareNodeForBackendSerialize, + serializeHitlNodeConfig, +} from '../adapters/qaConfigAdapter'; +import { FormArgInputTypeEnum } from '../enums/formArgInputType'; +import { HitlAnswerTypeEnum } from '../enums/hitlAnswerType'; + +describe('qaConfigAdapter', () => { + it('normalize: 补全 answerType 与 formArgs 默认值', () => { + const out = normalizeHitlNodeConfig({ + question: '你好?', + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', content: 'A', nextNodeIds: [] }], + }); + expect(out.question).toBe('你好?'); + expect(out.answerType).toBe(HitlAnswerTypeEnum.SELECT); + expect(out.options).toHaveLength(1); + expect(out.inputArgs).toEqual([]); + expect(out.formArgs).toEqual([]); + }); + + it('normalize: formArgs 非空推断为 FORM', () => { + expect( + normalizeHitlNodeConfig({ + formArgs: [{ name: 'x', inputType: FormArgInputTypeEnum.Text }], + }).answerType, + ).toBe(HitlAnswerTypeEnum.FORM); + }); + + it('normalize: contextWriteKey 迁移到 outputArgs[0].name', () => { + const out = normalizeHitlNodeConfig({ + contextWriteKey: 'user_reply', + outputArgs: [ + { + key: 'answer', + name: 'answer', + dataType: 'String', + }, + ], + }); + expect(out.contextWriteKey).toBeUndefined(); + expect(out.outputArgs[0].name).toBe('user_reply'); + expect(out.outputArgs[0].key).toBe('user_reply'); + }); + + it('serialize: 保留 answerType', () => { + const out = serializeHitlNodeConfig({ + question: 'q', + answerType: HitlAnswerTypeEnum.TEXT, + contextWriteKey: 'legacy', + }); + expect(out.answerType).toBe(HitlAnswerTypeEnum.TEXT); + expect(out.contextWriteKey).toBeUndefined(); + }); + + it('serialize: SELECT 保留 options 连线,FORM/TEXT 清空', () => { + const sel = serializeHitlNodeConfig({ + answerType: HitlAnswerTypeEnum.SELECT, + options: [{ uuid: 'o1', nextNodeIds: [5] }], + }); + expect(sel.options[0].nextNodeIds).toEqual([5]); + + const form = serializeHitlNodeConfig({ + answerType: HitlAnswerTypeEnum.FORM, + options: [{ uuid: 'o1', nextNodeIds: [5] }], + }); + expect(form.options[0].nextNodeIds).toEqual([]); + }); + + it('getHitlOptions 读取扁平 options', () => { + expect( + getHitlOptions({ + options: [{ content: 'flat' }], + }), + ).toEqual([{ content: 'flat' }]); + }); + + it('serialize: 选择类 selectConfig 归一化为 {label,value}[],其余清空', () => { + const out = serializeHitlNodeConfig({ + answerType: HitlAnswerTypeEnum.FORM, + formArgs: [ + { + name: '类型', + inputType: FormArgInputTypeEnum.Select, + require: true, + selectConfig: { dataSourceType: 'MANUAL', options: '退货\n换货' }, + }, + { + name: '说明', + inputType: FormArgInputTypeEnum.Text, + require: false, + selectConfig: { dataSourceType: 'MANUAL', options: '脏数据' }, + }, + ], + }); + expect(out.formArgs[0].selectConfig.options).toEqual([ + { label: '退货', value: '退货' }, + { label: '换货', value: '换货' }, + ]); + expect(out.formArgs[1].selectConfig).toBeNull(); + }); + + it('normalize: formArgs 补全 inputType 与 selectConfig 编辑态', () => { + const out = normalizeHitlNodeConfig({ + answerType: HitlAnswerTypeEnum.FORM, + formArgs: [ + { + name: '类型', + inputType: FormArgInputTypeEnum.MultipleSelect, + require: true, + selectConfig: { options: 'A\nB' }, + }, + ], + }); + expect(out.formArgs[0]).toMatchObject({ + name: '类型', + inputType: FormArgInputTypeEnum.MultipleSelect, + require: true, + }); + expect(out.formArgs[0].selectConfig.options).toBe('A\nB'); + }); + + it('coerceFormArgInputType: 非法值兜底 Text', () => { + expect(coerceFormArgInputType('radio')).toBe(FormArgInputTypeEnum.Text); + expect(coerceFormArgInputType(FormArgInputTypeEnum.Radio)).toBe( + FormArgInputTypeEnum.Radio, + ); + }); + + it('isFormArgChoiceInputType: Select/Radio/MultipleSelect 为真', () => { + expect(isFormArgChoiceInputType(FormArgInputTypeEnum.Radio)).toBe(true); + expect(isFormArgChoiceInputType(FormArgInputTypeEnum.Text)).toBe(false); + }); + + it('isHitlOptionsBranchMode 仅 SELECT 为真', () => { + expect( + isHitlOptionsBranchMode({ answerType: HitlAnswerTypeEnum.SELECT }), + ).toBe(true); + expect( + isHitlOptionsBranchMode({ answerType: HitlAnswerTypeEnum.FORM }), + ).toBe(false); + expect( + isHitlOptionsBranchMode({ answerType: HitlAnswerTypeEnum.TEXT }), + ).toBe(false); + }); + + it('prepareNodeForBackendSerialize: 剥离后端解析的完整 modelConfig(仅保留顶层 modelId)', () => { + const llm = prepareNodeForBackendSerialize({ + id: 1, + type: NodeTypeEnum.LLM, + nodeConfig: { + modelId: 5, + modelConfig: { + id: 5, + name: 'deepseek', + creator: { userId: 1 }, + tenantId: 1, + }, + temperature: 0.7, + }, + }); + expect(llm.nodeConfig.modelConfig).toBeUndefined(); + expect(llm.nodeConfig.modelId).toBe(5); + expect(llm.nodeConfig.temperature).toBe(0.7); + }); + + it('prepareNodeForBackendSerialize: HumanInteraction 同时扁平化并剥离 modelConfig', () => { + const out = prepareNodeForBackendSerialize({ + id: 2, + type: NodeTypeEnum.HumanInteraction, + nodeConfig: { + answerType: HitlAnswerTypeEnum.FORM, + modelId: 5, + modelConfig: { id: 5, name: 'deepseek', creator: {} }, + formArgs: [ + { + name: '类型', + inputType: FormArgInputTypeEnum.Select, + selectConfig: { options: 'a\nb' }, + }, + ], + }, + }); + expect(out.nodeConfig.modelConfig).toBeUndefined(); + expect(out.nodeConfig.modelId).toBe(5); + expect(out.nodeConfig.formArgs[0].selectConfig.options).toEqual([ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + ]); + }); + + it('prepareNodeForBackendSerialize: 节点顶层不重复 nodeConfig 字段(配置只在 nodeConfig)', () => { + const out = prepareNodeForBackendSerialize({ + id: 1, + name: '路由决策', + type: NodeTypeEnum.IntentRecognition, + nextNodeIds: [2], + nodeConfig: { + modelId: 5, + intentConfigs: [{ uuid: 'r1', intent: '退货' }], + inputArgs: [], + outputArgs: [], + }, + intentConfigs: [{ uuid: 'r1', intent: '退货' }], + inputArgs: [], + modelId: 5, + modelConfig: { id: 5, name: 'deepseek' }, + } as any); + expect(out.id).toBe(1); + expect(out.name).toBe('路由决策'); + expect(out.nextNodeIds).toEqual([2]); + expect((out as any).intentConfigs).toBeUndefined(); + expect((out as any).inputArgs).toBeUndefined(); + expect((out as any).modelId).toBeUndefined(); + expect((out as any).modelConfig).toBeUndefined(); + expect(out.nodeConfig.intentConfigs).toHaveLength(1); + expect(out.nodeConfig.modelId).toBe(5); + expect(out.nodeConfig.modelConfig).toBeUndefined(); + }); + + it('prepareNodeForBackendSerialize: KnowledgeInsert 顶层 name/description 与 nodeConfig 绑定字段并存', () => { + const out = prepareNodeForBackendSerialize({ + id: 10, + name: '知识库写入节点', + description: '写入产品文档', + type: NodeTypeEnum.KnowledgeInsert, + nextNodeIds: [11], + nodeConfig: { + knowledgeBaseId: 99, + name: '产品知识库', + description: '产品相关文档', + icon: 'https://example.com/icon.png', + inputArgs: [], + outputArgs: [], + }, + knowledgeBaseId: 99, + inputArgs: [], + } as any); + expect(out.name).toBe('知识库写入节点'); + expect(out.description).toBe('写入产品文档'); + expect(out.nextNodeIds).toEqual([11]); + expect((out as any).knowledgeBaseId).toBeUndefined(); + expect((out as any).inputArgs).toBeUndefined(); + expect(out.nodeConfig.knowledgeBaseId).toBe(99); + expect(out.nodeConfig.name).toBe('产品知识库'); + expect(out.nodeConfig.description).toBe('产品相关文档'); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/register.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/register.test.ts new file mode 100644 index 00000000..b4ec6882 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/register.test.ts @@ -0,0 +1,42 @@ +/** + * registerAgentFlowHandlers 注册入口单元测试 + */ +import { NodeTypeEnum } from '@/types/enums/common'; +import { describe, expect, it } from 'vitest'; +import { extensionRegistry } from '../../extensions/registry'; +import { registerAgentFlowHandlers } from '../register'; + +describe('registerAgentFlowHandlers', () => { + it('should register the HumanInteraction and RouteDecision handlers in the extension registry', () => { + registerAgentFlowHandlers(); + + const hitlHandler = extensionRegistry.get(NodeTypeEnum.HumanInteraction); + const routeHandler = extensionRegistry.get(NodeTypeEnum.RouteDecision); + + expect(hitlHandler).toBeDefined(); + expect(hitlHandler!.nodeType).toBe(NodeTypeEnum.HumanInteraction); + expect(routeHandler).toBeDefined(); + expect(routeHandler!.nodeType).toBe(NodeTypeEnum.RouteDecision); + }); + + it('should be idempotent — calling twice does not change registry', () => { + const hitlBefore = extensionRegistry.get(NodeTypeEnum.HumanInteraction); + const routeBefore = extensionRegistry.get(NodeTypeEnum.RouteDecision); + + registerAgentFlowHandlers(); + + const hitlAfter = extensionRegistry.get(NodeTypeEnum.HumanInteraction); + const routeAfter = extensionRegistry.get(NodeTypeEnum.RouteDecision); + + expect(hitlAfter).toBe(hitlBefore); + expect(routeAfter).toBe(routeBefore); + }); + + it('should not register handlers for non-AgentFlow node types', () => { + registerAgentFlowHandlers(); + + expect(extensionRegistry.get(NodeTypeEnum.Start)).toBeUndefined(); + expect(extensionRegistry.get(NodeTypeEnum.LLM)).toBeUndefined(); + expect(extensionRegistry.get(NodeTypeEnum.Condition)).toBeUndefined(); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/resolveNodePresentation.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/resolveNodePresentation.test.ts new file mode 100644 index 00000000..3010c620 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/resolveNodePresentation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveAgentFlowWorkflowNodeDescription, + resolveNodeDescriptionWithNameFallback, +} from '../resolveNodePresentation'; + +describe('resolveNodeDescriptionWithNameFallback', () => { + it('描述为空时回退到名称', () => { + expect(resolveNodeDescriptionWithNameFallback('产品知识库', '')).toBe( + '产品知识库', + ); + }); + + it('名称与描述均为空时返回空字符串', () => { + expect(resolveNodeDescriptionWithNameFallback('', '')).toBe(''); + }); +}); + +describe('resolveAgentFlowWorkflowNodeDescription', () => { + it('优先使用非空描述', () => { + expect( + resolveAgentFlowWorkflowNodeDescription('工作流A', ' 查询商品 '), + ).toBe('查询商品'); + }); + + it('描述为空时回退到节点名称', () => { + expect(resolveAgentFlowWorkflowNodeDescription('测试工作流专用', '')).toBe( + '测试工作流专用', + ); + }); + + it('名称与描述均为空时返回默认文案', () => { + expect(resolveAgentFlowWorkflowNodeDescription('', '')).toContain('工作流'); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/routeConditionAdapter.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/routeConditionAdapter.test.ts new file mode 100644 index 00000000..b2fc3782 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/routeConditionAdapter.test.ts @@ -0,0 +1,74 @@ +/** + * routeConditionAdapter 单元测试 + * + * RouteDecision 用结构化 conditionArgs(多条件 AND/OR);末尾固定一条 + * 「其他意图」兜底分支(intentType:OTHER),用户分支 intentType:NORMAL。 + */ + +import { describe, expect, it } from 'vitest'; +import { + createEmptyConditionArg, + createOtherIntentBranch, + hydrateIntentConfigs, +} from '../adapters/routeConditionAdapter'; + +describe('routeConditionAdapter', () => { + it('createEmptyConditionArg: 左值=变量、右值=字面值', () => { + const arg = createEmptyConditionArg(); + expect(arg.compareType).toBe('EQUAL'); + expect(arg.firstArg?.bindValueType).toBe('Reference'); + expect(arg.secondArg?.bindValueType).toBe('Input'); + }); + + it('createOtherIntentBranch: intentType=OTHER、name=其他意图、无条件', () => { + const b = createOtherIntentBranch(); + expect(b.intentType).toBe('OTHER'); + expect(b.name).toBe('其他意图'); + expect(b.conditionArgs).toEqual([]); + }); + + it('hydrateIntentConfigs: 旧字段对齐 intent→name / description→intent,用户分支标 NORMAL,末尾补 OTHER', () => { + const out = hydrateIntentConfigs([ + { + uuid: 'r-1', + intent: '退货', + description: '退货描述', + condition: '{{x}} == 1', // 废弃,应去除 + expression: null, // 废弃 + conditionArgs: [{ compareType: 'EQUAL' }], + nextNodeIds: [2], + }, + ]); + expect(out).toHaveLength(2); // 用户分支 + 末尾兜底 + expect(out[0]).toMatchObject({ + uuid: 'r-1', + name: '退货', + intent: '退货描述', + intentType: 'NORMAL', + nextNodeIds: [2], + }); + expect(out[0].condition).toBeUndefined(); + expect(out[0].expression).toBeUndefined(); + expect(out[0].conditionArgs).toHaveLength(1); + expect(out[1].intentType).toBe('OTHER'); + expect(out[1].name).toBe('其他意图'); + }); + + it('hydrateIntentConfigs: OTHER 兜底分支挪到末尾,仅保留一条', () => { + const out = hydrateIntentConfigs([ + { uuid: 'a', intentType: 'OTHER', name: '兜底', nextNodeIds: [9] }, + { uuid: 'b', name: '用户分支', conditionArgs: [] }, + ]); + expect(out).toHaveLength(2); + expect(out[0].uuid).toBe('b'); + expect(out[0].intentType).toBe('NORMAL'); + expect(out[1].uuid).toBe('a'); + expect(out[1].intentType).toBe('OTHER'); + }); + + it('hydrateIntentConfigs: 无数据时返回单条 OTHER 兜底分支', () => { + const out = hydrateIntentConfigs([]); + expect(out).toHaveLength(1); + expect(out[0].intentType).toBe('OTHER'); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/routeDecision.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/routeDecision.test.ts new file mode 100644 index 00000000..c7931da3 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/routeDecision.test.ts @@ -0,0 +1,360 @@ +/** + * RouteDecision 分支处理器单元测试 + * + * 覆盖与 evalGate 镜像: + * - generatePorts:default + N routes;空 routes 只有 default + * - parseSourcePort:default、route uuid 提取、防 `-default-` 子串误判 + * - updateConnection:add/remove、未知 uuid 返回 false + * - cleanupNodeReferences:从 defaultNextNodeIds + routes[i].nextNodeIds 清掉 + * - initBranchMap / mergeBranchData / handleSpecialNextIndex + * - isSpecialBranchNode + */ + +import { NodeTypeEnum } from '@/types/enums/common'; +import type { ChildNode } from '@/types/interfaces/graph'; +import type { PortConfig } from '@/types/interfaces/node'; +import { describe, expect, it } from 'vitest'; +import { SpecialPortType } from '../../types/enums'; +import { routeDecisionHandler } from '../handlers/routeDecision'; + +const createRouteNode = (overrides: Partial = {}): ChildNode => ({ + id: 50, + type: NodeTypeEnum.RouteDecision, + name: 'RouteDecision', + description: '', + workflowId: 1, + shape: 'custom-react' as any, + icon: '', + nextNodeIds: [], + nodeConfig: { + defaultNextNodeIds: [], + intentConfigs: [ + { + uuid: 'r1-uuid', + intent: '简单问题', + description: '简单问题', + nextNodeIds: [], + }, + { + uuid: 'r2-uuid', + intent: '复杂问题', + description: '复杂问题', + nextNodeIds: [], + }, + ], + }, + ...overrides, +}); + +const mockGeneratePortConfig = (config: PortConfig) => ({ + id: `test-${config.idSuffix}`, + group: config.group, + color: config.color, + zIndex: 99, + magnet: true, + args: { + x: config.xWidth ?? 200, + y: config.yHeight ?? 16, + offsetY: config.offsetY ?? 32, + offsetX: config.offsetX ?? (config.idSuffix === 'in' ? 0 : 200), + }, +}); + +const ctx = { generatePortConfig: mockGeneratePortConfig }; + +describe('RouteDecision Handler', () => { + describe('generatePorts', () => { + it('should generate default + N route ports', () => { + const node = createRouteNode(); + // 默认兜底端口仅在 defaultNextNodeIds 非空时生成 + (node.nodeConfig as any).defaultNextNodeIds = [999]; + const result = routeDecisionHandler.generatePorts!(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.inputPorts).toHaveLength(1); + // 1 default + 2 routes = 3 + expect(result!.outputPorts).toHaveLength(3); + expect(result!.outputPorts[0].id).toContain('route-default-out'); + expect(result!.outputPorts[1].id).toContain('route-r1-uuid-out'); + expect(result!.outputPorts[2].id).toContain('route-r2-uuid-out'); + }); + + it('should NOT generate default port when defaultNextNodeIds is empty', () => { + // createRouteNode 默认 defaultNextNodeIds 为空 → 只渲染 N 个路由端口 + const node = createRouteNode(); + const result = routeDecisionHandler.generatePorts!(node, ctx); + expect(result!.outputPorts).toHaveLength(2); + expect(result!.outputPorts[0].id).toContain('route-r1-uuid-out'); + expect(result!.outputPorts[1].id).toContain('route-r2-uuid-out'); + }); + + it('should generate only default port when routes is empty', () => { + const node = createRouteNode({ + nodeConfig: { defaultNextNodeIds: [999], intentConfigs: [] } as any, + }); + const result = routeDecisionHandler.generatePorts!(node, ctx); + expect(result!.outputPorts).toHaveLength(1); + expect(result!.outputPorts[0].id).toContain('route-default-out'); + }); + + it('should produce no output ports when default and routes are both empty', () => { + const node = createRouteNode({ + nodeConfig: { defaultNextNodeIds: [], intentConfigs: [] } as any, + }); + const result = routeDecisionHandler.generatePorts!(node, ctx); + expect(result!.outputPorts).toHaveLength(0); + }); + + it('should use fallback uuid `r{index}` when route has no uuid', () => { + // 无默认端口时,路由端口从下标 0 开始:r0、r1 + const node = createRouteNode({ + nodeConfig: { + defaultNextNodeIds: [], + intentConfigs: [{ intent: 'a' }, { intent: 'b' }], + } as any, + }); + const result = routeDecisionHandler.generatePorts!(node, ctx); + expect(result!.outputPorts[0].id).toContain('route-r0-out'); + expect(result!.outputPorts[1].id).toContain('route-r1-out'); + }); + + it('should set ROUTE_DEFAULT_PORT_COLOR for default port', () => { + const node = createRouteNode(); + (node.nodeConfig as any).defaultNextNodeIds = [999]; + const result = routeDecisionHandler.generatePorts!(node, ctx); + expect(result!.outputPorts[0].color).toBe('#bfbfbf'); + }); + }); + + describe('parseSourcePort', () => { + it('should detect default port', () => { + const node = createRouteNode(); + const result = routeDecisionHandler.parseSourcePort!( + node, + '50-route-default-out', + ); + expect(result).toEqual({ type: SpecialPortType.RouteDecisionDefault }); + }); + + it('should extract route uuid', () => { + const node = createRouteNode(); + const result = routeDecisionHandler.parseSourcePort!( + node, + '50-route-r1-uuid-out', + ); + expect(result).toEqual({ + type: SpecialPortType.RouteDecisionRoute, + uuid: 'r1-uuid', + }); + }); + + /** + * 已知行为:parseSourcePort 用 `includes('-route-default-')` 防御性 + * 排除(避免 `route-default-fallback-out` 这类 uuid 被误判为 Default)。 + * 副作用:此类 uuid 暂时返回 null,调用方按"未知端口"处理。 + */ + it('should return null for route uuid that contains "default" substring (defensive check)', () => { + const node = createRouteNode(); + const result = routeDecisionHandler.parseSourcePort!( + node, + '50-route-default-fallback-out', + ); + expect(result).toBeNull(); + }); + + it('should return null for non-matching port', () => { + const node = createRouteNode(); + const result = routeDecisionHandler.parseSourcePort!(node, '50-out'); + expect(result).toBeNull(); + }); + }); + + describe('updateConnection', () => { + it('should add/remove to defaultNextNodeIds', () => { + const node = createRouteNode(); + const ok1 = routeDecisionHandler.updateConnection!( + node, + { type: SpecialPortType.RouteDecisionDefault }, + 100, + 'add', + ); + expect(ok1).toBe(true); + expect((node.nodeConfig as any).defaultNextNodeIds).toEqual([100]); + + routeDecisionHandler.updateConnection!( + node, + { type: SpecialPortType.RouteDecisionDefault }, + 100, + 'remove', + ); + expect((node.nodeConfig as any).defaultNextNodeIds).toEqual([]); + }); + + it('should add/remove to a specific route.nextNodeIds', () => { + const node = createRouteNode(); + const ok1 = routeDecisionHandler.updateConnection!( + node, + { type: SpecialPortType.RouteDecisionRoute, uuid: 'r1-uuid' }, + 200, + 'add', + ); + expect(ok1).toBe(true); + expect((node.nodeConfig as any).intentConfigs[0].nextNodeIds).toEqual([ + 200, + ]); + + routeDecisionHandler.updateConnection!( + node, + { type: SpecialPortType.RouteDecisionRoute, uuid: 'r1-uuid' }, + 200, + 'remove', + ); + expect((node.nodeConfig as any).intentConfigs[0].nextNodeIds).toEqual([]); + // 不影响其他 route + expect((node.nodeConfig as any).intentConfigs[1].nextNodeIds).toEqual([]); + }); + + it('should return false for unknown route uuid', () => { + const node = createRouteNode(); + const ok = routeDecisionHandler.updateConnection!( + node, + { type: SpecialPortType.RouteDecisionRoute, uuid: 'unknown' }, + 1, + 'add', + ); + expect(ok).toBe(false); + }); + + it('should return false for unknown portInfo type', () => { + const node = createRouteNode(); + const ok = routeDecisionHandler.updateConnection!( + node, + { type: 'SomethingElse' as any }, + 1, + 'add', + ); + expect(ok).toBe(false); + }); + }); + + describe('cleanupNodeReferences', () => { + it('should remove deleted id from default + each route', () => { + const node = createRouteNode(); + (node.nodeConfig as any).defaultNextNodeIds = [10, 11]; + (node.nodeConfig as any).intentConfigs[0].nextNodeIds = [10, 12]; + (node.nodeConfig as any).intentConfigs[1].nextNodeIds = [10]; + + routeDecisionHandler.cleanupNodeReferences!(node, 10); + + expect((node.nodeConfig as any).defaultNextNodeIds).toEqual([11]); + expect((node.nodeConfig as any).intentConfigs[0].nextNodeIds).toEqual([ + 12, + ]); + expect((node.nodeConfig as any).intentConfigs[1].nextNodeIds).toEqual([]); + }); + }); + + describe('initBranchMap', () => { + it('should produce default + each route as empty array', () => { + const node = createRouteNode(); + const map = routeDecisionHandler.initBranchMap!(node); + expect(map).not.toBeNull(); + expect(Array.from(map!.keys()).sort()).toEqual([ + 'route-default', + 'route-r1-uuid', + 'route-r2-uuid', + ]); + for (const arr of map!.values()) { + expect(arr).toEqual([]); + } + }); + }); + + describe('mergeBranchData', () => { + it('should write defaultNextNodeIds and each route.nextNodeIds', () => { + const node = createRouteNode(); + const map = new Map(); + map.set('route-default', [5, 6]); + map.set('route-r1-uuid', [10]); + map.set('route-r2-uuid', []); + + routeDecisionHandler.mergeBranchData!(node, map); + + expect((node.nodeConfig as any).defaultNextNodeIds).toEqual([5, 6]); + expect((node.nodeConfig as any).intentConfigs[0].nextNodeIds).toEqual([ + 10, + ]); + expect((node.nodeConfig as any).intentConfigs[1].nextNodeIds).toEqual([]); + }); + }); + + describe('getBranchKey', () => { + it('default type returns route-default', () => { + expect( + routeDecisionHandler.getBranchKey!({ + type: SpecialPortType.RouteDecisionDefault, + }), + ).toBe('route-default'); + }); + + it('route type returns route-{uuid}', () => { + expect( + routeDecisionHandler.getBranchKey!({ + type: SpecialPortType.RouteDecisionRoute, + uuid: 'r1-uuid', + }), + ).toBe('route-r1-uuid'); + }); + + it('returns undefined for unknown type', () => { + expect( + routeDecisionHandler.getBranchKey!({ type: 'X' as any }), + ).toBeUndefined(); + }); + }); + + describe('isSpecialBranchNode', () => { + it('should always return true', () => { + expect(routeDecisionHandler.isSpecialBranchNode!(createRouteNode())).toBe( + true, + ); + }); + }); + + describe('handleSpecialNextIndex', () => { + it('should add to defaultNextNodeIds when port is route-default', () => { + const node = createRouteNode(); + const result = routeDecisionHandler.handleSpecialNextIndex!( + node, + '50-route-default-out', + 88, + ); + expect((result!.nodeConfig as any).defaultNextNodeIds).toEqual([88]); + }); + + it('should add to specific route nextNodeIds', () => { + const node = createRouteNode(); + const result = routeDecisionHandler.handleSpecialNextIndex!( + node, + '50-route-r2-uuid-out', + 99, + ); + expect((result!.nodeConfig as any).intentConfigs[1].nextNodeIds).toEqual([ + 99, + ]); + }); + + it('should return node unchanged for unknown port (no special branch match)', () => { + const node = createRouteNode(); + const before = JSON.stringify(node.nodeConfig); + const result = routeDecisionHandler.handleSpecialNextIndex!( + node, + '50-unknown-out', + 1, + ); + // 当前实现:未命中 default / route- 分支时返回原 node(无变更) + expect(result).not.toBeNull(); + expect(JSON.stringify(result!.nodeConfig)).toBe(before); + }); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/startInsertHandlers.test.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/startInsertHandlers.test.ts new file mode 100644 index 00000000..d1ace97c --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/__tests__/startInsertHandlers.test.ts @@ -0,0 +1,127 @@ +import { FlowKindEnum, NodeTypeEnum } from '@/types/enums/common'; +import type { ChildNode } from '@/types/interfaces/graph'; +import type { Cell, Edge, Graph } from '@antv/x6'; +import { + handleAgentFlowStartDragInsert, + resolveStartPortQuickAddRedirect, + shouldRejectStartSecondDrag, +} from '../startInsertHandlers'; + +const tailNode: ChildNode = { + id: 2, + type: NodeTypeEnum.Agent, + name: 'A', +} as ChildNode; + +const makeEdge = (id: string, sourceCellId: string): Edge => + ({ + id, + remove: vi.fn(), + getSource: () => ({ cell: sourceCellId, port: `${sourceCellId}-out` }), + getSourcePortId: () => `${sourceCellId}-out`, + getTargetCell: () => ({ getData: () => tailNode }), + } as unknown as Edge); + +const makeGraph = (edges: Edge[]): Graph => + ({ getEdges: () => edges } as unknown as Graph); + +const makeCell = (id: string, type: NodeTypeEnum): Cell => + ({ + id, + getData: () => ({ id: Number(id), type }), + } as unknown as Cell); + +describe('startInsertHandlers', () => { + describe('resolveStartPortQuickAddRedirect', () => { + it('Workflow 下不介入', () => { + const graph = makeGraph([makeEdge('e1', '1')]); + expect( + resolveStartPortQuickAddRedirect({ + graph, + flowKind: FlowKindEnum.Workflow, + sourceNode: { id: 1, type: NodeTypeEnum.Start } as ChildNode, + }), + ).toEqual({ kind: 'skip' }); + }); + + it('AgentFlow 已连出时返回 redirect', () => { + const graph = makeGraph([makeEdge('e1', '1')]); + expect( + resolveStartPortQuickAddRedirect({ + graph, + flowKind: FlowKindEnum.AgentFlow, + sourceNode: { id: 1, type: NodeTypeEnum.Start } as ChildNode, + }), + ).toEqual({ + kind: 'redirect', + sourcePort: '1-out', + tailNode, + edgeId: 'e1', + }); + }); + }); + + describe('shouldRejectStartSecondDrag', () => { + it('拖向原后继时拒绝', () => { + const graph = makeGraph([makeEdge('e1', '1')]); + expect( + shouldRejectStartSecondDrag({ + graph, + flowKind: FlowKindEnum.AgentFlow, + sourceCell: makeCell('1', NodeTypeEnum.Start), + targetCell: makeCell('2', NodeTypeEnum.Agent), + edges: graph.getEdges(), + }), + ).toBe(true); + }); + + it('拖向其他节点时放行', () => { + const graph = makeGraph([makeEdge('e1', '1')]); + expect( + shouldRejectStartSecondDrag({ + graph, + flowKind: FlowKindEnum.AgentFlow, + sourceCell: makeCell('1', NodeTypeEnum.Start), + targetCell: makeCell('3', NodeTypeEnum.Agent), + excludeEdgeId: 'e-new', + edges: graph.getEdges(), + }), + ).toBe(false); + }); + }); + + describe('handleAgentFlowStartDragInsert', () => { + it('成功插入时移除临时边并调用 insertNodeBetween', async () => { + const oldEdge = makeEdge('e-old', '1'); + const newEdge = makeEdge('e-new', '1'); + const graph = makeGraph([oldEdge, newEdge]); + const insertNodeBetween = vi.fn().mockResolvedValue(undefined); + const onComplete = vi.fn(); + + const handled = handleAgentFlowStartDragInsert({ + graph, + flowKind: FlowKindEnum.AgentFlow, + edge: newEdge, + edges: graph.getEdges(), + sourceNode: { id: 1, type: NodeTypeEnum.Start } as ChildNode, + targetNode: { id: 3, type: NodeTypeEnum.Agent } as ChildNode, + sourcePort: '1-out', + insertNodeBetween, + onComplete, + }); + + expect(handled).toBe(true); + expect(newEdge.remove).toHaveBeenCalled(); + expect(insertNodeBetween).toHaveBeenCalledWith( + expect.objectContaining({ + middleNode: { id: 3, type: NodeTypeEnum.Agent }, + tailNode, + oldEdgeId: 'e-old', + }), + ); + + await Promise.resolve(); + expect(onComplete).toHaveBeenCalled(); + }); + }); +}); diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/adapters/qaConfigAdapter.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/adapters/qaConfigAdapter.ts new file mode 100644 index 00000000..e2bbdbe4 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/adapters/qaConfigAdapter.ts @@ -0,0 +1,289 @@ +/** + * HumanInteraction(询问用户)nodeConfig 适配 + * + * 仅在 AgentFlow 场景对 HumanInteraction 节点生效: + * - 加载:补全 formArgs 默认值、统一 selectConfig 编辑态结构 + * - 保存:清理废弃字段,序列化为后端 QA 契约 + * + * answerType(HitlAnswerTypeEnum)为唯一权威字段: + * TEXT=文本回复 / SELECT=选项回复(分支端口)/ FORM=表单回复 + * + * formArgs 元素直接复用 Arg(InputAndOutConfig): + * - 控件类型写入 Arg.inputType(FormArgInputTypeEnum) + * - Select / Radio / MultipleSelect 使用 Arg.selectConfig(MANUAL + options) + */ + +import { DataTypeEnum, NodeTypeEnum } from '@/types/enums/common'; +import { FormArgInputTypeEnum } from '../enums/formArgInputType'; +import { HitlAnswerTypeEnum } from '../enums/hitlAnswerType'; + +const VALID_ANSWER_TYPES = new Set([ + HitlAnswerTypeEnum.TEXT, + HitlAnswerTypeEnum.SELECT, + HitlAnswerTypeEnum.FORM, +]); + +const FORM_ARG_INPUT_TYPE_VALUES = new Set( + Object.values(FormArgInputTypeEnum), +); + +/** 需要选项(selectConfig)的控件类型 */ +export const FORM_ARG_CHOICE_INPUT_TYPES = new Set([ + FormArgInputTypeEnum.Select, + FormArgInputTypeEnum.MultipleSelect, + FormArgInputTypeEnum.Radio, +]); + +/** + * 校验并收敛 inputType 为合法 FormArgInputTypeEnum,非法值兜底 Text + */ +export function coerceFormArgInputType( + inputType: unknown, +): FormArgInputTypeEnum { + if ( + typeof inputType === 'string' && + FORM_ARG_INPUT_TYPE_VALUES.has(inputType) + ) { + return inputType as FormArgInputTypeEnum; + } + return FormArgInputTypeEnum.Text; +} + +/** 是否为需要配置选项的控件类型 */ +export function isFormArgChoiceInputType(inputType: unknown): boolean { + return FORM_ARG_CHOICE_INPUT_TYPES.has(coerceFormArgInputType(inputType)); +} + +/** 读取选项列表(扁平 options) */ +export function getHitlOptions( + nc: Record | undefined | null, +): any[] { + if (!nc) return []; + return nc.options?.length ? nc.options : []; +} + +/** + * 选项归一化为 `{ label, value }[]`(契约:label === value,同一文本)。 + * 兼容「换行字符串」/ `string[]` / `{label,value}[]`,裁剪空白/空行。 + */ +function toLabelValueOptions( + raw: unknown, +): Array<{ label: string; value: string }> { + let arr: any[]; + if (Array.isArray(raw)) arr = raw; + else if (typeof raw === 'string') arr = raw.split('\n'); + else return []; + return arr + .map((item) => { + let text = ''; + if (typeof item === 'string') { + text = item.trim(); + } else if (typeof item === 'number' && Number.isFinite(item)) { + text = String(item); + } else if (item && typeof item === 'object') { + text = String(item.label ?? item.value ?? '').trim(); + } + return text ? { label: text, value: text } : null; + }) + .filter((x): x is { label: string; value: string } => x !== null); +} + +/** 选项 → 多行字符串(编辑态表单文本框) */ +function optionsToMultiline(raw: unknown): string { + return toLabelValueOptions(raw) + .map((o) => o.label) + .join('\n'); +} + +/** + * 归一化单个 formArg:补全 inputType、按控件类型同步 dataType、选择类补 selectConfig。 + * @param mode 'edit'(options 为多行字符串)/ 'save'(options 为 {label,value}[]) + */ +function processFormArg( + a: any, + mode: 'edit' | 'save' = 'edit', +): Record { + if (!a) return a; + const fa: Record = { ...a }; + fa.inputType = coerceFormArgInputType(fa.inputType); + fa.dataType = + fa.inputType === FormArgInputTypeEnum.Number + ? DataTypeEnum.Integer + : DataTypeEnum.String; + if (isFormArgChoiceInputType(fa.inputType)) { + const raw = fa.selectConfig?.options; + fa.selectConfig = { + dataSourceType: 'MANUAL', + options: + mode === 'save' ? toLabelValueOptions(raw) : optionsToMultiline(raw), + }; + } else { + fa.selectConfig = null; + } + return fa; +} + +/** formArgs 归一化 */ +function normalizeFormArgs(formArgs: any): Record[] { + if (!Array.isArray(formArgs) || !formArgs.length) return []; + return formArgs.map((a) => processFormArg(a, 'edit')); +} + +/** 推断 answerType:合法 answerType → formArgs 非空为 FORM → 兜底 TEXT */ +function getHitlAnswerType( + nc: Record | undefined | null, +): HitlAnswerTypeEnum { + if (!nc) return HitlAnswerTypeEnum.TEXT; + if (VALID_ANSWER_TYPES.has(nc.answerType)) return nc.answerType; + if (nc.formArgs?.length) return HitlAnswerTypeEnum.FORM; + return HitlAnswerTypeEnum.TEXT; +} + +/** 是否处于选项分支模式(供端口/画布逻辑使用) */ +export function isHitlOptionsBranchMode( + nc: Record | undefined | null, +): boolean { + return getHitlAnswerType(nc) === HitlAnswerTypeEnum.SELECT; +} + +/** 将历史 contextWriteKey 迁移到 outputArgs[0].name(与 QA 输出变量一致) */ +function migrateContextWriteKeyToOutputArgs( + nc: Record, +): Record { + const contextWriteKey = + typeof nc.contextWriteKey === 'string' ? nc.contextWriteKey.trim() : ''; + if (!contextWriteKey) { + return nc; + } + const outputArgs = Array.isArray(nc.outputArgs) ? [...nc.outputArgs] : []; + if (!outputArgs.length) { + outputArgs.push({ + key: contextWriteKey, + name: contextWriteKey, + dataType: DataTypeEnum.String, + require: true, + systemVariable: true, + }); + } else if (!outputArgs[0]?.name || outputArgs[0].name === 'answer') { + outputArgs[0] = { + ...outputArgs[0], + key: contextWriteKey, + name: contextWriteKey, + }; + } + const rest = { ...nc }; + delete rest.contextWriteKey; + return { ...rest, outputArgs }; +} + +/** 加载后:补全 HumanInteraction QA 扁平字段默认值 */ +export function normalizeHitlNodeConfig( + nodeConfig: Record | undefined | null, +): Record { + if (!nodeConfig) return {}; + let nc = { ...nodeConfig }; + + nc = migrateContextWriteKeyToOutputArgs(nc); + nc.answerType = getHitlAnswerType(nc); + if (!nc.inputArgs) nc.inputArgs = []; + if (!nc.options) nc.options = []; + nc.formArgs = normalizeFormArgs(nc.formArgs); + + return nc; +} + +/** 保存前:序列化为后端 QA 可接受的扁平 nodeConfig */ +export function serializeHitlNodeConfig( + nodeConfig: Record | undefined | null, +): Record { + if (!nodeConfig) return {}; + const nc = { ...nodeConfig }; + const answerType = getHitlAnswerType(nc); + + if (answerType === HitlAnswerTypeEnum.SELECT) { + nc.answerType = HitlAnswerTypeEnum.SELECT; + nc.options = getHitlOptions(nc); + } else { + nc.answerType = answerType; + nc.options = + nc.options?.map((item: any) => ({ ...item, nextNodeIds: [] })) || []; + } + + if (Array.isArray(nc.formArgs) && nc.formArgs.length) { + nc.formArgs = nc.formArgs.map((a) => processFormArg(a, 'save')); + } + + // 输出变量走 outputArgs(与 QA 一致),不再下发 contextWriteKey + delete nc.contextWriteKey; + + return nc; +} + +/** + * 节点外壳字段:与 nodeConfig 内业务字段同名时仍保留在顶层(如 KnowledgeInsert 的 name/description + * 在 nodeConfig 表示知识库绑定,顶层表示节点展示名,二者语义不同不可剥离)。 + */ +const NODE_ENVELOPE_KEYS = new Set([ + 'id', + 'name', + 'description', + 'workflowId', + 'type', + 'shape', + 'icon', + 'preNodes', + 'nextNodes', + 'nextNodeIds', + 'innerNodes', + 'innerStartNodeId', + 'innerEndNodeId', + 'unreachableNextNodeIds', + 'modified', + 'created', + 'loopNodeId', + 'typeId', +]); + +/** + * 保存前节点预处理(供 WorkflowSaveService / workflowProxyV3 共用): + * - HumanInteraction:扁平化 QA 字段(formArgs/selectConfig 等) + * - 通用:剥离 modelConfig、节点顶层重复配置字段(外壳字段除外) + */ +export function prepareNodeForBackendSerialize< + T extends { type?: any; nodeConfig?: any }, +>(node: T): T { + if (!node.nodeConfig) return node; + const configKeys = new Set(Object.keys(node.nodeConfig)); + configKeys.add('modelConfig'); + + let nc = node.nodeConfig as Record; + if (node.type === NodeTypeEnum.HumanInteraction) { + nc = serializeHitlNodeConfig(nc); + } + if ( + node.type === NodeTypeEnum.RouteDecision && + Array.isArray(nc.intentConfigs) + ) { + nc = { + ...nc, + intentConfigs: nc.intentConfigs.map((branch: Record) => ({ + ...branch, + conditionType: branch?.conditionType === 'OR' ? 'OR' : 'AND', + })), + }; + } + if (nc.modelConfig !== undefined) { + const rest = { ...nc }; + delete rest.modelConfig; + nc = rest; + } + const cleaned: Record = {}; + for (const [k, v] of Object.entries(node)) { + if (k === 'nodeConfig') continue; + // 外壳字段始终保留在顶层;仅剥离误挂在顶层的 nodeConfig 业务字段 + if (configKeys.has(k) && !NODE_ENVELOPE_KEYS.has(k)) continue; + cleaned[k] = v; + } + cleaned.nodeConfig = nc; + return cleaned as T; +} diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/adapters/routeConditionAdapter.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/adapters/routeConditionAdapter.ts new file mode 100644 index 00000000..bd8d522f --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/adapters/routeConditionAdapter.ts @@ -0,0 +1,89 @@ +/** + * 路由决策分支条件适配 + * + * RouteDecision 的 intentConfigs 直接使用结构化 conditionArgs(多条件,AND/OR 连接), + * 对齐条件节点。不再保留 condition 字符串字段。 + * + * 末尾固定一条「其他意图」兜底分支(intentType:'OTHER'):不可删、始终在最后、无条件匹配。 + */ + +import type { BindConfigWithSub } from '@/types/interfaces/common'; +import type { ConditionArgs } from '@/types/interfaces/node'; +import { v4 as uuidv4 } from 'uuid'; + +/** 创建空的条件匹配项(左值=变量 Reference,右值=字面值 Input) */ +export function createEmptyConditionArg(): ConditionArgs { + return { + compareType: 'EQUAL', + firstArg: { + bindValue: '', + bindValueType: 'Reference', + name: '', + } as BindConfigWithSub, + secondArg: { + bindValue: '', + bindValueType: 'Input', + name: '', + } as BindConfigWithSub, + }; +} + +/** 创建「其他意图」兜底分支(intentType:OTHER,无条件匹配) */ +export function createOtherIntentBranch(): Record { + return { + uuid: uuidv4(), + name: '其他意图', + intent: '', + intentType: 'OTHER', + conditionArgs: [], + conditionType: 'AND', + nextNodeIds: [], + }; +} + +/** + * 加载时归一化 intentConfigs: + * - 字段对齐:旧 intent→name(分支名)、旧 description→intent(描述) + * - 移除废弃字段:expression / condition + * - intentType 规范化:仅 'OTHER'(兜底)/ 'NORMAL'(用户分支)两种;历史脏值统一归 NORMAL + * - 非兜底分支确保有 conditionArgs、conditionType 默认 AND + * - 末尾固定一条「其他意图」(intentType:OTHER) 兜底分支(缺失则补;多余则仅保留最后一条、其余降级 NORMAL) + */ +export function hydrateIntentConfigs( + intentConfigs: Array> | undefined, +): Array> { + if (!intentConfigs?.length) return [createOtherIntentBranch()]; + + const mapped = intentConfigs.map((item) => { + const isLegacy = item.description !== undefined; + const name = isLegacy ? item.intent : item.name ?? ''; + const intent = isLegacy ? item.description : item.intent; + const isOther = item.intentType === 'OTHER'; + const conditionArgs = isOther + ? [] + : Array.isArray(item.conditionArgs) && item.conditionArgs.length + ? item.conditionArgs + : [createEmptyConditionArg()]; + return { + uuid: item.uuid, + nextNodeIds: item.nextNodeIds ?? [], + name, + intent, + intentType: isOther ? 'OTHER' : 'NORMAL', + conditionArgs, + conditionType: item.conditionType === 'OR' ? 'OR' : 'AND', + }; + }); + + // 末尾仅保留一条 OTHER:其余 OTHER 降级为 NORMAL(留在原位) + const lastOtherIdx = mapped.map((b) => b.intentType).lastIndexOf('OTHER'); + const normalized = mapped.map((b, i) => + b.intentType === 'OTHER' && i !== lastOtherIdx + ? { ...b, intentType: 'NORMAL' } + : b, + ); + const otherBranch = + lastOtherIdx >= 0 ? normalized[lastOtherIdx] : createOtherIntentBranch(); + const rest = normalized.filter((_, i) => i !== lastOtherIdx); + return [...rest, { ...otherBranch, name: otherBranch.name || '其他意图' }]; +} diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/createdPicker.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/createdPicker.ts new file mode 100644 index 00000000..5162ffa8 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/createdPicker.ts @@ -0,0 +1,106 @@ +/** + * AgentFlow 场景下 Created 选择弹窗策略 + * + * 仅由 WorkflowLayout 在 isAgentFlow 时引用,不影响普通 Workflow。 + */ + +import { CREATED_TABS } from '@/constants/common.constants'; +import { AgentComponentTypeEnum } from '@/types/enums/agent'; +import { AgentSubTypeEnum, AgentTypeEnum } from '@/types/enums/space'; +import type { CreatedNodeItem } from '@/types/interfaces/common'; + +type CreatedTab = { label: string; key: AgentComponentTypeEnum }; + +/** AgentFlow 添加节点时 Created 顶部 Tab:智能体 / 工作流仅展示对应单一 Tab */ +export function resolveAgentFlowCreatedModalTabs( + isAgentFlow: boolean, + createdItem: AgentComponentTypeEnum, + workflowCreatedTabs: CreatedTab[], +): CreatedTab[] { + if (!isAgentFlow) { + return workflowCreatedTabs; + } + if (createdItem === AgentComponentTypeEnum.Agent) { + return CREATED_TABS.filter( + (item) => item.key === AgentComponentTypeEnum.Agent, + ); + } + if (createdItem === AgentComponentTypeEnum.Workflow) { + return CREATED_TABS.filter( + (item) => item.key === AgentComponentTypeEnum.Workflow, + ); + } + return workflowCreatedTabs; +} + +type PublishedAgentItem = CreatedNodeItem & { + subType?: string; + /** 已发布列表常用:ChatBot / PageApp / TaskAgent / AgentFlow */ + agentType?: string; +}; + +const AGENT_SUB_TYPE_VALUES = new Set(Object.values(AgentSubTypeEnum)); + +/** AgentFlow 智能体节点可选子类型:ChatBot / General / Custom */ +const AGENT_FLOW_SELECTABLE_SUB_TYPES = new Set([ + AgentSubTypeEnum.ChatBot, + AgentSubTypeEnum.General, + AgentSubTypeEnum.Custom, +]); + +function isKnownAgentSubType(value: string): value is AgentSubTypeEnum { + return AGENT_SUB_TYPE_VALUES.has(value); +} + +/** + * 将已发布智能体列表项归一为 AgentSubTypeEnum。 + * + * 列表接口字段不统一:subType / targetSubType / agentType / type 可能混用; + * 不能简单 `subType ?? targetSubType`,无效 subType 会挡住后面的 ChatBot 标识。 + */ +export function normalizePublishedAgentSubType( + item: PublishedAgentItem, +): AgentSubTypeEnum | undefined { + const rawSubType = + typeof item.subType === 'string' ? item.subType.trim() : ''; + if (rawSubType && isKnownAgentSubType(rawSubType)) { + return rawSubType; + } + + if (item.targetSubType === 'ChatBot') { + return AgentSubTypeEnum.ChatBot; + } + + const topLevelType = (item as { type?: string }).type; + const agentType = item.agentType ?? topLevelType; + + if (agentType === AgentTypeEnum.ChatBot || agentType === 'ChatBot') { + return AgentSubTypeEnum.ChatBot; + } + if (agentType === AgentTypeEnum.TaskAgent || agentType === 'TaskAgent') { + if (rawSubType === AgentSubTypeEnum.Custom) { + return AgentSubTypeEnum.Custom; + } + return AgentSubTypeEnum.General; + } + if (agentType === AgentTypeEnum.AgentFlow || agentType === 'AgentFlow') { + return AgentSubTypeEnum.Flow; + } + if (agentType === AgentTypeEnum.AgentGroup || agentType === 'AgentGroup') { + return AgentSubTypeEnum.Group; + } + + return undefined; +} + +/** + * AgentFlow「智能体」节点可选范围:当前空间已发布的 ChatBot / General / Custom。 + * 排除 Flow、Group 等形态,避免后端 add 报错。 + */ +export function isAgentFlowSelectableAgent(item: CreatedNodeItem): boolean { + const subType = normalizePublishedAgentSubType(item as PublishedAgentItem); + if (!subType) { + return false; + } + return AGENT_FLOW_SELECTABLE_SUB_TYPES.has(subType); +} diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/edgeConnect.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/edgeConnect.ts new file mode 100644 index 00000000..406bcbd0 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/edgeConnect.ts @@ -0,0 +1,68 @@ +/** + * AgentFlow 画布手动拖线:RouteDecision / HumanInteraction 分支端口 + * + * 与后端类型的映射关系: + * RouteDecision ↔ IntentRecognition(intentConfigs / route-* 端口) + * HumanInteraction ↔ QA SELECT(options[].nextNodeIds / hitl-option-* 端口) + * + * 逻辑集中在 agentFlow,不改动 Workflow 侧 handleSpecialNodeTypes 等既有实现。 + */ + +import { NodeTypeEnum } from '@/types/enums/common'; +import type { ChildNode } from '@/types/interfaces/graph'; +import type { Edge, Graph } from '@antv/x6'; +import { extensionRegistry } from '../extensions/registry'; +import { isHitlOptionsBranchMode } from './adapters/qaConfigAdapter'; + +/** + * 是否为 AgentFlow 分支端口拖线(应写 nodeConfig 分支字段,而非节点级 nextNodeIds) + */ +export function isAgentFlowBranchEdgeConnect( + sourceNode: ChildNode, + sourcePort: string, +): boolean { + if (sourceNode.type === NodeTypeEnum.RouteDecision) { + return true; + } + if ( + sourceNode.type === NodeTypeEnum.HumanInteraction && + isHitlOptionsBranchMode(sourceNode.nodeConfig as Record) + ) { + const handler = extensionRegistry.get(NodeTypeEnum.HumanInteraction); + return !!handler?.parseSourcePort?.(sourceNode, sourcePort); + } + return false; +} + +/** + * 拖线连入后更新源节点分支配置(委托 extensionRegistry 已注册的 handler) + */ +export function applyAgentFlowBranchEdgeConnect( + sourceNode: ChildNode, + targetNode: ChildNode, + sourcePort: string, +): ChildNode | null { + const handler = extensionRegistry.get(sourceNode.type as NodeTypeEnum); + return ( + handler?.handleSpecialNextIndex?.( + sourceNode, + sourcePort, + targetNode.id, + targetNode, + ) ?? null + ); +} + +/** + * 拖线结束时边通常已在画布上;仅在缺失且实例仍有效时补加, + * 避免代理回滚 dispose 后对同一实例 graph.addEdge 触发 shape 序列化错误。 + */ +export function ensureEdgeOnGraph(graph: Graph, edge: Edge): void { + if (graph.getCellById(edge.id)) { + return; + } + const prop = edge.prop(); + if (prop?.shape) { + graph.addEdge(prop); + } +} diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/edgeSync.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/edgeSync.ts new file mode 100644 index 00000000..df0c03ef --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/edgeSync.ts @@ -0,0 +1,88 @@ +/** + * 画布边与 workflowProxy 边数据双向对齐(修复「数据有、画布无」导致的 Edge already exists) + */ + +import type { Edge, Graph } from '@antv/x6'; +import { workflowProxy } from '../services/workflowProxyV3'; + +/** 按节点 cellId 匹配(忽略 port 后缀) */ +export function findGraphEdgesBetween( + graph: Graph, + sourceCellId: string, + targetCellId: string, +): Edge[] { + const source = String(sourceCellId); + const target = String(targetCellId); + return graph.getEdges().filter((edge) => { + return ( + edge.getSourceCellId() === source && edge.getTargetCellId() === target + ); + }); +} + +export function hasGraphEdgeBetween( + graph: Graph, + sourceCellId: string, + targetCellId: string, +): boolean { + return findGraphEdgesBetween(graph, sourceCellId, targetCellId).length > 0; +} + +/** + * 从数据模型与画布移除 source→target(幂等,尽量多种 source 格式) + */ +export function purgeEdgeBetween(params: { + graph?: Graph; + sourceCellId: string; + targetCellId: string; + sourcePort?: string; + graphDeleteEdge?: (edgeId: string) => void; +}): void { + const { graph, sourceCellId, targetCellId, sourcePort, graphDeleteEdge } = + params; + const source = String(sourceCellId); + const target = String(targetCellId); + + workflowProxy.deleteEdge(source, target, sourcePort); + if (sourcePort) { + workflowProxy.deleteEdge(source, target); + } + + if (graph && graphDeleteEdge) { + findGraphEdgesBetween(graph, source, target).forEach((edge) => { + graphDeleteEdge(String(edge.id)); + }); + } +} + +/** + * 清理节点在数据模型 + 画布上的所有关联边(含仅存在于 proxy 的残留边) + */ +export function purgeNodeIncidentEdges(params: { + graph?: Graph; + nodeId: string; + excludeEdgeIds?: string[]; + graphDeleteEdge?: (edgeId: string) => void; +}): void { + const { graph, nodeId, excludeEdgeIds = [], graphDeleteEdge } = params; + const id = String(nodeId); + const exclude = new Set(excludeEdgeIds.map(String)); + + const proxyEdges = workflowProxy + .getEdges() + .filter((e) => String(e.source) === id || String(e.target) === id); + for (const e of proxyEdges) { + workflowProxy.deleteEdge(String(e.source), String(e.target), e.sourcePort); + } + + if (graph && graphDeleteEdge) { + graph.getEdges().forEach((edge) => { + if (exclude.has(String(edge.id))) return; + const src = edge.getSourceCellId(); + const tgt = edge.getTargetCellId(); + if (src === id || tgt === id) { + graphDeleteEdge(String(edge.id)); + } + }); + } +} diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/enums/formArgInputType.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/enums/formArgInputType.ts new file mode 100644 index 00000000..c860f891 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/enums/formArgInputType.ts @@ -0,0 +1,12 @@ +/** + * HumanInteraction formArgs 表单控件类型(对齐后端 Arg.inputType 枚举名) + * 可用值:Text / Select / MultipleSelect / Number / File / Radio + */ +export enum FormArgInputTypeEnum { + Text = 'Text', + Select = 'Select', + MultipleSelect = 'MultipleSelect', + Number = 'Number', + File = 'File', + Radio = 'Radio', +} diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/enums/hitlAnswerType.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/enums/hitlAnswerType.ts new file mode 100644 index 00000000..2e0d7519 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/enums/hitlAnswerType.ts @@ -0,0 +1,9 @@ +/** + * HumanInteraction 回答类型(对齐后端 nodeConfig.answerType) + * TEXT / SELECT 与 Workflow QA 共用;FORM 为 AgentFlow 表单回复扩展 + */ +export enum HitlAnswerTypeEnum { + TEXT = 'TEXT', + SELECT = 'SELECT', + FORM = 'FORM', +} diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/enums/hitlMode.ts b/qiming/src/pages/Antv-X6/v3/agentFlow/enums/hitlMode.ts new file mode 100644 index 00000000..0a7cfd1d --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/enums/hitlMode.ts @@ -0,0 +1,7 @@ +/** + * HumanInteraction HITL 模式(仅 Ask;approve 模式已移除) + * 对齐后端 nodeConfig.hitlMode + */ +export enum HitlModeEnum { + Ask = 'ask', +} diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowEndForm.tsx b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowEndForm.tsx new file mode 100644 index 00000000..5268ba02 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowEndForm.tsx @@ -0,0 +1,20 @@ +/** + * AgentFlow 结束节点属性面板(独立维护,样式对齐 Workflow V3 空面板提示) + */ + +import { t } from '@/services/i18nRuntime'; +import { NodeDisposeProps } from '@/types/interfaces/workflow'; +import React from 'react'; + +const AgentFlowEndForm: React.FC = () => { + return ( +
    + {t( + 'PC.Pages.AgentFlowNode.endEmptyHint', + '结束节点无需额外配置,流程执行至此自动结束。', + )} +
    + ); +}; + +export default AgentFlowEndForm; diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowStartForm.tsx b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowStartForm.tsx new file mode 100644 index 00000000..c4687711 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowStartForm.tsx @@ -0,0 +1,20 @@ +/** + * AgentFlow 开始节点属性面板(独立维护,无配置项) + */ + +import { t } from '@/services/i18nRuntime'; +import { NodeDisposeProps } from '@/types/interfaces/workflow'; +import React from 'react'; + +const AgentFlowStartForm: React.FC = () => { + return ( +
    + {t( + 'PC.Pages.AgentFlowNode.startEmptyHint', + '开始节点无需额外配置,流程从此节点启动。', + )} +
    + ); +}; + +export default AgentFlowStartForm; diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowWorkflowForm.tsx b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowWorkflowForm.tsx new file mode 100644 index 00000000..7d6cb77e --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentFlowWorkflowForm.tsx @@ -0,0 +1,48 @@ +/** + * AgentFlow 工作流节点属性面板(独立维护,样式对齐 Workflow V3 PluginInNode) + * + * 字段:inputArgs 入参映射、outputArgs 出参展示 + */ + +import TreeInput from '@/components/FormListItem/TreeInput'; +import { t } from '@/services/i18nRuntime'; +import { NodeDisposeProps } from '@/types/interfaces/workflow'; +import { Form } from 'antd'; +import React from 'react'; +import { TreeOutput } from '../../component/commonNode'; +import '../../component/pluginNode.less'; + +const AgentFlowWorkflowForm: React.FC = ({ form }) => { + return ( + <> + + {() => + form.getFieldValue('inputArgs') && ( +
    + +
    + ) + } +
    + + + {() => + form.getFieldValue('outputArgs') && ( + <> +
    + {t('PC.Pages.AntvX6Data.output')} +
    + + + ) + } +
    + + ); +}; + +export default AgentFlowWorkflowForm; diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentNodeForm.tsx b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentNodeForm.tsx new file mode 100644 index 00000000..836957b6 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/AgentNodeForm.tsx @@ -0,0 +1,112 @@ +/** + * Agent 节点属性面板 + * + * 数据格式(与后端对齐): + * - agentId: Long 智能体ID(添加节点弹窗选定,当前空间已发布 ChatBot) + * - inputArgs: ArgItem[] 入参(引用上游变量,对照开始节点) + * - extraPrompt: String 补充提示词 + * - selfLoopTimes: int 自身循环次数 + * - reminderPrompt: String 循环提醒提示词 + */ + +import ExpandableInputTextarea from '@/components/ExpandTextArea'; +import { transformToPromptVariables } from '@/components/TiptapVariableInput/utils/variableTransform'; +import { t } from '@/services/i18nRuntime'; +import { InputItemNameEnum } from '@/types/enums/node'; +import { InputAndOutConfig } from '@/types/interfaces/node'; +import { NodeDisposeProps } from '@/types/interfaces/workflow'; +import { Form, Input, InputNumber } from 'antd'; +import React from 'react'; +import { useModel } from 'umi'; +import { outPutConfigs } from '../../ParamsV3'; +import { InputAndOut } from '../../component/commonNode'; + +const AgentNodeForm: React.FC = ({ form, nodeConfig }) => { + const { referenceList } = useModel('workflowV3'); + + const inputArgs = + Form.useWatch('inputArgs', { form, preserve: true }) || + nodeConfig?.inputArgs || + []; + + const promptVariables = transformToPromptVariables( + (inputArgs as InputAndOutConfig[]).filter( + (item) => !['', null, undefined].includes(item.name), + ), + referenceList?.argMap, + ); + + return ( + <> + {/* agentId:添加节点时于 Created 弹窗绑定,属性面板只读保留 */} + + +
    + +
    + +
    + +
    + + {/* 自循环配置暂时隐藏 */} +
    +
    + + + +
    + +
    + +
    +
    + + ); +}; + +export default AgentNodeForm; diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/forms/HumanInteractionAskForm.less b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/HumanInteractionAskForm.less new file mode 100644 index 00000000..eabe79a2 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/HumanInteractionAskForm.less @@ -0,0 +1,80 @@ +.ask-form-fields { + &__hint { + font-size: 11px; + color: #999; + margin-bottom: 8px; + } + + &__add { + width: 100%; + margin-top: 8px; + font-size: 12px; + } +} + +.ask-form-field-card { + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 8px; + margin-top: 8px; + background: #fafafa; + + &__row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + } + + &__name { + flex: 1; + font-size: 12px; + } + + &__type { + width: 100px; + flex-shrink: 0; + } + + &__required { + flex-shrink: 0; + font-size: 11px; + color: #666; + white-space: nowrap; + } + + &__del { + flex-shrink: 0; + cursor: pointer; + color: #ccc; + font-size: 14px; + line-height: 1; + + &:hover { + color: #ff4d4f; + } + } + + &__desc { + font-size: 12px; + } + + &__options-title { + margin-top: 8px; + margin-bottom: 4px; + font-size: 12px; + font-weight: 600; + color: #333; + } + + &__options { + margin-top: 2px; + font-size: 12px; + // 撤销面板内 .fold-wrap-style .ant-input { height: 28px } 的全局覆盖 + //(该规则特异性更高,会把 TextArea 压成单行),恢复 rows={3} 的固定 3 行高度 + height: auto !important; + min-height: 66px; + // 固定 3 行高度,超出滚动(不自动撑高) + resize: vertical; + } +} diff --git a/qiming/src/pages/Antv-X6/v3/agentFlow/forms/HumanInteractionAskForm.tsx b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/HumanInteractionAskForm.tsx new file mode 100644 index 00000000..09d080e3 --- /dev/null +++ b/qiming/src/pages/Antv-X6/v3/agentFlow/forms/HumanInteractionAskForm.tsx @@ -0,0 +1,343 @@ +/** + * HumanInteraction(询问用户)属性面板 + * + * 独立维护,对照 Workflow QuestionsNode;字段对齐 QA 扁平结构。 + * 样式对齐 Workflow V3:node-title-style + node-item-style + * + * 中文 IME:V3 WorkflowLayout Form 根节点统一挂载 workflowFormImeGuard。 + */ + +import ExpandableInputTextarea from '@/components/ExpandTextArea'; +import CustomTree from '@/components/FormListItem/NestedForm'; +import { ModelSelected } from '@/components/ModelSetting'; +import { transformToPromptVariables } from '@/components/TiptapVariableInput/utils/variableTransform'; +import { t } from '@/services/i18nRuntime'; +import { DataTypeEnum } from '@/types/enums/common'; +import { InputItemNameEnum } from '@/types/enums/node'; +import { InputAndOutConfig } from '@/types/interfaces/node'; +import { NodeDisposeProps } from '@/types/interfaces/workflow'; +import { CloseOutlined, PlusOutlined } from '@ant-design/icons'; +import { + Button, + Checkbox, + Form, + Input, + Radio, + RadioChangeEvent, + Select, + Space, +} from 'antd'; +import React from 'react'; +import { useModel } from 'umi'; +import { v4 as uuidv4 } from 'uuid'; +import { FormList, InputAndOut } from '../../component/commonNode'; +import { outPutConfigs } from '../../ParamsV3'; +import { isFormArgChoiceInputType } from '../adapters/qaConfigAdapter'; +import { FormArgInputTypeEnum } from '../enums/formArgInputType'; +import { HitlAnswerTypeEnum } from '../enums/hitlAnswerType'; +import './HumanInteractionAskForm.less'; + +const { TextArea } = Input; + +const FORM_FIELD_TYPE_OPTIONS = [ + { + label: t('PC.Pages.AgentFlowNode.formTypeInput', '单行文本'), + value: FormArgInputTypeEnum.Text, + }, + { + label: t('PC.Pages.AgentFlowNode.formTypeSelect', '下拉单选'), + value: FormArgInputTypeEnum.Select, + }, + { + label: t('PC.Pages.AgentFlowNode.formTypeRadio', '单选'), + value: FormArgInputTypeEnum.Radio, + }, + { + label: t('PC.Pages.AgentFlowNode.formTypeCheckbox', '多选'), + value: FormArgInputTypeEnum.MultipleSelect, + }, + { + label: t('PC.Pages.AgentFlowNode.formTypeNumber', '数字'), + value: FormArgInputTypeEnum.Number, + }, + { + label: t('PC.Pages.AgentFlowNode.formTypeFile', '文件上传'), + value: FormArgInputTypeEnum.File, + }, +]; + +const HumanInteractionAskForm: React.FC = ({ + form, + nodeConfig, + type, + id, +}) => { + const { referenceList } = useModel('workflowV3'); + + const answerType = + Form.useWatch('answerType', { form, preserve: true }) || + HitlAnswerTypeEnum.TEXT; + + const inputArgs = + Form.useWatch(InputItemNameEnum.inputArgs, { form, preserve: true }) || []; + + const promptVariables = transformToPromptVariables( + (inputArgs as InputAndOutConfig[]).filter( + (item) => !['', null, undefined].includes(item.name), + ), + referenceList?.argMap, + ); + + /** 切换回答类型,同步 options(answerType 为权威字段) */ + const changeAnswerType = (type: HitlAnswerTypeEnum) => { + let options = form.getFieldValue('options'); + if (type === HitlAnswerTypeEnum.SELECT && (!options || !options.length)) { + options = [ + { uuid: uuidv4(), index: 0, content: '', nextNodeIds: [] }, + { + uuid: uuidv4(), + index: 1, + content: t('PC.Pages.AntvX6CommonNode.otherBranchHint'), + nextNodeIds: [], + }, + ]; + } + if (type !== HitlAnswerTypeEnum.SELECT) { + options = options?.map((item: any) => ({ + ...item, + nextNodeIds: [], + })); + } + + form.setFieldsValue({ + answerType: type, + options, + }); + form.submit(); + }; + + return ( +
    + + +
    + +
    + +
    + +
    + +
    + + changeAnswerType(e.target.value)} + > + + + {t('PC.Pages.AgentFlowNode.replyModeTextReply', '文本回复')} + + + {t('PC.Pages.AgentFlowNode.replyModeOptionsReply', '选项回复')} + + + {t('PC.Pages.AgentFlowNode.replyModeFormReply', '表单回复')} + + + + +
    + + {answerType === HitlAnswerTypeEnum.SELECT && ( +
    + +
    + )} + + {answerType === HitlAnswerTypeEnum.FORM && ( +
    + + {(fields, { add, remove }) => ( +
    +
    + {t('PC.Pages.AgentFlowNode.formFieldsTitle', '表单字段')} +
    +
    + {t( + 'PC.Pages.AgentFlowNode.formFieldsHint', + '定义用户需要填写的表单字段', + )} +
    + + {fields.map(({ key, name }) => ( +
    +
    + + + + + + + + {/* 仅 inputType 变化时重渲染,避免 name/description 输入触发整表刷新 */} + + {() => { + const inputType = form.getFieldValue([ + 'formArgs', + name, + 'inputType', + ]); + if (!isFormArgChoiceInputType(inputType)) { + return null; + } + return ( + <> +
    + {t( + 'PC.Pages.AgentFlowNode.askOptionsTitle', + '选项内容', + )} +
    + +