From 4cd7ccaead5b6c7044a848028daf6823dda4fb1a Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 31 Jul 2026 03:54:21 -0400 Subject: [PATCH] release: v2.0.0 --- .gitattributes | 19 + .gitignore | 9 +- README.md | 18 +- README_zh.md | 18 +- assets/architecture-biz-en.svg | 8 +- assets/architecture-biz-zh.svg | 8 +- assets/architecture-tech-en.svg | 6 +- assets/architecture-tech-zh.svg | 6 +- mateclaw-desktop/electron/main/index.ts | 35 +- mateclaw-desktop/package.json | 2 +- .../api/memory/PluginMemoryProvider.java | 40 + mateclaw-plugin-mem0/pom.xml | 74 + .../java/vip/mate/plugin/mem0/Mem0Client.java | 182 ++ .../java/vip/mate/plugin/mem0/Mem0Config.java | 47 + .../vip/mate/plugin/mem0/Mem0Exception.java | 21 + .../java/vip/mate/plugin/mem0/Mem0Plugin.java | 107 + .../vip/mate/plugin/mem0/Mem0Provider.java | 177 ++ .../src/main/resources/mateclaw-plugin.json | 48 + .../vip/mate/plugin/mem0/Mem0ClientTest.java | 163 ++ .../vip/mate/plugin/mem0/Mem0ConfigTest.java | 38 + .../vip/mate/plugin/mem0/Mem0PluginTest.java | 146 ++ .../mate/plugin/mem0/Mem0ProviderTest.java | 219 ++ mateclaw-server/Dockerfile | 7 + .../vip/mate/agent/AgentGraphBuilder.java | 33 +- .../java/vip/mate/agent/AgentService.java | 52 + .../agent/context/AgentWorkspaceResolver.java | 38 + .../agent/graph/NodeStreamingChatHelper.java | 590 ++++-- .../graph/executor/ToolExecutionExecutor.java | 96 +- .../graph/executor/ToolResultProperties.java | 9 +- .../graph/executor/ToolResultStorage.java | 15 + .../vip/mate/agent/graph/node/ActionNode.java | 29 +- .../mate/agent/graph/node/ReasoningNode.java | 27 + .../plan/edge/PlanGenerationDispatcher.java | 7 + .../graph/plan/node/PlanGenerationNode.java | 191 +- .../graph/plan/node/PlanSummaryNode.java | 4 +- .../agent/progress/ProgressLedgerService.java | 33 + .../controller/ApprovalGrantController.java | 30 + .../grant/repository/ApprovalGrantMapper.java | 15 + .../grant/service/ApprovalGrantResolver.java | 15 + .../mate/channel/AbstractChannelAdapter.java | 17 + .../mate/channel/ChannelDedupProperties.java | 79 + .../vip/mate/channel/ChannelMagicCommand.java | 134 ++ .../java/vip/mate/channel/ChannelManager.java | 2 +- .../mate/channel/ChannelMessageRenderer.java | 35 +- .../mate/channel/ChannelMessageRouter.java | 639 ++++-- .../vip/mate/channel/ChannelSessionStore.java | 128 +- .../channel/InboundMessageDeduplicator.java | 187 ++ .../mate/channel/StreamingChannelAdapter.java | 22 + .../dingtalk/DingTalkChannelAdapter.java | 76 +- .../discord/DiscordChannelAdapter.java | 19 +- .../channel/feishu/FeishuChannelAdapter.java | 69 +- .../tool_guard/ToolGuardCardHandler.java | 27 +- .../tool_guard/ToolGuardCardRenderer.java | 6 +- .../channel/web/AgentStreamAccumulator.java | 530 +++++ .../vip/mate/channel/web/ChatController.java | 649 ++---- .../mate/channel/web/ChatStreamTracker.java | 3 +- .../channel/webchat/WebChatController.java | 52 +- .../channel/wecom/WeComChannelAdapter.java | 532 ++++- .../wecom/WeComKeepaliveScheduler.java | 52 +- .../channel/wecom/WeComProgressRenderer.java | 280 +++ .../tool_guard/ToolGuardCardHandler.java | 9 +- .../tool_guard/ToolGuardCardRenderer.java | 18 +- .../vip/mate/channel/weixin/ILinkClient.java | 222 +- .../channel/weixin/WeixinChannelAdapter.java | 133 +- .../datasource/service/DatasourceService.java | 1 + .../mate/llm/chatmodel/OpenAiModelsPath.java | 50 + .../llm/failover/AvailableProviderPool.java | 88 +- .../failover/ProviderHealthProperties.java | 28 + .../llm/failover/ProviderHealthTracker.java | 32 + .../OpenAiCompatibleListModelsProbe.java | 47 +- .../vip/mate/llm/model/ModelProtocol.java | 58 +- .../llm/service/ModelDiscoveryService.java | 18 +- .../llm/service/ModelProviderService.java | 10 +- .../fact/model/FactContradictionEntity.java | 2 +- .../mate/memory/fact/model/FactEntity.java | 2 +- .../lifecycle/MemoryLifecycleMediator.java | 2 +- .../memory/model/MorningCardSeenEntity.java | 2 +- .../vip/mate/memory/spi/MemoryManager.java | 12 +- .../vip/mate/memory/spi/MemoryProvider.java | 18 + .../decorator/MemoryProviderDecorator.java | 3 + .../spi/decorator/MetricsMemoryProvider.java | 7 +- .../decorator/RetryableMemoryProvider.java | 7 +- .../planning/service/PlanningService.java | 29 + .../plugin/bridge/PluginMemoryBridge.java | 15 + .../skill/controller/SkillController.java | 175 +- .../mate/skill/installer/SkillInstaller.java | 36 +- .../mate/skill/installer/ZipSkillFetcher.java | 116 +- .../skill/lessons/SkillLessonsService.java | 2 +- .../mate/skill/lifecycle/SkillCuratorJob.java | 2 +- .../lifecycle/SkillLifecycleService.java | 6 +- .../mate/skill/manifest/SkillManifest.java | 8 +- .../skill/runtime/SkillContentReconciler.java | 258 +++ .../skill/runtime/SkillDependencyChecker.java | 105 +- .../skill/runtime/SkillPackageResolver.java | 88 +- .../skill/runtime/SkillRuntimeService.java | 40 +- .../mate/skill/service/SkillFileService.java | 68 + .../vip/mate/skill/service/SkillService.java | 33 +- .../synthesis/SkillSynthesisService.java | 7 +- .../skill/template/SkillTemplateService.java | 6 +- .../skill/workspace/BundledSkillSyncer.java | 105 +- .../mate/skill/workspace/SkillFileSyncer.java | 69 +- .../SkillWorkspaceBootstrapRunner.java | 31 + .../workspace/SkillWorkspaceManager.java | 181 +- .../workspace/bundle/SkillBundleFiles.java | 53 + .../mate/system/model/SystemSettingsDTO.java | 7 + .../system/service/SystemSettingService.java | 106 + .../mate/team/controller/TeamController.java | 398 ++++ .../vip/mate/team/event/TeamChangedEvent.java | 14 + .../team/event/TeamTasksDelegatedEvent.java | 13 + .../vip/mate/team/model/AgentTeamEntity.java | 49 + .../team/model/AgentTeamMemberEntity.java | 36 + .../java/vip/mate/team/model/TeamRole.java | 21 + .../team/model/TeamTaskCommentEntity.java | 45 + .../team/model/TeamTaskCreateCommand.java | 52 + .../vip/mate/team/model/TeamTaskEntity.java | 97 + .../mate/team/model/TeamTaskEventEntity.java | 64 + .../vip/mate/team/model/TeamTaskStatus.java | 46 + .../mate/team/repository/AgentTeamMapper.java | 14 + .../repository/AgentTeamMemberMapper.java | 14 + .../repository/TeamTaskCommentMapper.java | 14 + .../team/repository/TeamTaskEventMapper.java | 12 + .../mate/team/repository/TeamTaskMapper.java | 14 + .../team/service/TeamAnnounceService.java | 229 +++ .../mate/team/service/TeamContextBuilder.java | 188 ++ .../team/service/TeamDispatchService.java | 345 ++++ .../mate/team/service/TeamEventChannel.java | 71 + .../vip/mate/team/service/TeamPlanBridge.java | 304 +++ .../vip/mate/team/service/TeamService.java | 239 +++ .../mate/team/service/TeamTaskService.java | 702 +++++++ .../vip/mate/team/tool/TeamTasksTool.java | 367 ++++ .../mate/tool/builtin/CodeExecuteTool.java | 7 +- .../vip/mate/tool/builtin/DatasourceTool.java | 8 +- .../vip/mate/tool/builtin/SkillFileTool.java | 31 +- .../vip/mate/tool/builtin/SkillLoadTool.java | 9 +- .../mate/tool/builtin/SkillManageTool.java | 36 +- .../mate/tool/builtin/SkillScriptTool.java | 6 +- .../tool/builtin/WorkspaceMemoryTool.java | 26 +- .../preview/OfficePreviewService.java | 194 ++ .../tool/guard/ToolExecutionGuardHelper.java | 17 +- .../guard/model/ToolGuardAuditLogEntity.java | 8 + .../guard/service/ToolGuardAuditService.java | 13 + .../tool/guard/service/ToolGuardService.java | 41 +- .../WikiTransformationController.java | 17 + .../java/vip/mate/wiki/job/WikiKbConfig.java | 19 + .../job/model/WikiProcessingJobEntity.java | 2 +- .../mate/wiki/model/WikiHotCacheEntity.java | 2 +- .../model/WikiImageCaptionCacheEntity.java | 2 +- .../wiki/model/WikiPageCitationEntity.java | 2 +- .../mate/wiki/model/WikiRelationEntity.java | 2 +- .../wiki/model/WikiTransformationEntity.java | 2 +- .../model/WikiTransformationRunEntity.java | 2 +- .../mate/wiki/service/WikiContextService.java | 37 +- .../service/WikiEntityExtractionService.java | 104 +- .../service/WikiKnowledgeBaseService.java | 15 +- .../wiki/service/WikiProcessingService.java | 2 +- .../service/WikiTransformationService.java | 27 +- .../conversation/ConversationService.java | 153 +- .../controller/ConversationController.java | 24 + .../document/WorkspaceFileService.java | 18 + .../controller/WorkspaceFileController.java | 34 + .../src/main/resources/application.yml | 15 + ...V170__guard_audit_auto_approve_outcome.sql | 7 + ...en_conversation_id_for_channel_scoping.sql | 9 + .../h2/V172__agent_team_foundation.sql | 89 + .../h2/V173__register_team_tasks_tool.sql | 6 + .../h2/V174__team_task_event_timeline.sql | 20 + ...V170__guard_audit_auto_approve_outcome.sql | 4 + ...en_conversation_id_for_channel_scoping.sql | 6 + .../kingbase/V172__agent_team_foundation.sql | 76 + .../V173__register_team_tasks_tool.sql | 6 + .../V174__team_task_event_timeline.sql | 17 + ...V170__guard_audit_auto_approve_outcome.sql | 16 + ...en_conversation_id_for_channel_scoping.sql | 8 + .../mysql/V172__agent_team_foundation.sql | 76 + .../mysql/V173__register_team_tasks_tool.sql | 6 + .../mysql/V174__team_task_event_timeline.sql | 17 + .../src/main/resources/docs/en/channels.md | 30 +- .../src/main/resources/docs/en/chat.md | 18 + .../src/main/resources/docs/en/index.md | 3 + .../src/main/resources/docs/en/memory.md | 58 + .../src/main/resources/docs/en/models.md | 21 + .../src/main/resources/docs/en/releases.md | 1 + .../src/main/resources/docs/en/roadmap.md | 48 +- .../src/main/resources/docs/en/security.md | 11 + .../src/main/resources/docs/en/skills.md | 38 +- .../src/main/resources/docs/en/teams.md | 161 ++ .../main/resources/docs/en/wecom-tuning.md | 15 + .../src/main/resources/docs/en/wiki.md | 6 + .../src/main/resources/docs/en/workspaces.md | 15 + .../src/main/resources/docs/zh/channels.md | 30 +- .../src/main/resources/docs/zh/chat.md | 18 + .../src/main/resources/docs/zh/index.md | 3 + .../src/main/resources/docs/zh/memory.md | 58 + .../src/main/resources/docs/zh/models.md | 21 + .../src/main/resources/docs/zh/releases.md | 1 + .../src/main/resources/docs/zh/roadmap.md | 44 +- .../src/main/resources/docs/zh/security.md | 11 + .../src/main/resources/docs/zh/skills.md | 38 +- .../src/main/resources/docs/zh/teams.md | 161 ++ .../main/resources/docs/zh/wecom-tuning.md | 15 + .../src/main/resources/docs/zh/wiki.md | 6 + .../src/main/resources/docs/zh/workspaces.md | 15 + .../resources/mapper/ApprovalGrantMapper.xml | 48 + .../src/main/resources/messages.properties | 8 + .../src/main/resources/messages_en.properties | 8 + .../agent/graph/ErrorClassificationTest.java | 51 +- .../mate/agent/graph/ErrorTypePolicyTest.java | 79 + .../agent/graph/RetryAfterExtractionTest.java | 98 + .../executor/LaneDExecutorAndConfigTest.java | 19 +- ...xecutionExecutorSkillAutoRedirectTest.java | 3 +- .../ToolExecutionExecutorSkillHintTest.java | 3 +- .../node/ActionNodeAutoRecordSkipTest.java | 117 ++ .../plan/node/PlanGenerationStepDepsTest.java | 60 + .../ProgressLedgerClearAutoRecordedTest.java | 126 ++ .../grant/ApprovalGrantResolverTest.java | 39 + .../ApprovalGrantControllerTest.java | 54 +- .../AutoIncrementFreePrimaryKeyTest.java | 49 + .../mate/channel/ChannelMagicCommandTest.java | 395 ++++ .../ChannelMessageRouterApprovalDenyTest.java | 3 +- ...nelMessageRouterExecutionMetadataTest.java | 178 ++ .../ChannelMessageRouterInboundDedupTest.java | 196 ++ .../ChannelMessageRouterNarrationTest.java | 181 ++ .../channel/ChannelOutboundFilterTest.java | 97 + .../mate/channel/ChannelSessionStoreTest.java | 87 + .../InboundMessageDeduplicatorTest.java | 166 ++ .../FeishuConversationIdAlignmentTest.java | 53 +- .../web/ChatControllerPreviewRouteTest.java | 130 ++ .../web/ChatStreamTrackerCleanupTest.java | 6 +- .../channel/wecom/ReplyStreamDedupTest.java | 20 + .../wecom/WeComInboundConversationIdTest.java | 53 +- .../wecom/WeComKeepaliveSchedulerTest.java | 12 +- .../channel/wecom/WeComProcessStreamTest.java | 437 ++++ .../wecom/WeComProgressRendererTest.java | 160 ++ .../weixin/ILinkClientUploadUrlTest.java | 120 ++ ...asourceServicePasswordPersistenceTest.java | 48 + .../llm/chatmodel/OpenAiModelsPathTest.java | 74 + .../failover/AvailableProviderPoolTest.java | 77 + .../failover/ProviderHealthTrackerTest.java | 41 + .../OpenAiCompatibleListModelsProbeTest.java | 69 - .../vip/mate/llm/model/ModelProtocolTest.java | 62 + ...odelProviderServiceCustomProviderTest.java | 35 + .../MemoryManagerPluginPrefetchTest.java | 281 +++ .../lifecycle/LifecycleFlagGuardTest.java | 6 +- .../MemoryLifecycleMediatorTest.java | 6 +- .../plugin/bridge/PluginMemoryBridgeTest.java | 255 +++ .../SkillControllerBundleFilesTest.java | 188 ++ .../SkillControllerLifecycleTest.java | 4 +- .../SkillControllerListEnabledTest.java | 4 +- .../SkillControllerVirtualGuardTest.java | 8 +- .../skill/installer/ZipSkillFetcherTest.java | 42 + .../lessons/SkillLessonsServiceTest.java | 4 +- .../skill/lifecycle/SkillCuratorJobTest.java | 3 +- .../lifecycle/SkillLifecycleServiceTest.java | 17 +- .../runtime/SkillContentReconcilerTest.java | 235 +++ .../SkillDependencyCheckerEndpointTest.java | 155 ++ ...lRuntimeServiceWorkspaceExecutionTest.java | 144 ++ .../SkillServiceUpdatePartialTest.java | 10 +- .../workspace/BundledSkillSyncerTest.java | 130 ++ .../skill/workspace/SkillFileSyncerTest.java | 29 +- .../SkillWorkspaceManagerApplyBundleTest.java | 24 +- .../SkillWorkspaceManagerPathTest.java | 99 +- .../service/SystemSettingBoolApiTest.java | 3 +- .../SystemSettingServiceCatalogTest.java | 11 +- ...SystemSettingWorkspaceStorageRootTest.java | 140 ++ .../team/controller/TeamControllerTest.java | 243 +++ .../team/service/TeamAnnounceServiceTest.java | 163 ++ .../team/service/TeamContextBuilderTest.java | 190 ++ .../team/service/TeamDispatchServiceTest.java | 290 +++ .../mate/team/service/TeamPlanBridgeTest.java | 228 ++ .../mate/team/service/TeamServiceTest.java | 75 + .../team/service/TeamTaskServiceTest.java | 377 ++++ .../vip/mate/team/tool/TeamTasksToolTest.java | 295 +++ .../tool/builtin/CodeExecuteToolArgsTest.java | 2 +- .../builtin/CodeExecuteToolArtifactTest.java | 4 +- ...sourceToolPostgresqlViewDiscoveryTest.java | 64 + .../mate/tool/builtin/SkillFileToolTest.java | 106 +- .../mate/tool/builtin/SkillLoadToolTest.java | 22 +- .../builtin/SkillManageToolWriteFileTest.java | 33 +- .../tool/builtin/SkillScriptToolArgsTest.java | 2 +- .../preview/OfficePreviewServiceTest.java | 90 + .../WikiTransformationControllerTest.java | 37 + .../model/WikiProcessingJobEntityTest.java | 19 + .../model/WikiPageCitationEntityTest.java | 19 + .../service/WikiContextServiceBudgetTest.java | 59 +- .../WikiEntityExtractionServiceTest.java | 40 + .../service/WikiKnowledgeBaseServiceTest.java | 35 + ...ransformationStarterPackGlobalE2ETest.java | 60 + ...ersationServiceOwnershipWorkspaceTest.java | 33 + ...rsationServiceRewindAndRegenerateTest.java | 174 ++ .../document/WorkspaceMemorySearchTest.java | 28 + mateclaw-ui/eslint.config.mjs | 61 + mateclaw-ui/package.json | 15 +- mateclaw-ui/pnpm-lock.yaml | 969 ++++++++- mateclaw-ui/pnpm-workspace.yaml | 3 + mateclaw-ui/src/App.vue | 5 + .../src/api/__tests__/wikiUpload.test.ts | 23 + mateclaw-ui/src/api/index.ts | 155 ++ .../components/channels/ChannelEditModal.vue | 13 +- .../src/components/chat/MessageBubble.vue | 62 +- .../src/components/chat/MessageList.vue | 2 + .../chat/__tests__/toolCallDedup.test.ts | 87 + .../components/chat/preview/DocxPreview.vue | 69 + .../chat/preview/FilePreviewDialog.vue | 301 +++ .../components/chat/preview/HtmlPreview.vue | 64 + .../components/chat/preview/PdfPreview.vue | 136 ++ .../chat/preview/PreviewSpinner.vue | 40 + .../components/chat/preview/SheetPreview.vue | 195 ++ .../components/chat/preview/TextPreview.vue | 84 + .../preview/__tests__/previewKind.test.ts | 68 + .../src/components/chat/preview/previewBus.ts | 18 + .../components/chat/preview/previewKind.ts | 76 + .../workflow/WorkflowJsonEditor.vue | 1 - mateclaw-ui/src/composables/chat/useChat.ts | 37 +- .../src/composables/chat/useMessages.ts | 13 +- .../composables/useGlobalFileDownloadClick.ts | 22 +- mateclaw-ui/src/composables/useTeamEvents.ts | 64 + mateclaw-ui/src/composables/wikilink.ts | 1 - mateclaw-ui/src/i18n/locales/en-US.ts | 176 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 176 +- mateclaw-ui/src/router/index.ts | 87 + mateclaw-ui/src/stores/useTeamStore.ts | 174 ++ mateclaw-ui/src/types/components.d.ts | 3 +- mateclaw-ui/src/types/desktop.d.ts | 35 + mateclaw-ui/src/types/index.ts | 6 + .../src/utils/__tests__/wikiUpload.test.ts | 27 + mateclaw-ui/src/utils/wikiUpload.ts | 23 + mateclaw-ui/src/views/AgentContext.vue | 89 +- mateclaw-ui/src/views/Agents.vue | 46 +- mateclaw-ui/src/views/Channels.vue | 25 +- mateclaw-ui/src/views/ChatConsole.vue | 162 +- .../src/views/Security/AuditLogs/index.vue | 91 +- .../Security/AutoApproveGrants/index.vue | 158 +- mateclaw-ui/src/views/Settings/Layout.vue | 13 + .../src/views/Settings/LocalTools/index.vue | 191 ++ .../src/views/Settings/System/index.vue | 25 +- mateclaw-ui/src/views/SkillMarket.vue | 280 ++- mateclaw-ui/src/views/Teams.vue | 1827 +++++++++++++++++ .../src/views/Wiki/components/JobStageBar.vue | 2 +- .../Wiki/components/RawMaterialPanel.vue | 12 +- .../src/views/Wiki/components/WikiConfig.vue | 118 ++ .../__tests__/chatConsoleModelSeed.test.ts | 170 ++ mateclaw-ui/src/views/layout/MainLayout.vue | 12 + mateclaw-ui/src/views/mcp/icons.ts | 1 - pom.xml | 3 +- 344 files changed, 26458 insertions(+), 1873 deletions(-) create mode 100644 .gitattributes create mode 100644 mateclaw-plugin-mem0/pom.xml create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Client.java create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Config.java create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Exception.java create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Plugin.java create mode 100644 mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java create mode 100644 mateclaw-plugin-mem0/src/main/resources/mateclaw-plugin.json create mode 100644 mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ClientTest.java create mode 100644 mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ConfigTest.java create mode 100644 mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0PluginTest.java create mode 100644 mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/context/AgentWorkspaceResolver.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/ChannelDedupProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/InboundMessageDeduplicator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComProgressRenderer.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiModelsPath.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillContentReconciler.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/workspace/bundle/SkillBundleFiles.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/event/TeamChangedEvent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/event/TeamTasksDelegatedEvent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamMemberEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamRole.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCommentEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEventEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskStatus.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMemberMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskCommentMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskEventMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/document/preview/OfficePreviewService.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V170__guard_audit_auto_approve_outcome.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V171__widen_conversation_id_for_channel_scoping.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V172__agent_team_foundation.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V173__register_team_tasks_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V174__team_task_event_timeline.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V170__guard_audit_auto_approve_outcome.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V171__widen_conversation_id_for_channel_scoping.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V172__agent_team_foundation.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V173__register_team_tasks_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V174__team_task_event_timeline.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V170__guard_audit_auto_approve_outcome.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V171__widen_conversation_id_for_channel_scoping.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V172__agent_team_foundation.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V173__register_team_tasks_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V174__team_task_event_timeline.sql create mode 100644 mateclaw-server/src/main/resources/docs/en/teams.md create mode 100644 mateclaw-server/src/main/resources/docs/zh/teams.md create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorTypePolicyTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/RetryAfterExtractionTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeAutoRecordSkipTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationStepDepsTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerClearAutoRecordedTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/architecture/AutoIncrementFreePrimaryKeyTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterExecutionMetadataTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterInboundDedupTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterNarrationTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/ChannelOutboundFilterTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/ChannelSessionStoreTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/InboundMessageDeduplicatorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPreviewRouteTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComProcessStreamTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComProgressRendererTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/weixin/ILinkClientUploadUrlTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/datasource/service/DatasourceServicePasswordPersistenceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiModelsPathTest.java delete mode 100644 mateclaw-server/src/test/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/model/ModelProtocolTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/MemoryManagerPluginPrefetchTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/plugin/bridge/PluginMemoryBridgeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerBundleFilesTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillContentReconcilerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillDependencyCheckerEndpointTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceWorkspaceExecutionTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/workspace/BundledSkillSyncerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingWorkspaceStorageRootTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolPostgresqlViewDiscoveryTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/document/preview/OfficePreviewServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/job/model/WikiProcessingJobEntityTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/model/WikiPageCitationEntityTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceRewindAndRegenerateTest.java create mode 100644 mateclaw-ui/eslint.config.mjs create mode 100644 mateclaw-ui/pnpm-workspace.yaml create mode 100644 mateclaw-ui/src/api/__tests__/wikiUpload.test.ts create mode 100644 mateclaw-ui/src/components/chat/__tests__/toolCallDedup.test.ts create mode 100644 mateclaw-ui/src/components/chat/preview/DocxPreview.vue create mode 100644 mateclaw-ui/src/components/chat/preview/FilePreviewDialog.vue create mode 100644 mateclaw-ui/src/components/chat/preview/HtmlPreview.vue create mode 100644 mateclaw-ui/src/components/chat/preview/PdfPreview.vue create mode 100644 mateclaw-ui/src/components/chat/preview/PreviewSpinner.vue create mode 100644 mateclaw-ui/src/components/chat/preview/SheetPreview.vue create mode 100644 mateclaw-ui/src/components/chat/preview/TextPreview.vue create mode 100644 mateclaw-ui/src/components/chat/preview/__tests__/previewKind.test.ts create mode 100644 mateclaw-ui/src/components/chat/preview/previewBus.ts create mode 100644 mateclaw-ui/src/components/chat/preview/previewKind.ts create mode 100644 mateclaw-ui/src/composables/useTeamEvents.ts create mode 100644 mateclaw-ui/src/stores/useTeamStore.ts create mode 100644 mateclaw-ui/src/types/desktop.d.ts create mode 100644 mateclaw-ui/src/utils/__tests__/wikiUpload.test.ts create mode 100644 mateclaw-ui/src/utils/wikiUpload.ts create mode 100644 mateclaw-ui/src/views/Settings/LocalTools/index.vue create mode 100644 mateclaw-ui/src/views/Teams.vue create mode 100644 mateclaw-ui/src/views/__tests__/chatConsoleModelSeed.test.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..53cdcbed --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Line-ending policy. +# +# Shell scripts are bind-mounted into Linux containers (e.g. +# docker/postgres/init/ -> /docker-entrypoint-initdb.d) and executed there. +# A CRLF checkout on Windows (core.autocrlf=true is the Git for Windows +# default) turns the shebang into "#!/bin/sh\r", which fails with +# "cannot execute: required file not found". Pin them to LF everywhere. +# +# SQL files are pinned to LF too so Flyway migration checksums stay +# identical across platforms. + +*.sh text eol=lf +*.bash text eol=lf +*.sql text eol=lf + +# Windows-native scripts keep CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore index e946f6b7..ee97c977 100644 --- a/.gitignore +++ b/.gitignore @@ -94,8 +94,13 @@ deploy/nginx/ssl/*.crt deploy/nginx/ssl/*.key deploy/nginx/ssl/*.pem -# Deploy env -deploy/.env +# Env files (real secrets - do not commit) +# .env matches any level; .env.example / *.env.example are templates and stay tracked. +.env +.env.local +.env.*.local +!.env.example +!**/.env.example # Claude Code local settings CLAUDE.md diff --git a/README.md b/README.md index 09e12138..2f55171b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,9 @@ Same brain. Same memory. Same tools. Different doors. ### Digital employees, not chatbots You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work. +### Agent Teams (2.0.0+) +One lead, a crew of employees, one **shared task board**. Tell the lead a goal and it breaks the goal into tasks on the board (`blockedBy` declares dependencies); the dispatch engine hands tasks to members in parallel, prerequisite results hand off to downstream tasks automatically, and settled results are announced back to the lead for synthesis. Execution leases + heartbeats eliminate double execution, **cancel actually interrupts** a running member session, and sensitive tasks park at `in_review` for a human. Deliverables (docx / pptx / xlsx / pdf) register on tasks for download, timelines record everything, and you can jump into any member's child conversation to watch it execute word by word. A Plan-Execute lead hands its **whole plan over to the board** — a lead that can plan turns planning into orchestration. + ### Knowledge & memory - **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline - **Workspace memory** — `AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes @@ -203,7 +206,7 @@ Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/ | Layer | Technology | |---|---| | Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway | -| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution | +| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution · Team task board (2.0.0+) | | Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) | | Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) | | Database | H2 (dev) · MySQL 8.0+ (prod) | @@ -220,6 +223,19 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc ## Roadmap +**v2.0.0 (shipped 2026-07-26)** — from "one person who gets things done" to "a team that collaborates": **Agent Teams** become a standing roster around a shared task board: + +- **Agent teams and a shared task board** — teams / roles (lead · member · reviewer), an eight-status kanban, `blockedBy` dependency orchestration, member-level parallel dispatch, automatic prerequisite hand-off, settled results waking the lead; the Teams page ships an event-driven live board + activity banner + task timelines + deliverable downloads + manual task creation +- **An execution chain hardened for long tasks** — execution leases + runtime heartbeats against double execution, cancel that actually interrupts, `in_review` approval gates, retry for failed/stale +- **Plan-Execute plans hand over to the board** — steps become tasks, dependencies become parallelism, a parked-plan resume gate synthesizes deterministically +- **Workspace isolation fully sealed** — channel-scoped conversation ids; same-named skills coexist per workspace with conversation-scoped runtime resolution +- **Channel experience** — magic commands on every channel (`/new` `/clear` `/status` `/stop` `/model` `/help`), WeCom's event-driven progress bubble (live tool trace + per-stage rolling narration) +- **Server-side rewind / regenerate** · **explainable auto-approval misses** (reason codes on audit rows + one-click grant creation) · **policy-driven LLM error recovery** (overload vs rate-limit split · `Retry-After`-aware backoff · provider TTL readmission) + +Plus: in-chat attachment preview (pdf / docx / xlsx / html / text), single-source SKILL.md + console bundle-file management, the optional Mem0 plugin memory provider, and the knowledge-graph relation schema whitelist. + +Full story in the [v2.0.0 release notes](https://claw.mate.vip/docs/en/releases/2.0.0). + **v1.8.0 (shipped 2026-07-12)** — the employee turns *outward and does a whole job*: **Content Studio**, the first flagship scene built end-to-end on MateClaw's own primitives: - **Content Studio — one sentence to a publishable post** — a seeded "Content Studio" employee runs pick-topic → research → draft → illustrate → de-AI → layout → deliver. **WeChat Official Account (公众号)** image-text articles (inline-style HTML → draft box) and **Xiaohongshu (小红书)** image-first notes (≥3 vertical 3:4 cards + online preview) ship first-class diff --git a/README_zh.md b/README_zh.md index 69aefcaa..63240396 100644 --- a/README_zh.md +++ b/README_zh.md @@ -81,6 +81,9 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长 ### 数字员工,不是聊天机器人 你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**,像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。 +### 团队协作(2.0.0+) +一个 Lead 带一群员工,围着一块**共享任务板**干活。你对 Lead 说一句目标,它拆成任务上板(`blockedBy` 声明依赖);派发引擎把任务并行分给成员,前置结果自动传给下游,完成结果自动通报回 Lead 汇总。执行租约 + 心跳杜绝双重执行,**取消即中断**正在跑的成员会话,敏感任务停在 `in_review` 等人批。交付物(docx / pptx / xlsx / pdf)登记到任务可下载,任务时间线记录全程,还能跳进任意成员的子会话看它逐字执行。Plan-Execute 型 Lead 的计划**整体移交任务板**——会规划的 Lead,规划能力就是编排能力。 + ### 知识与记忆 - **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**(1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线" - **工作区记忆** — `AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记 @@ -203,7 +206,7 @@ mateclaw/ | 层次 | 技术 | |---|---| | 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway | -| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 | +| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 · 团队任务板(2.0.0+)| | 业务编排 | 工作流(7 step mode · Pebble DSL)· 触发器(6 pattern type · 事件治理)· Wiki 加工器(1.3.0+)| | 能力扩展 | SKILL.md 包 · MCP(stdio / SSE / HTTP · per-agent 绑定)· ACP 桥接(Claude Code / Codex) | | 数据库 | H2(开发)· MySQL 8.0+(生产)| @@ -220,6 +223,19 @@ mateclaw/ ## 路线图 +**v2.0.0(2026-07-26 发布)** — 从"一个能干活的人"到"一支能协作的队伍":**Agent 团队**成为常设编制,围着一块共享任务板干活: + +- **Agent 团队与共享任务板** — 团队 / 角色(lead · member · reviewer)、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 Lead;Teams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务 +- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`in_review` 审批卡点、失败/过期可重试 +- **Plan-Execute 计划整体移交任务板** — 步骤变任务、依赖变并行、停靠恢复门确定性汇总 +- **工作空间隔离全面收口** — 渠道会话 id 编入渠道标识、同名技能跨工作空间共存且运行时按会话工作空间解析 +- **渠道体验** — 全渠道魔法命令(`/new` `/clear` `/status` `/stop` `/model` `/help`)、企业微信事件驱动进度气泡(实时工具轨迹 + 分阶段滚动叙述) +- **会话回退 / 重新生成服务端语义** · **自动批准未命中可解释**(原因码落审计行 + 一键补策略) · **LLM 错误恢复策略化**(过载/限流分治 · `Retry-After` 回馈退避 · provider TTL 回收) + +外加:聊天附件在线预览(pdf / docx / xlsx / html / 文本)、SKILL.md 单一事实源 + 捆绑文件控制台管理、Mem0 可选插件记忆 provider、知识图谱关系模式白名单。 + +完整故事见 [v2.0.0 release notes](https://claw.mate.vip/docs/zh/releases/2.0.0)。 + **v1.8.0(2026-07-12 发布)** — 员工*转向对外、干完一整件活*:**内容工作室**——第一个完全用 MateClaw 自身原子能力端到端搭起来的招牌场景: - **内容工作室——一句话到可发布成品** — 预置「内容工作室」员工跑通 选题 → 搜集 → 成文 → 配图 → 去 AI 化 → 排版 → 交付。**微信公众号(公众号)** 图文文章(内联样式 HTML → 草稿箱)与 **小红书** 以图为主图文笔记(≥3 张竖版 3:4 卡片 + 在线预览)首批一等公民 diff --git a/assets/architecture-biz-en.svg b/assets/architecture-biz-en.svg index 424421a5..4e511380 100644 --- a/assets/architecture-biz-en.svg +++ b/assets/architecture-biz-en.svg @@ -98,10 +98,10 @@ - - - Orchestration · Workflow + Trigger - Events → multi-employee → approval / dispatch / memory + + + Orchestration · Team Board (2.0.0+) + Workflow + Trigger + Lead decomposes → members run in parallel → approve / deliver diff --git a/assets/architecture-biz-zh.svg b/assets/architecture-biz-zh.svg index 25133463..5750334f 100644 --- a/assets/architecture-biz-zh.svg +++ b/assets/architecture-biz-zh.svg @@ -105,10 +105,10 @@ - - - 业务编排 · 工作流 + 触发器 - 事件触发 → 多员工协作 → 审批 / 分发 / 写记忆 + + + 业务编排 · 团队任务板(2.0.0+)+ 工作流 + 触发器 + Lead 拆解派发 → 成员并行执行 → 审批 / 交付物 / 分发 / 写记忆 diff --git a/assets/architecture-tech-en.svg b/assets/architecture-tech-en.svg index 1c5afd41..b7e79b4e 100644 --- a/assets/architecture-tech-en.svg +++ b/assets/architecture-tech-en.svg @@ -80,9 +80,9 @@ - Workflow + Trigger - 7 step modes · 6 patterns - Business orchestration (1.3.0+) + Team · Workflow · Trigger + Task-board dispatch (2.0.0+) + 7 step modes · 6 patterns diff --git a/assets/architecture-tech-zh.svg b/assets/architecture-tech-zh.svg index 6fbe8be7..f1f28bfb 100644 --- a/assets/architecture-tech-zh.svg +++ b/assets/architecture-tech-zh.svg @@ -83,9 +83,9 @@ - 工作流 + 触发器 - 7 step mode · 6 pattern - 业务流程编排(1.3.0+) + 团队 · 工作流 · 触发器 + 任务板派发 + 并行(2.0.0+) + 7 step mode · 6 pattern diff --git a/mateclaw-desktop/electron/main/index.ts b/mateclaw-desktop/electron/main/index.ts index dc5f4f84..5e288468 100644 --- a/mateclaw-desktop/electron/main/index.ts +++ b/mateclaw-desktop/electron/main/index.ts @@ -855,12 +855,17 @@ async function showLocalToolsSettings(): Promise { ].join('\n') const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + const hasDirs = cfg.allowedDirs.length > 0 + const buttons = hasDirs + ? ['关闭', '添加目录…', '移除目录…', cfg.enabled ? '停用' : '启用'] + : ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用'] + const toggleId = buttons.length - 1 const opts = { type: 'info' as const, title: '本地工具设置', message: '本地文件/命令工具', detail, - buttons: ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用'], + buttons, defaultId: 0, cancelId: 0, noLink: true, @@ -871,13 +876,39 @@ async function showLocalToolsSettings(): Promise { if (res.response === 1) { await pickAllowedDirectory() - } else if (res.response === 2) { + } else if (hasDirs && res.response === 2) { + await pickDirectoryToRemove(cfg.allowedDirs) + } else if (res.response === toggleId) { const saved = saveLocalToolsConfig({ enabled: !cfg.enabled }) if (saved.enabled && backendReady) localBridge.start() else if (!saved.enabled) localBridge.stop() } } +// Second-level picker for removing a whitelisted directory: native dialogs +// cannot render per-item delete controls, so each directory becomes a button. +async function pickDirectoryToRemove(dirs: string[]): Promise { + const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + const opts = { + type: 'question' as const, + title: '移除目录', + message: '选择要从白名单移除的目录', + detail: '移除后,本地文件/命令工具将无法再访问该目录。', + buttons: ['取消', ...dirs], + defaultId: 0, + cancelId: 0, + noLink: true, + } + const res = parent + ? await dialog.showMessageBox(parent, opts) + : await dialog.showMessageBox(opts) + if (res.response === 0) return + + const dir = dirs[res.response - 1] + const cfg = loadLocalToolsConfig() + saveLocalToolsConfig({ allowedDirs: cfg.allowedDirs.filter((d) => d !== dir) }) +} + async function menuCheckForUpdates(): Promise { if (!app.isPackaged) { dialog.showMessageBox({ type: 'info', message: 'Update check is not available in dev mode.' }) diff --git a/mateclaw-desktop/package.json b/mateclaw-desktop/package.json index f7fbdef7..84d065ea 100644 --- a/mateclaw-desktop/package.json +++ b/mateclaw-desktop/package.json @@ -1,6 +1,6 @@ { "name": "mateclaw-desktop", - "version": "1.8.0", + "version": "2.0.0", "description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba", "author": "MateClaw Team", "license": "Apache-2.0", diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java index 2474d585..b4b47833 100644 --- a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java +++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java @@ -54,6 +54,24 @@ public interface PluginMemoryProvider { return ""; } + /** + * Pre-turn context recall with per-owner isolation. Called by the platform + * when an owner key (e.g. {@code "user:42"}, {@code "feishu:sender_abc"}) + * is resolved for the current conversation. + *

+ * Default implementation degrades to the two-arg variant, dropping the + * owner key. External providers that need per-owner recall (e.g. Mem0) + * should override this to use {@code ownerKey} as their per-user identifier. + * + * @param agentId the agent ID + * @param userQuery the current user message + * @param ownerKey memory owner key (e.g. {@code "user:42"}), or null if unknown + * @return context text to inject, or empty string + */ + default String prefetch(Long agentId, String userQuery, String ownerKey) { + return prefetch(agentId, userQuery); + } + /** * Post-turn sync. Called after LLM response is available. * Should be non-blocking (async). @@ -62,6 +80,28 @@ public interface PluginMemoryProvider { String userMessage, String assistantReply) { } + /** + * Post-turn sync with per-owner isolation. Called by the platform with the + * same {@code ownerKey} that was resolved for this turn's prefetch, so + * providers can persist the turn under the same per-user identifier they + * recall by. + *

+ * Default implementation degrades to the four-arg variant, dropping the + * owner key. External providers that isolate memory per end-user should + * override this so that written memories stay reachable by owner-scoped + * recall. + * + * @param agentId the agent ID + * @param conversationId the conversation ID + * @param userMessage user's message text + * @param assistantReply assistant's reply text + * @param ownerKey memory owner key (e.g. {@code "user:42"}), or null if unknown + */ + default void syncTurn(Long agentId, String conversationId, + String userMessage, String assistantReply, String ownerKey) { + syncTurn(agentId, conversationId, userMessage, assistantReply); + } + /** * Tool beans this provider wants to expose to the agent. */ diff --git a/mateclaw-plugin-mem0/pom.xml b/mateclaw-plugin-mem0/pom.xml new file mode 100644 index 00000000..d9c75a1e --- /dev/null +++ b/mateclaw-plugin-mem0/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + + vip.mate + mateclaw + ${revision} + ../pom.xml + + + mateclaw-plugin-mem0 + jar + + MateClaw Mem0 Memory Provider Plugin + + Optional community plugin that bridges MateClaw's memory system to a self-hosted + Mem0 service (FastAPI + pgvector + Neo4j). Provides semantic recall via Mem0's + REST API alongside the built-in local memory providers. Not in the default stack; + users must deploy Mem0 separately and install this JAR into the plugins/ directory. + + + + + + vip.mate + mateclaw-plugin-api + provided + + + + + org.springframework.ai + spring-ai-model + provided + + + + + com.fasterxml.jackson.core + jackson-databind + provided + + + + + org.slf4j + slf4j-api + provided + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + org.slf4j + slf4j-simple + test + + + diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Client.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Client.java new file mode 100644 index 00000000..9c64c758 --- /dev/null +++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Client.java @@ -0,0 +1,182 @@ +package vip.mate.plugin.mem0; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Thin HTTP client for a self-hosted Mem0 REST API. + *

+ * Covers the two endpoints used by {@link Mem0Provider}: + *

    + *
  • {@code POST /memories/} — add a turn (user + assistant message) for extraction
  • + *
  • {@code POST /memories/search/} — semantic recall by query + user_id
  • + *
+ * + *

Failure semantics: every call either returns a parsed result or throws + * {@link Mem0Exception}. Callers are expected to catch and degrade gracefully + * (return empty recall / log sync failures). + * + * @author MateClaw Team + */ +class Mem0Client { + + private final Mem0Config config; + private final HttpClient http; + private final ObjectMapper mapper = new ObjectMapper(); + + Mem0Client(Mem0Config config) { + this.config = config; + this.http = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(config.timeoutMs())) + .build(); + } + + /** + * Push a conversation turn to Mem0 for extraction. + * + * @param userId Mem0 user_id, typically MateClaw's ownerKey + * @param agentId Mem0 agent_id, typically MateClaw's agentId + * @param conversationId optional conversation identifier (stored as metadata) + * @param userMessage user's message text + * @param assistantReply assistant's reply text + */ + void addMemories(String userId, String agentId, String conversationId, + String userMessage, String assistantReply) { + ObjectNode body = mapper.createObjectNode(); + body.put("user_id", userId); + if (agentId != null && !agentId.isBlank()) { + body.put("agent_id", agentId); + } + ArrayNode messages = body.putArray("messages"); + if (userMessage != null && !userMessage.isBlank()) { + ObjectNode m = messages.addObject(); + m.put("role", "user"); + m.put("content", userMessage); + } + if (assistantReply != null && !assistantReply.isBlank()) { + ObjectNode m = messages.addObject(); + m.put("role", "assistant"); + m.put("content", assistantReply); + } + if (conversationId != null && !conversationId.isBlank()) { + ObjectNode meta = body.putObject("metadata"); + meta.put("conversation_id", conversationId); + } + + post("/memories/", body); + } + + /** + * Semantic recall. + * + * @param userId Mem0 user_id (ownerKey) + * @param agentId Mem0 agent_id + * @param query user query text + * @return list of memory strings, possibly empty; never null + */ + List searchMemories(String userId, String agentId, String query) { + ObjectNode body = mapper.createObjectNode(); + body.put("query", query); + body.put("user_id", userId); + if (agentId != null && !agentId.isBlank()) { + body.put("agent_id", agentId); + } + body.put("limit", config.maxResults()); + + JsonNode resp = post("/memories/search/", body); + JsonNode results = resp.path("results"); + List out = new ArrayList<>(); + if (results.isArray()) { + for (JsonNode r : results) { + String mem = r.path("memory").asText(""); + if (!mem.isBlank()) { + out.add(mem); + } + } + } + return out; + } + + /** + * Shared POST helper. Returns the parsed JSON body on 2xx. + * + * @throws Mem0Exception on non-2xx response or IO error + */ + private JsonNode post(String path, ObjectNode body) { + String url = config.normalizedBaseUrl() + path; + try { + String payload = mapper.writeValueAsString(body); + HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofMillis(config.timeoutMs())) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)); + if (config.apiKey() != null && !config.apiKey().isBlank()) { + req.header("Authorization", "Bearer " + config.apiKey()); + } + + HttpResponse resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString()); + int code = resp.statusCode(); + if (code < 200 || code >= 300) { + throw new Mem0Exception("Mem0 " + path + " returned HTTP " + code + + ": " + truncate(resp.body(), 500)); + } + return mapper.readTree(resp.body() == null ? "{}" : resp.body()); + } catch (Mem0Exception e) { + throw e; + } catch (Exception e) { + throw new Mem0Exception("Mem0 " + path + " request failed: " + e.getMessage(), e); + } + } + + private static String truncate(String s, int max) { + if (s == null) return ""; + return s.length() > max ? s.substring(0, max) + "..." : s; + } + + /** + * Test-only accessor for verifying configuration wiring. + */ + Mem0Config config() { + return config; + } + + /** + * Test-only helper to inspect what would be POSTed without sending. + * Builds the same payload as {@link #addMemories} and returns it as a Map. + */ + Map buildAddPayload(String userId, String agentId, String conversationId, + String userMessage, String assistantReply) { + ObjectNode body = mapper.createObjectNode(); + body.put("user_id", userId); + if (agentId != null && !agentId.isBlank()) { + body.put("agent_id", agentId); + } + ArrayNode messages = body.putArray("messages"); + if (userMessage != null && !userMessage.isBlank()) { + ObjectNode m = messages.addObject(); + m.put("role", "user"); + m.put("content", userMessage); + } + if (assistantReply != null && !assistantReply.isBlank()) { + ObjectNode m = messages.addObject(); + m.put("role", "assistant"); + m.put("content", assistantReply); + } + if (conversationId != null && !conversationId.isBlank()) { + ObjectNode meta = body.putObject("metadata"); + meta.put("conversation_id", conversationId); + } + return mapper.convertValue(body, Map.class); + } +} diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Config.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Config.java new file mode 100644 index 00000000..ea7ae20e --- /dev/null +++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Config.java @@ -0,0 +1,47 @@ +package vip.mate.plugin.mem0; + +/** + * Mem0 plugin configuration snapshot. + *

+ * Read once from {@link vip.mate.plugin.api.PluginContext#getConfig} at plugin + * load time and passed to {@link Mem0Client} / {@link Mem0Provider}. Snapshot + * semantics — config changes require a plugin reload. + * + * @param baseUrl Mem0 REST API base URL, e.g. {@code http://localhost:8080} + * @param apiKey optional bearer token; null/blank means no Authorization header + * @param searchEnabled whether prefetch should query Mem0 /memories/search/ + * @param syncEnabled whether syncTurn should POST to Mem0 /memories/ + * @param maxResults cap on memories returned per recall + * @param timeoutMs HTTP timeout for both recall and sync + * @author MateClaw Team + */ +record Mem0Config( + String baseUrl, + String apiKey, + boolean searchEnabled, + boolean syncEnabled, + int maxResults, + int timeoutMs +) { + static final int DEFAULT_MAX_RESULTS = 5; + static final int DEFAULT_TIMEOUT_MS = 3000; + + /** + * Whether this provider should participate at all. + * Mem0 without a base URL is unusable; treat as unavailable. + */ + boolean isUsable() { + return baseUrl != null && !baseUrl.isBlank(); + } + + /** + * Strip trailing slashes from the base URL to avoid double-slash in path joins. + */ + String normalizedBaseUrl() { + String url = baseUrl; + while (url.endsWith("/")) { + url = url.substring(0, url.length() - 1); + } + return url; + } +} diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Exception.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Exception.java new file mode 100644 index 00000000..064fffa3 --- /dev/null +++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Exception.java @@ -0,0 +1,21 @@ +package vip.mate.plugin.mem0; + +/** + * Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout). + *

+ * Caught and logged by {@link Mem0Provider} so that Mem0 outages degrade + * gracefully (empty recall / dropped sync) without affecting the agent's + * response path. + * + * @author MateClaw Team + */ +class Mem0Exception extends RuntimeException { + + Mem0Exception(String message) { + super(message); + } + + Mem0Exception(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Plugin.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Plugin.java new file mode 100644 index 00000000..62696403 --- /dev/null +++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Plugin.java @@ -0,0 +1,107 @@ +package vip.mate.plugin.mem0; + +import org.slf4j.Logger; +import vip.mate.plugin.api.MateClawPlugin; +import vip.mate.plugin.api.PluginContext; + +import java.net.URI; + +/** + * MateClaw plugin entrypoint that registers {@link Mem0Provider} with the + * platform's memory subsystem. + *

+ * Lifecycle: + *

    + *
  1. {@code onLoad} — read config from {@link PluginContext}, build + * {@link Mem0Config} → {@link Mem0Client} → {@link Mem0Provider}, + * then {@code context.registerMemoryProvider(provider)}. + * If the config is incomplete (no baseUrl), the provider is registered + * but reports {@code isAvailable()=false} — the platform silently + * skips it.
  2. + *
  3. {@code onEnable} / {@code onDisable} — lifecycle log only.
  4. + *
+ * + *

This plugin is NOT part of the default stack. Users must: + *

    + *
  1. Self-host a Mem0 service (FastAPI + pgvector + optional Neo4j)
  2. + *
  3. Drop the built JAR into the platform's {@code plugins/} directory
  4. + *
  5. Configure {@code baseUrl} (and optionally {@code apiKey}) via the + * plugin admin UI
  6. + *
+ * + * @author MateClaw Team + */ +public class Mem0Plugin implements MateClawPlugin { + + private static final String CONFIG_BASE_URL = "baseUrl"; + private static final String CONFIG_API_KEY = "apiKey"; + private static final String CONFIG_SEARCH_ENABLED = "searchEnabled"; + private static final String CONFIG_SYNC_ENABLED = "syncEnabled"; + private static final String CONFIG_MAX_RESULTS = "maxResults"; + private static final String CONFIG_TIMEOUT_MS = "timeoutMs"; + + private Logger log; + + @Override + public void onLoad(PluginContext context) { + this.log = context.getLogger(); + + Mem0Config config = readConfig(context); + if (!config.isUsable()) { + log.warn("Mem0 plugin loaded without baseUrl — provider will stay unavailable. " + + "Configure 'baseUrl' in the plugin config to enable."); + } + + Mem0Client client = new Mem0Client(config); + Mem0Provider provider = new Mem0Provider(config, client, log); + context.registerMemoryProvider(provider); + + log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}", + maskUrl(config.baseUrl()), config.searchEnabled(), config.syncEnabled(), + config.maxResults(), config.timeoutMs()); + } + + @Override + public void onEnable() { + if (log != null) log.info("Mem0 plugin enabled"); + } + + @Override + public void onDisable() { + if (log != null) log.info("Mem0 plugin disabled"); + } + + private Mem0Config readConfig(PluginContext ctx) { + String baseUrl = ctx.getConfig(CONFIG_BASE_URL, String.class); + String apiKey = ctx.getConfig(CONFIG_API_KEY, String.class); + Boolean searchEnabled = ctx.getConfig(CONFIG_SEARCH_ENABLED, Boolean.class); + Boolean syncEnabled = ctx.getConfig(CONFIG_SYNC_ENABLED, Boolean.class); + Integer maxResults = ctx.getConfig(CONFIG_MAX_RESULTS, Integer.class); + Integer timeoutMs = ctx.getConfig(CONFIG_TIMEOUT_MS, Integer.class); + + return new Mem0Config( + baseUrl, + apiKey, + searchEnabled == null ? true : searchEnabled, + syncEnabled == null ? true : syncEnabled, + maxResults == null ? Mem0Config.DEFAULT_MAX_RESULTS : maxResults, + timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs + ); + } + + /** + * Mask credentials in the URL when logging. Keeps the scheme + host, + * strips any user info and path. + */ + private static String maskUrl(String url) { + if (url == null || url.isBlank()) return "(unset)"; + try { + URI u = URI.create(url); + String host = u.getHost(); + int port = u.getPort(); + return u.getScheme() + "://" + host + (port > 0 ? ":" + port : ""); + } catch (Exception e) { + return "(malformed)"; + } + } +} diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java new file mode 100644 index 00000000..fc42efd6 --- /dev/null +++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java @@ -0,0 +1,177 @@ +package vip.mate.plugin.mem0; + +import org.slf4j.Logger; +import vip.mate.plugin.api.memory.PluginMemoryProvider; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted + * Mem0 service. + *

+ * Behavior matrix: + *

    + *
  • {@code systemPromptBlock} — no-op (returns ""), aligns with SessionSearchProvider
  • + *
  • {@code prefetch(agentId, query, ownerKey)} — when {@code searchEnabled} + * and {@code ownerKey} is non-blank, calls {@code POST /memories/search/} + * and returns a {@code [Mem0 Recall]} block. Returns "" on any failure + * or when disabled.
  • + *
  • {@code syncTurn(agentId, conversationId, messages, ownerKey)} — when + * {@code syncEnabled} and {@code ownerKey} is non-blank, asynchronously + * pushes the turn to {@code POST /memories/} under {@code user_id = + * ownerKey}, the same identifier prefetch recalls by. Failures are + * logged and swallowed; never blocks the response path. The four-arg + * variant (no ownerKey) skips — writing under any other identifier + * would produce memories that owner-scoped recall can never surface.
  • + *
  • {@code getToolBeans} — empty (no agent-facing tools in v1)
  • + *
+ * + *

Per-owner isolation: {@code ownerKey} (e.g. {@code "user:42"}) is passed + * verbatim as Mem0's {@code user_id}; {@code agentId} as Mem0's {@code agent_id}. + * When {@code ownerKey} is null/blank, both recall and sync are skipped — Mem0 + * requires {@code user_id}. + * + *

Asynchronous sync: a single-thread daemon executor is used + * so that bursts of turns don't pile up on the platform's request thread. + * + * @author MateClaw Team + */ +class Mem0Provider implements PluginMemoryProvider { + + static final String ID = "mem0"; + + private final Mem0Config config; + private final Mem0Client client; + private final Logger log; + private final Executor async; + + Mem0Provider(Mem0Config config, Mem0Client client, Logger log) { + this.config = config; + this.client = client; + this.log = log; + // Single-thread executor is enough — syncTurn calls are sequential per + // agent and not latency-sensitive; the platform's request thread must + // not be blocked. A bounded single-thread queue keeps memory footprint + // predictable even under burst load. + this.async = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "mem0-sync"); + t.setDaemon(true); + return t; + }); + } + + @Override + public String id() { + return ID; + } + + @Override + public int order() { + // Same as the SPI default; declared explicitly for clarity. + return 200; + } + + @Override + public boolean isAvailable() { + // Provider is "available" if at least one of recall/sync can fire. + return config.isUsable() && (config.searchEnabled() || config.syncEnabled()); + } + + @Override + public String systemPromptBlock(Long agentId) { + return ""; + } + + @Override + public String prefetch(Long agentId, String userQuery) { + // Two-arg variant: no owner key → cannot isolate per-user → skip. + // Mem0 requires user_id; without it the call would either fail or + // return global memories breaking per-owner isolation. + return ""; + } + + @Override + public String prefetch(Long agentId, String userQuery, String ownerKey) { + if (!config.searchEnabled()) { + return ""; + } + if (ownerKey == null || ownerKey.isBlank()) { + return ""; + } + if (userQuery == null || userQuery.isBlank()) { + return ""; + } + try { + List memories = client.searchMemories( + ownerKey, agentId == null ? null : agentId.toString(), userQuery); + if (memories.isEmpty()) { + return ""; + } + return formatRecallBlock(memories); + } catch (Exception e) { + // Fault isolation: log and return empty so the platform falls back + // to the other (local) providers without affecting the response. + log.warn("[Mem0] prefetch failed for agent={} owner={}: {}", + agentId, ownerKey, e.getMessage()); + return ""; + } + } + + @Override + public void syncTurn(Long agentId, String conversationId, + String userMessage, String assistantReply) { + // Four-arg variant: no owner key → skip. Mem0 keys memories by user_id; + // writing under any fallback identifier (e.g. agentId) would store + // memories that owner-scoped prefetch can never recall. + } + + @Override + public void syncTurn(Long agentId, String conversationId, + String userMessage, String assistantReply, String ownerKey) { + if (!config.syncEnabled()) { + return; + } + if (ownerKey == null || ownerKey.isBlank()) { + // Same guard as prefetch: Mem0 requires user_id; without the owner + // key the write would break per-owner isolation. + return; + } + if ((userMessage == null || userMessage.isBlank()) + && (assistantReply == null || assistantReply.isBlank())) { + return; + } + CompletableFuture.runAsync(() -> { + try { + client.addMemories(ownerKey, agentId == null ? null : agentId.toString(), + conversationId, userMessage, assistantReply); + } catch (Exception e) { + log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}", + agentId, ownerKey, e.getMessage()); + } + }, async); + } + + @Override + public void onSessionEnd(Long agentId, String conversationId) { + // No Mem0-specific session cleanup needed in v1. + } + + /** + * Format the recalled memories into a labeled block. + *

+ * The {@code [Mem0 Recall]} label is intentional: it lets the LLM + * distinguish this block from the local providers' output and avoid + * treating it as authoritative PROFILE.md content. + */ + private String formatRecallBlock(List memories) { + StringBuilder sb = new StringBuilder(); + sb.append("[Mem0 Recall — semantic matches from external service, treat as hints]\n"); + for (int i = 0; i < memories.size(); i++) { + sb.append(i + 1).append(". ").append(memories.get(i)).append('\n'); + } + return sb.toString(); + } +} diff --git a/mateclaw-plugin-mem0/src/main/resources/mateclaw-plugin.json b/mateclaw-plugin-mem0/src/main/resources/mateclaw-plugin.json new file mode 100644 index 00000000..a0e974a9 --- /dev/null +++ b/mateclaw-plugin-mem0/src/main/resources/mateclaw-plugin.json @@ -0,0 +1,48 @@ +{ + "name": "mateclaw-plugin-mem0", + "version": "1.0.0", + "type": "memory", + "displayName": "Mem0 Memory Provider (Optional)", + "description": "Bridges MateClaw's memory system to a self-hosted Mem0 service. Adds semantic recall from Mem0 alongside the built-in local memory providers. Requires a separately deployed Mem0 service (FastAPI + pgvector). Not part of the default stack.", + "entrypoint": "vip.mate.plugin.mem0.Mem0Plugin", + "minPlatformVersion": "2.0.0", + "author": "MateClaw Team", + "config": { + "baseUrl": { + "type": "string", + "required": true, + "secret": false, + "description": "Mem0 REST API base URL, e.g. http://localhost:8080" + }, + "apiKey": { + "type": "string", + "required": false, + "secret": true, + "description": "Optional bearer token sent as Authorization header to Mem0" + }, + "searchEnabled": { + "type": "boolean", + "required": false, + "secret": false, + "description": "Enable semantic recall via Mem0 /memories/search/. Default true." + }, + "syncEnabled": { + "type": "boolean", + "required": false, + "secret": false, + "description": "Enable pushing each turn to Mem0 /memories/. Default true." + }, + "maxResults": { + "type": "integer", + "required": false, + "secret": false, + "description": "Max number of memories returned per recall. Default 5." + }, + "timeoutMs": { + "type": "integer", + "required": false, + "secret": false, + "description": "HTTP timeout in milliseconds for both recall and sync. Default 3000." + } + } +} diff --git a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ClientTest.java b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ClientTest.java new file mode 100644 index 00000000..b39df290 --- /dev/null +++ b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ClientTest.java @@ -0,0 +1,163 @@ +package vip.mate.plugin.mem0; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class Mem0ClientTest { + + private HttpServer server; + private Mem0Client client; + private final AtomicReference lastPath = new AtomicReference<>(); + private final AtomicReference lastBody = new AtomicReference<>(); + private final AtomicReference lastAuthHeader = new AtomicReference<>(); + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void setUp() throws IOException { + // Capture request details so each test can assert what was sent. + HttpHandler handler = this::handle; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", handler); + server.start(); + + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + Mem0Config config = new Mem0Config(baseUrl, "test-token", true, true, 5, 3000); + client = new Mem0Client(config); + } + + @AfterEach + void tearDown() { + if (server != null) server.stop(0); + } + + private void handle(HttpExchange exchange) throws IOException { + lastPath.set(exchange.getRequestURI().getPath()); + lastAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + try (InputStream in = exchange.getRequestBody()) { + lastBody.set(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + String path = exchange.getRequestURI().getPath(); + if ("/memories/".equals(path) || "/memories/search/".equals(path)) { + byte[] resp; + if ("/memories/".equals(path)) { + resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"x\",\"event\":\"ADD\"}]}".getBytes(StandardCharsets.UTF_8); + } else { + resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"likes Go\",\"score\":0.9},{\"id\":\"m2\",\"memory\":\"works at Acme\",\"score\":0.7}]}".getBytes(StandardCharsets.UTF_8); + } + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + } else { + byte[] resp = "{\"error\":\"not found\"}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(404, resp.length); + exchange.getResponseBody().write(resp); + } + exchange.close(); + } + + @Test + void addMemories_postsToMemoriesEndpointWithCorrectPayload() throws Exception { + client.addMemories("user:42", "1", "conv-abc", "hello", "world"); + + assertThat(lastPath.get()).isEqualTo("/memories/"); + assertThat(lastAuthHeader.get()).isEqualTo("Bearer test-token"); + + JsonNode body = mapper.readTree(lastBody.get()); + assertThat(body.get("user_id").asText()).isEqualTo("user:42"); + assertThat(body.get("agent_id").asText()).isEqualTo("1"); + assertThat(body.get("metadata").get("conversation_id").asText()).isEqualTo("conv-abc"); + assertThat(body.get("messages").size()).isEqualTo(2); + assertThat(body.get("messages").get(0).get("role").asText()).isEqualTo("user"); + assertThat(body.get("messages").get(0).get("content").asText()).isEqualTo("hello"); + assertThat(body.get("messages").get(1).get("role").asText()).isEqualTo("assistant"); + assertThat(body.get("messages").get(1).get("content").asText()).isEqualTo("world"); + } + + @Test + void addMemories_omitsBlankMessages() throws Exception { + client.addMemories("user:42", "1", null, " ", "reply"); + + JsonNode body = mapper.readTree(lastBody.get()); + assertThat(body.get("messages").size()).isEqualTo(1); + assertThat(body.get("messages").get(0).get("role").asText()).isEqualTo("assistant"); + // metadata should be absent since conversationId is null + assertThat(body.has("metadata")).isFalse(); + } + + @Test + void searchMemories_returnsParsedMemoryStrings() { + List results = client.searchMemories("user:42", "1", "what language"); + + assertThat(results).containsExactly("likes Go", "works at Acme"); + + assertThat(lastPath.get()).isEqualTo("/memories/search/"); + assertThat(lastAuthHeader.get()).isEqualTo("Bearer test-token"); + } + + @Test + void searchMemories_includesQueryUserIdAndLimitInBody() throws Exception { + client.searchMemories("user:42", "1", "query text"); + + JsonNode body = mapper.readTree(lastBody.get()); + assertThat(body.get("query").asText()).isEqualTo("query text"); + assertThat(body.get("user_id").asText()).isEqualTo("user:42"); + assertThat(body.get("agent_id").asText()).isEqualTo("1"); + assertThat(body.get("limit").asInt()).isEqualTo(5); // from Mem0Config in setUp + } + + @Test + void non2xxResponseThrowsMem0Exception() { + // Use a client pointed at a non-existent path on the running server. + // Reconfigure handler to return 500 for the next call. + server.removeContext("/"); + server.createContext("/", ex -> { + ex.sendResponseHeaders(500, 0); + ex.close(); + }); + + assertThatThrownBy(() -> client.searchMemories("user:42", "1", "q")) + .isInstanceOf(Mem0Exception.class) + .hasMessageContaining("HTTP 500"); + } + + @Test + void connectionFailureThrowsMem0Exception() { + // Stop the server, then call — should fail with connection refused. + int port = server.getAddress().getPort(); + server.stop(0); + Mem0Config cfg = new Mem0Config("http://127.0.0.1:" + port, null, true, true, 5, 500); + Mem0Client deadClient = new Mem0Client(cfg); + + assertThatThrownBy(() -> deadClient.searchMemories("user:42", "1", "q")) + .isInstanceOf(Mem0Exception.class) + .hasMessageContaining("request failed"); + } + + @Test + void buildAddPayload_isConsistentWithAddMemories() { + // buildAddPayload is a test helper used to inspect payload structure + // without sending; verify it matches what addMemories would send. + Map payload = client.buildAddPayload("user:42", "1", "conv-x", "hi", "there"); + assertThat(payload).containsEntry("user_id", "user:42"); + assertThat(payload).containsEntry("agent_id", "1"); + assertThat(payload).containsKey("messages"); + assertThat(payload).containsKey("metadata"); + } +} diff --git a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ConfigTest.java b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ConfigTest.java new file mode 100644 index 00000000..f1d0a279 --- /dev/null +++ b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ConfigTest.java @@ -0,0 +1,38 @@ +package vip.mate.plugin.mem0; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class Mem0ConfigTest { + + @Test + void isUsable_false_whenBaseUrlNull() { + Mem0Config c = new Mem0Config(null, null, true, true, 5, 1000); + assertThat(c.isUsable()).isFalse(); + } + + @Test + void isUsable_false_whenBaseUrlBlank() { + Mem0Config c = new Mem0Config(" ", null, true, true, 5, 1000); + assertThat(c.isUsable()).isFalse(); + } + + @Test + void isUsable_true_whenBaseUrlSet() { + Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000); + assertThat(c.isUsable()).isTrue(); + } + + @Test + void normalizedBaseUrl_stripsTrailingSlashes() { + Mem0Config c = new Mem0Config("http://localhost:8080///", null, true, true, 5, 1000); + assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080"); + } + + @Test + void normalizedBaseUrl_keepsUrlWithoutTrailingSlash() { + Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000); + assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080"); + } +} diff --git a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0PluginTest.java b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0PluginTest.java new file mode 100644 index 00000000..b4f0739a --- /dev/null +++ b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0PluginTest.java @@ -0,0 +1,146 @@ +package vip.mate.plugin.mem0; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.tool.ToolCallback; +import vip.mate.plugin.api.PluginContext; +import vip.mate.plugin.api.PluginException; +import vip.mate.plugin.api.channel.PluginChannelAdapter; +import vip.mate.plugin.api.memory.PluginMemoryProvider; +import vip.mate.plugin.api.search.PluginSearchProvider; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class Mem0PluginTest { + + @Test + void onLoad_readsConfigAndRegistersProvider() { + Map config = new HashMap<>(); + config.put("baseUrl", "http://localhost:8080"); + config.put("apiKey", "secret"); + config.put("searchEnabled", true); + config.put("syncEnabled", false); + config.put("maxResults", 7); + config.put("timeoutMs", 5000); + + AtomicReference registered = new AtomicReference<>(); + PluginContext ctx = new StubContext(config, registered); + + Mem0Plugin plugin = new Mem0Plugin(); + plugin.onLoad(ctx); + plugin.onEnable(); + + PluginMemoryProvider p = registered.get(); + assertThat(p).isNotNull(); + assertThat(p.id()).isEqualTo("mem0"); + assertThat(p.isAvailable()).isTrue(); // baseUrl set + searchEnabled true + + plugin.onDisable(); + } + + @Test + void onLoad_withMissingBaseUrl_stillRegistersButUnavailable() { + // No baseUrl configured — plugin should register but report unavailable + // rather than throwing. + Map config = new HashMap<>(); // empty + AtomicReference registered = new AtomicReference<>(); + PluginContext ctx = new StubContext(config, registered); + + Mem0Plugin plugin = new Mem0Plugin(); + plugin.onLoad(ctx); + + PluginMemoryProvider p = registered.get(); + assertThat(p).isNotNull(); + assertThat(p.isAvailable()).isFalse(); + } + + @Test + void onLoad_appliesDefaultsToOptionalConfig() { + // Only baseUrl set — searchEnabled/syncEnabled/maxResults/timeoutMs + // should default. + Map config = new HashMap<>(); + config.put("baseUrl", "http://localhost:8080"); + + AtomicReference registered = new AtomicReference<>(); + PluginContext ctx = new StubContext(config, registered); + + Mem0Plugin plugin = new Mem0Plugin(); + plugin.onLoad(ctx); + + // Verify defaults indirectly: searchEnabled and syncEnabled both default + // to true → isAvailable() must be true. + assertThat(registered.get().isAvailable()).isTrue(); + } + + @Test + void onLoad_throwsWhenContextRejectsSecondProvider() { + // Simulate the platform's single-select constraint by throwing from + // registerMemoryProvider. + Map config = new HashMap<>(); + config.put("baseUrl", "http://localhost:8080"); + AtomicReference registered = new AtomicReference<>(); + PluginContext ctx = new StubContext(config, registered) { + @Override + public void registerMemoryProvider(PluginMemoryProvider provider) { + throw new PluginException("Only one external memory provider allowed"); + } + }; + + Mem0Plugin plugin = new Mem0Plugin(); + assertThatThrownBy(() -> plugin.onLoad(ctx)) + .isInstanceOf(PluginException.class) + .hasMessageContaining("Only one"); + } + + /** + * Minimal PluginContext stub: only getConfig / registerMemoryProvider / + * getLogger are exercised by Mem0Plugin; everything else throws. + */ + static class StubContext implements PluginContext { + private final Map config; + private final AtomicReference registered; + + StubContext(Map config, AtomicReference registered) { + this.config = config; + this.registered = registered; + } + + @Override + @SuppressWarnings("unchecked") + public T getConfig(String key, Class type) { + Object v = config.get(key); + if (v == null) return null; + if (type.isInstance(v)) return (T) v; + // Best-effort scalar coercion for Integer/Boolean from String/Number + if (type == Integer.class && v instanceof Number n) return (T) (Integer) n.intValue(); + if (type == Boolean.class && v instanceof Boolean b) return (T) b; + return null; + } + + @Override + public Logger getLogger() { + return LoggerFactory.getLogger("test.Mem0Plugin"); + } + + @Override + public void registerMemoryProvider(PluginMemoryProvider provider) { + registered.set(provider); + } + + // The remaining methods are not used by Mem0Plugin; stub them out. + + @Override public void registerTool(ToolCallback tool) { throw new UnsupportedOperationException(); } + @Override public void registerTool(ToolCallback tool, Supplier availabilityCheck) { throw new UnsupportedOperationException(); } + @Override public void registerProvider(String providerId, ChatModel chatModel) { throw new UnsupportedOperationException(); } + @Override public void registerChannel(PluginChannelAdapter channel) { throw new UnsupportedOperationException(); } + @Override public void registerSearchProvider(PluginSearchProvider provider) { throw new UnsupportedOperationException(); } + } +} diff --git a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java new file mode 100644 index 00000000..0a84d40d --- /dev/null +++ b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java @@ -0,0 +1,219 @@ +package vip.mate.plugin.mem0; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class Mem0ProviderTest { + + private HttpServer server; + private Mem0Provider provider; + private final AtomicInteger addCount = new AtomicInteger(); + private final AtomicInteger searchCount = new AtomicInteger(); + private final AtomicReference lastAddBody = new AtomicReference<>(); + + @BeforeEach + void setUp() throws IOException { + addCount.set(0); + searchCount.set(0); + lastAddBody.set(null); + HttpHandler handler = this::handle; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", handler); + server.start(); + + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + Mem0Config config = new Mem0Config(baseUrl, null, true, true, 3, 3000); + Mem0Client client = new Mem0Client(config); + provider = new Mem0Provider(config, client, LoggerFactory.getLogger("test")); + } + + @AfterEach + void tearDown() { + if (server != null) server.stop(0); + } + + private void handle(HttpExchange exchange) throws IOException { + String body; + try (InputStream in = exchange.getRequestBody()) { + body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + String path = exchange.getRequestURI().getPath(); + byte[] resp; + if ("/memories/".equals(path)) { + addCount.incrementAndGet(); + lastAddBody.set(body); + resp = "{\"results\":[]}".getBytes(StandardCharsets.UTF_8); + } else if ("/memories/search/".equals(path)) { + searchCount.incrementAndGet(); + resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"likes PostgreSQL\",\"score\":0.9}]}".getBytes(StandardCharsets.UTF_8); + } else { + resp = "{}".getBytes(StandardCharsets.UTF_8); + } + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.close(); + } + + @Test + void id_isMem0() { + assertThat(provider.id()).isEqualTo("mem0"); + } + + @Test + void isAvailable_true_whenConfigUsableAndAtLeastOneFeatureEnabled() { + assertThat(provider.isAvailable()).isTrue(); + } + + @Test + void isAvailable_false_whenBaseUrlMissing() { + Mem0Config cfg = new Mem0Config(null, null, true, true, 5, 1000); + Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test")); + assertThat(p.isAvailable()).isFalse(); + } + + @Test + void isAvailable_false_whenBothFeaturesDisabled() { + Mem0Config cfg = new Mem0Config("http://localhost:8080", null, false, false, 5, 1000); + Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test")); + assertThat(p.isAvailable()).isFalse(); + } + + @Test + void systemPromptBlock_isEmpty() { + assertThat(provider.systemPromptBlock(1L)).isEmpty(); + } + + @Test + void twoArgPrefetch_returnsEmptyBecauseNoOwnerKey() { + // Without ownerKey, Mem0 cannot isolate per-user; provider skips. + assertThat(provider.prefetch(1L, "hello")).isEmpty(); + assertThat(searchCount.get()).isZero(); + } + + @Test + void threeArgPrefetch_returnsRecallBlock() { + String result = provider.prefetch(1L, "what database", "user:42"); + + assertThat(result).startsWith("[Mem0 Recall"); + assertThat(result).contains("likes PostgreSQL"); + assertThat(searchCount.get()).isEqualTo(1); + } + + @Test + void threeArgPrefetch_returnsEmptyWhenOwnerKeyBlank() { + assertThat(provider.prefetch(1L, "query", "")).isEmpty(); + assertThat(provider.prefetch(1L, "query", null)).isEmpty(); + assertThat(searchCount.get()).isZero(); + } + + @Test + void threeArgPrefetch_returnsEmptyWhenQueryBlank() { + assertThat(provider.prefetch(1L, "", "user:42")).isEmpty(); + assertThat(provider.prefetch(1L, null, "user:42")).isEmpty(); + assertThat(searchCount.get()).isZero(); + } + + @Test + void threeArgPrefetch_returnsEmptyOnServerError() { + // Replace handler to fail; the provider should swallow and return "". + server.removeContext("/"); + server.createContext("/", ex -> { + ex.sendResponseHeaders(500, 0); + ex.close(); + }); + + String result = provider.prefetch(1L, "q", "user:42"); + assertThat(result).isEmpty(); + } + + @Test + void syncTurn_pushesAsynchronouslyWithOwnerKeyAsUserId() throws Exception { + provider.syncTurn(1L, "conv-1", "hello", "world", "user:42"); + + // Wait briefly for the async executor to fire the POST. + long deadline = System.currentTimeMillis() + 2000; + while (addCount.get() == 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + assertThat(addCount.get()).isEqualTo(1); + // The write must land under the same user_id that prefetch recalls by. + assertThat(lastAddBody.get()).contains("\"user_id\":\"user:42\""); + assertThat(lastAddBody.get()).contains("\"agent_id\":\"1\""); + } + + @Test + void fourArgSyncTurn_skipsBecauseNoOwnerKey() throws Exception { + // Without ownerKey, a write would be keyed by an identifier that + // owner-scoped prefetch never queries; the provider must skip. + provider.syncTurn(1L, "conv-1", "hello", "world"); + Thread.sleep(200); // give async a chance to (not) fire + assertThat(addCount.get()).isZero(); + } + + @Test + void syncTurn_skipsWhenOwnerKeyBlank() throws Exception { + provider.syncTurn(1L, "conv-1", "hello", "world", ""); + provider.syncTurn(1L, "conv-1", "hello", "world", null); + Thread.sleep(200); + assertThat(addCount.get()).isZero(); + } + + @Test + void syncTurn_skipsWhenBothMessagesBlank() throws Exception { + provider.syncTurn(1L, "conv-1", " ", "", "user:42"); + Thread.sleep(200); // give async a chance to (not) fire + assertThat(addCount.get()).isZero(); + } + + @Test + void syncTurn_failureIsSwallowedAndDoesNotThrow() throws Exception { + // Stop the server so the async POST fails; provider must not propagate. + server.stop(0); + // Re-create a stub server just so tearDown doesn't NPE; not listening + // on the original port anymore — the client will get connection refused. + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", ex -> { ex.sendResponseHeaders(200, 0); ex.close(); }); + // Note: client still points at the old port → connection refused. + + provider.syncTurn(1L, "conv-1", "hi", "there", "user:42"); + Thread.sleep(500); + // No exception thrown; nothing to assert beyond "test didn't blow up". + } + + @Test + void syncTurn_skippedWhenSyncDisabled() throws Exception { + // Build a provider with sync disabled. + Mem0Config cfg = new Mem0Config( + "http://127.0.0.1:" + server.getAddress().getPort(), + null, true, false, 3, 3000); + Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test")); + p.syncTurn(1L, "conv-1", "hi", "there", "user:42"); + Thread.sleep(200); + assertThat(addCount.get()).isZero(); + } + + @Test + void prefetch_skippedWhenSearchDisabled() { + Mem0Config cfg = new Mem0Config( + "http://127.0.0.1:" + server.getAddress().getPort(), + null, false, true, 3, 3000); + Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test")); + assertThat(p.prefetch(1L, "q", "user:42")).isEmpty(); + assertThat(searchCount.get()).isZero(); + } +} diff --git a/mateclaw-server/Dockerfile b/mateclaw-server/Dockerfile index 4e9cd4b5..8875b50c 100644 --- a/mateclaw-server/Dockerfile +++ b/mateclaw-server/Dockerfile @@ -42,12 +42,19 @@ ARG MAVEN_FLAGS="" COPY mateclaw-server/settings.xml /root/.m2/settings.xml # Copy the root parent plus module POMs first for Docker layer caching. +# +# This list MUST mirror in the root pom.xml, even for modules this +# image never builds. Maven fails while constructing the reactor if a declared +# module directory is missing ("Child module /build/ does not exist"), +# so `-pl mateclaw-server -am` aborts before it ever gets to dependency +# resolution. When a module is added to the root POM, add its pom.xml here too. WORKDIR /build COPY pom.xml ./pom.xml COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml COPY mateclaw-server/pom.xml mateclaw-server/pom.xml COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml COPY mateclaw-plugin-search-sample/pom.xml mateclaw-plugin-search-sample/pom.xml +COPY mateclaw-plugin-mem0/pom.xml mateclaw-plugin-mem0/pom.xml # Pre-fetch backend dependencies through the reactor so the parent POM, # dependencyManagement, and internal module versions all resolve consistently. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 2c669e0d..aacdc66a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -60,6 +60,8 @@ import vip.mate.tool.guard.service.ToolGuardService; import vip.mate.workspace.conversation.ConversationService; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.service.TeamContextBuilder; +import vip.mate.team.service.TeamPlanBridge; import vip.mate.wiki.service.WikiContextService; import java.lang.reflect.Field; @@ -101,6 +103,8 @@ public class AgentGraphBuilder { "${mate.agent.markdown-normalize-enabled:true}") private boolean markdownNormalizeEnabled; private final ConversationService conversationService; + private final TeamContextBuilder teamContextBuilder; + private final TeamPlanBridge teamPlanBridge; private final ModelConfigService modelConfigService; private final ModelProviderService modelProviderService; private final ModelContextWindowResolver contextWindowResolver; @@ -641,6 +645,9 @@ public class AgentGraphBuilder { executor.setAuditEventService(auditEventService); } PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService); + // Team hand-off: a lead-of-team plan agent parks multi-step plans on + // the team task board instead of the serial delegation pipeline. + planGenerationNode.setTeamPlanBridge(teamPlanBridge); StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer); // Per-step delegation: route a step assigned to a specialist agent // through DelegateAgentTool (null when delegation deps aren't wired). @@ -785,7 +792,10 @@ public class AgentGraphBuilder { AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()), Map.of( PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE, - PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE)) + PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE, + // Board-delegated plan settled: step results were + // rebuilt from team tasks — summarize directly. + PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE)) .addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE, AsyncEdgeAction.edge_async(new StepProgressDispatcher()), Map.of( @@ -960,6 +970,9 @@ public class AgentGraphBuilder { // C4: wire the environment-notification registry so ReasoningNode // can drain pending MCP/skill events and inject them as a SystemMessage. reasoningNode.setRunningConversationRegistry(runningConversationRegistry); + // Live team-board snapshot for leads, injected per turn as a meta + // user message; no-op for agents outside any team. + reasoningNode.setTeamContextBuilder(teamContextBuilder); ActionNode actionNode = new ActionNode(executor, streamTracker); // B2/B5: wire optional collaborators so ActionNode can pin skill // constraints and auto-record tool completions into ProgressLedger. @@ -1743,10 +1756,20 @@ public class AgentGraphBuilder { """; } - // Wiki 知识库上下文注入 - String wikiContext = wikiContextService.buildWikiContext(entity.getId()); + // Wiki 知识库上下文注入。Share the same prefix budget as the memory + // block so a large KB's page listing can't consume a fixed + // maxContextChars-sized slice of a small model's window every turn + // (issue #521). Integer.MAX_VALUE (the unbudgeted default path) keeps + // the legacy chars-only cap for large cloud models. + Integer wikiBudgetTokens = memoryBudgetTokens == Integer.MAX_VALUE ? null : memoryBudgetTokens; + String wikiContext = wikiContextService.buildWikiContext(entity.getId(), wikiBudgetTokens); - return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext; + // Team context (role-specific board playbook, or a negative notice for + // agents outside any team). Baked here so it shares the prompt-cache + // prefix; TeamChangedEvent evicts the cached agent on composition changes. + String teamContext = teamContextBuilder.buildTeamContext(entity.getId()); + + return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext + teamContext; } /** @@ -1779,7 +1802,7 @@ public class AgentGraphBuilder { boolean anyHasConstraints = false; for (String skillName : loaded) { try { - vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName); + vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName, workspaceId); if (skill != null && skill.getManifest() != null) { List constraints = skill.getManifest().getConstraints(); if (constraints != null && !constraints.isEmpty()) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 5671523a..fdada970 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -13,6 +13,7 @@ import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOriginHolder; import vip.mate.agent.event.AgentLifecycleEvent; import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.progress.ProgressLedgerService; import vip.mate.agent.repository.AgentMapper; import vip.mate.exception.MateClawException; import vip.mate.llm.chatmodel.ThinkingLevelHolder; @@ -21,6 +22,7 @@ import vip.mate.memory.MemoryProperties; import vip.mate.memory.lifecycle.MemoryLifecycleMediator; import vip.mate.memory.lifecycle.TurnContext; import vip.mate.memory.service.MemoryRecallTracker; +import vip.mate.team.event.TeamChangedEvent; import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.repository.ConversationMapper; @@ -68,6 +70,14 @@ public class AgentService { @Autowired(required = false) private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry; + /** + * Optional — clears leftover auto-recorded ledger entries when a new + * user turn starts. Field-injected so existing test constructors of + * {@code AgentService} don't need to supply it. + */ + @Autowired(required = false) + private ProgressLedgerService progressLedgerService; + /** * Runtime Agent instance cache. Keyed first by agentId, then by a model * key, so a conversation that pins a non-default model gets its own graph @@ -239,8 +249,46 @@ public class AgentService { } } + /** + * Invalidate cached agents whenever their team's composition or settings + * change. The team context block is baked into the system prompt at build + * time, so membership edits would otherwise stay invisible until restart. + */ + @EventListener + public void onTeamChanged(TeamChangedEvent event) { + if (event.agentIds() != null) { + event.agentIds().forEach(agentInstances::remove); + } + } + // ==================== 运行时入口 ==================== + /** + * New-user-turn housekeeping: drop auto-recorded ledger entries left + * over from the previous turn. They mark past tool calls as DONE, and + * the ledger snapshot's "已完成的步骤不要重复执行" instruction would + * otherwise stop the agent from re-running status-query tools when the + * user repeats a question that needs fresh data. + * + *

Only the fresh-turn entries ({@code chat} / {@code chatStream} / + * {@code chatStructuredStream} / {@code execute}) call this. The + * approval-replay entries ({@code chatWithReplay*}) resume the SAME + * logical turn after a tool approval and must keep the safety net for + * work already done before the pause. + */ + private void clearAutoRecordedForNewTurn(String conversationId) { + if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) { + return; + } + try { + progressLedgerService.clearAutoRecorded(conversationId); + } catch (Exception e) { + // Ledger housekeeping must never block the chat itself. + log.warn("Failed to clear auto-recorded ledger entries for {}: {}", + conversationId, e.getMessage()); + } + } + public String chat(Long agentId, String message, String conversationId) { return chat(agentId, message, conversationId, ChatOrigin.EMPTY); } @@ -251,6 +299,7 @@ public class AgentService { * down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}. */ public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) { + clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); @@ -287,6 +336,7 @@ public class AgentService { } public Flux chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) { + clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); // Capture the origin into a request-scoped holder; cleared on Flux @@ -323,6 +373,7 @@ public class AgentService { public Flux chatStructuredStream(Long agentId, String message, String conversationId, String requesterId, String thinkingLevel, ChatOrigin origin) { + clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); @@ -369,6 +420,7 @@ public class AgentService { } public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) { + clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, goal); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/AgentWorkspaceResolver.java b/mateclaw-server/src/main/java/vip/mate/agent/context/AgentWorkspaceResolver.java new file mode 100644 index 00000000..e66fcd9a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/AgentWorkspaceResolver.java @@ -0,0 +1,38 @@ +package vip.mate.agent.context; + +import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.approval.grant.WorkspaceLookupCache; + +/** + * Resolves the workspace of the currently-executing conversation for the agent + * runtime skill-resolution path. + *

+ * The {@link ChatOrigin} carried in a tool's {@code ToolContext} usually already + * holds the workspaceId (populated at the web / channel entry point). Some paths + * — notably approval replay — carry a conversationId but a {@code null} + * workspaceId; there we fall back to {@link WorkspaceLookupCache}, which maps a + * conversationId to its owning workspace. When neither yields a workspace, the + * result is {@code null}: callers must treat that conservatively (resolve only + * builtin / global skills, never another workspace's skill). + */ +@Component +@RequiredArgsConstructor +public class AgentWorkspaceResolver { + + private final WorkspaceLookupCache workspaceLookupCache; + + /** Best-effort workspace id for the given origin; {@code null} if unresolved. */ + @Nullable + public Long resolve(@Nullable ChatOrigin origin) { + if (origin == null) { + return null; + } + if (origin.workspaceId() != null) { + return origin.workspaceId(); + } + String conversationId = origin.conversationId(); + return conversationId != null ? workspaceLookupCache.resolveByConversation(conversationId) : null; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index 3aacd086..ba5ecd35 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -8,6 +8,8 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.http.HttpHeaders; +import org.springframework.web.client.RestClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.llm.chatmodel.AssistantThinkingRelay; @@ -15,6 +17,10 @@ import vip.mate.llm.chatmodel.ReasoningContentCache; import reactor.core.Disposable; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -193,6 +199,16 @@ public class NodeStreamingChatHelper { else healthTracker.recordFailure(primaryProviderId); } + /** + * Record a primary failure carrying a provider-stated retry window so the + * health tracker can start a cooldown of exactly that length. No-op under + * the same conditions as {@link #recordPrimary}. + */ + private void recordPrimaryFailure(long cooldownOverrideMs) { + if (healthTracker == null || primaryProviderId == null) return; + healthTracker.recordFailure(primaryProviderId, cooldownOverrideMs); + } + /** * Map an {@link ErrorType} to the matching pool * {@link vip.mate.llm.failover.AvailableProviderPool.RemovalSource} for @@ -207,7 +223,11 @@ public class NodeStreamingChatHelper { * {@link vip.mate.llm.failover.ProviderHealthTracker}'s cooldown instead.

*/ private static vip.mate.llm.failover.AvailableProviderPool.RemovalSource hardRemovalSource(ErrorType type) { - if (type == null) return null; + // Policy lives on the enum ({@code evictsProvider}); this switch is + // only the name mapping to the pool's RemovalSource. A type marked + // evicting but missing here falls through to null (fail-open, logged + // nowhere) — extend the switch when adding a new evicting type. + if (type == null || !type.evictsProvider()) return null; return switch (type) { case AUTH_ERROR -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.AUTH_ERROR; case BILLING -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.BILLING; @@ -224,11 +244,7 @@ public class NodeStreamingChatHelper { * says nothing about whether the provider's other models still work. */ private static boolean isProviderLevelFailure(ErrorType type) { - if (type == null) return false; - return switch (type) { - case NONE, PROMPT_TOO_LONG, CLIENT_ERROR, THINKING_BLOCK_ERROR, MODEL_NOT_FOUND -> false; - default -> true; - }; + return type != null && type.countsHealth(); } /** Convenience: pool-aware membership check. Null pool means fail-open (everyone in). */ @@ -373,6 +389,21 @@ public class NodeStreamingChatHelper { // avoid masking truly fatal errors. MAX_TOTAL_DURATION_MS is the // ultimate safety net. static final int MAX_RETRIES_UNKNOWN = 5; + // OVERLOADED: the provider's serving capacity is saturated (Anthropic 529, + // "engine_overloaded", "model is overloaded"). Unlike RATE_LIMIT this says + // nothing about the caller's key, so waiting on the same provider is the + // productive move — recovery periods are typically tens of seconds, hence + // the dedicated long backoff table below instead of the generic 3s-based + // exponential. + static final int MAX_RETRIES_OVERLOADED = 5; + /** + * Backoff table for {@link ErrorType#OVERLOADED} retries, indexed by + * {@code attempt - 1} (attempts past the table reuse the last entry). + * A ±30% jitter is applied on top so concurrent conversations don't + * re-hit a saturated provider in lockstep. The 3-minute wall-clock + * budget still bounds the total wait. + */ + static final long[] OVERLOADED_BACKOFF_MS = {10_000, 20_000, 40_000, 60_000, 60_000}; // Hard time budget for the primary retry loop (3 min). Prevents // retries from stalling a single conversation turn indefinitely. // Aligned with WikiProcessingService.llmMaxTotalDurationMs. @@ -455,9 +486,24 @@ public class NodeStreamingChatHelper { || msg.contains("authentication") || msg.contains("AuthenticationError")) { return ErrorType.AUTH_ERROR; } + // Overloaded — the provider's serving capacity is saturated. Checked + // BEFORE the rate-limit patterns: providers commonly surface overload + // through a reused 429 status ("engine_overloaded" arrives alongside + // "429" in the same chain), and the more specific semantic must win — + // an overloaded provider deserves patient same-provider backoff, not + // the rate-limit fast-failover path. + if (msg.contains("engine_overloaded") + || msg.contains("overloaded_error") // Anthropic 529 body type + || msg.contains("Overloaded") // Anthropic 529 message + || msg.contains("model is overloaded") // Gemini / OpenAI-compatible + || msg.contains("529") + || msg.contains("server is busy") + || msg.contains("当前分组上游负载已饱和")) { // SiliconFlow group saturation + return ErrorType.OVERLOADED; + } // Rate limit if (msg.contains("429") || msg.contains("rate_limit") || msg.contains("RateLimitError") - || msg.contains("Too Many Requests") || msg.contains("engine_overloaded")) { + || msg.contains("Too Many Requests")) { return ErrorType.RATE_LIMIT; } // Thinking block errors (Anthropic: old thinking blocks cannot be modified) @@ -549,8 +595,7 @@ public class NodeStreamingChatHelper { // surfaced as HTTP 400 with a body that describes the upstream // outage. These are transient server-side failures — retryable. || msg.contains("temporarily unavailable") - || msg.contains("service unavailable") - || msg.contains("model is overloaded")) { + || msg.contains("service unavailable")) { return ErrorType.SERVER_ERROR; } // Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable. @@ -568,6 +613,111 @@ public class NodeStreamingChatHelper { return ErrorType.UNKNOWN; } + /** + * Ceiling for honoring a provider-stated retry window as an in-loop + * backoff sleep. Longer windows (quota resets measured in minutes or + * hours) are not worth blocking a conversation turn for — the call fails + * over instead, and the window is honored as a + * {@code ProviderHealthTracker} cooldown override so later turns skip + * the provider without re-probing it. + */ + static final long HINTED_BACKOFF_CAP_MS = 90_000; + + /** Floor / ceiling for any parsed retry-window hint (guards absurd values). */ + private static final long MIN_HINT_MS = 1_000; + private static final long MAX_HINT_MS = 2 * 60 * 60 * 1000L; + + private static final List ANTHROPIC_RESET_HEADERS = List.of( + "anthropic-ratelimit-requests-reset", + "anthropic-ratelimit-tokens-reset", + "anthropic-ratelimit-input-tokens-reset", + "anthropic-ratelimit-output-tokens-reset"); + + private static final List OPENAI_RESET_HEADERS = List.of( + "x-ratelimit-reset-requests", + "x-ratelimit-reset-tokens"); + + /** Matches Go-style duration strings ("1s", "6m0s", "120ms", "1h2m"). */ + private static final java.util.regex.Pattern GO_DURATION = java.util.regex.Pattern.compile( + "^(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+(?:\\.\\d+)?)s)?(?:(\\d+)ms)?$"); + + /** + * Walk the error chain for an HTTP response exception and parse the + * provider-stated retry window from its headers. Returns milliseconds + * clamped to {@code [MIN_HINT_MS, MAX_HINT_MS]}, or {@code 0} when no + * usable hint is present. + * + *

Priority: {@code Retry-After} (delta-seconds or HTTP-date) → + * Anthropic RFC-3339 reset instants → OpenAI-style duration resets. For + * multi-bucket reset headers the earliest future instant wins — + * optimistic, because a premature retry just re-records the hint, while + * over-waiting silently costs the user the whole window.

+ */ + static long extractRetryAfterMs(Throwable error) { + for (Throwable cur = error; cur != null; cur = cur.getCause()) { + HttpHeaders headers = null; + if (cur instanceof WebClientResponseException wre) { + headers = wre.getHeaders(); + } else if (cur instanceof RestClientResponseException rre) { + headers = rre.getResponseHeaders(); + } + if (headers == null) continue; + long ms = parseRetryWindowMs(headers); + if (ms > 0) return ms; + } + return 0; + } + + private static long parseRetryWindowMs(HttpHeaders headers) { + String retryAfter = headers.getFirst("retry-after"); + if (retryAfter != null && !retryAfter.isBlank()) { + String v = retryAfter.trim(); + if (v.chars().allMatch(Character::isDigit)) { + return clampHint(Long.parseLong(v) * 1000); + } + try { + long epochMs = ZonedDateTime.parse(v, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant().toEpochMilli(); + return clampHint(epochMs - System.currentTimeMillis()); + } catch (DateTimeParseException ignored) { + // fall through to the reset headers + } + } + long best = 0; + for (String name : ANTHROPIC_RESET_HEADERS) { + String v = headers.getFirst(name); + if (v == null || v.isBlank()) continue; + try { + long delta = Instant.parse(v.trim()).toEpochMilli() - System.currentTimeMillis(); + if (delta > 0 && (best == 0 || delta < best)) best = delta; + } catch (DateTimeParseException ignored) { + } + } + if (best > 0) return clampHint(best); + for (String name : OPENAI_RESET_HEADERS) { + long ms = parseGoDurationMs(headers.getFirst(name)); + if (ms > 0 && (best == 0 || ms < best)) best = ms; + } + return best > 0 ? clampHint(best) : 0; + } + + private static long parseGoDurationMs(String value) { + if (value == null || value.isBlank()) return 0; + java.util.regex.Matcher m = GO_DURATION.matcher(value.trim()); + if (!m.matches()) return 0; + long ms = 0; + if (m.group(1) != null) ms += Long.parseLong(m.group(1)) * 3_600_000L; + if (m.group(2) != null) ms += Long.parseLong(m.group(2)) * 60_000L; + if (m.group(3) != null) ms += (long) (Double.parseDouble(m.group(3)) * 1000); + if (m.group(4) != null) ms += Long.parseLong(m.group(4)); + return ms; + } + + private static long clampHint(long ms) { + if (ms <= 0) return 0; + return Math.max(MIN_HINT_MS, Math.min(ms, MAX_HINT_MS)); + } + /** 提取完整异常链信息用于关键字匹配 */ private static String extractFullErrorChain(Throwable error) { StringBuilder sb = new StringBuilder(); @@ -640,6 +790,17 @@ public class NodeStreamingChatHelper { int failoverCount = 0; int llmCallCount = 0; long callStartMs = System.currentTimeMillis(); + // True once the generic routing below has already recorded a health + // failure for this incident — stops the post-loop fallback record from + // double-counting it. + boolean healthRecorded = false; + // Carries the ErrorType behind each null-return retry so the next + // attempt's backoff can be type-aware (see doStreamCall). + AtomicReference retryType = new AtomicReference<>(); + // Provider-stated retry window (ms) parsed from the latest 429/529 + // response headers; 0 when absent. Consumed by the next attempt's + // backoff and by the health-cooldown override on failover. + AtomicReference retryHint = new AtomicReference<>(0L); // 主模型重试循环 StreamResult lastResult = null; @@ -655,111 +816,82 @@ public class NodeStreamingChatHelper { } llmCallCount++; if (attempt > 0) retryCount++; - lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true); + lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true, retryType, retryHint); if (lastResult != null) { - // PTL: 不重试,直接返回给上层 Node 处理 - if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) { - return lastResult; - } - // AUTH: primary key 失效不会自愈,跳过同模型重试,交给 fallback chain - // — 其它 provider 的 key 可能仍然可用(与 BILLING / MODEL_NOT_FOUND 同策略)。 - // recordPrimary(false) 仍记一次失败用于 healthTracker 冷却累计。 - // 若 fallback chain 全部 401,walker 末尾会把最后一次 AUTH_ERROR 透出, - // 不会静默吞错。 - if (lastResult.errorType() == ErrorType.AUTH_ERROR) { - log.warn("[{}] Primary auth failed — skipping same-model retries, handing off to fallback chain", phase); - recordPrimary(false); - removeFromPool(primaryProviderId, ErrorType.AUTH_ERROR, lastResult.errorMessage()); - break; - } - // BILLING — provider-side hard failure (out of credit). Won't change - // on retry and affects every model on the provider, so evict it and - // hand off to the fallback chain (a different provider may have credits). - if (lastResult.errorType() == ErrorType.BILLING) { - log.warn("[{}] Primary billing failure — skipping same-model retries, handing off to fallback chain", phase); - recordPrimary(false); - removeFromPool(primaryProviderId, ErrorType.BILLING, lastResult.errorMessage()); - break; - } - // MODEL_NOT_FOUND — the provider rejected this specific model id. The - // provider itself is healthy, so do NOT evict it from the pool or - // record a provider-level failure: that would take its sibling models - // down too. Just skip same-model retries and hand off to the fallback - // chain — a different provider may recognize the model name. - if (lastResult.errorType() == ErrorType.MODEL_NOT_FOUND) { - log.warn("[{}] Primary model not found — handing off to fallback chain " - + "(provider kept available for its other models)", phase); - break; - } - // CLIENT_ERROR (400 Bad Request): 不重试(参数/格式错误重试也不会变) - if (lastResult.errorType() == ErrorType.CLIENT_ERROR) { - return lastResult; - } - // THINKING_BLOCK_ERROR: 剥离旧 thinking 块后单次重试 - if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR && attempt == 0) { - log.warn("[{}] Thinking block error detected, stripping old thinking and retrying once", phase); - prompt = stripThinkingFromPrompt(prompt); - continue; // 重试一次 - } - if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) { - return lastResult; // 已经重试过了 - } - // EMPTY_RESPONSE — transient gateway blip often resolves on same-model - // retry (e.g., proxy timeout returns HTTP 200 with empty body). - // Retry up to MAX_RETRIES_EMPTY_RESPONSE before handing off to the - // fallback chain. A different provider has a better chance of - // succeeding if the same model repeatedly returns nothing. - if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) { - if (attempt < MAX_RETRIES_EMPTY_RESPONSE) { - log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...", - phase, attempt + 1, MAX_RETRIES_EMPTY_RESPONSE + 1); - continue; - } - log.warn("[{}] Primary exhausted empty-response retries — handing off to fallback chain", phase); - recordPrimary(false); - break; - } - // 成功 - if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) { + ErrorType errType = lastResult.errorType(); + // Success — reaffirm health / pool membership and return. + if (lastResult.errorMessage() == null || errType == ErrorType.NONE) { recordPrimary(true); addToPool(primaryProviderId); logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount); return lastResult; } - // RATE_LIMIT / SERVER_ERROR / UNKNOWN past their retry budget are - // provider-level failures: the same model will not recover within - // this turn, but a different provider can. Break to the fallback - // chain instead of returning — recordPrimary(false) runs once at - // the post-loop provider health check below, and if every fallback - // also fails the chain walker re-surfaces this same error to the - // caller. - // UNKNOWN errors are included defensively: an error we can't - // classify may be a transient (mis-classified by our keyword - // patterns) or a fatal (truly new error shape). Retrying with a - // smaller budget (MAX_RETRIES_UNKNOWN=5 vs MAX_RETRIES=10) is - // safer than immediate termination — MAX_TOTAL_DURATION_MS provides - // the ultimate safety net. - if (lastResult.errorType() == ErrorType.RATE_LIMIT - || lastResult.errorType() == ErrorType.SERVER_ERROR - || lastResult.errorType() == ErrorType.UNKNOWN) { - log.warn("[{}] Primary exhausted retries (type={}) — handing off to fallback chain", - phase, lastResult.errorType()); - break; + // PROMPT_TOO_LONG — side-effectful recovery owned by the caller: + // the node runs structured compaction and retries by itself, so + // the error must surface unchanged (never routed to fallback — + // a different provider has a different window and the caller + // would lose the compaction signal). + if (errType == ErrorType.PROMPT_TOO_LONG) { + return lastResult; } - // Any truly unhandled error type — safety net. Prefer falling back - // over terminating the entire call. If this branch is ever hit in - // production, the type should be added explicitly above. - recordPrimary(false); - log.warn("[{}] Primary returned unhandled error type={} — handing off to fallback chain", - phase, lastResult.errorType()); + // THINKING_BLOCK_ERROR — side-effectful recovery: strip stale + // thinking blocks from the prompt, then retry once. Kept as an + // explicit branch because the generic path cannot mutate the + // outgoing prompt. + if (errType == ErrorType.THINKING_BLOCK_ERROR) { + if (attempt == 0) { + log.warn("[{}] Thinking block error detected, stripping old thinking and retrying once", phase); + prompt = stripThinkingFromPrompt(prompt); + continue; + } + return lastResult; + } + // EMPTY_RESPONSE retries here in the outer loop — it is a + // result (HTTP 200 with an empty body), not an exception, so + // the inner retry gate never sees it. Same-model retry often + // resolves the transient gateway blip. + if (errType == ErrorType.EMPTY_RESPONSE && attempt < errType.retryBudget()) { + log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...", + phase, attempt + 1, errType.retryBudget() + 1); + continue; + } + // Generic routing — driven entirely by the ErrorType policy + // attributes. By the time a typed error result surfaces here + // the type's same-model retry budget is already exhausted + // (enforced inside the call for exception-path types). + if (!errType.failsOver()) { + // Fails identically everywhere (e.g. CLIENT_ERROR) — + // surface to the caller instead of burning the chain. + return lastResult; + } + if (errType.countsHealth()) { + // A rate-limit response carrying an explicit retry window + // becomes a health-cooldown override: later turns skip the + // provider until the stated instant instead of re-probing + // it every ~5 minutes and re-collecting the same 429. + Long hintMs = retryHint.get(); + if (errType == ErrorType.RATE_LIMIT && hintMs != null && hintMs > 0) { + recordPrimaryFailure(hintMs); + } else { + recordPrimary(false); + } + healthRecorded = true; + } + if (errType.evictsProvider()) { + removeFromPool(primaryProviderId, errType, lastResult.errorMessage()); + } + log.warn("[{}] Primary failed (type={}) — handing off to fallback chain", phase, errType); break; } // lastResult == null 表示需要重试 } - // If we exhausted the retry loop without a verdict, primary effectively - // failed. Only count it against provider health for provider-level errors — - // a MODEL_NOT_FOUND break above must not nudge the provider toward cooldown. - if (!primarySkipped && lastResult != null && isProviderLevelFailure(lastResult.errorType())) { + // Exits that bypassed the generic routing (time-budget break, an + // EMPTY_RESPONSE retry cut short by the loop bound) still count one + // health failure for provider-level errors. healthRecorded guards + // against double-counting the generic-path breaks; model-scoped + // errors (MODEL_NOT_FOUND et al.) never dent provider health. + if (!primarySkipped && !healthRecorded && lastResult != null + && isProviderLevelFailure(lastResult.errorType())) { recordPrimary(false); } @@ -797,7 +929,8 @@ public class NodeStreamingChatHelper { failoverCount++; llmCallCount++; StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId, - phase + "_fallback_" + (i + 1), broadcast, 0, false); + phase + "_fallback_" + (i + 1), broadcast, 0, false, + new AtomicReference<>(), new AtomicReference<>(0L)); // Accept only fully successful fallbacks. Non-successful results (auth // error, client error, still-rate-limited) propagate to the next // fallback instead of being surfaced as the final result. @@ -840,11 +973,17 @@ public class NodeStreamingChatHelper { /** * 单次流式调用尝试。 + * @param retryTypeRef carries the {@link ErrorType} that caused the + * previous attempt's retry (set on every + * {@code return null}) so the next attempt's backoff + * can be type-aware (OVERLOADED uses the long table). * @return StreamResult 如果成功/降级/不可重试;null 如果应该重试 */ private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt, String conversationId, String phase, - boolean broadcast, int attempt, boolean primaryCall) { + boolean broadcast, int attempt, boolean primaryCall, + AtomicReference retryTypeRef, + AtomicReference retryHintRef) { // Collapse every SystemMessage in the prompt into a single SystemMessage // at index 0. Some OpenAI-compatible providers (LM Studio's built-in // server, certain strict vLLM / SGLang deployments) reject 400 @@ -897,7 +1036,7 @@ public class NodeStreamingChatHelper { } try { - return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt, primaryCall); + return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt, primaryCall, retryTypeRef, retryHintRef); } finally { // Idempotent: if consumer already took the entry, discard is a no-op. if (relayToken != null) { @@ -929,18 +1068,42 @@ public class NodeStreamingChatHelper { private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt, String conversationId, String phase, - boolean broadcast, int attempt, boolean primaryCall) { + boolean broadcast, int attempt, boolean primaryCall, + AtomicReference retryTypeRef, + AtomicReference retryHintRef) { if (attempt > 0) { - long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs); - // 加入 jitter 防止雷群效应 - delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2)); - delay = Math.min(delay, backoffCapMs); - log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}", - phase, attempt, MAX_RETRIES, delay, conversationId); + boolean overloaded = retryTypeRef.get() == ErrorType.OVERLOADED; + Long hintedMs = retryHintRef.get(); + long delay; + if (hintedMs != null && hintedMs > 0) { + // The provider stated exactly when to come back — honor it + // (capped: longer windows are handled by failover + the + // health-cooldown override, not by blocking this turn), with + // a small additive jitter so concurrent sessions don't retry + // in lockstep at the stated instant. + delay = Math.min(hintedMs, HINTED_BACKOFF_CAP_MS) + + ThreadLocalRandom.current().nextLong(0, 1_000); + } else if (overloaded) { + // Saturated provider: recovery periods run tens of seconds, so + // the generic 3s-based exponential would burn attempts before + // capacity returns. Table lookup + ±30% jitter (decorrelates + // concurrent conversations re-hitting the same provider). + int idx = Math.min(attempt - 1, OVERLOADED_BACKOFF_MS.length - 1); + long base = OVERLOADED_BACKOFF_MS[idx]; + delay = base * (70 + ThreadLocalRandom.current().nextLong(61)) / 100; + } else { + delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs); + // 加入 jitter 防止雷群效应 + delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2)); + delay = Math.min(delay, backoffCapMs); + } + log.warn("[{}] Retry attempt {}/{} after {}ms (prev type={}) for conversation {}", + phase, attempt, MAX_RETRIES, delay, retryTypeRef.get(), conversationId); // 广播给前端:用户可见的重试倒计时 if (broadcast) { + String cause = overloaded ? "模型服务繁忙" : "请求频率受限"; broadcastDelta(conversationId, "warning", - buildDeltaJson("⏱️ 请求频率受限,等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)...")); + buildDeltaJson("⏱️ " + cause + ",等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)...")); } // Poll stop flag every 100ms so user Stop is honored mid-backoff. long remaining = delay; @@ -1256,6 +1419,14 @@ public class NodeStreamingChatHelper { // ===== 无内容:分类错误并决定是否重试 ===== ErrorType errorType = classifyError(error); + // Extract the provider-stated retry window once per failure and + // publish it for both consumers (next attempt's backoff; health + // cooldown override on failover). Non-throttling types clear the + // slot so a stale hint from an earlier attempt can't leak into an + // unrelated retry's backoff. + retryHintRef.set(errorType == ErrorType.RATE_LIMIT || errorType == ErrorType.OVERLOADED + ? extractRetryAfterMs(error) : 0L); + // PTL: 不重试,返回给上层 Node 处理压缩 if (errorType == ErrorType.PROMPT_TOO_LONG) { log.warn("[{}] Prompt too long error, returning to node for compaction: {}", @@ -1275,46 +1446,30 @@ public class NodeStreamingChatHelper { conversationId, phase, errorType); } - // Auth: 不重试 - if (errorType == ErrorType.AUTH_ERROR) { - log.error("[{}] Authentication error, not retrying: {}", phase, error.getMessage()); - return buildErrorResultWithType("认证失败: " + extractUserFriendlyError(error), - conversationId, phase, errorType); + // Generic retry gate — the ErrorType's own budget decides whether + // this attempt returns null (outer loop retries with backoff) or + // surfaces a typed terminal result for the routing skeleton. + // THINKING_BLOCK_ERROR is excluded: its retry needs the prompt + // mutation (strip thinking) that only the outer loop can do, so it + // always surfaces immediately despite a non-zero budget. + if (errorType != ErrorType.THINKING_BLOCK_ERROR && attempt < errorType.retryBudget()) { + log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}", + phase, attempt, errorType.retryBudget(), errorType, error.getMessage()); + retryTypeRef.set(errorType); + return null; } - // Client error (400): 不重试(参数/格式错误重试也不会变) - if (errorType == ErrorType.CLIENT_ERROR) { - log.error("[{}] Client error (400), not retrying: {}", phase, error.getMessage()); - return buildErrorResultWithType("Bad request: " + extractUserFriendlyError(error), - conversationId, phase, errorType); - } - - // Rate limit / Server error / Unknown: retryable, but with different budgets. - // RATE_LIMIT: cap at 2 retries then failover (RFC 06 D-2). - // SERVER_ERROR: keep full MAX_RETRIES — upstream flaps often self-heal. - // UNKNOWN: conservative cap (5 vs 10). Defensive: retry what we can't - // classify, but with a smaller budget to avoid masking truly fatal - // errors. MAX_TOTAL_DURATION_MS provides the ultimate safety net. - if (errorType == ErrorType.RATE_LIMIT - || errorType == ErrorType.SERVER_ERROR - || errorType == ErrorType.UNKNOWN) { - int effectiveMaxRetries = switch (errorType) { - case RATE_LIMIT -> MAX_RETRIES_RATE_LIMIT; - case UNKNOWN -> MAX_RETRIES_UNKNOWN; - default -> MAX_RETRIES; - }; - if (attempt < effectiveMaxRetries) { - log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}", - phase, attempt, effectiveMaxRetries, errorType, error.getMessage()); - return null; // 返回 null 触发重试 - } - } - - // 不可重试或已耗尽重试 - log.error("[{}] LLM call failed after {} attempts for conversation {}: {}", - phase, attempt + 1, conversationId, error.getMessage()); - return buildErrorResultWithType("LLM 调用失败: " + extractUserFriendlyError(error), - conversationId, phase, errorType); + // Not retryable, or retry budget exhausted — surface with a + // type-appropriate user-facing prefix. + String friendly = extractUserFriendlyError(error); + String message = switch (errorType) { + case AUTH_ERROR -> "认证失败: " + friendly; + case CLIENT_ERROR -> "Bad request: " + friendly; + default -> "LLM 调用失败: " + friendly; + }; + log.error("[{}] LLM call failed (type={}) after {} attempts for conversation {}: {}", + phase, errorType, attempt + 1, conversationId, error.getMessage()); + return buildErrorResultWithType(message, conversationId, phase, errorType); } // ===== 成功(检查是否因 thinking-only 软上限或内容重复被截断) ===== @@ -1786,46 +1941,123 @@ public class NodeStreamingChatHelper { /** * LLM 调用错误类型分类 */ + /** + * Error classification with the recovery policy attached to each type. + * + *

Each constant carries four policy attributes so the retry loop, the + * fallback-chain router, the pool eviction hook, and the health tracker + * all read one source of truth instead of maintaining parallel + * per-type branch chains:

+ *
    + *
  • {@link #retryBudget()} — same-model retry attempts before the + * type is considered exhausted (0 = never retried).
  • + *
  • {@link #failsOver()} — whether an exhausted failure of this type + * hands off to the fallback chain (vs. returning the error to the + * caller, for errors that would fail identically on every provider + * or that the caller must handle, e.g. prompt compaction).
  • + *
  • {@link #evictsProvider()} — provider-wide HARD failure: remove + * the provider from {@code AvailableProviderPool} so later walks + * skip it entirely.
  • + *
  • {@link #countsHealth()} — whether the failure reflects the + * provider's own health and feeds the consecutive-failure + * cooldown in {@code ProviderHealthTracker}. Model-scoped and + * request-scoped errors must not penalise a healthy provider.
  • + *
+ * + *

Two types additionally have side-effectful recovery steps that + * cannot be expressed as attributes and keep explicit branches in the + * loop: {@link #PROMPT_TOO_LONG} (report server-stated window, return to + * node for compaction) and {@link #THINKING_BLOCK_ERROR} (strip stale + * thinking blocks from the prompt, then retry once).

+ */ public enum ErrorType { - /** 无错误 */ - NONE, - /** 速率限制 (429) */ - RATE_LIMIT, - /** 服务端错误 (5xx, timeout) */ - SERVER_ERROR, - /** Prompt 过长 (context length exceeded) */ - PROMPT_TOO_LONG, - /** 认证错误 */ - AUTH_ERROR, - /** 客户端错误 (400 Bad Request, 不支持的格式等) — 不应重试 */ - CLIENT_ERROR, - /** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */ - THINKING_BLOCK_ERROR, + // retryBudget failsOver evicts countsHealth + /** No error. */ + NONE (0, false, false, false), + /** + * The caller's own key is throttled (HTTP 429). Small retry budget — + * staying on a rate-limited provider wastes time — then fail over. + */ + RATE_LIMIT (MAX_RETRIES_RATE_LIMIT, true, false, true), + /** + * The provider's serving capacity is saturated (HTTP 529, + * "engine_overloaded", "model is overloaded"). The caller's key is + * healthy, so this neither dents provider health (a busy provider is + * not a broken one) nor rotates away eagerly — it waits on the long + * backoff table, then falls over. + */ + OVERLOADED (MAX_RETRIES_OVERLOADED, true, false, false), + /** Transient server / network failure (5xx, timeout, TLS/socket flap). */ + SERVER_ERROR (MAX_RETRIES, true, false, true), + /** + * Context window exceeded. Never retried here — returned to the node, + * which owns structured compaction and its own retry. + */ + PROMPT_TOO_LONG (0, false, false, false), + /** Auth / infrastructure failure (bad key, cert, DNS). Will not self-heal. */ + AUTH_ERROR (0, true, true, true), + /** + * 400-class request-shape error. Fails identically on every provider, + * so neither retried nor failed over — surfaced to the caller. + */ + CLIENT_ERROR (0, false, false, false), + /** + * Stale thinking blocks rejected by the provider. Retried once after + * stripping thinking from the prompt (explicit branch — needs the + * prompt mutation the generic path cannot do). + */ + THINKING_BLOCK_ERROR (1, false, false, false), /** * RFC-009: LLM returned no content, no thinking, and no tool calls. - * Treated as a soft failure — skip same-model retries and hand off to - * the fallback chain directly. Typical cause: upstream rate-limit - * rejection that comes back as HTTP 200 with empty body. + * Typical cause: upstream soft failure surfaced as HTTP 200 with an + * empty body. Retried in the outer loop (it is a result, not an + * exception), then falls over. */ - EMPTY_RESPONSE, + EMPTY_RESPONSE (MAX_RETRIES_EMPTY_RESPONSE, true, false, true), /** * RFC-009 P3.2: payment / billing failure (HTTP 402, "insufficient_quota", * "credit balance is too low", etc.). Distinct from {@link #AUTH_ERROR} * because the right response is to switch provider (a different - * provider may have credits) rather than just terminate. Skips same-model - * retries and falls through to the fallback chain. + * provider may have credits) rather than just terminate. */ - BILLING, + BILLING (0, true, true, true), /** * RFC-009 P3.2: requested model id not recognized by the provider * (HTTP 404, "Model not exist", "model_not_found", DashScope's - * "url error"). Same handling as {@link #BILLING} — heads straight - * to the fallback chain instead of looping retries against a model - * that does not exist. + * "url error"). Model-scoped: heads to the fallback chain but never + * evicts the provider or dents its health — sibling models still work. */ - MODEL_NOT_FOUND, - /** 其他未知错误 */ - UNKNOWN + MODEL_NOT_FOUND (0, true, false, false), + /** + * Unclassifiable. Retried defensively with a conservative budget — + * a transient mis-missed by the keyword patterns is cheaper to retry + * than a lost turn; the wall-clock budget bounds the fatal case. + */ + UNKNOWN (MAX_RETRIES_UNKNOWN, true, false, true); + + private final int retryBudget; + private final boolean failsOver; + private final boolean evictsProvider; + private final boolean countsHealth; + + ErrorType(int retryBudget, boolean failsOver, boolean evictsProvider, boolean countsHealth) { + this.retryBudget = retryBudget; + this.failsOver = failsOver; + this.evictsProvider = evictsProvider; + this.countsHealth = countsHealth; + } + + /** Same-model retry attempts before this type is exhausted (0 = never retried). */ + public int retryBudget() { return retryBudget; } + + /** Whether an exhausted failure hands off to the fallback chain. */ + public boolean failsOver() { return failsOver; } + + /** Whether this failure HARD-removes the provider from the available pool. */ + public boolean evictsProvider() { return evictsProvider; } + + /** Whether this failure counts toward the provider health cooldown tracker. */ + public boolean countsHealth() { return countsHealth; } } /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index 66f917ca..7ea31c01 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -99,9 +99,12 @@ public class ToolExecutionExecutor { * │ when size > perResultThresholdChars and tool is not * │ in the spill exclusion list. Returns a SPILL_MARKER preview * │ on success, or the original string otherwise. - * └─ if no SPILL_MARKER on the return, truncateToolResult(...) - * caps inline to MAX_TOOL_RESULT_CHARS so a multi-MB raw - * body never enters the model prompt. + * ├─ retrieval-excluded tool (load_skill / read_file / ...) → + * │ returned RAW, never inline-truncated: a partial SKILL.md + * │ invites the model to fabricate the omitted span. + * └─ otherwise truncateToolResult(...) caps inline to + * MAX_TOOL_RESULT_CHARS so a multi-MB raw body never + * enters the model prompt. * → enforceTurnBudget(..., perTurnBudgetChars=32000) // per-turn aggregate * * Spill must see the RAW result so the full output is preserved on disk @@ -132,8 +135,10 @@ public class ToolExecutionExecutor { * @param toolUseId unique within the conversation; becomes the file name * @param conversationId spill files are scoped per conversation; blank/null falls back to "unknown" * @param workspaceBasePath where the spill directory lives when set - * @return the SPILL_MARKER preview when spill succeeded, otherwise the - * original string (when ≤ threshold) or the inline-truncated string. + * @return the SPILL_MARKER preview when spill succeeded; the original + * string when ≤ threshold or when the tool is retrieval-excluded + * (those must reach the model whole); otherwise the + * inline-truncated string. */ static String spillRawOrTruncate(ToolResultStorage storage, int maxTruncateChars, String result, String toolName, String toolUseId, @@ -147,6 +152,20 @@ public class ToolExecutionExecutor { if (candidate != null && candidate.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) { return candidate; } + // Retrieval-style tools (load_skill, read_file, readSkillFile, + // memory reads) are on the spill-exclusion list precisely so their + // full output reaches the model. persistIfOversized returns them + // unchanged (no spill), so control reaches here — but inline + // hard-truncation would silently re-introduce exactly the + // incompleteness the exclusion prevents: a chopped SKILL.md makes + // the model act on partial instructions, and weak models fabricate + // the omitted middle instead of heeding the fidelity note. Return + // the raw body; enforceTurnBudget (Layer 3) already skips these + // tools and only compacts them as a last resort when the whole + // turn blows its aggregate budget and nothing else can be freed. + if (storage.isRetrievalExcluded(toolName)) { + return result; + } } return truncateToolResult(result, maxTruncateChars); } @@ -594,7 +613,7 @@ public class ToolExecutionExecutor { toolCall.id(), toolName, redirect.response())); continue; } - String msg = skillAwareNotFoundMessage(toolName); + String msg = skillAwareNotFoundMessage(toolName, safeOrigin); log.warn("[ToolExecutor] {}", msg); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); allResponses.add(new ToolResponseMessage.ToolResponse( @@ -684,7 +703,7 @@ public class ToolExecutionExecutor { return new ToolResponseMessage.ToolResponse( toolCall.id(), toolName, redirect.response()); } - String msg = skillAwareNotFoundMessage(toolName); + String msg = skillAwareNotFoundMessage(toolName, replayOriginForRedirect); log.warn("[ToolExecutor] Pre-approved {}", msg); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg); @@ -1035,7 +1054,10 @@ public class ToolExecutionExecutor { .withWorkspaceBasePath(origin != null ? origin.workspaceBasePath() : null); if (toolGuardService != null) { - GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx); + // Defer the NEEDS_APPROVAL audit row: it is written below, once, after + // the auto-grant decision, so it carries the resolution outcome + // (AUTO_GRANT / SEVERITY_CEILING / NO_GRANT / …) and the pendingId. + GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx, true); if (evaluation.shouldBlock()) { log.warn("[ToolExecutor] Tool call BLOCKED: tool={}, summary={}", toolName, evaluation.summary()); @@ -1049,6 +1071,7 @@ public class ToolExecutionExecutor { // HARD_BLOCK short-circuits to a blocked decision (no approval banner). // APPROVED skips createPending() and lets the tool run as normal. // REQUIRES_HUMAN falls through to the existing manual approval path. + String autoOutcome = null; if (autoGrantWired) { AutoApproveResult auto = approvalGrantResolver.tryAutoApprove(guardCtx, evaluation); if (auto.isHardBlocked()) { @@ -1057,29 +1080,34 @@ public class ToolExecutionExecutor { + "Please use a safer alternative."; log.warn("[ToolExecutor] Auto-grant HARD_BLOCK: tool={}, reason={}", toolName, auto.reason()); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); + toolGuardService.recordApprovalAudit(guardCtx, evaluation, null, "HARD_BLOCK"); return GuardDecision.blocked(msg); } if (auto.isApproved()) { log.info("[ToolExecutor] Auto-grant APPROVED: tool={}, grantId={}", toolName, auto.grantId()); + toolGuardService.recordApprovalAudit(guardCtx, evaluation, null, "AUTO_GRANT"); return GuardDecision.allowed(); } - // requiresHuman → fall through to legacy human-approval path below. + // requiresHuman → fall through to legacy human-approval path below, + // carrying the denial reason for the audit row. + autoOutcome = auto.reason(); } // No human can resolve an approval in a non-interactive (scheduled-job) // run, so a pending request would hang the turn until it times out with // no answer. Deny immediately with an actionable message instead. if (origin != null && origin.cronOrigin()) { + toolGuardService.recordApprovalAudit(guardCtx, evaluation, null, autoOutcome); return denyNonInteractiveApproval(toolCall, toolName, events); } List remaining = allToolCalls.subList(currentIndex + 1, allToolCalls.size()); - String approvalResponse = ToolExecutionGuardHelper.handleToolApproval( + ToolExecutionGuardHelper.ApprovalRequest approval = ToolExecutionGuardHelper.handleToolApproval( toolCall, toolName, arguments, evaluation, conversationId, agentId, requesterId, approvalService, streamTracker, events, remaining); - // Extract pendingId from response (format: "[APPROVAL_PENDING] tool=xxx awaiting user decision") - return GuardDecision.needsApproval(approvalResponse, extractPendingId(approvalResponse)); + toolGuardService.recordApprovalAudit(guardCtx, evaluation, approval.pendingId(), autoOutcome); + return GuardDecision.needsApproval(approval.response(), approval.pendingId()); } } else if (toolGuard != null) { ToolGuardResult guardResult = toolGuard.check(toolName, arguments); @@ -1100,7 +1128,9 @@ public class ToolExecutionExecutor { toolCall, toolName, arguments, guardResult, conversationId, agentId, requesterId, approvalService, streamTracker, events, remaining); - return GuardDecision.needsApproval(approvalResponse, extractPendingId(approvalResponse)); + // Legacy path never persisted a pendingId to carry here; the value + // is unused downstream (only the boolean awaitingApproval is read). + return GuardDecision.needsApproval(approvalResponse, null); } } @@ -1196,14 +1226,6 @@ public class ToolExecutionExecutor { return "Tool execution failed: " + message; } - /** - * 从 approval response 中提取 pendingId(best-effort) - */ - private String extractPendingId(String approvalResponse) { - // handleToolApproval 内部已经创建了 pending,这里只做标记 - return approvalResponse; - } - /** * Issue #46 — when a tool callback miss happens, check whether the * unrecognized name actually matches an active skill. If it does, return @@ -1273,10 +1295,34 @@ public class ToolExecutionExecutor { return Map.copyOf(result); } - private String skillAwareNotFoundMessage(String toolName) { + /** + * Best-effort conversation workspace from a {@link ChatOrigin}, with a + * {@code WorkspaceLookupCache} fallback for paths (e.g. approval replay) + * that carry a conversationId but no workspaceId. A {@code null} result + * makes the skill lookup scope to builtin/global only — never another + * workspace's skill. + */ + private Long resolveWorkspaceId(ChatOrigin origin) { + if (origin == null) return null; + if (origin.workspaceId() != null) return origin.workspaceId(); + return workspaceIdForConversation(origin.conversationId()); + } + + /** + * Resolve a conversation's owning workspace via the lookup cache, or + * {@code null} when unavailable. Exposed so sibling graph nodes (e.g. + * {@code ActionNode}) that only hold a conversationId can scope skill + * resolution to the right workspace without their own cache dependency. + */ + public Long workspaceIdForConversation(String conversationId) { + return (workspaceLookupCache != null && conversationId != null) + ? workspaceLookupCache.resolveByConversation(conversationId) : null; + } + + private String skillAwareNotFoundMessage(String toolName, ChatOrigin origin) { if (skillRuntimeService != null && toolName != null && !toolName.isBlank()) { try { - boolean isSkill = skillRuntimeService.getActiveSkills().stream() + boolean isSkill = skillRuntimeService.getActiveSkills(resolveWorkspaceId(origin)).stream() .anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName)); if (isSkill) { return String.format( @@ -1395,7 +1441,9 @@ public class ToolExecutionExecutor { private SkillRedirect tryAutoRedirectSkillCall(String toolName, String originalArgs, ChatOrigin origin) { if (skillRuntimeService == null || toolName == null || toolName.isBlank()) return null; try { - boolean isSkill = skillRuntimeService.getActiveSkills().stream() + // Scope to the conversation's workspace so an agent is never redirected + // into (and handed the SKILL.md content of) another workspace's skill. + boolean isSkill = skillRuntimeService.getActiveSkills(resolveWorkspaceId(origin)).stream() .anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName)); if (!isSkill) return null; } catch (Exception e) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultProperties.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultProperties.java index e53c5fac..f016df66 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultProperties.java @@ -94,8 +94,15 @@ public class ToolResultProperties { *

Defaults to file-read tools that already cap their own output internally. * Configurable so deployments can add more retrieval-style tools (e.g., * MCP-provided readers) without code changes.

+ * + *

{@code readSkillFile} / {@code load_skill} are included because they + * deliberately return the full SKILL.md — the skill's usage contract — + * and spilling it down to a preview makes the model act on incomplete + * instructions (e.g. wrong API parameter names). Their references/scripts + * reads are already self-paginated to a bounded size.

*/ - private List excludedTools = List.of("read_file", "read_workspace_memory_file"); + private List excludedTools = List.of( + "read_file", "read_workspace_memory_file", "readSkillFile", "load_skill"); /** * Days to retain spill files before the scheduled cleanup deletes them. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java index dccd7ec3..cb85bd48 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java @@ -105,6 +105,21 @@ public class ToolResultStorage { return spillCount.get(); } + /** + * Public view of the exclusion list for callers outside this class (e.g. + * the executor's spill/truncate dispatcher). Retrieval-style tools on this + * list must have their full output preserved: they are never spilled here + * (Layer 2) and never inline-truncated by the executor — a partial + * {@code SKILL.md} / {@code read_file} body makes the model act on + * incomplete data, and weak models fabricate the omitted span instead of + * heeding the fidelity note. The aggregate turn budget (Layer 3) remains + * the only place an excluded result may be compacted, and only as a last + * resort when nothing else can free budget. + */ + public boolean isRetrievalExcluded(String toolName) { + return isExcluded(toolName); + } + /** * Returns true when {@code toolName} is in the configured exclusion list. * Excluded tools (typically retrieval tools like {@code read_file}) are diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java index c952328c..0041e648 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java @@ -56,12 +56,28 @@ public class ActionNode implements NodeAction { /** * Tools whose results should NOT be auto-recorded into the ledger. - * Meta-tools (load_skill, enable_tool, progress_update) either have - * their own ledger side-effects or are the ledger itself. + * Two groups: + *
    + *
  • Meta-tools (load_skill, enable_tool, progress_update, + * skill helpers) — they either have their own ledger side-effects + * or are the ledger itself.
  • + *
  • Read-only / status-query tools — querying live state is + * not a task step that must not be repeated. Recording it as DONE + * (with a frozen result excerpt in the note) pushes the model to + * answer follow-up questions from stale output instead of + * re-checking, because the snapshot instructs "已完成的步骤不要 + * 重复执行".
  • + *
*/ private static final Set AUTO_RECORD_SKIP = Set.of( LOAD_SKILL_TOOL, ENABLE_TOOL, PROGRESS_UPDATE_TOOL, - "listAvailableSkills", "readSkillFile", "runSkillScript" + "listAvailableSkills", "readSkillFile", "runSkillScript", + // read-only / status-query tools + "read_file", "web_search", + "extract_document_text", "extract_pdf_text", "extract_docx_text", + "detect_file_type", + "getCurrentDateTime", "getCurrentDate", "getCurrentTime", + "listSubagents" ); private final ToolExecutionExecutor executor; @@ -213,7 +229,8 @@ public class ActionNode implements NodeAction { } for (String skillName : skillNames) { try { - vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName); + vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill( + skillName, executor.workspaceIdForConversation(conversationId)); if (skill == null || skill.getManifest() == null) { continue; } @@ -255,8 +272,8 @@ public class ActionNode implements NodeAction { * avoid collisions between servers that expose tools with the same slug. * The display label uses the simplified slug for readability. */ - private void autoRecordToolCalls(String conversationId, - List responses) { + void autoRecordToolCalls(String conversationId, + List responses) { if (progressLedgerService == null || conversationId == null || conversationId.isBlank() || responses == null || responses.isEmpty()) { return; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 381a84da..b4006776 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -32,6 +32,7 @@ import vip.mate.agent.graph.state.MateClawStateKeys; import vip.mate.agent.graph.state.SourceEvidenceLedger; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.service.TeamContextBuilder; import java.util.*; import java.util.concurrent.CancellationException; @@ -390,6 +391,18 @@ public class ReasoningNode implements NodeAction { this.runningConversationRegistry = runningConversationRegistry; } + /** + * Live team-board snapshot source for agents leading a team. When non-null, + * each turn's prompt prefix carries the board's in-flight tasks as a meta + * user message so the lead never duplicates or prematurely closes work. + * Null in tests / legacy paths — injection is simply skipped. + */ + private TeamContextBuilder teamContextBuilder; + + public void setTeamContextBuilder(TeamContextBuilder teamContextBuilder) { + this.teamContextBuilder = teamContextBuilder; + } + /** Floor for the window-aware output clamp — an answer needs at least this much room. */ private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512; @@ -1318,6 +1331,20 @@ public class ReasoningNode implements NodeAction { // agentId not numeric — skip wiki injection (matches prior behavior). } } + // Live team-board snapshot for leads: a UserMessage (never SystemMessage, + // per the runtime-context cache discipline) listing in-flight tasks, so a + // lead mid-conversation neither duplicates nor prematurely closes work. + // buildBoardSnapshot returns null for non-leads and idle boards. + if (teamContextBuilder != null && agentIdStr != null && !agentIdStr.isEmpty()) { + try { + String boardSnapshot = teamContextBuilder.buildBoardSnapshot(Long.parseLong(agentIdStr)); + if (boardSnapshot != null && !boardSnapshot.isBlank()) { + prefix.add(new UserMessage(boardSnapshot)); + } + } catch (NumberFormatException ignored) { + // agentId not numeric — skip board injection. + } + } return prefix; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/PlanGenerationDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/PlanGenerationDispatcher.java index 4ef99922..6af9f361 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/PlanGenerationDispatcher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/PlanGenerationDispatcher.java @@ -3,6 +3,7 @@ package vip.mate.agent.graph.plan.edge; import com.alibaba.cloud.ai.graph.OverAllState; import com.alibaba.cloud.ai.graph.action.EdgeAction; import vip.mate.agent.graph.plan.state.PlanStateKeys; +import vip.mate.agent.graph.state.MateClawStateKeys; /** * Routes the graph after the triage node. @@ -22,6 +23,12 @@ public class PlanGenerationDispatcher implements EdgeAction { @Override public String apply(OverAllState state) { + // A board-delegated plan whose tasks all settled resumes straight into + // the summary — its step results were rebuilt from the team task board + // by the resume gate; the step loop has nothing left to execute. + if ("plan_delegated_settled".equals(state.value(MateClawStateKeys.CURRENT_PHASE, ""))) { + return PlanStateKeys.PLAN_SUMMARY_NODE; + } boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, false); if (!needsPlanning) { return PlanStateKeys.DIRECT_ANSWER_NODE; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index fac3be5a..571e60e1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -28,6 +28,8 @@ import vip.mate.goal.model.GoalCriterion; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.service.GoalService; import vip.mate.planning.service.PlanningService; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.service.TeamPlanBridge; import java.util.ArrayList; import java.util.HashMap; @@ -70,6 +72,17 @@ public class PlanGenerationNode implements NodeAction { * resolve per-step assignments. Null disables per-step delegation (legacy/test). */ private final AgentService agentService; + /** + * Optional — hands a team lead's plan off to the team task board and + * resumes parked plans on later messages. Null keeps the legacy serial + * pipeline (non-team deployments / tests). + */ + private TeamPlanBridge teamPlanBridge; + + public void setTeamPlanBridge(TeamPlanBridge teamPlanBridge) { + this.teamPlanBridge = teamPlanBridge; + } + /** Plan steps below this size are trivial tool tasks, not goal-worthy. */ private static final int MIN_STEPS_FOR_AUTO_GOAL = 2; /** Cap the auto-derived goal title; the full request rides in the description. */ @@ -88,7 +101,13 @@ public class PlanGenerationNode implements NodeAction { // order). An empty string / missing entry means "run with the parent // agent". Only populated when delegatable specialist agents are // advertised to the planner; absent for backward compatibility. - @JsonProperty("step_agents") List stepAgents + @JsonProperty("step_agents") List stepAgents, + // Optional per-step prerequisites, parallel to steps: each entry is + // a comma-separated list of earlier 1-based step numbers ("" = no + // prerequisite, may start immediately). Only requested when steps + // hand off to a team board, where independent steps run in + // parallel; anything invalid falls back to a sequential chain. + @JsonProperty("step_deps") List stepDeps ) {} private static final String PLANNING_PROMPT = """ @@ -331,6 +350,60 @@ public class PlanGenerationNode implements NodeAction { } } + /** + * Parse the planner's step_deps (1-based step numbers, comma separated) + * into 0-based prerequisite indices. Any irregularity — missing field, + * length mismatch, unparseable entry, self/forward reference — falls back + * to a sequential chain (step i depends on step i-1), which is exactly + * the serial semantics the plan pipeline has today: dependency info is a + * parallelism bonus, never a correctness requirement. Package-private for + * direct unit testing. + */ + static List> parseStepDeps(List stepDeps, int stepCount) { + if (stepDeps == null || stepDeps.size() != stepCount) { + return sequentialChain(stepCount); + } + List> parsed = new ArrayList<>(); + for (int i = 0; i < stepCount; i++) { + List deps = new ArrayList<>(); + String raw = stepDeps.get(i); + if (raw != null && !raw.isBlank()) { + for (String part : raw.split("[,,]")) { + if (part.isBlank()) { + continue; + } + try { + int depIndex = Integer.parseInt(part.trim()) - 1; + if (depIndex < 0 || depIndex >= i) { + return sequentialChain(stepCount); + } + deps.add(depIndex); + } catch (NumberFormatException e) { + return sequentialChain(stepCount); + } + } + } + parsed.add(deps); + } + return parsed; + } + + private static List> sequentialChain(int stepCount) { + List> chain = new ArrayList<>(); + for (int i = 0; i < stepCount; i++) { + chain.add(i == 0 ? List.of() : List.of(i - 1)); + } + return chain; + } + + private static Long parseNumericAgentId(String agentId) { + try { + return Long.valueOf(agentId); + } catch (Exception e) { + return null; + } + } + /** * Enabled agents in the given workspace, excluding the parent (plan) agent * itself — these are the agents a step can be delegated to. Empty when @@ -422,6 +495,45 @@ public class PlanGenerationNode implements NodeAction { List events = new ArrayList<>(); events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal))); + // Delegated-plan resume gate: a plan parked on the team board resumes + // here on ANY inbound message (settle announcement or a user asking + // for status) — deterministic routing that never depends on how the + // triage LLM classifies the wake-up text. Mirrors the approval-replay + // pattern: park in the DB, resume from the DB. + if (teamPlanBridge != null) { + TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId); + if (parked instanceof TeamPlanBridge.Settled settled) { + log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary", + settled.planId(), settled.completedResults().size()); + return PlanStateAccessor.output() + .needsPlanning(true) + .planId(settled.planId()) + .planSteps(settled.steps()) + .planValid(true) + .currentStepIndex(settled.steps().size()) + // Summarize against the original plan goal, not the + // wake-up message that happened to trigger the resume. + .goal(settled.goal()) + .put(PlanStateKeys.COMPLETED_RESULTS, settled.completedResults()) + .currentPhase("plan_delegated_settled") + .events(events) + .build(); + } + if (parked instanceof TeamPlanBridge.InFlight inFlight) { + log.info("[PlanGeneration] Delegated plan still in flight — answering with progress"); + if (streamingHelper != null) { + streamingHelper.broadcastContent(conversationId, inFlight.progressText()); + } + return PlanStateAccessor.output() + .needsPlanning(false) + .directAnswer(inFlight.progressText()) + .currentPhase("direct_answer") + .contentStreamed(true) + .events(events) + .build(); + } + } + // Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM. Long existingPlanId = state.value(PlanStateKeys.PLAN_ID).orElse(null); if (existingPlanId != null) { @@ -467,22 +579,42 @@ public class PlanGenerationNode implements NodeAction { + "\n单次工具调用应归为单步(B),不要拆成多步。")); } - // Advertise delegatable specialist agents so the planner can assign a - // multi-step plan's step to a dedicated agent (e.g. a test step to a - // QA agent, a UI step to a frontend agent). Only fills the step's - // step_agents slot; unassigned steps stay with the parent agent. - // Skipped entirely when no peer agents exist in the workspace. - List delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId); - if (!delegatable.isEmpty()) { - String agentLines = delegatable.stream() + // Advertise delegatable agents. A team lead advertises its member + // roster with mandatory assignment (steps hand off to the team + // board and run in parallel there); everyone else advertises the + // workspace-wide specialist list with optional assignment. + AgentTeamEntity leadTeam = null; + if (teamPlanBridge != null) { + Long numericAgentId = parseNumericAgentId(agentId); + leadTeam = numericAgentId == null ? null + : teamPlanBridge.leadTeam(numericAgentId).orElse(null); + } + if (leadTeam != null) { + String memberLines = teamPlanBridge.roster(leadTeam).stream() .map(a -> "- " + a.getName() + (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : "")) .collect(Collectors.joining("\n")); promptMessages.add(new UserMessage( - "可委派的专职 Agent(仅当某步骤明显属于其专长时才指派,否则该步骤留空、由你自己执行):\n" - + agentLines - + "\n若要委派,在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);" - + "不委派的步骤填空字符串。多数步骤通常不需要委派。")); + "你是团队「" + leadTeam.getName() + "」的 lead。多步任务的每个步骤都将分派到团队任务板," + + "由成员并行执行。团队成员:\n" + memberLines + + "\n要求:\n" + + "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n" + + "2. 在 step_deps 数组标注每个步骤的前置步骤序号(1 起始,逗号分隔;无前置填空字符串)。" + + "相互独立的步骤请不要标注前置,以便并行执行。\n" + + "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。")); + } else { + List delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId); + if (!delegatable.isEmpty()) { + String agentLines = delegatable.stream() + .map(a -> "- " + a.getName() + + (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : "")) + .collect(Collectors.joining("\n")); + promptMessages.add(new UserMessage( + "可委派的专职 Agent(仅当某步骤明显属于其专长时才指派,否则该步骤留空、由你自己执行):\n" + + agentLines + + "\n若要委派,在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);" + + "不委派的步骤填空字符串。多数步骤通常不需要委派。")); + } } // Inject working context (rolling conversation summary) so triage respects @@ -599,6 +731,39 @@ public class PlanGenerationNode implements NodeAction { steps = List.of(persistGoal); } + // Team hand-off: a lead whose every step resolved to a member + // parks the plan on the task board and ends the turn — execution + // continues through the board's dispatch/announce machinery, and + // any later message resumes via the delegated-plan gate above. + if (leadTeam != null) { + List memberIds = teamPlanBridge.resolveMembers(leadTeam, steps, + triage != null ? triage.stepAgents() : null); + if (memberIds != null) { + List> stepDeps = parseStepDeps( + triage != null ? triage.stepDeps() : null, steps.size()); + var delegatedPlan = planningService.createPlan( + agentId, conversationId, persistGoal, steps, memberIds); + events.add(GraphEventPublisher.planCreated(delegatedPlan.getId(), steps)); + String announcement = teamPlanBridge.delegatePlan(leadTeam, + delegatedPlan.getId(), persistGoal, steps, stepDeps, + memberIds, conversationId); + streamingHelper.broadcastContent(conversationId, announcement); + log.info("[PlanGeneration] Plan {} handed off to team {} board ({} steps)", + delegatedPlan.getId(), leadTeam.getId(), steps.size()); + return PlanStateAccessor.output() + .needsPlanning(false) + .directAnswer(announcement) + .currentPhase("direct_answer") + .contentStreamed(true) + .thinkingStreamed(!result.thinking().isEmpty()) + .mergeUsage(state, result) + .events(events) + .build(); + } + log.info("[PlanGeneration] Lead plan not fully assigned to members; " + + "falling back to the serial pipeline"); + } + // Resolve any per-step agent delegation the planner asked for. Null // when nothing is delegated, keeping createPlan on the legacy path. List stepAgentIds = resolveStepAgents(steps, diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java index d2a1a0a5..19faa464 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java @@ -74,7 +74,9 @@ public class PlanSummaryNode implements NodeAction { Prompt prompt = new Prompt(List.of( new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。" + "直接回答用户的原始问题,不要罗列步骤。" - + "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"), + + "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。" + + "若执行结果中包含交付物下载链接,请在回答中原样列出这些链接。" + + "若某些步骤未完成,如实说明未完成的部分及原因。"), new UserMessage(userContent.toString()) )); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java index 272027b6..5d3e9694 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java @@ -202,6 +202,39 @@ public class ProgressLedgerService { } } + /** + * Remove every auto-recorded entry ({@code auto_} key prefix) from the + * conversation's ledger. Called at the start of each new user turn. + * + *

Auto-recorded entries are a safety net against context trimming + * within one turn's tool loop. Letting them survive into the next + * user turn is harmful: the snapshot renders them as DONE alongside the + * "已完成的步骤不要重复执行" instruction, which stops the agent from + * re-running read-only / status-query tools when the user repeats a + * question that needs fresh data (e.g. "看下会议室有没有人"), and the + * frozen 120-char result note tempts it to answer from stale output. + * + *

LLM-authored regular entries and pinned skill constraints are + * untouched — multi-turn task tracking keeps working. + */ + public void clearAutoRecorded(String conversationId) { + if (conversationId == null || conversationId.isBlank()) { + return; + } + ReentrantLock lock = upsertLocks.computeIfAbsent(conversationId, k -> new ReentrantLock()); + lock.lock(); + try { + LedgerWrapper wrapper = loadWrapper(conversationId); + boolean removed = wrapper.entries.keySet().removeIf( + k -> k != null && k.startsWith(ProgressLedger.AUTO_RECORDED_PREFIX)); + if (removed) { + persistWrapper(conversationId, wrapper); + } + } finally { + lock.unlock(); + } + } + /** * Auto-record a completed tool call as a ledger entry (B5). Uses the * {@link ProgressLedger#AUTO_RECORDED_PREFIX} on the key so the renderer diff --git a/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java b/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java index 6074c80b..42ded287 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java @@ -11,6 +11,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import vip.mate.approval.grant.entity.ApprovalGrant; import vip.mate.approval.grant.entity.ApprovalResolutionLog; +import vip.mate.agent.repository.AgentMapper; import vip.mate.approval.grant.repository.ApprovalGrantMapper; import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper; import vip.mate.approval.grant.service.ApprovalGrantService; @@ -55,6 +56,7 @@ public class ApprovalGrantController { private static final long DEFAULT_WORKSPACE_ID = 1L; private final ApprovalGrantService grantService; + private final AgentMapper agentMapper; private final ApprovalGrantMapper grantMapper; private final ApprovalResolutionLogMapper resolutionMapper; private final AuthService authService; @@ -295,6 +297,7 @@ public class ApprovalGrantController { } } case ApprovalGrant.ScopeType.AGENT -> { + requireExistingAgent(body.scopeId); if (toolNull) { requireAdminPlusPassword(isAdmin, body.password, actorId); } else if (!isAdmin) { @@ -304,6 +307,15 @@ public class ApprovalGrantController { } } case ApprovalGrant.ScopeType.WORKSPACE -> { + // WORKSPACE-scope matching requires scope_id == the invocation's + // workspaceId AND the grant row's workspace_id (tenant column) to + // equal that same workspace — a scopeId pointing anywhere else can + // never fire. Reject the dead configuration outright. + if (!String.valueOf(workspaceId).equals(body.scopeId)) { + throw new MateClawException("err.approval.workspace_scope_mismatch", 400, + "WORKSPACE-scope scopeId must equal the current workspace id (" + + workspaceId + "); a cross-workspace grant can never match"); + } workspaceService.requirePermission(workspaceId, actorId, "admin"); if (toolNull) { requireAdminPlusPassword(true, body.password, actorId); @@ -314,6 +326,24 @@ public class ApprovalGrantController { } } + /** + * AGENT-scope scopeId must reference an existing agent — a workspace or + * conversation id pasted here compiles into a grant that never matches. + */ + private void requireExistingAgent(String scopeId) { + Long agentId; + try { + agentId = Long.parseLong(scopeId); + } catch (NumberFormatException e) { + throw new MateClawException("err.approval.agent_not_found", 400, + "AGENT-scope scopeId must be a numeric agent id: " + scopeId); + } + if (agentMapper.selectById(agentId) == null) { + throw new MateClawException("err.approval.agent_not_found", 400, + "AGENT-scope scopeId does not reference an existing agent: " + scopeId); + } + } + private void requireAdminPlusPassword(boolean isAdmin, String rawPassword, Long actorId) { if (!isAdmin) { throw new MateClawException("err.approval.admin_required", 403, "admin role required"); diff --git a/mateclaw-server/src/main/java/vip/mate/approval/grant/repository/ApprovalGrantMapper.java b/mateclaw-server/src/main/java/vip/mate/approval/grant/repository/ApprovalGrantMapper.java index f041de68..d7c1f888 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/grant/repository/ApprovalGrantMapper.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/grant/repository/ApprovalGrantMapper.java @@ -49,6 +49,21 @@ public interface ApprovalGrantMapper extends BaseMapper { @Param("candidateRuleIds") List candidateRuleIds, @Param("evalSeverity") String evalSeverity); + /** + * Diagnostic twin of {@link #findFirstMatching}: identical matching except the + * severity-ceiling comparison is dropped. Called only when {@code findFirstMatching} + * returned no row, to distinguish "a grant exists but its ceiling is below this + * invocation's severity" (SEVERITY_CEILING) from "no grant matches at all" (NO_GRANT). + */ + ApprovalGrant findFirstMatchingIgnoringSeverity( + @Param("workspaceId") Long workspaceId, + @Param("userId") String userId, + @Param("agentId") String agentId, + @Param("conversationId") String conversationId, + @Param("workspaceScopeId") String workspaceScopeId, + @Param("toolName") String toolName, + @Param("candidateRuleIds") List candidateRuleIds); + /** * Soft-revokes every active {@code UNTIL_CONVERSATION_END} grant attached to the given * conversation. Called by {@code ConversationLifecycleListener} on diff --git a/mateclaw-server/src/main/java/vip/mate/approval/grant/service/ApprovalGrantResolver.java b/mateclaw-server/src/main/java/vip/mate/approval/grant/service/ApprovalGrantResolver.java index 69dc5824..c57ea0ea 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/grant/service/ApprovalGrantResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/grant/service/ApprovalGrantResolver.java @@ -114,6 +114,21 @@ public class ApprovalGrantResolver { evalSeverity); if (matched == null) { + // Miss-path diagnosis: re-run the same match without the severity + // ceiling. A hit here means a grant exists but its ceiling is below + // this invocation's severity — the single most common misconfiguration + // (form default LOW vs HIGH findings). Only executed on the miss path, + // so the hot path stays one query. + ApprovalGrant ceilingBlocked = grantMapper.findFirstMatchingIgnoringSeverity( + ctx.workspaceId(), + ctx.userId(), ctx.agentId(), ctx.conversationId(), + workspaceScopeId, + ctx.toolName(), + candidateRuleIds); + if (ceilingBlocked != null) { + return AutoApproveResult.requiresHuman( + "SEVERITY_CEILING:" + ceilingBlocked.getMaxSeverity() + "<" + evalSeverity); + } return AutoApproveResult.requiresHuman("NO_GRANT"); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java index a8e34fea..a40f9a82 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java @@ -319,6 +319,23 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter { } } + /** + * 按渠道配置过滤外发文本(不做平台分割) + *

+ * 卡片式流式渠道不经过 {@link #renderAndSend}(它们自己管理消息长度和 + * 卡片更新节奏),如果不在流式收尾处调用本方法, + * {@code filter_thinking} / {@code filter_tool_messages} 两个开关 + * 在这些路径上就完全不生效。 + * + * @param content 原始文本 + * @return 过滤后的文本(入参为空时返回空串) + */ + protected String filterOutboundContent(String content) { + return ChannelMessageRenderer.applyFilters(content, + getConfigBoolean("filter_thinking", true), + getConfigBoolean("filter_tool_messages", true)); + } + /** * Approval notice rendering — primary implementation position. * diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelDedupProperties.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelDedupProperties.java new file mode 100644 index 00000000..08227874 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelDedupProperties.java @@ -0,0 +1,79 @@ +package vip.mate.channel; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** + * Tunables for inbound channel-message deduplication. + * + *

IM platforms redeliver the same message when an acknowledgement is late, + * lost, or answered with a non-200 — DingTalk, WeCom and Feishu all do this. + * Every redelivery that reaches the router starts a full, independent agent + * turn, so the user sees the same answer twice (and the conversation gains a + * duplicate user/assistant pair). {@link InboundMessageDeduplicator} keeps a + * short-lived record of the message identities already claimed so a + * redelivery is dropped instead of answered again. + * + *

入站渠道消息去重配置。平台重投同一条消息时,若不去重则每次重投都会跑一轮完整 + * 的 Agent 回合,用户看到重复答复。 + * + *

+ * mate:
+ *   channel:
+ *     dedup:
+ *       enabled: true
+ *       ttl: 5m
+ *       max-size: 2000
+ * 
+ */ +@ConfigurationProperties(prefix = "mate.channel.dedup") +public class ChannelDedupProperties { + + /** + * Master switch. When false every message is treated as new — only useful + * when debugging a suspected false-positive drop. + */ + private boolean enabled = true; + + /** + * How long a claimed message identity keeps suppressing redeliveries. + * + *

Must comfortably exceed the platforms' redelivery windows (seconds to + * low minutes) while staying short enough that a user who genuinely resends + * the identical payload later is not silenced. Note that a resend carries a + * fresh platform message id in every channel we support, so the TTL only + * matters for the id-less fallback identity. + */ + private Duration ttl = Duration.ofMinutes(5); + + /** + * Hard cap on tracked identities. Reached only under sustained traffic + * within one TTL window; the oldest claims are dropped first. + */ + private int maxSize = 2000; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Duration getTtl() { + return ttl; + } + + public void setTtl(Duration ttl) { + this.ttl = ttl; + } + + public int getMaxSize() { + return maxSize; + } + + public void setMaxSize(int maxSize) { + this.maxSize = maxSize; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java new file mode 100644 index 00000000..3e3e0e4f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java @@ -0,0 +1,134 @@ +package vip.mate.channel; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * User-typed channel control commands that should be handled by the platform + * instead of being sent to the agent as normal prompt text. + *

+ * Matching rules: + *

    + *
  • Case-insensitive; the whole message is trimmed first.
  • + *
  • Bare aliases (no leading "/") match only when the entire message is + * exactly the alias — "clear 一下北京天气" is normal prompt text.
  • + *
  • Slash-prefixed aliases may carry trailing arguments after the first + * whitespace; the remainder is passed through verbatim as args.
  • + *
+ */ +final class ChannelMagicCommand { + + /** Platform-level command kinds, dispatched by {@link ChannelMessageRouter}. */ + enum Type { CLEAR, NEW, HELP, STATUS, STOP, MODEL } + + /** A recognized command plus its raw (possibly empty) argument string. */ + record Parsed(Type type, String args) { + } + + /** + * Alias token → command type. LinkedHashMap keeps registration ordering + * stable. Every bare alias also registers its "/"-prefixed twin. + */ + private static final Map ALIASES = buildAliases(); + + private ChannelMagicCommand() { + } + + static Optional parse(String text) { + String trimmed = text == null ? "" : text.trim(); + if (trimmed.isEmpty()) { + return Optional.empty(); + } + String lower = trimmed.toLowerCase(Locale.ROOT); + Type wholeMatch = ALIASES.get(lower); + if (wholeMatch != null) { + return Optional.of(new Parsed(wholeMatch, "")); + } + // Only slash-prefixed commands may carry arguments; bare words with a + // trailing remainder are ordinary prompts, never commands. + if (!lower.startsWith("/")) { + return Optional.empty(); + } + int ws = indexOfWhitespace(lower); + if (ws < 0) { + return Optional.empty(); + } + Type type = ALIASES.get(lower.substring(0, ws)); + if (type == null) { + return Optional.empty(); + } + return Optional.of(new Parsed(type, trimmed.substring(ws).trim())); + } + + static String clearConfirmation() { + return "✅ 上下文已清理,后续消息会从新的上下文开始。"; + } + + static String newConfirmation() { + return "✨ 已开启新会话,之前的上下文不会带入。"; + } + + static String stopConfirmation() { + return "⏹️ 已停止当前任务。"; + } + + static String stopNothingRunning() { + return "当前没有进行中的任务。"; + } + + static String helpText() { + return """ + 🪄 可用命令: + /clear — 清空当前会话上下文(别名:/reset、清空上下文) + /new — 开启新会话(别名:新会话) + /stop — 停止当前进行中的任务(别名:停止) + /status — 查看当前会话状态(别名:状态) + /model — 查看可用模型;/model <名称> 切换本会话模型;/model reset 恢复默认 + /help — 显示本帮助(别名:帮助)"""; + } + + private static Map buildAliases() { + Map aliases = new LinkedHashMap<>(); + register(aliases, Type.CLEAR, + "clear", "reset", + "清空", "清空上下文", "清理上下文", "清除上下文", "重置上下文"); + register(aliases, Type.NEW, + "new", "新会话", "新对话"); + register(aliases, Type.HELP, + "help", "帮助"); + register(aliases, Type.STATUS, + "status", "状态"); + register(aliases, Type.STOP, + "stop", "停止"); + // Slash-only: "model" / "模型" are common standalone words in normal + // prompts ("模型是什么?"), so the bare form must never be a command. + registerSlashOnly(aliases, Type.MODEL, + "model", "模型"); + return aliases; + } + + private static void register(Map aliases, Type type, String... names) { + for (String name : names) { + aliases.put(name, type); + aliases.put("/" + name, type); + } + } + + /** Register only the "/"-prefixed form — for aliases whose bare word is ordinary prose. */ + private static void registerSlashOnly(Map aliases, Type type, String... names) { + for (String name : names) { + aliases.put("/" + name, type); + } + } + + private static int indexOfWhitespace(String text) { + for (int i = 0; i < text.length(); i++) { + if (Character.isWhitespace(text.charAt(i))) { + return i; + } + } + return -1; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index dc3bd96d..5d30c771 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -1213,7 +1213,7 @@ public class ChannelManager { generatedFileCache, chatUploadLocationResolver); case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper); case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper, - chatUploadLocationResolver); + chatUploadLocationResolver, generatedFileScrubber); case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper); case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper); default -> throw new IllegalArgumentException("Unsupported channel type: " + type); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRenderer.java index 4cdcf8f5..f9824bfc 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRenderer.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRenderer.java @@ -72,10 +72,34 @@ public final class ChannelMessageRenderer { boolean filterToolMessages, String messageFormat, int maxLength) { - if (content == null || content.isBlank()) { + String rendered = applyFilters(content, filterThinking, filterToolMessages); + + if (rendered.isEmpty()) { return List.of(""); } + // 按平台限制分割 + return truncateForPlatform(rendered, maxLength); + } + + /** + * 只做内容过滤,不做平台分割 + *

+ * 卡片式流式渠道(钉钉 AI Card、飞书 CardKit)自己管理长度限制, + * 但同样需要遵守渠道的消息过滤配置,因此把过滤部分单独暴露出来。 + * + * @param content 原始内容 + * @param filterThinking 是否过滤 thinking 标签 + * @param filterToolMessages 是否过滤工具调用信息 + * @return 过滤后的内容(入参为空时返回空串) + */ + public static String applyFilters(String content, + boolean filterThinking, + boolean filterToolMessages) { + if (content == null || content.isBlank()) { + return ""; + } + String rendered = content; // 1. 过滤 thinking @@ -89,14 +113,7 @@ public final class ChannelMessageRenderer { } // 3. 清理多余空行 - rendered = rendered.replaceAll("\n{3,}", "\n\n").trim(); - - if (rendered.isEmpty()) { - return List.of(""); - } - - // 4. 按平台限制分割 - return truncateForPlatform(rendered, maxLength); + return rendered.replaceAll("\n{3,}", "\n\n").trim(); } // ==================== 过滤方法 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 397f6766..23db1dd8 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -15,11 +15,15 @@ import vip.mate.channel.event.ChannelMessageReceivedEvent; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.notification.ApprovalNotificationService; import vip.mate.channel.service.ChannelService; +import vip.mate.channel.web.AgentStreamAccumulator; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.exception.MateClawException; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.tts.TtsService; import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.core.service.ChatUploadLocationResolver; import vip.mate.workspace.conversation.model.MessageEntity; @@ -33,6 +37,7 @@ import java.nio.file.Paths; import java.time.Duration; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.*; @@ -65,6 +70,7 @@ public class ChannelMessageRouter { private final ChatStreamTracker streamTracker; private final ChannelChatOriginFactory chatOriginFactory; private final ChannelErrorClassifier errorClassifier; + private final InboundMessageDeduplicator inboundDedup; /** Field-injected (rather than constructor) to avoid a signature * change that would ripple through every test that constructs the * router directly. Spring's stock publisher is always available. */ @@ -78,6 +84,22 @@ public class ChannelMessageRouter { @Autowired(required = false) private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + /** Field-injected for the same reason as {@link #events}: backs the + * /model magic command (list + switch). Optional so tests that build + * the router directly still work; when unset the command degrades to + * a "service unavailable" reply instead of failing message intake. */ + @Autowired(required = false) + private ModelConfigService modelConfigService; + + /** Field-injected so the IM sync path can scrub hallucinated + * {@code /api/v1/files/generated/{id}} URLs (LLM wrote a UUID-shaped + * link without ever calling a render tool). The graph's FinalAnswerNode + * already does this, but the IM sync path accumulates {@code delta.content()} + * directly and bypasses FinalAnswerNode — without this scrub, the fake + * URL reaches the IM channel as a clickable link that 404s. */ + @Autowired(required = false) + private vip.mate.tool.document.GeneratedFileCache generatedFileCache; + /** 队列条目:封装消息及其路由上下文 */ private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {} @@ -148,30 +170,6 @@ public class ChannelMessageRouter { return currentMergedLength > LONG_TEXT_THRESHOLD ? LONG_DEBOUNCE_MS : DEBOUNCE_MS; } - /** - * Plan-Execute SSE events that the Web Console mirror needs to see when - * a conversation runs through an IM channel. - *

- * The agent emits these via {@code GraphEventPublisher} and they ride on - * the {@code chatStructuredStream} Flux as {@code StreamDelta.event(...)}. - * Web direct chats already broadcast them via the ChatController - * accumulator. IM channels (DingTalk + the seven sync-path adapters) - * historically dropped them — DingTalk's {@code processStreamAsText} - * only consumes {@code delta.content()}, and the sync {@code chat()} - * collector explicitly filters {@code delta.isEvent()} out. The whitelist - * is applied in the IM stream path so PlanStepsPanel renders correctly - * when an operator monitors an IM conversation in the Web Console. - *

- * Whitelist (not pass-through) so Web-side accumulator-internal events - * like {@code _usage_final} or future agent-internal markers don't leak - * to subscribers. - */ - private static final Set MIRRORED_PLAN_EVENTS = Set.of( - "plan_created", - "plan_step_started", - "plan_step_completed" - ); - /** 是否已关闭 */ private volatile boolean shutdown = false; @@ -186,7 +184,8 @@ public class ChannelMessageRouter { ObjectMapper objectMapper, ChatStreamTracker streamTracker, ChannelChatOriginFactory chatOriginFactory, - ChannelErrorClassifier errorClassifier) { + ChannelErrorClassifier errorClassifier, + InboundMessageDeduplicator inboundDedup) { this.agentService = agentService; this.conversationService = conversationService; this.channelService = channelService; @@ -199,6 +198,7 @@ public class ChannelMessageRouter { this.streamTracker = streamTracker; this.chatOriginFactory = chatOriginFactory; this.errorClassifier = errorClassifier; + this.inboundDedup = inboundDedup; } // ==================== 防抖辅助类 ==================== @@ -271,6 +271,23 @@ public class ChannelMessageRouter { } channelEntity = fresh; + // Inbound idempotency, before ANY side effect (magic commands, trigger + // fan-out, agent turn). IM platforms redeliver a message whose ack was + // late, lost, or non-200; without this claim every redelivery runs its + // own agent turn and the user gets the same answer again. Claiming here + // rather than in each adapter means every channel — including the four + // that never had dedup — is covered by one code path. + if (!inboundDedup.claim(channelEntity.getId(), inboundIdentity(message))) { + log.info("[{}] Duplicate inbound message (id={}) on channel {}; dropping", + adapter.getChannelType(), inboundIdentity(message), channelEntity.getId()); + return; + } + + String conversationId = buildConversationId(message, channelEntity.getId()); + if (handleMagicCommand(message, adapter, channelEntity, conversationId)) { + return; + } + // Fan out to the trigger pipeline FIRST — channel_message and // content_match triggers fire on every received message regardless // of whether the channel has an agent attached. If we returned @@ -292,7 +309,6 @@ public class ChannelMessageRouter { } String channelType = adapter.getChannelType(); - String conversationId = buildConversationId(message); log.info("[{}] Enqueuing message: sender={}, conversationId={}, agentId={}", channelType, message.getSenderId(), conversationId, agentId); @@ -372,15 +388,18 @@ public class ChannelMessageRouter { try { long ws = channelEntity.getWorkspaceId() == null ? 0L : channelEntity.getWorkspaceId(); String channelType = adapter.getChannelType(); - // messageId may be null for adapters that don't surface one; - // fall back to a sender+timestamp composite so the dedup key - // is at least deterministic-ish per webhook delivery. - String messageId = message.getMessageId(); - if (messageId == null || messageId.isBlank()) { - messageId = channelType + ":" + message.getSenderId() + ":" - + (message.getTimestamp() == null ? System.currentTimeMillis() - : message.getTimestamp()); - } + // Reuse the identity the inbound claim uses so both agree on what + // "the same message" is. Unlike the claim — which the deduplicator + // scopes by channel id — this key travels to the trigger pipeline + // unscoped, so anything we derive ourselves keeps the channelType + // prefix: two channels can otherwise produce the same + // sender+timestamp pair inside one workspace. + String platformId = message.getMessageId(); + String messageId = (platformId != null && !platformId.isBlank()) + ? platformId + : channelType + ":" + (inboundIdentity(message) != null + ? inboundIdentity(message) + : message.getSenderId() + "@" + System.currentTimeMillis()); events.publishEvent(new ChannelMessageReceivedEvent( ws, channelType, @@ -395,6 +414,53 @@ public class ChannelMessageRouter { } } + /** + * The stable identity of an inbound message, used both for the inbound + * dedup claim and as the trigger pipeline's dedup key. + * + *

Prefers the platform message id — every adapter that has one puts it + * on {@link ChannelMessage#getMessageId()}, and a redelivery carries the + * same value. Adapters whose stable token is not the raw message id (WeCom + * uses its {@code context_token}) put that token there instead. + * + *

Falls back to {@code sender@timestamp} when there is no id but the + * platform stamped the message — still stable across redeliveries of the + * same payload. Returns {@code null} when neither exists: there is nothing + * to tell a redelivery apart from a fresh message, so the caller must fail + * open rather than guess. + * + *

Package-private for unit-test access. + */ + static String inboundIdentity(ChannelMessage message) { + if (message == null) { + return null; + } + String messageId = message.getMessageId(); + if (messageId != null && !messageId.isBlank()) { + return messageId; + } + if (message.getTimestamp() == null) { + return null; + } + return message.getSenderId() + "@" + message.getTimestamp(); + } + + /** + * Has this inbound message already been claimed? A peek, not a claim — + * the authoritative claim happens once, in {@link #enqueue}. + * + *

For adapters to call before expensive inbound work (media download, + * payload decryption) so a known redelivery costs nothing. Adapters reach + * it through the router they already hold, which keeps the deduplicator + * out of every adapter constructor. + * + * @param identity the same value the adapter will put on + * {@link ChannelMessage#getMessageId()} + */ + public boolean isDuplicateInbound(Long channelId, String identity) { + return inboundDedup.contains(channelId, identity); + } + /** * 防抖到期:将合并后的消息真正放入渠道队列 */ @@ -415,6 +481,12 @@ public class ChannelMessageRouter { if (!offered) { log.error("[{}] Message queue full (capacity={}), dropping message from {}", channelType, QUEUE_CAPACITY, pending.firstMessage.getSenderId()); + // Never handed off — give the claim back so the platform's own + // retry can still get an answer. A turn that ran and *failed* + // keeps its claim: the user already got the error reply, and a + // retry would only produce a second one. + inboundDedup.release(pending.channelEntity != null ? pending.channelEntity.getId() : null, + inboundIdentity(pending.firstMessage)); try { String replyTarget = resolveReplyTarget(pending.firstMessage); pending.adapter.sendMessage(replyTarget, "系统繁忙,请稍后再试"); @@ -467,7 +539,7 @@ public class ChannelMessageRouter { continue; // 超时,重新检查 shutdown 标志 } - String conversationId = buildConversationId(entry.message()); + String conversationId = buildConversationId(entry.message(), entry.channelEntity().getId()); ReentrantLock lock = sessionLocks.computeIfAbsent(conversationId, k -> new ReentrantLock()); lock.lock(); @@ -592,15 +664,21 @@ public class ChannelMessageRouter { } channelEntity = fresh; Long agentId = channelEntity.getAgentId(); - if (agentId == null) { - log.warn("[{}] Channel {} has no associated agent at processing time; dropping message from {}", - adapter.getChannelType(), channelEntity.getName(), message.getSenderId()); - return; - } log.info("[{}] Processing message: sender={}, conversationId={}, agentId={}", adapter.getChannelType(), message.getSenderId(), conversationId, agentId); try { + // Magic commands run before the agent-binding check so /help and + // /status still answer on a channel with no agent attached. + if (handleMagicCommand(message, adapter, channelEntity, conversationId)) { + return; + } + if (agentId == null) { + log.warn("[{}] Channel {} has no associated agent at processing time; dropping message from {}", + adapter.getChannelType(), channelEntity.getName(), message.getSenderId()); + return; + } + // ======= 审批拦截层 ======= String userText = message.getContent() != null ? message.getContent().trim() : ""; PendingApproval pending = approvalService.findPendingByConversation(conversationId); @@ -780,46 +858,72 @@ public class ChannelMessageRouter { if (adapter instanceof StreamingChannelAdapter streamingAdapter) { savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin); } else { - // Sync path for non-streaming IM adapters (feishu / wecom / weixin / - // slack / discord / qq / telegram). We can't use agentService.chat() + // Sync path for non-streaming IM adapters (weixin / slack / + // discord / qq / telegram). We can't use agentService.chat() // because its collector filters out `delta.isEvent()` deltas — that - // would silently drop plan_created / plan_step_* events that the Web - // Console mirror needs to render PlanStepsPanel. Instead we consume - // chatStructuredStream directly: content gets accumulated for the IM - // reply, and whitelisted plan events are mirrored to ChatStreamTracker - // for any Web SSE viewer of the same conversationId. - StringBuilder replyAccumulator = new StringBuilder(); + // would silently drop the tool/plan events the Web Console + // mirror and the persisted execution metadata both need. + // Instead we consume chatStructuredStream directly through + // the shared accumulator (reply text, metadata, live mirror). final String channelType = adapter.getChannelType(); - // Token usage + model attribution: capture _usage_final event emitted at stream end - final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning] - final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider] + // Channel-level toggle for relaying per-stage narration as + // standalone messages mid-run. Shares the key the streaming + // progress path uses so operators have one knob per channel. + // Disabled → narration is dropped from the IM channel (it is + // never part of the final reply either way; web observers + // still see it via the live broadcast). + final boolean relayNarration = channelConfigBoolean( + channelEntity, "stream_progress", true); + // Shared accumulator: builds the segments/toolCalls metadata + // the Web console renders for history, mirrors events + + // deltas to live Web observers, and captures token usage + + // model attribution (_usage_final is consumed internally). + // Reply text also comes from it — same semantics as the + // legacy collector: persistOnly deltas included (DirectAnswerNode- + // routed answers arrive as persistOnly when CONTENT_STREAMED=true + // and IM channels still need the text for the outgoing reply), + // segmentOnly narration excluded (issue #120). + AgentStreamAccumulator accumulator = newAccumulator(); agentService.chatStructuredStream(agentId, promptText, conversationId, message.getSenderId(), chatOrigin) .doOnNext(delta -> { - if (delta.isEvent()) { - if ("_usage_final".equals(delta.eventType())) { - Map data = delta.eventData(); - usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); - usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); - usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue(); - usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue(); - usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue(); - Object model = data.get("runtimeModelName"); - Object provider = data.get("runtimeProviderId"); - if (model != null) modelInfo[0] = model.toString(); - if (provider != null) modelInfo[1] = provider.toString(); + accumulator.accept(delta, conversationId); + if (!delta.isEvent() && delta.segmentOnly()) { + // Per-stage narration ("Let me look that up…"), emitted as + // one complete delta per agent loop iteration. Relay it + // immediately as its own outgoing message so the user sees + // progress mid-run. + String narration = delta.content() != null ? delta.content().trim() : ""; + if (relayNarration && !narration.isEmpty() && replyTarget != null) { + try { + adapter.renderAndSend(replyTarget, narration); + } catch (Exception sendErr) { + // A failed progress send must not abort the agent + // run — the final reply still goes out below. + log.warn("[{}] Narration relay failed (non-fatal): {}", + channelType, sendErr.getMessage()); + } } - mirrorPlanEventToTracker(conversationId, delta, channelType); - } else if (delta.content() != null) { - // Match the legacy agentService.chat() behavior: include - // persistOnly deltas too. DirectAnswerNode-routed answers - // arrive as persistOnly when CONTENT_STREAMED=true and IM - // channels still need the text for the outgoing reply. - replyAccumulator.append(delta.content()); } }) .blockLast(Duration.ofMinutes(10)); - String reply = replyAccumulator.toString(); + String reply = accumulator.getContent(); + + // The IM sync path bypasses FinalAnswerNode, so hallucinated + // /api/v1/files/generated/{id} URLs (LLM wrote a fake link + // without calling a render tool) reach here verbatim. Scrub + // them to the user-visible warning so IM clients don't see + // a clickable link that 404s. Real tool-produced URLs are + // left intact for the channel adapter's scrubber to upgrade + // into native attachments. + if (generatedFileCache != null) { + String scrubbed = generatedFileCache.scrubMissingReferences(reply); + if (!scrubbed.equals(reply)) { + log.info("[{}] Scrubbed hallucinated generated-file URL(s) from IM reply ({} -> {} chars)", + adapter.getChannelType(), reply.length(), scrubbed.length()); + reply = scrubbed; + } + } // 检查 chat 过程中是否产生了审批 pending PendingApproval newPending = approvalService.findPendingByConversation(conversationId); @@ -842,9 +946,19 @@ public class ChannelMessageRouter { // error turns must not pollute memory extraction. boolean isError = errorClassifier.isErrorReply(reply); String status = isError ? "error" : "completed"; + // Persist the full execution record (parts + metadata) + // so the Web console renders IM-routed turns exactly + // like Web direct chats. The content column keeps the + // scrubbed reply text that actually went out. MessageEntity saved = conversationService.saveMessage( - conversationId, "assistant", reply, null, status, - usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null); + conversationId, "assistant", reply, + accumulator.toAssistantParts(), status, + accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), + blankToNull(accumulator.getRuntimeModelName()), + blankToNull(accumulator.getRuntimeProviderId()), + accumulator.toMetadataJson()); savedAssistantId = saved != null ? saved.getId() : null; if (!isError) { publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin); @@ -910,6 +1024,272 @@ public class ChannelMessageRouter { } } + /** + * Handle channel-native control commands before the message is persisted + * or forwarded to the agent. A recognized command is terminal for this + * inbound message: it never reaches the debounce queue or the LLM. + */ + private boolean handleMagicCommand(ChannelMessage message, ChannelAdapter adapter, + ChannelEntity channelEntity, String conversationId) { + String userText = message != null ? message.getContent() : null; + ChannelMagicCommand.Parsed command = ChannelMagicCommand.parse(userText).orElse(null); + if (command == null) { + return false; + } + String replyTarget = resolveReplyTarget(message); + String reply = switch (command.type()) { + case CLEAR -> { + cancelPending(conversationId); + conversationService.clearMessages(conversationId); + yield ChannelMagicCommand.clearConfirmation(); + } + case NEW -> { + // Channel conversation ids are deterministic (channelType:chatId), + // so "new session" cannot rotate the id — it clears the context + // like CLEAR and only differs in the confirmation wording. + cancelPending(conversationId); + conversationService.clearMessages(conversationId); + yield ChannelMagicCommand.newConfirmation(); + } + case STOP -> { + cancelPending(conversationId); + boolean stopped = streamTracker.requestStop(conversationId); + yield stopped ? ChannelMagicCommand.stopConfirmation() + : ChannelMagicCommand.stopNothingRunning(); + } + case HELP -> ChannelMagicCommand.helpText(); + case STATUS -> buildStatusReply(channelEntity, conversationId); + case MODEL -> handleModelCommand(channelEntity, conversationId, command.args()); + }; + if (replyTarget != null && reply != null) { + // renderAndSend (not sendMessage) so adapters that pre-post a + // "thinking..." placeholder on inbound (WeCom reply_stream) + // consume it here: the confirmation overwrites the placeholder + // bubble in place and the keepalive refresher is stopped. + // Plain sendMessage would leave the placeholder dangling forever. + adapter.renderAndSend(replyTarget, reply); + } + log.info("[{}] Magic command handled: {} conversationId={}, sender={}", + adapter.getChannelType(), command.type(), conversationId, + message != null ? message.getSenderId() : null); + return true; + } + + /** Build the /status reply; every lookup degrades gracefully to keep the command side-effect free. */ + private String buildStatusReply(ChannelEntity channelEntity, String conversationId) { + StringBuilder sb = new StringBuilder("📊 会话状态\n"); + sb.append("- 会话: ").append(conversationId).append('\n'); + Long agentId = channelEntity != null ? channelEntity.getAgentId() : null; + String[] pinned = findPinnedModel(conversationId); + if (agentId == null) { + sb.append("- 智能体: 未绑定\n"); + } else { + try { + AgentEntity agent = agentService.getAgent(agentId); + if (agent != null) { + sb.append("- 智能体: ").append(agent.getName()).append('\n'); + // Conversation-pinned model wins over the agent default — + // mirrors the resolution order in AgentService, so /status + // never contradicts what /model just switched to. + if (pinned != null) { + sb.append("- 模型: ").append(pinned[0]).append(':').append(pinned[1]) + .append("(会话指定)\n"); + } else if (agent.getModelName() != null && !agent.getModelName().isBlank()) { + sb.append("- 模型: ").append(agent.getModelName()).append('\n'); + } + } else { + sb.append("- 智能体: 未找到(id=").append(agentId).append(")\n"); + } + } catch (Exception e) { + log.warn("Failed to load agent {} for /status: {}", agentId, e.getMessage()); + sb.append("- 智能体: 查询失败\n"); + } + } + try { + sb.append("- 历史消息数: ").append(conversationService.countMessages(conversationId)).append('\n'); + } catch (Exception e) { + log.warn("Failed to count messages for /status: {}", e.getMessage()); + } + boolean running = streamTracker.isRunning(conversationId); + sb.append("- 当前任务: ").append(running ? "进行中(可用 /stop 停止)" : "空闲"); + return sb.toString(); + } + + /** + * Handle the /model command: list enabled chat models, pin one on this + * conversation, or reset to the agent default. Listing and resetting work + * without a bound agent; switching requires one because the pinned pair + * only takes effect when the agent graph is built. + */ + private String handleModelCommand(ChannelEntity channelEntity, String conversationId, String args) { + if (modelConfigService == null) { + return "⚠️ 模型管理服务不可用,请稍后再试。"; + } + String arg = args == null ? "" : args.trim(); + if ("reset".equalsIgnoreCase(arg) || "恢复默认".equals(arg)) { + conversationService.clearConversationModel(conversationId); + return "✅ 已恢复默认模型(跟随智能体配置),下一条消息生效。"; + } + List models; + try { + models = modelConfigService.listEnabledModels(); + } catch (Exception e) { + log.warn("Failed to list models for /model on {}: {}", conversationId, e.getMessage()); + return "⚠️ 查询模型列表失败,请稍后再试。"; + } + if (arg.isEmpty() || "list".equalsIgnoreCase(arg)) { + return buildModelListReply(models, conversationId); + } + return switchConversationModel(channelEntity, conversationId, arg, models); + } + + /** Max rows shown by /model list — a full catalog can exceed 180 rows, + * which segments into several IM bubbles and buries the usage hint. */ + private static final int MODEL_LIST_MAX_ROWS = 20; + + private String buildModelListReply(List models, String conversationId) { + if (models.isEmpty()) { + return "当前没有已启用的对话模型,请先在控制台配置。"; + } + String[] pinned = findPinnedModel(conversationId); + StringBuilder sb = new StringBuilder("🧠 可用模型(/model <名称> 切换,/model reset 恢复默认):\n"); + int shown = 0; + for (ModelConfigEntity m : models) { + if (shown >= MODEL_LIST_MAX_ROWS) { + break; + } + sb.append("- ").append(m.getProvider()).append(':').append(m.getModelName()); + if (pinned != null && pinned[0].equalsIgnoreCase(String.valueOf(m.getProvider())) + && pinned[1].equalsIgnoreCase(String.valueOf(m.getModelName()))) { + sb.append(" ✅ 当前"); + } + sb.append('\n'); + shown++; + } + if (models.size() > MODEL_LIST_MAX_ROWS) { + sb.append("…共 ").append(models.size()) + .append(" 个已启用模型,仅展示前 ").append(MODEL_LIST_MAX_ROWS) + .append(" 个;发送 /model <关键词> 搜索其余模型。\n"); + } + sb.append(pinned == null + ? "当前:跟随智能体默认模型" + : "当前会话已指定:" + pinned[0] + ":" + pinned[1]); + return sb.toString(); + } + + private String switchConversationModel(ChannelEntity channelEntity, String conversationId, + String arg, List models) { + if (channelEntity == null || channelEntity.getAgentId() == null) { + return "⚠️ 当前渠道未绑定智能体,请先在控制台绑定后再切换模型。"; + } + String wantedProvider = null; + String wantedName = arg; + int colon = arg.indexOf(':'); + if (colon > 0 && colon < arg.length() - 1) { + wantedProvider = arg.substring(0, colon).trim(); + wantedName = arg.substring(colon + 1).trim(); + } + final String fProvider = wantedProvider; + final String fName = wantedName; + List matches = models.stream() + .filter(m -> fName.equalsIgnoreCase(m.getModelName())) + .filter(m -> fProvider == null || fProvider.equalsIgnoreCase(m.getProvider())) + .toList(); + if (matches.isEmpty()) { + // No exact hit — treat the arg as a search keyword so users can + // discover models the capped /model list didn't show. + String keyword = fName.toLowerCase(Locale.ROOT); + List fuzzy = models.stream() + .filter(m -> String.valueOf(m.getModelName()).toLowerCase(Locale.ROOT).contains(keyword) + || String.valueOf(m.getProvider()).toLowerCase(Locale.ROOT).contains(keyword)) + .limit(MODEL_LIST_MAX_ROWS) + .toList(); + if (fuzzy.isEmpty()) { + return "⚠️ 未找到已启用的模型「" + arg + "」,发送 /model 查看可用列表。"; + } + StringBuilder sb = new StringBuilder("未找到精确匹配「").append(arg) + .append("」,相近的可用模型:\n"); + for (ModelConfigEntity m : fuzzy) { + sb.append("- /model ").append(m.getProvider()).append(':') + .append(m.getModelName()).append('\n'); + } + return sb.toString().stripTrailing(); + } + if (matches.size() > 1) { + StringBuilder sb = new StringBuilder("⚠️ 模型「").append(fName) + .append("」在多个 provider 下存在,请带上前缀再试:\n"); + for (ModelConfigEntity m : matches) { + sb.append("- /model ").append(m.getProvider()).append(':').append(m.getModelName()).append('\n'); + } + return sb.toString().stripTrailing(); + } + ModelConfigEntity target = matches.get(0); + try { + // The magic-command layer runs before processMessage's + // get-or-create, so a /model sent as the very first message must + // create the conversation row itself — updateConversationModel + // silently no-ops on a missing row. + conversationService.getOrCreateSharedConversation( + conversationId, channelEntity.getAgentId(), channelEntity.getWorkspaceId()); + conversationService.updateConversationModel( + conversationId, target.getProvider(), target.getModelName()); + } catch (Exception e) { + log.warn("Failed to pin model {} on {}: {}", arg, conversationId, e.getMessage()); + return "⚠️ 切换失败,请稍后再试。"; + } + return "✅ 本会话模型已切换为 " + target.getProvider() + ":" + target.getModelName() + + ",下一条消息生效。发送 /model reset 可恢复默认。"; + } + + /** Conversation-pinned (provider, model) pair, or null when unpinned/unavailable. */ + private String[] findPinnedModel(String conversationId) { + try { + ConversationEntity conv = conversationService.findByConversationId(conversationId); + if (conv != null + && conv.getModelProvider() != null && !conv.getModelProvider().isBlank() + && conv.getModelName() != null && !conv.getModelName().isBlank()) { + return new String[]{conv.getModelProvider(), conv.getModelName()}; + } + } catch (Exception e) { + log.debug("Failed to load pinned model for {}: {}", conversationId, e.getMessage()); + } + return null; + } + + private void cancelPending(String conversationId) { + PendingMessage pending; + synchronized (pendingMessages) { + pending = pendingMessages.remove(conversationId); + } + if (pending != null && pending.timer != null) { + pending.timer.cancel(false); + } + } + + /** + * Build a per-turn accumulator wired to the stream tracker, so live Web + * observers of an IM conversation receive the same event fan-out as Web + * direct chats, and the persisted metadata matches byte-for-byte. + */ + private AgentStreamAccumulator newAccumulator() { + return new AgentStreamAccumulator(objectMapper, new AgentStreamAccumulator.Sink() { + @Override + public void broadcast(String conversationId, String eventName, Object payload) { + streamTracker.broadcastObject(conversationId, eventName, payload); + } + + @Override + public void updatePhase(String conversationId, String phase) { + streamTracker.updatePhase(conversationId, phase); + } + }); + } + + /** Map the accumulator's empty-string defaults back to SQL NULL. */ + private static String blankToNull(String s) { + return s == null || s.isBlank() ? null : s; + } + /** * 流式处理路径(渠道无关) *

@@ -918,30 +1298,6 @@ public class ChannelMessageRouter { * - StreamingChannelAdapter 负责渲染(AI Card / 卡片更新 / 文本累积等) * - Router 负责后续的审批检查、消息持久化、事件发布 */ - /** - * Forward whitelisted Plan-Execute SSE events to ChatStreamTracker so a - * Web Console viewer of an IM-routed conversation sees PlanStepsPanel. - *

- * Bounded to {@link #MIRRORED_PLAN_EVENTS} — see the constant's javadoc - * for why this is a whitelist rather than a pass-through. Failures here - * are best-effort and never propagate, since dropping a UI update is - * preferable to derailing the channel reply. - */ - private void mirrorPlanEventToTracker(String conversationId, - AgentService.StreamDelta delta, - String channelTypeForLog) { - String eventType = delta.eventType(); - if (eventType == null || !MIRRORED_PLAN_EVENTS.contains(eventType)) { - return; - } - try { - streamTracker.broadcastObject(conversationId, eventType, delta.eventData()); - } catch (Exception ex) { - log.debug("[{}] Failed to mirror plan event {}: {}", - channelTypeForLog, eventType, ex.getMessage()); - } - } - private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter, String conversationId, Long agentId, String promptText, ChannelEntity channelEntity, ChatOrigin chatOrigin) { @@ -949,33 +1305,22 @@ public class ChannelMessageRouter { log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId); try { - // Step 1: 产生事件流(RFC-063r §2.5: forward ChatOrigin so tools see channelId) + // Step 1: 产生事件流(forward ChatOrigin so tools see channelId) Flux stream = agentService.chatStructuredStream( agentId, promptText, conversationId, message.getSenderId(), chatOrigin); - // Mirror plan-execute SSE events to ChatStreamTracker before the - // adapter consumes the Flux. DingTalkChannelAdapter.processStreamAsText - // only reads `delta.content()` and would otherwise eat plan_created / - // plan_step_* events, leaving the Web Console mirror with no - // PlanStepsPanel for IM-routed conversations. - // Token usage + model attribution: capture _usage_final event emitted at stream end - final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning] - final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider] - Flux mirroredStream = stream.doOnNext(delta -> { - if (delta.isEvent() && "_usage_final".equals(delta.eventType())) { - Map data = delta.eventData(); - usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); - usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); - usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue(); - usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue(); - usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue(); - Object model = data.get("runtimeModelName"); - Object provider = data.get("runtimeProviderId"); - if (model != null) modelInfo[0] = model.toString(); - if (provider != null) modelInfo[1] = provider.toString(); - } - mirrorPlanEventToTracker(conversationId, delta, channelType); - }); + // Feed every delta through the shared accumulator before the + // adapter consumes the Flux. The accumulator builds the same + // segments/toolCalls metadata the Web SSE path persists (so the + // console renders the execution timeline for IM-routed turns), + // mirrors tool/plan/content events to any live Web observer of + // this conversation, and captures token usage + model + // attribution. Internal bookkeeping events (_usage_final, + // _routing_decision) are consumed inside the accumulator and + // never reach subscribers. + AgentStreamAccumulator accumulator = newAccumulator(); + Flux mirroredStream = stream.doOnNext(delta -> + accumulator.accept(delta, conversationId)); // Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新) String finalContent = streamingAdapter.processStream(mirroredStream, message, conversationId); @@ -994,9 +1339,20 @@ public class ChannelMessageRouter { } else if (finalContent != null && !finalContent.isBlank()) { boolean isError = errorClassifier.isErrorReply(finalContent); String status = isError ? "error" : "completed"; + // Persist the full execution record — parts (text/thinking/ + // tool_call) and metadata (segments/toolCalls/plan/…) — so + // the Web console renders IM-routed turns exactly like Web + // direct chats. The content column keeps the adapter's final + // text (the adapter may have post-processed it). MessageEntity saved = conversationService.saveMessage( - conversationId, "assistant", finalContent, null, status, - usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null); + conversationId, "assistant", finalContent, + accumulator.toAssistantParts(), status, + accumulator.getPromptTokens(), accumulator.getCompletionTokens(), + accumulator.getCacheReadTokens(), accumulator.getCacheWriteTokens(), + accumulator.getReasoningTokens(), + blankToNull(accumulator.getRuntimeModelName()), + blankToNull(accumulator.getRuntimeProviderId()), + accumulator.toMetadataJson()); if (!isError) { publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin); } @@ -1202,7 +1558,7 @@ public class ChannelMessageRouter { return Flux.error(new IllegalStateException("Channel has no associated agent")); } - String conversationId = buildConversationId(message); + String conversationId = buildConversationId(message, channelEntity.getId()); String username = message.getSenderName() != null ? message.getSenderName() : message.getSenderId(); conversationService.getOrCreateConversation(conversationId, agentId, username, channelEntity.getWorkspaceId()); @@ -1319,9 +1675,26 @@ public class ChannelMessageRouter { * 格式:{channelType}:{chatId 或 senderId} * 格式采用 {channelType}:{identifier} 命名规则 */ - private String buildConversationId(ChannelMessage message) { + /** + * Build the conversation id for an inbound channel message. + * + *

The id is scoped by {@code channelId} so the same sender reaching two + * different workspaces' same-type channels (e.g. two separate wecom channels) + * no longer collapses into one shared conversation row. {@code channelId} is + * the {@code ChannelEntity} primary key, which binds to exactly one workspace. + * + *

Format: {@code {channelType}:{channelId}:{chatId|senderId}}. When + * {@code channelId} is null (defensive; the routed channel row always has an + * id) the legacy {@code {channelType}:{identifier}} form is used so nothing + * NPEs — those ids remain workspace-ambiguous but that path is not reachable + * for a persisted channel. + */ + private String buildConversationId(ChannelMessage message, Long channelId) { String identifier = message.getChatId() != null ? message.getChatId() : message.getSenderId(); - return message.getChannelType() + ":" + identifier; + if (channelId == null) { + return message.getChannelType() + ":" + identifier; + } + return message.getChannelType() + ":" + channelId + ":" + identifier; } /** @@ -1577,6 +1950,18 @@ public class ChannelMessageRouter { return "auto".equals(voiceMode) && "voice".equals(message.getInputMode()); } + /** + * Boolean lookup on the channel's configJson. Accepts Boolean or String + * values, mirroring the adapter-side config parsing rules, so the router + * and the adapters read the same key identically. + */ + private boolean channelConfigBoolean(ChannelEntity channelEntity, String key, boolean defaultValue) { + Object value = parseChannelConfig(channelEntity.getConfigJson()).get(key); + if (value instanceof Boolean b) return b; + if (value instanceof String s && !s.isBlank()) return Boolean.parseBoolean(s.trim()); + return defaultValue; + } + /** * 解析 Channel 的 configJson 为 Map */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelSessionStore.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelSessionStore.java index d6a85032..aed2c5c7 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelSessionStore.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelSessionStore.java @@ -8,6 +8,7 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import vip.mate.channel.model.ChannelSessionEntity; import vip.mate.channel.repository.ChannelSessionMapper; +import vip.mate.workspace.conversation.event.ConversationDeletedEvent; import java.time.LocalDateTime; import java.util.Comparator; @@ -56,6 +57,30 @@ public class ChannelSessionStore { log.info("ChannelSessionStore initialized: loaded {} sessions from DB", sessions.size()); } + /** + * Drop the cached session for a conversation the user just deleted. + * + *

{@code deleteConversation} removes the {@code mate_channel_session} + * row inside its DB cascade, but the cache is this class's private state + * and no DB delete can reach it. Without this listener the entry survives + * as a phantom: the next inbound message takes the "update existing" branch + * and calls {@code updateById} against a primary key that no longer exists, + * which affects 0 rows and never re-inserts — so the channel session stays + * missing and proactive push / cron channel resolution silently degrade + * after the next restart. + * + *

Runs after the DB cascade commits — see {@link ConversationDeletedEvent}. + * + *

会话被删除后清理内存缓存,避免留下指向已删除行的幽灵条目。 + */ + @EventListener + public void onConversationDeleted(ConversationDeletedEvent event) { + if (cache.remove(event.conversationId()) != null) { + log.info("[ChannelSession] Evicted cached session for deleted conversation {}", + event.conversationId()); + } + } + /** * 保存或更新会话标识(收到用户消息时调用) * @@ -78,40 +103,51 @@ public class ChannelSessionStore { existing.setSenderName(senderName); existing.setChannelId(channelId); existing.setLastActiveTime(now); - sessionMapper.updateById(existing); - log.debug("Updated channel session: conversationId={}, targetId={}", conversationId, targetId); - } else { - // 先查 DB(可能是上次启动后的新记录) - ChannelSessionEntity dbEntity = sessionMapper.selectOne( - new LambdaQueryWrapper() - .eq(ChannelSessionEntity::getConversationId, conversationId)); - - if (dbEntity != null) { - dbEntity.setTargetId(targetId); - dbEntity.setSenderId(senderId); - dbEntity.setSenderName(senderName); - dbEntity.setChannelId(channelId); - dbEntity.setLastActiveTime(now); - sessionMapper.updateById(dbEntity); - cache.put(conversationId, dbEntity); - log.debug("Updated channel session from DB: conversationId={}", conversationId); - } else { - // 新建 - ChannelSessionEntity entity = new ChannelSessionEntity(); - entity.setConversationId(conversationId); - entity.setChannelType(channelType); - entity.setTargetId(targetId); - entity.setSenderId(senderId); - entity.setSenderName(senderName); - entity.setChannelId(channelId); - entity.setLastActiveTime(now); - sessionMapper.insert(entity); - cache.put(conversationId, entity); - log.debug("Created channel session: conversationId={}, targetId={}", conversationId, targetId); - - // 容量保护:超过上限时淘汰最久未活跃的会话 - evictIfNeeded(); + int updated = sessionMapper.updateById(existing); + if (updated > 0) { + log.debug("Updated channel session: conversationId={}, targetId={}", conversationId, targetId); + return; } + // The cached entity points at a row that no longer exists — the + // conversation was deleted out from under us (deletes are physical; + // no logical-delete column is honoured project-wide). Without this + // self-heal the update silently affects 0 rows on every subsequent + // message and the session is never re-created, so proactive push + // and cron channel resolution break after the next restart. + log.info("Channel session row for {} vanished; re-creating from cache miss", conversationId); + cache.remove(conversationId); + } + + // 先查 DB(可能是上次启动后的新记录) + ChannelSessionEntity dbEntity = sessionMapper.selectOne( + new LambdaQueryWrapper() + .eq(ChannelSessionEntity::getConversationId, conversationId)); + + if (dbEntity != null) { + dbEntity.setTargetId(targetId); + dbEntity.setSenderId(senderId); + dbEntity.setSenderName(senderName); + dbEntity.setChannelId(channelId); + dbEntity.setLastActiveTime(now); + sessionMapper.updateById(dbEntity); + cache.put(conversationId, dbEntity); + log.debug("Updated channel session from DB: conversationId={}", conversationId); + } else { + // 新建 + ChannelSessionEntity entity = new ChannelSessionEntity(); + entity.setConversationId(conversationId); + entity.setChannelType(channelType); + entity.setTargetId(targetId); + entity.setSenderId(senderId); + entity.setSenderName(senderName); + entity.setChannelId(channelId); + entity.setLastActiveTime(now); + sessionMapper.insert(entity); + cache.put(conversationId, entity); + log.debug("Created channel session: conversationId={}, targetId={}", conversationId, targetId); + + // 容量保护:超过上限时淘汰最久未活跃的会话 + evictIfNeeded(); } } @@ -189,13 +225,29 @@ public class ChannelSessionStore { } /** - * 删除会话 + * Drop a channel session from both layers. + * + *

Use this rather than the mapper: this class owns the cache, so a + * caller that deletes the row directly leaves a phantom entry behind — + * every later {@code saveOrUpdate} then updates a primary key that no + * longer exists and the session is never re-created. + * + *

The conversation-delete cascade does not come through here: it removes + * the row inside its own transaction and lets + * {@link #onConversationDeleted} drop the cache after commit, so the cache + * is never cleared for a delete that later rolls back. + * + *

删除会话(内存 + DB 双层)。 + * + * @return number of DB rows removed */ - public void remove(String conversationId) { - ChannelSessionEntity removed = cache.remove(conversationId); - if (removed != null) { - sessionMapper.deleteById(removed.getId()); + public int remove(String conversationId) { + cache.remove(conversationId); + int deleted = sessionMapper.delete(new LambdaQueryWrapper() + .eq(ChannelSessionEntity::getConversationId, conversationId)); + if (deleted > 0) { log.debug("Removed channel session: conversationId={}", conversationId); } + return deleted; } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/InboundMessageDeduplicator.java b/mateclaw-server/src/main/java/vip/mate/channel/InboundMessageDeduplicator.java new file mode 100644 index 00000000..9d02461e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/InboundMessageDeduplicator.java @@ -0,0 +1,187 @@ +package vip.mate.channel; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * TTL- and capacity-bounded claim register for inbound channel messages. + * + *

One shared implementation for every channel. Before this existed, four + * adapters carried four hand-rolled variants (a 500-entry LRU, an unbounded + * set halved on overflow, an access-ordered map) and four adapters carried + * none at all — DingTalk among them, which is why a redelivered DingTalk + * message produced a second full answer. + * + *

A message is identified by {@code channelId + identity}, where identity is + * the platform message id (see + * {@link ChannelMessageRouter#inboundIdentity(ChannelMessage)}). Scoping by + * channel keeps two channels of the same type from colliding on a platform id + * that is only unique per app. + * + *

Three operations, matching the three things a caller needs: + *

    + *
  • {@link #claim} — take ownership of a message. The first caller gets + * {@code true} and proceeds; a redelivery inside the TTL gets + * {@code false} and must drop the message.
  • + *
  • {@link #contains} — peek without claiming, so an adapter can drop a + * known redelivery before expensive inbound work (media + * download, payload decryption) and still leave the authoritative claim + * to the router.
  • + *
  • {@link #release} — give a claim back when the message was never handed + * off for processing (e.g. the channel queue was full), so the + * platform's own retry can still get through.
  • + *
+ * + *

Fail-open by design: a blank identity means "this platform gave us + * nothing stable to dedup on", and the message is always let through. Dropping + * a real message is worse than answering a redelivery twice. + * + *

入站消息去重登记表(TTL + 容量双约束),全渠道共用一份实现。 + */ +@Slf4j +@Component +@EnableConfigurationProperties(ChannelDedupProperties.class) +public class InboundMessageDeduplicator { + + private final ChannelDedupProperties props; + + /** + * Claimed identity -> claim timestamp (epoch millis). Insertion-ordered so + * the eldest entries sit at the head and overflow trimming is a head scan. + * Guarded by its own monitor — claims are short, contended only by the + * channel intake threads. + */ + private final LinkedHashMap claims = new LinkedHashMap<>(); + + public InboundMessageDeduplicator(ChannelDedupProperties props) { + this.props = props; + } + + /** + * Take ownership of an inbound message. + * + * @return {@code true} when the caller owns this message and should process + * it; {@code false} when it is a redelivery already claimed inside + * the TTL window and must be dropped + */ + public boolean claim(Long channelId, String identity) { + String key = key(channelId, identity); + if (key == null || !props.isEnabled()) { + return true; + } + long now = System.currentTimeMillis(); + long ttlMs = ttlMillis(); + synchronized (claims) { + Long claimedAt = claims.get(key); + if (claimedAt != null && now - claimedAt < ttlMs) { + return false; + } + // Either new, or an expired claim being retaken. Remove first so + // the re-insert moves the entry to the tail — insertion order is + // what the overflow trim relies on to find the eldest claims. + claims.remove(key); + claims.put(key, now); + if (claims.size() > props.getMaxSize()) { + trim(now, ttlMs); + } + return true; + } + } + + /** + * Peek at a claim without taking one. Lets an adapter short-circuit a + * redelivery before doing expensive inbound work while leaving the single + * authoritative claim to the router. + */ + public boolean contains(Long channelId, String identity) { + String key = key(channelId, identity); + if (key == null || !props.isEnabled()) { + return false; + } + long now = System.currentTimeMillis(); + long ttlMs = ttlMillis(); + synchronized (claims) { + Long claimedAt = claims.get(key); + if (claimedAt == null) { + return false; + } + if (now - claimedAt < ttlMs) { + return true; + } + claims.remove(key); + return false; + } + } + + /** + * Hand a claim back. Call this only when the message was never handed off + * for processing — a turn that ran and failed keeps its claim, because the + * user already received the error and a platform retry would just send a + * second one. + */ + public void release(Long channelId, String identity) { + String key = key(channelId, identity); + if (key == null) { + return; + } + synchronized (claims) { + claims.remove(key); + } + } + + /** Drop every claim. Called when a channel restarts. */ + public void clear() { + synchronized (claims) { + claims.clear(); + } + } + + /** Live claim count. Package-private for tests. */ + int size() { + synchronized (claims) { + return claims.size(); + } + } + + /** + * Evict expired claims first; if the map is still over capacity (every + * entry fresh under sustained traffic), drop the eldest until it fits. + * Caller holds the monitor. + */ + private void trim(long now, long ttlMs) { + claims.entrySet().removeIf(e -> now - e.getValue() >= ttlMs); + int overflow = claims.size() - props.getMaxSize(); + if (overflow <= 0) { + return; + } + Iterator> it = claims.entrySet().iterator(); + for (int i = 0; i < overflow && it.hasNext(); i++) { + it.next(); + it.remove(); + } + log.debug("[dedup] Trimmed {} eldest claims (cap={})", overflow, props.getMaxSize()); + } + + private long ttlMillis() { + Duration ttl = props.getTtl(); + return ttl != null ? Math.max(1L, ttl.toMillis()) : Duration.ofMinutes(5).toMillis(); + } + + /** + * Compose the tracking key, or {@code null} when there is nothing stable to + * track. Scoped by channel id so two channels of the same type can't + * collide on a per-app platform id. + */ + private static String key(Long channelId, String identity) { + if (identity == null || identity.isBlank()) { + return null; + } + return (channelId == null ? "-" : channelId.toString()) + ":" + identity; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/StreamingChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/StreamingChannelAdapter.java index 6b7e7581..2861b8e6 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/StreamingChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/StreamingChannelAdapter.java @@ -36,4 +36,26 @@ public interface StreamingChannelAdapter extends ChannelAdapter { * @return 最终完整回复内容 */ String processStream(Flux stream, ChannelMessage message, String conversationId); + + /** + * 判断一个 delta 的文本是否属于"最终回复内容"。 + *

+ * {@code segmentOnly} 的 delta 携带的是每轮 ReAct 的旁白("我来查一下…"), + * 共享累加器刻意不把它写进 {@code mate_message.content}。适配器如果直接 + * 累加 {@code delta.content()},就会把每轮旁白拼进外发文本 —— 而旁白通常 + * 是对答案的复述,用户就会把同一段内容读到两三遍。被污染的文本还会回写 + * 持久化并在下一轮作为历史重放,重复量随轮次增长,而不是稳定在 2 倍。 + *

+ * 旁白要不要露出,由渠道的 {@code stream_progress} 开关决定:想露出就作为 + * 独立的进度消息下发,而不是混进最终答案。 + * + * @param delta 流式片段 + * @return true 表示该片段的文本应计入最终回复 + */ + static boolean contributesToFinalContent(StreamDelta delta) { + return delta != null + && !delta.isEvent() + && !delta.segmentOnly() + && delta.content() != null; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java index 8144479e..1cd177b2 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java @@ -27,6 +27,8 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * 钉钉渠道适配器 @@ -62,6 +64,12 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St /** AI Card 管理器(message_type=card 时初始化) */ private DingTalkAICardManager aiCardManager; + /** + * Off-callback worker for inbound parsing, so the Stream frame is acked + * immediately. See {@link #dispatchInbound}. + */ + private volatile ExecutorService inboundExecutor; + /** 钉钉媒体上传器(doStart 时初始化) */ private DingTalkMediaUploader mediaUploader; @@ -118,6 +126,11 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St // 启动 Stream 模式或 Webhook 模式 if (isStreamMode()) { + this.inboundExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "dingtalk-inbound-" + channelEntity.getId()); + t.setDaemon(true); + return t; + }); startStreamMode(clientId, clientSecret); } else { log.info("[dingtalk] Webhook mode: waiting for callbacks at /api/v1/channels/webhook/dingtalk"); @@ -234,12 +247,44 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St return; } - handleWebhook(payload); + dispatchInbound(payload); } catch (Exception e) { log.error("[dingtalk-stream] Failed to parse stream message: {}", e.getMessage(), e); } } + /** + * Hand the parsed payload to a worker and return, so the SDK can ack the + * Stream frame immediately. + * + *

{@link #handleWebhook} resolves media inline — each attachment costs a + * download-URL call plus a byte fetch against DingTalk. Running that on the + * callback thread delays the ack by however long the downloads take, and a + * late ack makes DingTalk redeliver the message: the user gets the same + * answer once per redelivery. Acking first removes the cause; the router's + * inbound claim is the second line of defence for redeliveries we can't + * prevent. + * + *

Single-threaded on purpose — the agent turn itself already runs on the + * router's queue, so this thread only parses, and keeping it serial + * preserves the arrival order of a sender's messages. + */ + private void dispatchInbound(Map payload) { + ExecutorService executor = inboundExecutor; + if (executor == null || executor.isShutdown()) { + // Channel stopped mid-flight — process inline rather than drop. + handleWebhook(payload); + return; + } + executor.execute(() -> { + try { + handleWebhook(payload); + } catch (Exception e) { + log.error("[dingtalk-stream] Inbound dispatch failed: {}", e.getMessage(), e); + } + }); + } + @Override protected void doStop() { // 关闭 Stream 客户端 @@ -252,6 +297,10 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St } streamClient = null; } + if (inboundExecutor != null) { + inboundExecutor.shutdownNow(); + inboundExecutor = null; + } if (aiCardManager != null) { aiCardManager.cleanup(); aiCardManager = null; @@ -348,7 +397,10 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St StringBuilder contentAccumulator = new StringBuilder(); try { stream.doOnNext(delta -> { - if (delta.content() != null) { + // segmentOnly narration is skipped: appending every + // ReAct iteration's "我来查一下…" into the card text is + // what makes the answer read as if it were sent twice. + if (StreamingChannelAdapter.contributesToFinalContent(delta)) { contentAccumulator.append(delta.content()); aiCardManager.appendContent(outTrackId, delta.content(), false); } @@ -361,15 +413,21 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St .blockLast(Duration.ofMinutes(5)); // Step 3: 完成 - String finalContent = contentAccumulator.toString(); - if (finalContent.isBlank()) { - finalContent = "(无回复内容)"; + // The AI Card path never touches renderAndSend, so the channel's + // message-filter config has to be applied here — otherwise + // filter_thinking / filter_tool_messages are inert whenever AI + // Card mode is on. The unfiltered text is still what we return, + // so persistence keeps the model's original answer. + String rawContent = contentAccumulator.toString(); + String cardContent = filterOutboundContent(rawContent); + if (cardContent.isBlank()) { + cardContent = "(无回复内容)"; } - aiCardManager.finishCard(outTrackId, finalContent); + aiCardManager.finishCard(outTrackId, cardContent); log.info("[dingtalk] AI Card streaming completed: outTrackId={}, contentLen={}", - outTrackId, finalContent.length()); - return finalContent; + outTrackId, cardContent.length()); + return rawContent.isBlank() ? cardContent : rawContent; } catch (Exception e) { log.error("[dingtalk] AI Card streaming failed: outTrackId={}, error={}", @@ -398,7 +456,7 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St StringBuilder contentAccumulator = new StringBuilder(); stream.doOnNext(delta -> { - if (delta.content() != null) { + if (StreamingChannelAdapter.contributesToFinalContent(delta)) { contentAccumulator.append(delta.content()); } }) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/discord/DiscordChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/discord/DiscordChannelAdapter.java index 86658eea..803ee926 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/discord/DiscordChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/discord/DiscordChannelAdapter.java @@ -60,14 +60,6 @@ public class DiscordChannelAdapter extends AbstractChannelAdapter { /** 媒体下载用 HttpClient(复用 http_proxy 配置) */ private volatile HttpClient mediaHttpClient; - /** 已处理消息去重(LRU,最多保留 500 条) */ - private final Set processedMessageIds = Collections.newSetFromMap(new LinkedHashMap<>() { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > 500; - } - }); - public DiscordChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { @@ -146,7 +138,6 @@ public class DiscordChannelAdapter extends AbstractChannelAdapter { } selfId = null; mediaHttpClient = null; - processedMessageIds.clear(); log.info("[discord] Discord channel stopped"); } @@ -447,14 +438,10 @@ public class DiscordChannelAdapter extends AbstractChannelAdapter { return; } - // 去重 + // Inbound dedup lives in ChannelMessageRouter.enqueue now — msgId is + // carried on the ChannelMessage below and claimed there, once, for + // every channel. String msgId = message.getId(); - synchronized (processedMessageIds) { - if (processedMessageIds.contains(msgId)) { - return; - } - processedMessageIds.add(msgId); - } String channelId = message.getChannel().getId(); String guildId = message.isFromGuild() ? message.getGuild().getId() : null; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index b79340ac..5134bfb0 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -84,9 +84,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre /** 定时 Token 刷新任务 */ private ScheduledFuture tokenRefreshFuture; - /** 消息去重:最近处理过的 message_id */ - private final Set processedMessageIds = ConcurrentHashMap.newKeySet(); - /** * 群内 bot 别名缓存:chatId → 学到的别名集合(openId / unionId / userId / name)。 *

飞书 SDK 投递的 mention 里,bot 的标识可能是群内自定义别名({@code ou_357e...} / 自定义名称), @@ -460,7 +457,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre this.botName = null; this.botOpenIdLastFailureMs = 0L; } - this.processedMessageIds.clear(); this.chatBotAliases.clear(); this.mentionTracker.clear(); this.nicknameCache.clear(); @@ -1212,7 +1208,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre if (isGroup && chatId != null) { shortSuffix = resolveGroupSessionSuffix(chatId); } - String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup); + String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup, + channelEntity != null ? channelEntity.getId() : null); String stagedUploadPath = null; if (isFileMessage) { @@ -1232,12 +1229,14 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre log.warn("[feishu] require_mention=true but bot open_id unavailable; allowing messageId={}", messageId); } - // 消息去重 - if (messageId != null && !processedMessageIds.add(messageId)) { + // Early duplicate gate. The authoritative claim happens once, in + // ChannelMessageRouter.enqueue; this peek only spares a redelivery the + // side effects below (the "received" reaction, media downloads) that + // would otherwise fire again before the router ever sees the message. + if (messageRouter.isDuplicateInbound(channelEntity.getId(), messageId)) { log.debug("[feishu] Duplicate message_id: {}, skipping", messageId); return; } - cleanupProcessedIds(); // 添加消息反应(非阻塞,表示"已收到") if (messageId != null && getConfigBoolean("enable_reaction", true)) { @@ -1298,21 +1297,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre onMessage(channelMessage); } - /** - * 清理旧的去重记录:超过 1000 条时保留最近添加的(移除最早的一半) - */ - private void cleanupProcessedIds() { - if (processedMessageIds.size() > 1000) { - int toRemove = processedMessageIds.size() / 2; - var iterator = processedMessageIds.iterator(); - while (iterator.hasNext() && toRemove > 0) { - iterator.next(); - iterator.remove(); - toRemove--; - } - } - } - // ==================== 消息反应 ==================== /** @@ -1731,14 +1715,22 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre * {@code senderId} is the full open id. Mirror that exactly: * {@code groups → feishu:{shortSuffix}}, {@code DMs → feishu:{senderOpenId}}. */ - static String buildConversationId(String shortSuffix, String senderOpenId, boolean isGroup) { + static String buildConversationId(String shortSuffix, String senderOpenId, boolean isGroup, + Long channelId) { // The routed ChannelMessage carries chatId = (isGroup ? shortSuffix : null); // the router then falls back to senderId when that chatId is null. Mirror both // steps so the storage id matches the runtime id in every case (including the // degenerate group-with-no-suffix path). String routedChatId = isGroup ? shortSuffix : null; String identifier = routedChatId != null ? routedChatId : senderOpenId; - return identifier != null ? CHANNEL_TYPE + ":" + identifier : null; + if (identifier == null) { + return null; + } + // Mirror ChannelMessageRouter#buildConversationId: scope the id by channelId so + // the same sender on two workspaces' feishu channels never shares a conversation. + return channelId != null + ? CHANNEL_TYPE + ":" + channelId + ":" + identifier + : CHANNEL_TYPE + ":" + identifier; } // ==================== Per-chat recent file cache ==================== @@ -2613,7 +2605,10 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre StringBuilder accumulator = new StringBuilder(); try { stream.doOnNext(delta -> { - if (delta.content() != null) { + // segmentOnly narration is skipped: appending every + // ReAct iteration's "我来查一下…" into the card text is + // what makes the answer read as if it were sent twice. + if (StreamingChannelAdapter.contributesToFinalContent(delta)) { accumulator.append(delta.content()); streamingCardManager.appendContent(sessionKey, delta.content(), false); } @@ -2626,8 +2621,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre .blockLast(Duration.ofMinutes(5)); String finalContent = accumulator.toString(); - if (finalContent.isBlank()) { - finalContent = "(无回复内容)"; + // Card streaming never touches renderAndSend, so the channel's + // message-filter config has to be applied here — otherwise + // filter_thinking / filter_tool_messages are inert on this path. + String cardContent = filterOutboundContent(finalContent); + if (cardContent.isBlank()) { + cardContent = "(无回复内容)"; } // Strip any /api/v1/files/generated/{id} URLs out of the card // text (replacing each with a "📎 filename" marker) AND send @@ -2636,11 +2635,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // the user sees a broken-looking download link instead of the // actual file. Cache-miss URLs fall back to the user-facing // retry hint that GeneratedFileScrubber emits. - String renderedContent = scrubAndSendAttachments(receiveId, finalContent); + String renderedContent = scrubAndSendAttachments(receiveId, cardContent); streamingCardManager.finishCard(sessionKey, renderedContent); log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}", sessionKey, renderedContent.length()); - return finalContent; + return finalContent.isBlank() ? cardContent : finalContent; } catch (Exception e) { log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}", @@ -2671,13 +2670,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre private String processStreamAsText(Flux stream, ChannelMessage message) { StringBuilder accumulator = new StringBuilder(); stream.doOnNext(delta -> { - if (delta.content() != null) { + if (StreamingChannelAdapter.contributesToFinalContent(delta)) { accumulator.append(delta.content()); } }) .blockLast(Duration.ofMinutes(5)); String finalContent = accumulator.toString(); - if (!finalContent.isBlank()) { + // sendMessage is called directly (rather than renderAndSend) because + // Feishu does its own card/text split and chunking, so the channel's + // message-filter config is applied explicitly here. + String outbound = filterOutboundContent(finalContent); + if (!outbound.isBlank()) { String replyTarget = message.getReplyToken() != null ? message.getReplyToken() : (message.getChatId() != null ? message.getChatId() : message.getSenderId()); @@ -2685,7 +2688,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // Same scrub-and-upload hop as the streaming card finish path — // a generated-file URL in plain text would otherwise reach the // user as a markdown link that opens to nothing useful in IM. - String renderedContent = scrubAndSendAttachments(replyTarget, finalContent); + String renderedContent = scrubAndSendAttachments(replyTarget, outbound); sendMessage(replyTarget, renderedContent); } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java index c6c85d4f..20261639 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java @@ -284,8 +284,8 @@ public class ToolGuardCardHandler implements FeishuCardHandler { // Schema 2.0 body works fine on cardkit/v1 card.create and on // im/v1 message.create msg_type=interactive. Two different // server-side validators, only one of which has been upgraded - // for Schema 2.0. QwenPaw's production Feishu adapter uses the - // same type="raw" approach for callback updates. + // for Schema 2.0, so callback updates must go through + // type="raw" with a Schema 1.0 body. cb.setType("raw"); cb.setData(cardJson); return cb; @@ -320,15 +320,22 @@ public class ToolGuardCardHandler implements FeishuCardHandler { private static ChannelMessage buildSynthetic(String commandText, String clickerOpenId, PendingApproval pending, P2CardActionTriggerData data) { - // pending.conversationId looks like "feishu:" where - // is either ou_xxx (1:1 chat — derived from senderId) - // or oc_xxx (group chat — derived from chatId). Reverse the - // scope back into the right chatId field so buildConversationId - // reproduces the exact same key. + // pending.conversationId looks like "feishu:{channelId}:{scope}" (or the + // legacy two-segment "feishu:{scope}"), where {scope} is either ou_xxx + // (1:1 chat — derived from senderId) or oc_xxx (group chat — derived from + // chatId). Extract the trailing {scope} and reverse it back into the right + // chatId field so buildConversationId reproduces the exact same key. The + // replay routes through this same feishu channel, so the router re-embeds + // the matching channelId automatically. Scopes never contain ':', so + // splitting on the first ':' after the "feishu:" prefix is unambiguous and + // handles both the new three-segment and the legacy two-segment forms. String convId = pending.getConversationId(); - String scope = (convId != null && convId.startsWith("feishu:")) - ? convId.substring("feishu:".length()) - : null; + String scope = null; + if (convId != null && convId.startsWith("feishu:")) { + String rest = convId.substring("feishu:".length()); + int colon = rest.indexOf(':'); + scope = colon >= 0 ? rest.substring(colon + 1) : rest; + } boolean isGroup = scope != null && scope.startsWith("oc_"); String chatId = isGroup ? scope : null; String replyToken = isGroup ? scope : clickerOpenId; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java index 7ab29305..5c28ed82 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java @@ -74,8 +74,7 @@ public class ToolGuardCardRenderer implements FeishuCardRenderer { // mismatch error. Schema 2.0 is supported by im/v1/message.create // BUT the callback response validator only accepts Schema 1.0 // inline (type="raw") — once we commit to Schema 1.0 here the - // resolved-state card update lands cleanly. QwenPaw's - // production Feishu integration uses the same Schema 1.0 path. + // resolved-state card update lands cleanly. Map approveBtn = new LinkedHashMap<>(); approveBtn.put("tag", "button"); approveBtn.put("text", plainText("批准")); @@ -116,8 +115,7 @@ public class ToolGuardCardRenderer implements FeishuCardRenderer { * {@code cardkit/v1 card.create}, both of which DO accept Schema * 2.0. So we keep the original approval card (sent via message * create) in Schema 2.0 for the column_set button layout, but the - * resolved-state update has to be Schema 1.0. QwenPaw's production - * Feishu integration uses the same split. + * resolved-state update has to be Schema 1.0. * *

Caller passes the resulting Map to a {@code CallBackCard} * with {@code type="raw"} (NOT {@code card_json}). diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java b/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java new file mode 100644 index 00000000..4cc6ba2d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java @@ -0,0 +1,530 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import vip.mate.agent.AgentService; +import vip.mate.agent.GraphEventPublisher; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 流式累积器 — 收集 StreamDelta 事件,持久化到 DB。 + *

+ * 维护两份数据: + *

    + *
  • {@code toolCalls} — 兼容旧逻辑(执行面板等 UI 使用)
  • + *
  • {@code segments} — 按事件到达顺序记录的有序时间线(前端分段渲染用)
  • + *
+ * 两份数据从同一事件流构建,保证一致。segments 保留了 thinking → tools → content + * 的真实交错顺序,toolCalls 是 segments 中 tool_call 类型的平铺视图。 + *

+ * Shared by the Web SSE path ({@code ChatController}) and the IM channel + * router — live fan-out side effects go through the injected {@link Sink} + * so each caller keeps its own broadcast semantics. Internal bookkeeping + * events ({@code _usage_final}, {@code _routing_decision}) are consumed + * here and never reach the sink. + */ +@Slf4j +public final class AgentStreamAccumulator { + + /** + * Live fan-out hooks. The accumulator itself only builds the persisted + * metadata/parts; anything a subscriber should see in real time is + * delegated here. + */ + public interface Sink { + /** Broadcast a named event to live subscribers of the conversation. */ + void broadcast(String conversationId, String eventName, Object payload); + + /** Update the conversation's current phase indicator. */ + void updatePhase(String conversationId, String phase); + } + + /** Markdown link pointing at a generated-file download URL. Used to + * surface generated artifacts in the run-overview rail. */ + private static final Pattern GENERATED_FILE_LINK_PATTERN = + Pattern.compile("\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)"); + + private final ObjectMapper objectMapper; + private final Sink sink; + + private final StringBuilder content = new StringBuilder(); + private final StringBuilder thinking = new StringBuilder(); + private final List> toolCalls = new ArrayList<>(); + /** 有序事件时间线 — 前端分段渲染的权威数据源 */ + private final List> segments = new ArrayList<>(); + private final List> browserActions = new ArrayList<>(); + private final List warnings = new ArrayList<>(); + private final List> planStepResults = new ArrayList<>(); + /** Tool names whose returnDirect output was folded into the assistant message */ + private final List directToolNames = new ArrayList<>(); + /** Generated file artifacts extracted from tool results — surfaced in the run-overview rail. */ + private final List> generatedFiles = new ArrayList<>(); + private int segCounter = 0; + private int promptTokens = 0; + private int completionTokens = 0; + private int cacheReadTokens = 0; + private int cacheWriteTokens = 0; + private int reasoningTokens = 0; + private String runtimeModelName = ""; + private String runtimeProviderId = ""; + private boolean awaitingApproval = false; + private String currentPhase = ""; + /** + * Graph-emitted FinishReason for the turn (e.g. {@code "incomplete"}, + * {@code "stopped"}, {@code "evidence_insufficient"}). Sourced from + * the {@code finish_reason} {@link GraphEventPublisher} + * event that {@code FinalAnswerNode} attaches to its PENDING_EVENTS + * output — same pipeline the SSE accumulator already drains, so the + * value is delivered alongside the assistant content (not via a + * sibling SSE-only broadcast that would bypass this accumulator). + * Persisted into message metadata so downstream filters + * (memory promotion gate) see a machine-readable status instead of + * having to guess from text. Empty string until the event arrives. + */ + private String finishReason = ""; + /** + * Recovery affordance payload from {@link GraphEventPublisher#feedback}. + * Persisted into {@code metadata.feedbackEvent} so a page reload still + * surfaces the retry/regenerate/report card on the failed assistant + * bubble. Null when the turn ended cleanly. + */ + private Map feedbackEvent = null; + private Long planId = null; + private List planSteps = List.of(); + private Integer currentPlanStep = null; + private Map pendingApproval = null; + /** + * Multimodal sidecar routing decision for this turn (null when no + * routing happened). Captured from the {@code _routing_decision} + * event emitted before the graph stream and folded into + * {@code metadata.routing} on persistence so the chat UI can show + * which sidecar (if any) was invoked. + */ + private Map routingDecision = null; + + public AgentStreamAccumulator(ObjectMapper objectMapper, Sink sink) { + this.objectMapper = objectMapper; + this.sink = sink; + } + + public synchronized void accept(AgentService.StreamDelta delta, String conversationId) { + if (delta == null) return; + + if (delta.isEvent()) { + if ("_usage_final".equals(delta.eventType())) { + Map data = delta.eventData(); + promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); + completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); + cacheReadTokens = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue(); + cacheWriteTokens = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue(); + reasoningTokens = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue(); + runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", "")); + runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", "")); + return; + } + if ("phase".equals(delta.eventType())) { + String phase = String.valueOf(delta.eventData().getOrDefault("phase", "")); + if (!phase.isBlank()) { + currentPhase = phase; + sink.updatePhase(conversationId, phase); + // phase 切换时关闭 running 的 content/thinking segment,保留边界 + finalizeRunningSegments("content", "thinking"); + } + } + if ("finish_reason".equals(delta.eventType())) { + Object reason = delta.eventData().get("reason"); + if (reason != null) { + // Last-write-wins: graph normally fires this exactly once + // at FinalAnswerNode completion. Replay paths that re-enter + // the graph after approval will emit a fresh value, which + // is the correct behavior — the latest reason is what gets + // persisted with the assistant message. + finishReason = String.valueOf(reason); + } + } + if (GraphEventPublisher.EVENT_FEEDBACK.equals(delta.eventType())) { + // Snapshot the affordance payload so it persists into + // message metadata. The same event is also rebroadcast + // live (via the sink fall-through below) so an + // already-mounted UI sees it instantly without + // waiting for the message-save round trip. + feedbackEvent = delta.eventData(); + } + if (GraphEventPublisher.EVENT_ROUTING_DECISION.equals(delta.eventType())) { + // Captured at turn start; persisted under metadata.routing so the + // chat UI can render which sidecar (if any) was invoked. Internal + // event — return early to skip rebroadcast on IM channels. + routingDecision = delta.eventData(); + return; + } + accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId); + try { + sink.broadcast(conversationId, delta.eventType(), delta.eventData()); + } catch (Exception e) { + log.warn("Failed to broadcast event {}: {}", delta.eventType(), e.getMessage()); + } + return; + } + + // content_delta + if (delta.content() != null && !delta.content().isBlank()) { + // segmentOnly deltas route per-iteration narration to the + // segments timeline only — the persisted top-level content + // field stays clean so it carries the final answer span, + // not "我来…让我…" concatenations across iterations (issue + // #120 narration leg). segmentOnly implies persistenceOnly, + // so no broadcast either. + if (!delta.segmentOnly()) { + content.append(delta.content()); + } + sink.updatePhase(conversationId, "drafting_answer"); + if (!delta.persistenceOnly()) { + sink.broadcast(conversationId, "content_delta", Map.of("delta", delta.content())); + } + // segments: 追加到当前 running content segment,或创建新的 + var seg = findLastRunning("content"); + if (seg != null) { + seg.put("text", seg.getOrDefault("text", "") + delta.content()); + } else { + finalizeRunningSegments("thinking"); + var s = newSegment("content"); + s.put("text", delta.content()); + segments.add(s); + } + } + + // thinking_delta + if (delta.thinking() != null && !delta.thinking().isBlank()) { + if (!delta.segmentOnly()) { + thinking.append(delta.thinking()); + } + if (!delta.persistenceOnly()) { + sink.broadcast(conversationId, "thinking_delta", Map.of("delta", delta.thinking())); + } + var seg = findLastRunning("thinking"); + if (seg != null) { + seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + delta.thinking()); + } else { + var s = newSegment("thinking"); + s.put("thinkingText", delta.thinking()); + segments.add(s); + } + } + } + + public boolean isAwaitingApproval() { return awaitingApproval; } + + private void accumulateToolEvent(String eventType, Map data, String conversationId) { + if ("tool_approval_requested".equals(eventType)) { + awaitingApproval = true; + currentPhase = "awaiting_approval"; + pendingApproval = new LinkedHashMap<>(); + pendingApproval.put("pendingId", data.getOrDefault("pendingId", "")); + pendingApproval.put("toolName", data.getOrDefault("toolName", "")); + pendingApproval.put("arguments", data.getOrDefault("arguments", "")); + pendingApproval.put("reason", data.getOrDefault("reason", "")); + pendingApproval.put("status", "pending_approval"); + if (data.containsKey("findings")) pendingApproval.put("findings", data.get("findings")); + if (data.containsKey("maxSeverity")) pendingApproval.put("maxSeverity", data.get("maxSeverity")); + if (data.containsKey("summary")) pendingApproval.put("summary", data.get("summary")); + sink.updatePhase(conversationId, "awaiting_approval"); + } else if ("tool_approval_resolved".equals(eventType)) { + if (pendingApproval != null) { + pendingApproval.put("status", + "approved".equals(String.valueOf(data.getOrDefault("decision", ""))) ? "approved" : "denied"); + } + } else if ("plan_created".equals(eventType)) { + Object rawPlanId = data.get("planId"); + if (rawPlanId instanceof Number n) { + planId = n.longValue(); + } else if (rawPlanId != null) { + try { planId = Long.valueOf(String.valueOf(rawPlanId)); } catch (Exception ignored) {} + } + Object steps = data.get("steps"); + if (steps instanceof List list) { + planSteps = list.stream().map(String::valueOf).toList(); + planStepResults.clear(); + for (int i = 0; i < planSteps.size(); i++) { + planStepResults.add(null); + } + } + currentPlanStep = 0; + } else if ("plan_step_started".equals(eventType)) { + Object idx = data.get("index"); + if (idx instanceof Number n) { + currentPlanStep = n.intValue(); + } + } else if ("plan_step_completed".equals(eventType)) { + Object idx = data.get("index"); + if (idx instanceof Number n) { + int index = n.intValue(); + currentPlanStep = index; + ensurePlanStepCapacity(index + 1); + Map stepResult = new LinkedHashMap<>(); + stepResult.put("result", data.getOrDefault("result", "")); + stepResult.put("status", "completed"); + planStepResults.set(index, stepResult); + } + } else if ("browser_action".equals(eventType)) { + browserActions.add(new LinkedHashMap<>(data)); + } else if ("warning".equals(eventType)) { + String warning = String.valueOf(data.getOrDefault("message", + data.getOrDefault("delta", ""))); + if (!warning.isBlank()) { + warnings.add(warning); + } + } else if ("tool_call_started".equals(eventType)) { + // toolCalls(兼容) + Map tc = new LinkedHashMap<>(); + // toolCallId is required for history replay to pair the persisted + // assistant tool_call with its tool_response — providers reject any + // sequence whose ids don't match. Always record it (empty string + // when the upstream event didn't carry one, e.g. forced tool calls). + tc.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", ""))); + tc.put("name", data.getOrDefault("toolName", "")); + tc.put("arguments", data.getOrDefault("arguments", "")); + tc.put("status", "running"); + toolCalls.add(tc); + // segments: 关闭 running thinking/content,插入 tool_call + finalizeRunningSegments("thinking", "content"); + var seg = newSegment("tool_call"); + seg.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", ""))); + seg.put("toolName", data.getOrDefault("toolName", "")); + seg.put("toolArgs", data.getOrDefault("arguments", "")); + segments.add(seg); + } else if ("tool_direct_result".equals(eventType)) { + // returnDirect tool — track the tool name so history replay can + // render a "data returned directly by tool" badge. The actual + // textual content reaches the user/persistence layer through the + // regular content_delta path (FinalAnswerNode's FINAL_ANSWER → + // StateGraphReActAgent → StreamDelta), so we intentionally do + // NOT add a content-bearing segment here to avoid the user + // seeing the same text twice. + String toolName = String.valueOf(data.getOrDefault("toolName", "")); + if (!toolName.isBlank() && !directToolNames.contains(toolName)) { + directToolNames.add(toolName); + } + } else if ("tool_call_completed".equals(eventType)) { + String toolName = String.valueOf(data.getOrDefault("toolName", "")); + String toolCallId = String.valueOf(data.getOrDefault("toolCallId", "")); + // toolCalls(兼容)— prefer toolCallId match so parallel calls of + // the same tool don't collide on the running+toolName fallback. + for (int i = toolCalls.size() - 1; i >= 0; i--) { + Map tc = toolCalls.get(i); + boolean matches = (!toolCallId.isEmpty() + && toolCallId.equals(String.valueOf(tc.getOrDefault("toolCallId", "")))) + || (toolCallId.isEmpty() + && "running".equals(tc.get("status")) + && toolName.equals(tc.get("name"))); + if (matches) { + tc.put("result", data.getOrDefault("result", "")); + tc.put("success", data.getOrDefault("success", true)); + tc.put("status", "completed"); + break; + } + } + // segments: 标记对应 tool_call 完成 + for (int i = segments.size() - 1; i >= 0; i--) { + var seg = segments.get(i); + if (!"tool_call".equals(seg.get("type"))) continue; + boolean matches = (!toolCallId.isEmpty() + && toolCallId.equals(String.valueOf(seg.getOrDefault("toolCallId", "")))) + || (toolCallId.isEmpty() + && "running".equals(seg.get("status")) + && toolName.equals(seg.get("toolName"))); + if (matches) { + seg.put("status", "completed"); + seg.put("toolResult", data.getOrDefault("result", "")); + seg.put("toolSuccess", data.getOrDefault("success", true)); + break; + } + } + // Extract generated-file links from the tool result so the + // run-overview rail can surface artifacts without re-scanning + // segments on the frontend. + extractGeneratedFiles(String.valueOf(data.getOrDefault("result", "")), toolName); + } + } + + /** Scan a tool result for markdown links pointing at generated-file + * download URLs and collect them into {@link #generatedFiles}. + * De-duplicates by URL so a link echoed in later tool results doesn't + * produce duplicate entries in the run-overview rail. */ + private void extractGeneratedFiles(String result, String toolName) { + if (result == null || result.isBlank()) return; + Matcher m = GENERATED_FILE_LINK_PATTERN.matcher(result); + while (m.find()) { + String url = m.group(2); + boolean dup = generatedFiles.stream() + .anyMatch(f -> url.equals(String.valueOf(f.get("url")))); + if (dup) continue; + Map file = new LinkedHashMap<>(); + file.put("filename", m.group(1)); + file.put("url", url); + file.put("toolName", toolName); + generatedFiles.add(file); + } + } + + private void ensurePlanStepCapacity(int size) { + while (planStepResults.size() < size) { + planStepResults.add(null); + } + } + + // ==================== Segment helpers ==================== + + private Map newSegment(String type) { + Map seg = new LinkedHashMap<>(); + seg.put("id", type.substring(0, 2) + "-" + segCounter++); + seg.put("type", type); + seg.put("status", "running"); + return seg; + } + + private Map findLastRunning(String type) { + for (int i = segments.size() - 1; i >= 0; i--) { + var seg = segments.get(i); + if (type.equals(seg.get("type")) && "running".equals(seg.get("status"))) return seg; + } + return null; + } + + private void finalizeRunningSegments(String... types) { + var typeSet = Set.of(types); + for (var seg : segments) { + if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) { + seg.put("status", "completed"); + } + } + } + + // ==================== 原有访问器 ==================== + + public String getContent() { return content.toString().trim(); } + public String getThinking() { return thinking.toString().trim(); } + public int getPromptTokens() { return promptTokens; } + public int getCompletionTokens() { return completionTokens; } + public int getCacheReadTokens() { return cacheReadTokens; } + public int getCacheWriteTokens() { return cacheWriteTokens; } + public int getReasoningTokens() { return reasoningTokens; } + public String getRuntimeModelName() { return runtimeModelName; } + public String getRuntimeProviderId() { return runtimeProviderId; } + public String getCurrentPhase() { return currentPhase; } + public String getFinishReason() { return finishReason; } + public boolean segmentsEmpty() { return segments.isEmpty(); } + + public synchronized List toAssistantParts() { + List parts = new ArrayList<>(); + if (!getContent().isBlank()) { + MessageContentPart textPart = new MessageContentPart(); + textPart.setType("text"); + textPart.setText(getContent()); + parts.add(textPart); + } + if (!getThinking().isBlank()) { + MessageContentPart thinkingPart = new MessageContentPart(); + thinkingPart.setType("thinking"); + thinkingPart.setText(getThinking()); + parts.add(thinkingPart); + } + for (Map tc : toolCalls) { + try { + parts.add(MessageContentPart.toolCall(objectMapper.writeValueAsString(tc))); + } catch (Exception e) { + log.warn("Failed to serialize tool call: {}", e.getMessage()); + } + } + return parts; + } + + private void finalizeToolCalls() { + for (Map tc : toolCalls) { + if ("running".equals(tc.get("status"))) tc.put("status", "completed"); + } + } + + /** + * 生成 metadata JSON:包含 toolCalls + segments。 + * toolCalls 保留兼容旧 UI,segments 是按事件顺序的完整时间线。 + */ + public synchronized String toMetadataJson() { + finalizeToolCalls(); + finalizeRunningSegments("thinking", "content", "tool_call"); + SegmentSupersedeDetector.markSuperseded(segments); + try { + Map metadata = new LinkedHashMap<>(); + if (!toolCalls.isEmpty()) { + metadata.put("toolCalls", toolCalls); + } + if (!segments.isEmpty()) { + metadata.put("segments", segments); + } + if (!currentPhase.isBlank()) { + metadata.put("currentPhase", currentPhase); + } + if (planId != null || !planSteps.isEmpty() || currentPlanStep != null) { + Map plan = new LinkedHashMap<>(); + if (planId != null) plan.put("planId", planId); + if (!planSteps.isEmpty()) plan.put("steps", planSteps); + if (currentPlanStep != null) plan.put("currentStep", currentPlanStep); + if (planStepResults.stream().anyMatch(Objects::nonNull)) { + plan.put("stepResults", planStepResults); + } + metadata.put("plan", plan); + } + if (pendingApproval != null && !pendingApproval.isEmpty()) { + metadata.put("pendingApproval", pendingApproval); + } + if (!browserActions.isEmpty()) { + metadata.put("browserActions", browserActions); + } + if (!warnings.isEmpty()) { + metadata.put("warnings", warnings); + } + if (!directToolNames.isEmpty()) { + // Only the tool names go into metadata — the full content + // already lives in mate_message.content (assembled by + // FinalAnswerNode). UI uses this to badge historical + // messages as "data returned directly by tool". + metadata.put("directToolNames", directToolNames); + } + if (!generatedFiles.isEmpty()) { + metadata.put("generatedFiles", generatedFiles); + } + if (!finishReason.isEmpty()) { + // Surface graph FinishReason so MemorySummarizationGate and + // any other downstream consumer can branch on a structured + // status (e.g. skip INCOMPLETE / STOPPED / ERROR_FALLBACK + // turns from long-term memory promotion) instead of doing + // brittle text matching on the assistant content. + metadata.put("finishReason", finishReason); + } + if (feedbackEvent != null && !feedbackEvent.isEmpty()) { + // Persist the recovery-affordance payload so the + // retry/regenerate/report card survives page reload. + // Stored as-is (errorType, errorMessage, actions, + // timestamp) — frontend MessageBubble reads + // metadata.feedbackEvent and renders one button per + // entry in `actions`. + metadata.put("feedbackEvent", feedbackEvent); + } + if (routingDecision != null && !routingDecision.isEmpty()) { + metadata.put("routing", routingDecision); + } + return objectMapper.writeValueAsString(metadata); + } catch (Exception e) { + log.warn("Failed to serialize metadata: {}", e.getMessage()); + return "{}"; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index e83e95bd..a8334358 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -38,8 +39,6 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; @@ -65,9 +64,13 @@ public class ChatController { private final ConversationCompletionPublisher completionPublisher; private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver; + private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService; - // 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor()) - private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); + // Virtual thread per SSE task: matches the app-wide virtual-thread model + // (spring.threads.virtual.enabled=true) and, unlike a cached platform-thread + // pool, never reuses a thread across tasks, so no ThreadLocal state can leak + // from one stream into another. + private final ExecutorService sseExecutor = Executors.newVirtualThreadPerTaskExecutor(); /** * SSE 流式对话(支持断线重连) @@ -155,7 +158,7 @@ public class ChatController { // ---- 分支 B:正常请求 ---- Long agentId = request.getAgentId(); - String message = request.getMessage() != null ? request.getMessage() : ""; + String requestMessage = request.getMessage() != null ? request.getMessage() : ""; if (auth == null) { try { sendEvent(emitter, "error", Map.of("message", "未登录,请先登录")); @@ -189,7 +192,7 @@ public class ChatController { } // ---- 审批命令拦截:/approve、/deny 走 SSE 流式 replay ---- - String normalizedMsg = message.trim().toLowerCase(); + String normalizedMsg = requestMessage.trim().toLowerCase(); boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg); boolean isDenyCommand = "/deny".equals(normalizedMsg) || "deny".equals(normalizedMsg); @@ -244,7 +247,7 @@ public class ChatController { AtomicBoolean approvalEmitterDone = new AtomicBoolean(false); sseExecutor.execute(() -> { - StreamAccumulator accumulator = new StreamAccumulator(); + AgentStreamAccumulator accumulator = newAccumulator(); AtomicBoolean finalized = new AtomicBoolean(false); try { // 广播 approval_resolved 事件 @@ -513,6 +516,34 @@ public class ChatController { return emitter; } + // ---- 重新生成(regenerate=true):删除会话末尾的 assistant 回答块, + // 复用 DB 中的种子 user 消息作为本轮输入,不重复持久化 user 行。 + // message 字段被忽略,以持久化的种子为准。 ---- + final boolean regenerate = Boolean.TRUE.equals(request.getRegenerate()); + final ConversationService.RegenerateSeed regenerateSeed; + if (regenerate) { + if (!conversationService.isConversationOwner(conversationId, username)) { + sendErrorDoneAndComplete(emitter, "无权操作该会话"); + return emitter; + } + if (streamTracker.isRunning(conversationId)) { + sendErrorDoneAndComplete(emitter, "正在生成回复,请先停止再重新生成"); + return emitter; + } + regenerateSeed = conversationService.prepareRegenerate(conversationId); + if (regenerateSeed == null) { + sendErrorDoneAndComplete(emitter, "当前没有可重新生成的回答"); + return emitter; + } + log.info("SSE regenerate: conversationId={}, seedMessageId={}", + conversationId, regenerateSeed.seedMessageId()); + } else { + regenerateSeed = null; + } + final String message = regenerateSeed != null + ? (regenerateSeed.content() != null ? regenerateSeed.content() : "") + : requestMessage; + // ---- 正常请求:注册流状态并附着首个订阅者 ---- streamTracker.register(conversationId); streamTracker.bindRunMeta(conversationId, agentId, username); @@ -536,7 +567,7 @@ public class ChatController { AtomicBoolean emitterDone = new AtomicBoolean(false); sseExecutor.execute(() -> { - StreamAccumulator accumulator = new StreamAccumulator(); + AgentStreamAccumulator accumulator = newAccumulator(); AtomicBoolean finalized = new AtomicBoolean(false); try { conversationService.getOrCreateConversation(conversationId, agentId, username, workspaceId); @@ -545,9 +576,15 @@ public class ChatController { // of every other conversation. conversationService.updateConversationModel(conversationId, request.getModelProvider(), request.getModelName()); - List requestParts = normalizeRequestParts(request); + List requestParts = regenerateSeed != null + ? regenerateSeed.parts() + : normalizeRequestParts(request); String promptText = buildPromptText(message, requestParts); - conversationService.saveMessage(conversationId, "user", message, requestParts); + if (regenerateSeed == null) { + // Regenerate reuses the already-persisted seed user row — + // inserting again would duplicate it (issue #547). + conversationService.saveMessage(conversationId, "user", message, requestParts); + } conversationService.updateStreamStatus(conversationId, "running"); broadcastEvent(conversationId, "session", Map.of( @@ -1142,21 +1179,7 @@ public class ChatController { return ResponseEntity.status(403).build(); } - // Check every candidate root (workspace-scoped dir + legacy default dir) - // so attachments written before the workspace-aware relocation, and the - // current workspace-scoped ones, are both servable. Each candidate keeps - // its own startsWith traversal guard. - Path filePath = null; - // Sanitized-then-raw candidate dirs so both new writes (sanitized) and - // legacy Linux uploads (raw ':' dir) resolve. - for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) { - Path normDir = conversationDir.normalize(); - Path candidate = normDir.resolve(storedName).normalize(); - if (Files.exists(candidate) && candidate.startsWith(normDir)) { - filePath = candidate; - break; - } - } + Path filePath = resolveUploadedFile(conversationId, storedName); if (filePath == null) { return ResponseEntity.notFound().build(); } @@ -1183,6 +1206,62 @@ public class ChatController { .body(resource); } + @Operation(summary = "生成聊天附件的 PDF 预览(office 格式,soffice 转换)") + @GetMapping("/files/{conversationId}/{storedName:.+}/preview") + public ResponseEntity previewUploadedFile( + @PathVariable String conversationId, + @PathVariable String storedName, + Authentication auth) { + + // Same ownership gate as the raw file endpoint. + String username = auth != null ? auth.getName() : "anonymous"; + if (!conversationService.isConversationOwner(conversationId, username)) { + return ResponseEntity.status(403).build(); + } + + // 415: the client asked to preview a format this endpoint won't convert. + if (!officePreviewService.isConvertible(storedName)) { + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build(); + } + // 501: no soffice on this host — the UI degrades to a download link. + if (!officePreviewService.isAvailable()) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + Path filePath = resolveUploadedFile(conversationId, storedName); + if (filePath == null) { + return ResponseEntity.notFound().build(); + } + + try { + byte[] pdf = officePreviewService.renderPdf(filePath); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline") + .body(pdf); + } catch (IOException e) { + log.warn("[ChatController] office preview conversion failed for {}: {}", storedName, e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /** + * Resolve an uploaded attachment to its on-disk path, probing every + * candidate conversation dir (workspace-scoped + legacy default, sanitized + + * raw id) with a per-candidate path-traversal guard. Returns {@code null} + * when no candidate holds the file. + */ + private Path resolveUploadedFile(String conversationId, String storedName) { + for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) { + Path normDir = conversationDir.normalize(); + Path candidate = normDir.resolve(storedName).normalize(); + if (Files.exists(candidate) && candidate.startsWith(normDir)) { + return candidate; + } + } + return null; + } + /** * Build the {@link vip.mate.agent.context.ChatOrigin} that drives per-owner * memory isolation for a web request. When {@code endUserId} is supplied @@ -1292,6 +1371,12 @@ public class ChatController { * end-user when one MateClaw account fronts many of them. */ private String endUserId; + /** + * true 表示重新生成:删除会话末尾的 assistant 回答块,复用其前最近一条 + * 已持久化的 user 消息作为本轮输入({@link #message} 字段被忽略),且不 + * 重复插入 user 行。生成中的会话拒绝该请求。 + */ + private Boolean regenerate; } /** @@ -1353,7 +1438,7 @@ public class ChatController { streamTracker.attach(conversationId, emitter); // 启动新的流(复用现有 sseExecutor.execute 的逻辑模式) - StreamAccumulator accumulator = new StreamAccumulator(); + AgentStreamAccumulator accumulator = newAccumulator(); AtomicBoolean finalized = new AtomicBoolean(false); broadcastEvent(conversationId, "message_start", Map.of("role", "assistant")); @@ -1485,6 +1570,22 @@ public class ChatController { () -> emergencySaveAccumulator(conversationId, accumulator)); } + /** + * Terminal error path for requests rejected before a stream is registered: + * emit an {@code error} + terminal {@code done} pair and complete the + * emitter, so the client's SSE reader exits cleanly instead of waiting + * for a timeout. + */ + private void sendErrorDoneAndComplete(SseEmitter emitter, String errorMessage) { + try { + sendEvent(emitter, "error", Map.of("message", errorMessage)); + sendEvent(emitter, "done", Map.of("status", "completed")); + } catch (IOException e) { + log.warn("SSE pre-stream error send failed: {}", e.getMessage()); + } + emitter.complete(); + } + private void sendEvent(SseEmitter emitter, String name, Object data) throws IOException { String payload; try { @@ -1569,7 +1670,7 @@ public class ChatController { } private MessageEntity saveEmptyAssistantPlaceholder(String conversationId, String status, - StreamAccumulator accumulator, String source) { + AgentStreamAccumulator accumulator, String source) { log.warn("{} with empty accumulator: conversationId={}, status={}, finishReason={}, phase={}, hasSegments={}", source, conversationId, status, accumulator.getFinishReason(), accumulator.getCurrentPhase(), !accumulator.segmentsEmpty()); @@ -1675,7 +1776,7 @@ public class ChatController { * (race window is sub-second between dispose and save) and acceptable. Skipping * save when nothing to save avoids empty rows. */ - private void emergencySaveAccumulator(String conversationId, StreamAccumulator accumulator) { + private void emergencySaveAccumulator(String conversationId, AgentStreamAccumulator accumulator) { try { String text = accumulator.getContent(); List parts = accumulator.toAssistantParts(); @@ -1820,491 +1921,23 @@ public class ChatController { || lower.contains("client abort") || lower.contains("closed"); } - /** Markdown link pointing at a generated-file download URL. Used by the - * StreamAccumulator to surface generated artifacts in the run-overview rail. */ - private static final Pattern GENERATED_FILE_LINK_PATTERN = - Pattern.compile("\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)"); - /** - * 流式累积器 — 收集 StreamDelta 事件,持久化到 DB。 - *

- * 维护两份数据: - *

    - *
  • {@code toolCalls} — 兼容旧逻辑(执行面板等 UI 使用)
  • - *
  • {@code segments} — 按事件到达顺序记录的有序时间线(前端分段渲染用)
  • - *
- * 两份数据从同一事件流构建,保证一致。segments 保留了 thinking → tools → content - * 的真实交错顺序,toolCalls 是 segments 中 tool_call 类型的平铺视图。 + * Build a per-stream accumulator wired to this controller's SSE + * broadcast and phase tracking. Kept as a factory so every stream gets + * its own instance while the fan-out semantics stay in one place. */ - private final class StreamAccumulator { - private final StringBuilder content = new StringBuilder(); - private final StringBuilder thinking = new StringBuilder(); - private final List> toolCalls = new ArrayList<>(); - /** 有序事件时间线 — 前端分段渲染的权威数据源 */ - private final List> segments = new ArrayList<>(); - private final List> browserActions = new ArrayList<>(); - private final List warnings = new ArrayList<>(); - private final List> planStepResults = new ArrayList<>(); - /** RFC-052: tool names whose returnDirect output was folded into the assistant message */ - private final List directToolNames = new ArrayList<>(); - /** Generated file artifacts extracted from tool results — surfaced in the run-overview rail. */ - private final List> generatedFiles = new ArrayList<>(); - private int segCounter = 0; - private int promptTokens = 0; - private int completionTokens = 0; - private int cacheReadTokens = 0; - private int cacheWriteTokens = 0; - private int reasoningTokens = 0; - private String runtimeModelName = ""; - private String runtimeProviderId = ""; - private boolean awaitingApproval = false; - private String currentPhase = ""; - /** - * Graph-emitted FinishReason for the turn (e.g. {@code "incomplete"}, - * {@code "stopped"}, {@code "evidence_insufficient"}). Sourced from - * the {@code finish_reason} {@link vip.mate.agent.GraphEventPublisher} - * event that {@code FinalAnswerNode} attaches to its PENDING_EVENTS - * output — same pipeline the SSE accumulator already drains, so the - * value is delivered alongside the assistant content (not via a - * sibling SSE-only broadcast that would bypass this accumulator). - * Persisted into message metadata so downstream filters - * (memory promotion gate) see a machine-readable status instead of - * having to guess from text. Empty string until the event arrives. - */ - private String finishReason = ""; - /** - * Recovery affordance payload from {@link - * vip.mate.agent.GraphEventPublisher#feedback}. Persisted into - * {@code metadata.feedbackEvent} so a page reload still surfaces - * the retry/regenerate/report card on the failed assistant - * bubble. Null when the turn ended cleanly. - */ - private Map feedbackEvent = null; - private Long planId = null; - private List planSteps = List.of(); - private Integer currentPlanStep = null; - private Map pendingApproval = null; - /** - * Multimodal sidecar routing decision for this turn (null when no - * routing happened). Captured from the {@code _routing_decision} - * event emitted before the graph stream and folded into - * {@code metadata.routing} on persistence so the chat UI can show - * which sidecar (if any) was invoked. - */ - private Map routingDecision = null; - - synchronized void accept(AgentService.StreamDelta delta, String conversationId) { - if (delta == null) return; - - if (delta.isEvent()) { - if ("_usage_final".equals(delta.eventType())) { - Map data = delta.eventData(); - promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); - completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); - cacheReadTokens = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue(); - cacheWriteTokens = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue(); - reasoningTokens = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue(); - runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", "")); - runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", "")); - return; - } - if ("phase".equals(delta.eventType())) { - String phase = String.valueOf(delta.eventData().getOrDefault("phase", "")); - if (!phase.isBlank()) { - currentPhase = phase; - streamTracker.updatePhase(conversationId, phase); - // phase 切换时关闭 running 的 content/thinking segment,保留边界 - finalizeRunningSegments("content", "thinking"); - } - } - if ("finish_reason".equals(delta.eventType())) { - Object reason = delta.eventData().get("reason"); - if (reason != null) { - // Last-write-wins: graph normally fires this exactly once - // at FinalAnswerNode completion. Replay paths that re-enter - // the graph after approval will emit a fresh value, which - // is the correct behavior — the latest reason is what gets - // persisted with the assistant message. - finishReason = String.valueOf(reason); - } - } - if (vip.mate.agent.GraphEventPublisher.EVENT_FEEDBACK - .equals(delta.eventType())) { - // Snapshot the affordance payload so it persists into - // message metadata. The same event is also rebroadcast - // live (via the broadcastEvent fall-through below) so - // an already-mounted UI sees it instantly without - // waiting for the message-save round trip. - feedbackEvent = delta.eventData(); - } - if (vip.mate.agent.GraphEventPublisher.EVENT_ROUTING_DECISION.equals(delta.eventType())) { - // Captured at turn start; persisted under metadata.routing so the - // chat UI can render which sidecar (if any) was invoked. Internal - // event — return early to skip rebroadcast on IM channels. - routingDecision = delta.eventData(); - return; - } - accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId); - try { - broadcastEvent(conversationId, delta.eventType(), delta.eventData()); - } catch (Exception e) { - log.warn("Failed to broadcast event {}: {}", delta.eventType(), e.getMessage()); - } - return; + private AgentStreamAccumulator newAccumulator() { + return new AgentStreamAccumulator(objectMapper, new AgentStreamAccumulator.Sink() { + @Override + public void broadcast(String conversationId, String eventName, Object payload) { + broadcastEvent(conversationId, eventName, payload); } - // content_delta - if (delta.content() != null && !delta.content().isBlank()) { - // segmentOnly deltas route per-iteration narration to the - // segments timeline only — the persisted top-level content - // field stays clean so it carries the final answer span, - // not "我来…让我…" concatenations across iterations (issue - // #120 narration leg). segmentOnly implies persistenceOnly, - // so no broadcast either. - if (!delta.segmentOnly()) { - content.append(delta.content()); - } - streamTracker.updatePhase(conversationId, "drafting_answer"); - if (!delta.persistenceOnly()) { - broadcastEvent(conversationId, "content_delta", Map.of("delta", delta.content())); - } - // segments: 追加到当前 running content segment,或创建新的 - var seg = findLastRunning("content"); - if (seg != null) { - seg.put("text", seg.getOrDefault("text", "") + delta.content()); - } else { - finalizeRunningSegments("thinking"); - var s = newSegment("content"); - s.put("text", delta.content()); - segments.add(s); - } + @Override + public void updatePhase(String conversationId, String phase) { + streamTracker.updatePhase(conversationId, phase); } - - // thinking_delta - if (delta.thinking() != null && !delta.thinking().isBlank()) { - if (!delta.segmentOnly()) { - thinking.append(delta.thinking()); - } - if (!delta.persistenceOnly()) { - broadcastEvent(conversationId, "thinking_delta", Map.of("delta", delta.thinking())); - } - var seg = findLastRunning("thinking"); - if (seg != null) { - seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + delta.thinking()); - } else { - var s = newSegment("thinking"); - s.put("thinkingText", delta.thinking()); - segments.add(s); - } - } - } - - boolean isAwaitingApproval() { return awaitingApproval; } - - private void accumulateToolEvent(String eventType, Map data, String conversationId) { - if ("tool_approval_requested".equals(eventType)) { - awaitingApproval = true; - currentPhase = "awaiting_approval"; - pendingApproval = new LinkedHashMap<>(); - pendingApproval.put("pendingId", data.getOrDefault("pendingId", "")); - pendingApproval.put("toolName", data.getOrDefault("toolName", "")); - pendingApproval.put("arguments", data.getOrDefault("arguments", "")); - pendingApproval.put("reason", data.getOrDefault("reason", "")); - pendingApproval.put("status", "pending_approval"); - if (data.containsKey("findings")) pendingApproval.put("findings", data.get("findings")); - if (data.containsKey("maxSeverity")) pendingApproval.put("maxSeverity", data.get("maxSeverity")); - if (data.containsKey("summary")) pendingApproval.put("summary", data.get("summary")); - streamTracker.updatePhase(conversationId, "awaiting_approval"); - } else if ("tool_approval_resolved".equals(eventType)) { - if (pendingApproval != null) { - pendingApproval.put("status", - "approved".equals(String.valueOf(data.getOrDefault("decision", ""))) ? "approved" : "denied"); - } - } else if ("plan_created".equals(eventType)) { - Object rawPlanId = data.get("planId"); - if (rawPlanId instanceof Number n) { - planId = n.longValue(); - } else if (rawPlanId != null) { - try { planId = Long.valueOf(String.valueOf(rawPlanId)); } catch (Exception ignored) {} - } - Object steps = data.get("steps"); - if (steps instanceof List list) { - planSteps = list.stream().map(String::valueOf).toList(); - planStepResults.clear(); - for (int i = 0; i < planSteps.size(); i++) { - planStepResults.add(null); - } - } - currentPlanStep = 0; - } else if ("plan_step_started".equals(eventType)) { - Object idx = data.get("index"); - if (idx instanceof Number n) { - currentPlanStep = n.intValue(); - } - } else if ("plan_step_completed".equals(eventType)) { - Object idx = data.get("index"); - if (idx instanceof Number n) { - int index = n.intValue(); - currentPlanStep = index; - ensurePlanStepCapacity(index + 1); - Map stepResult = new LinkedHashMap<>(); - stepResult.put("result", data.getOrDefault("result", "")); - stepResult.put("status", "completed"); - planStepResults.set(index, stepResult); - } - } else if ("browser_action".equals(eventType)) { - browserActions.add(new LinkedHashMap<>(data)); - } else if ("warning".equals(eventType)) { - String warning = String.valueOf(data.getOrDefault("message", - data.getOrDefault("delta", ""))); - if (!warning.isBlank()) { - warnings.add(warning); - } - } else if ("tool_call_started".equals(eventType)) { - // toolCalls(兼容) - Map tc = new LinkedHashMap<>(); - // toolCallId is required for history replay to pair the persisted - // assistant tool_call with its tool_response — providers reject any - // sequence whose ids don't match. Always record it (empty string - // when the upstream event didn't carry one, e.g. forced tool calls). - tc.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", ""))); - tc.put("name", data.getOrDefault("toolName", "")); - tc.put("arguments", data.getOrDefault("arguments", "")); - tc.put("status", "running"); - toolCalls.add(tc); - // segments: 关闭 running thinking/content,插入 tool_call - finalizeRunningSegments("thinking", "content"); - var seg = newSegment("tool_call"); - seg.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", ""))); - seg.put("toolName", data.getOrDefault("toolName", "")); - seg.put("toolArgs", data.getOrDefault("arguments", "")); - segments.add(seg); - } else if ("tool_direct_result".equals(eventType)) { - // RFC-052: returnDirect tool — track the tool name so history - // replay can render a "data returned directly by tool" badge. - // The actual textual content reaches the user/persistence layer - // through the regular content_delta path (FinalAnswerNode's - // FINAL_ANSWER → StateGraphReActAgent → StreamDelta), so we - // intentionally do NOT add a content-bearing segment here to - // avoid the user seeing the same text twice. - String toolName = String.valueOf(data.getOrDefault("toolName", "")); - if (!toolName.isBlank() && !directToolNames.contains(toolName)) { - directToolNames.add(toolName); - } - } else if ("tool_call_completed".equals(eventType)) { - String toolName = String.valueOf(data.getOrDefault("toolName", "")); - String toolCallId = String.valueOf(data.getOrDefault("toolCallId", "")); - // toolCalls(兼容)— prefer toolCallId match so parallel calls of - // the same tool don't collide on the running+toolName fallback. - for (int i = toolCalls.size() - 1; i >= 0; i--) { - Map tc = toolCalls.get(i); - boolean matches = (!toolCallId.isEmpty() - && toolCallId.equals(String.valueOf(tc.getOrDefault("toolCallId", "")))) - || (toolCallId.isEmpty() - && "running".equals(tc.get("status")) - && toolName.equals(tc.get("name"))); - if (matches) { - tc.put("result", data.getOrDefault("result", "")); - tc.put("success", data.getOrDefault("success", true)); - tc.put("status", "completed"); - break; - } - } - // segments: 标记对应 tool_call 完成 - for (int i = segments.size() - 1; i >= 0; i--) { - var seg = segments.get(i); - if (!"tool_call".equals(seg.get("type"))) continue; - boolean matches = (!toolCallId.isEmpty() - && toolCallId.equals(String.valueOf(seg.getOrDefault("toolCallId", "")))) - || (toolCallId.isEmpty() - && "running".equals(seg.get("status")) - && toolName.equals(seg.get("toolName"))); - if (matches) { - seg.put("status", "completed"); - seg.put("toolResult", data.getOrDefault("result", "")); - seg.put("toolSuccess", data.getOrDefault("success", true)); - break; - } - } - // Extract generated-file links from the tool result so the - // run-overview rail can surface artifacts without re-scanning - // segments on the frontend. - extractGeneratedFiles(String.valueOf(data.getOrDefault("result", "")), toolName); - } - } - - /** Scan a tool result for markdown links pointing at generated-file - * download URLs and collect them into {@link #generatedFiles}. - * De-duplicates by URL so a link echoed in later tool results doesn't - * produce duplicate entries in the run-overview rail. */ - private void extractGeneratedFiles(String result, String toolName) { - if (result == null || result.isBlank()) return; - Matcher m = GENERATED_FILE_LINK_PATTERN.matcher(result); - while (m.find()) { - String url = m.group(2); - boolean dup = generatedFiles.stream() - .anyMatch(f -> url.equals(String.valueOf(f.get("url")))); - if (dup) continue; - Map file = new LinkedHashMap<>(); - file.put("filename", m.group(1)); - file.put("url", url); - file.put("toolName", toolName); - generatedFiles.add(file); - } - } - - private void ensurePlanStepCapacity(int size) { - while (planStepResults.size() < size) { - planStepResults.add(null); - } - } - - // ==================== Segment helpers ==================== - - private Map newSegment(String type) { - Map seg = new LinkedHashMap<>(); - seg.put("id", type.substring(0, 2) + "-" + segCounter++); - seg.put("type", type); - seg.put("status", "running"); - return seg; - } - - private Map findLastRunning(String type) { - for (int i = segments.size() - 1; i >= 0; i--) { - var seg = segments.get(i); - if (type.equals(seg.get("type")) && "running".equals(seg.get("status"))) return seg; - } - return null; - } - - private void finalizeRunningSegments(String... types) { - var typeSet = java.util.Set.of(types); - for (var seg : segments) { - if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) { - seg.put("status", "completed"); - } - } - } - - // ==================== 原有访问器 ==================== - - String getContent() { return content.toString().trim(); } - String getThinking() { return thinking.toString().trim(); } - int getPromptTokens() { return promptTokens; } - int getCompletionTokens() { return completionTokens; } - int getCacheReadTokens() { return cacheReadTokens; } - int getCacheWriteTokens() { return cacheWriteTokens; } - int getReasoningTokens() { return reasoningTokens; } - String getRuntimeModelName() { return runtimeModelName; } - String getRuntimeProviderId() { return runtimeProviderId; } - String getCurrentPhase() { return currentPhase; } - String getFinishReason() { return finishReason; } - boolean segmentsEmpty() { return segments.isEmpty(); } - - synchronized List toAssistantParts() { - List parts = new ArrayList<>(); - if (!getContent().isBlank()) { - MessageContentPart textPart = new MessageContentPart(); - textPart.setType("text"); - textPart.setText(getContent()); - parts.add(textPart); - } - if (!getThinking().isBlank()) { - MessageContentPart thinkingPart = new MessageContentPart(); - thinkingPart.setType("thinking"); - thinkingPart.setText(getThinking()); - parts.add(thinkingPart); - } - for (Map tc : toolCalls) { - try { - parts.add(MessageContentPart.toolCall(objectMapper.writeValueAsString(tc))); - } catch (Exception e) { - log.warn("Failed to serialize tool call: {}", e.getMessage()); - } - } - return parts; - } - - void finalizeToolCalls() { - for (Map tc : toolCalls) { - if ("running".equals(tc.get("status"))) tc.put("status", "completed"); - } - } - - /** - * 生成 metadata JSON:包含 toolCalls + segments。 - * toolCalls 保留兼容旧 UI,segments 是按事件顺序的完整时间线。 - */ - synchronized String toMetadataJson() { - finalizeToolCalls(); - finalizeRunningSegments("thinking", "content", "tool_call"); - SegmentSupersedeDetector.markSuperseded(segments); - try { - Map metadata = new LinkedHashMap<>(); - if (!toolCalls.isEmpty()) { - metadata.put("toolCalls", toolCalls); - } - if (!segments.isEmpty()) { - metadata.put("segments", segments); - } - if (!currentPhase.isBlank()) { - metadata.put("currentPhase", currentPhase); - } - if (planId != null || !planSteps.isEmpty() || currentPlanStep != null) { - Map plan = new LinkedHashMap<>(); - if (planId != null) plan.put("planId", planId); - if (!planSteps.isEmpty()) plan.put("steps", planSteps); - if (currentPlanStep != null) plan.put("currentStep", currentPlanStep); - if (planStepResults.stream().anyMatch(java.util.Objects::nonNull)) { - plan.put("stepResults", planStepResults); - } - metadata.put("plan", plan); - } - if (pendingApproval != null && !pendingApproval.isEmpty()) { - metadata.put("pendingApproval", pendingApproval); - } - if (!browserActions.isEmpty()) { - metadata.put("browserActions", browserActions); - } - if (!warnings.isEmpty()) { - metadata.put("warnings", warnings); - } - if (!directToolNames.isEmpty()) { - // RFC-052 §3.3: only the tool names go into metadata — - // the full content already lives in mate_message.content - // (assembled by FinalAnswerNode). UI uses this to badge - // historical messages as "data returned directly by tool". - metadata.put("directToolNames", directToolNames); - } - if (!generatedFiles.isEmpty()) { - metadata.put("generatedFiles", generatedFiles); - } - if (!finishReason.isEmpty()) { - // Surface graph FinishReason so MemorySummarizationGate and - // any other downstream consumer can branch on a structured - // status (e.g. skip INCOMPLETE / STOPPED / ERROR_FALLBACK - // turns from long-term memory promotion) instead of doing - // brittle text matching on the assistant content. - metadata.put("finishReason", finishReason); - } - if (feedbackEvent != null && !feedbackEvent.isEmpty()) { - // Persist the recovery-affordance payload so the - // retry/regenerate/report card survives page reload. - // Stored as-is (errorType, errorMessage, actions, - // timestamp) — frontend MessageBubble reads - // metadata.feedbackEvent and renders one button per - // entry in `actions`. - metadata.put("feedbackEvent", feedbackEvent); - } - if (routingDecision != null && !routingDecision.isEmpty()) { - metadata.put("routing", routingDecision); - } - return objectMapper.writeValueAsString(metadata); - } catch (Exception e) { - log.warn("Failed to serialize metadata: {}", e.getMessage()); - return "{}"; - } - } + }); } private static Long parseLongOrNull(String s) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java index 74d2077d..455aa82b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java @@ -1523,8 +1523,7 @@ public class ChatStreamTracker { /** * RunState 最长无活动时间。从 wall-clock {@code MAX_LIFETIME_MS=30min} - * 切换到 inactivity-based 后默认 30 min — 与 hermes-agent 的 - * {@code gateway_timeout=1800s} 同口径:只要 agent 还在持续产事件 + * 切换到 inactivity-based 后默认 30 min(1800s 空闲超时):只要 agent 还在持续产事件 * (tool call / content delta / phase transition / progress_update), * 就一直活下去,墙钟跑 1 小时 2 小时都可以。只有真正"完全静默 ≥ N 分钟" * 才视为卡死并强制清理。 diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index f4afe6af..58468044 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -1,5 +1,6 @@ package vip.mate.channel.webchat; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; @@ -193,7 +194,11 @@ public class WebChatController { // 保存用户消息(含访客本轮引用的附件)。附件元数据一律服务端按 fileId 回查, // 不信客户端传入;path 用于 Agent 侧工具读取,对外消息视图会被剥离。 List userParts = buildUserParts(conversationId, message, request.getAttachmentIds()); - conversationService.saveMessage(conversationId, "user", message, userParts); + if (!request.isInternalSkipUserPersist()) { + // Regenerate reuses the already-persisted seed user row — + // inserting again would duplicate it. + conversationService.saveMessage(conversationId, "user", message, userParts); + } // 初始化 SSE 流跟踪 streamTracker.register(conversationId); @@ -1381,14 +1386,16 @@ public class WebChatController { } /** - * 重新生成最后一条助手回复。 + * Regenerate the last assistant reply. *

- * 语义:找到会话最后一条 {@code role=user} 消息 → stop 当前流(如有)→ 删除最后一条 - * {@code role=assistant} 消息 → 用 last user message 重新启动 agent turn。 - * 实际启动复用 {@link #chatStream},它会重新 saveMessage user(新消息 id,内容相同)。 - * 这样不重复 100 行 SSE 代码,代价是用户消息多一条(语义上等同"重发")。 + * Semantics: stop any in-flight stream, rewind the conversation to the last + * {@code role=user} message (removing the trailing assistant reply), then + * re-run the agent turn from that message. The restart reuses + * {@link #chatStream} with {@code internalSkipUserPersist} set, so the + * existing user row is used as the seed and no duplicate user message is + * inserted. *

- * 没有任何 user 消息时返回 400(无内容可重新生成)。 + * Returns an error when the conversation has no user message to regenerate from. */ @Operation(summary = "重新生成最后一条助手回复") @PostMapping(value = "/sessions/regenerate", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @@ -1425,29 +1432,27 @@ public class WebChatController { // right disposable; multi-node is a separate epic. streamTracker.requestStop(conversationId); - MessageEntity lastAssistant = conversationService.findLastMessageByRole(conversationId, "assistant"); - if (lastAssistant != null) { - conversationService.deleteMessageById(lastAssistant.getId()); - } - MessageEntity lastUser = conversationService.findLastMessageByRole(conversationId, "user"); - if (lastUser == null) { + ConversationService.RegenerateSeed seed = conversationService.prepareRegenerate(conversationId); + if (seed == null) { sendErrorAndComplete(emitter, "No user message to regenerate from"); return emitter; } log.info("[WebChat] Regenerate: conversationId={}, visitor={}, seedMessageId={}", - conversationId, visitorId, lastUser.getId()); + conversationId, visitorId, seed.seedMessageId()); audit(channel, visitorId, "webchat.regenerate-session", conversationId, - "{\"sessionId\":\"" + sid + "\",\"seedMessageId\":" + lastUser.getId() + "}"); + "{\"sessionId\":\"" + sid + "\",\"seedMessageId\":" + seed.seedMessageId() + "}"); // Reuse chatStream: it'll resolve the agent again (cheap), re-derive - // conversationId, saveMessage user (new id, same content), and start - // the agent turn. visitorId echoes through to keep the visitor-scoped - // memory owner consistent. + // conversationId and start the agent turn. The seed user row is reused + // as-is — internalSkipUserPersist stops chatStream from inserting a + // duplicate user row. visitorId echoes through to keep the + // visitor-scoped memory owner consistent. WebChatRequest req = new WebChatRequest(); - req.setMessage(lastUser.getContent()); + req.setMessage(seed.content()); req.setVisitorId(visitorId); req.setSessionId(sid); + req.setInternalSkipUserPersist(true); return chatStream(apiKey, req); } @@ -1656,7 +1661,7 @@ public class WebChatController { return full; } return "webchat:" + key8 + ":#" - + sha256Hex(visitorId + "" + (sessionId == null ? "" : sessionId)).substring(0, 40); + + sha256Hex(visitorId + "\0" + (sessionId == null ? "" : sessionId)).substring(0, 40); } /** @@ -1917,6 +1922,13 @@ public class WebChatController { * for this conversation. Metadata is resolved server-side; unknown / foreign / expired * ids are dropped. */ private List attachmentIds; + /** + * Internal-only regenerate flag: the seed user row is already + * persisted, so {@code chatStream} must not insert a duplicate. + * Excluded from JSON binding — never client-settable. + */ + @JsonIgnore + private boolean internalSkipUserPersist; } /** Compact view of one of a visitor's conversation threads. */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index 5dbb483e..da5db099 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -2,18 +2,23 @@ package vip.mate.channel.wecom; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; +import vip.mate.agent.AgentService.StreamDelta; import vip.mate.channel.AbstractChannelAdapter; import vip.mate.channel.ChannelMessage; import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.StreamingChannelAdapter; import vip.mate.workspace.core.service.ChatUploadLocationResolver; import vip.mate.channel.ExponentialBackoff; import vip.mate.channel.media.InboundMediaDownloader; import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.wecom.cards.tool_guard.ToolGuardCardRenderer; import vip.mate.workspace.conversation.model.MessageContentPart; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; @@ -21,6 +26,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.WebSocket; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; @@ -30,6 +36,7 @@ import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; /** * 企业微信智能机器人渠道适配器 — WebSocket 长连接模式 @@ -55,12 +62,17 @@ import java.util.concurrent.atomic.AtomicInteger; *

  • welcome_text: 欢迎消息(可选)
  • *
  • media_download_enabled: 是否下载媒体文件(默认 true)
  • *
  • media_dir: 媒体文件保存目录(默认 data/media)
  • + *
  • stream_progress: 处理期间是否在气泡内展示实时进度(默认 true; + * false 退化为"累积后一次性发送")
  • + *
  • progress_interval_ms: 进度覆写最小间隔(默认 500ms)
  • + *
  • filter_thinking: false 时思考内容流式进入进度气泡(默认 true)
  • + *
  • filter_tool_messages: false 时每次工具调用发独立留痕消息(默认 true)
  • * * * @author MateClaw Team */ @Slf4j -public class WeComChannelAdapter extends AbstractChannelAdapter { +public class WeComChannelAdapter extends AbstractChannelAdapter implements StreamingChannelAdapter { public static final String CHANNEL_TYPE = "wecom"; @@ -188,6 +200,15 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { /** WebSocket 消息碎片缓冲区 */ private final StringBuilder wsBuffer = new StringBuilder(); + /** + * Raw-byte accumulator for fragmented binary WS frames. Bytes are only + * decoded (as UTF-8) once the final fragment arrives — decoding each + * fragment separately would corrupt any multi-byte character split + * across a fragment boundary. Accessed only from the JDK WebSocket + * listener callbacks, which are delivered serially per socket. + */ + private final ByteArrayOutputStream wsBinaryBuffer = new ByteArrayOutputStream(); + /** 请求 ID 计数器 */ private final AtomicInteger reqIdCounter = new AtomicInteger(0); @@ -490,6 +511,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { pendingFrames.clear(); replyContexts.clear(); streamLastContent.clear(); + // 断线时可能残留半截帧碎片,清空以免污染下一个连接的首帧 + wsBuffer.setLength(0); + wsBinaryBuffer.reset(); if (keepaliveScheduler != null) { keepaliveScheduler.shutdownAll(); } @@ -753,8 +777,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { } byte[] bytes = new byte[data.remaining()]; data.get(bytes); - wsBuffer.append(new String(bytes)); + wsBinaryBuffer.write(bytes, 0, bytes.length); if (last) { + wsBuffer.append(new String(wsBinaryBuffer.toByteArray(), StandardCharsets.UTF_8)); + wsBinaryBuffer.reset(); String fullMessage = wsBuffer.toString(); wsBuffer.setLength(0); handleWebSocketFrame(fullMessage); @@ -973,7 +999,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { Map imgBody = (Map) body.getOrDefault("image", Map.of()); String url = (String) imgBody.getOrDefault("url", ""); String aesKey = (String) imgBody.getOrDefault("aeskey", ""); - String inboundConvId = inboundConversationId(senderId, chatId, chatType); + String inboundConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null); if (!url.isBlank()) { contentParts.add(buildInboundImagePart(url, aesKey, msgId, "image.jpg", inboundConvId)); } @@ -1002,7 +1028,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { String filename = (String) fileBody.getOrDefault("filename", fileBody.getOrDefault("file_name", fileBody.getOrDefault("name", "file.bin"))); - String fileConvId = inboundConversationId(senderId, chatId, chatType); + String fileConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null); if (!url.isBlank()) { MessageContentPart filePart = buildInboundFilePart( url, aesKey, msgId, filename, fileConvId); @@ -1032,7 +1058,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { Map img = (Map) item.getOrDefault("image", Map.of()); String url = (String) img.getOrDefault("url", ""); String aesKey = (String) img.getOrDefault("aeskey", ""); - String mixedConvId = inboundConversationId(senderId, chatId, chatType); + String mixedConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null); if (!url.isBlank()) { contentParts.add(buildInboundImagePart( url, aesKey, msgId, "mixed_image.jpg", mixedConvId)); @@ -1345,6 +1371,345 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { } } + // ==================== StreamingChannelAdapter ==================== + + /** Minimum interval between progress overwrites of the stream bubble. */ + private static final long PROGRESS_MIN_INTERVAL_MS = 500; + + /** Max chars of a tool-argument summary in a standalone tool message. */ + private static final int TOOL_ARGS_SUMMARY_MAX = 120; + + /** + * 流式处理 Agent 事件并渲染到企业微信 + *

    + * 渲染策略:复用入站时发出的 "🤔 思考中..." reply_stream 气泡,随 + * agent 事件(思考、工具调用、计划步骤、内容产出)持续覆写为实时进度, + * 流结束后原地渐变为最终答案(走 renderAndSend 的分段逻辑)。 + *

      + *
    • 覆写节流 {@link #PROGRESS_MIN_INTERVAL_MS};工具/审批等关键事件立即刷新
    • + *
    • 静默期由 {@link WeComKeepaliveScheduler} 每 20s 用进度快照续期
    • + *
    • {@code stream_progress=false} 或无可用气泡(语音入站等)时退化为 + * "累积后一次性发送",与改造前行为一致
    • + *
    • {@code filter_tool_messages=false} 时,每次工具调用另发独立消息留痕
    • + *
    • {@code filter_thinking=false} 时,思考内容以引用块进入进度气泡
    • + *
    + */ + @Override + public String processStream(Flux stream, ChannelMessage message, String conversationId) { + String replyTarget = message.getReplyToken() != null ? message.getReplyToken() + : (message.getChatId() != null ? message.getChatId() : message.getSenderId()); + WeComReplyContext ctx = replyContexts.get(replyTarget); + boolean progressEnabled = getConfigBoolean("stream_progress", true); + boolean bubbleUsable = ctx != null && ctx.processingStreamId() != null + && !ctx.processingStreamId().isBlank() + && ctx.frameReqId() != null && !ctx.frameReqId().isBlank(); + + StringBuilder contentAccumulator = new StringBuilder(); + StreamOutcome outcome; + if (!progressEnabled || !bubbleUsable) { + // Degraded path — accumulate content only and send once at the end. + // segmentOnly narration is excluded: gluing it into the reply is + // what makes the same paragraph reach the user two or three times. + stream.doOnNext(delta -> { + if (StreamingChannelAdapter.contributesToFinalContent(delta)) { + contentAccumulator.append(delta.content()); + } + }) + .blockLast(Duration.ofMinutes(10)); + outcome = new StreamOutcome(null, false); + } else { + outcome = consumeWithProgress(stream, message, replyTarget, ctx, contentAccumulator); + } + + String finalContent = contentAccumulator.toString(); + + // The newest narration was held back until the answer was known: when + // a turn's closing narration and its final answer are the same text + // (routine once the answer is short — the model restates it before the + // last tool call), publishing both puts the identical bubble on screen + // twice. Same text → drop the narration, the final answer covers it. + String pendingNarration = outcome.pendingNarration(); + if (!finalContent.isBlank()) { + if (pendingNarration != null && !sameOutboundText(pendingNarration, finalContent)) { + publishNarrationBubble(replyTarget, pendingNarration); + } + renderAndSend(replyTarget, finalContent); + } else if (pendingNarration != null) { + // No final answer at all — the held-back narration is everything + // the user gets, so it closes the live bubble in place instead of + // being dropped. Not returned: narration never becomes persisted + // content. + renderAndSend(replyTarget, pendingNarration); + } else { + // Nothing to render. Without this the live bubble would sit at + // "🤔 思考中…" with the tool trail under it forever, because only + // renderAndSend ever finishes it. + closeIdleProgressBubble(replyTarget, outcome.approvalPending()); + } + return finalContent; + } + + /** + * What {@link #consumeWithProgress} learned while draining the stream, but + * which only {@link #processStream} can act on (it needs the final answer + * first). + * + * @param pendingNarration the last per-stage narration, still unpublished + * @param approvalPending a tool call is parked on human approval + */ + private record StreamOutcome(String pendingNarration, boolean approvalPending) {} + + /** Compare two outbound texts the way the user sees them (post-filter, trimmed). */ + private boolean sameOutboundText(String a, String b) { + if (a == null || b == null) { + return false; + } + String left = filterOutboundContent(a).trim(); + String right = filterOutboundContent(b).trim(); + return !left.isEmpty() && left.equals(right); + } + + /** + * Finish the live progress bubble when the turn produced no text at all + * (approval park, user stop, empty answer). Leaving it open strands a + * permanent "思考中…" bubble carrying the whole tool trail. + */ + private void closeIdleProgressBubble(String replyTarget, boolean approvalPending) { + WeComReplyContext ctx = replyContexts.get(replyTarget); + if (ctx == null || ctx.processingStreamId() == null || ctx.processingStreamId().isBlank()) { + return; + } + renderAndSend(replyTarget, approvalPending + ? "⏸️ 已暂停,等待工具审批。" + : "(本轮没有产生回复内容)"); + } + + /** + * Finalize the live progress bubble with a narration, or send the + * narration as a plain message when no bubble is available (the keepalive + * force-finish at the 180s ceiling evicts the reply context). + */ + private void publishNarrationBubble(String replyTarget, String narration) { + WeComReplyContext ctx = replyContexts.get(replyTarget); + if (ctx != null) { + rollProgressBubble(replyTarget, ctx, narration, null); + } else { + sendMessage(replyTarget, narration); + } + } + + /** Event-driven progress rendering into the processing-stream bubble, with per-stage bubble rolling. */ + private StreamOutcome consumeWithProgress(Flux stream, ChannelMessage message, + String replyTarget, WeComReplyContext initialCtx, + StringBuilder contentAccumulator) { + boolean showThinking = !getConfigBoolean("filter_thinking", true); + boolean standaloneToolMessages = !getConfigBoolean("filter_tool_messages", true); + long minIntervalMs = getConfigLong("progress_interval_ms", PROGRESS_MIN_INTERVAL_MS); + + WeComProgressRenderer progress = new WeComProgressRenderer( + System.currentTimeMillis(), showThinking, standaloneToolMessages); + if (keepaliveScheduler != null) { + // Silent stretches (long LLM calls with no events) keep showing a + // fresh elapsed-time snapshot instead of the static placeholder. + keepaliveScheduler.attachTextSupplier(initialCtx.processingStreamId(), progress::snapshot); + } + + // The live progress bubble rolls forward on every stage narration: + // the current stream is finished with the narration text (making it + // a permanent bubble in place) and a fresh stream id opens below it + // as the new progress bubble, so chat chronology stays intact and + // the final answer always lands in the newest bubble. + AtomicReference liveCtx = new AtomicReference<>(initialCtx); + AtomicReference pendingNarration = new AtomicReference<>(); + final long[] lastFlushAt = {0L}; + stream.doOnNext(delta -> { + boolean flushNow = false; + if (delta.isEvent()) { + flushNow = progress.onEvent(delta.eventType(), delta.eventData()); + if (standaloneToolMessages) { + maybeSendToolEventMessage(replyTarget, delta.eventType(), delta.eventData()); + } + } else if (delta.segmentOnly()) { + // Per-stage narration ("我来查一下…"), emitted once per agent + // loop iteration. Each becomes its own permanent bubble and is + // excluded from the final answer: glued together they read as + // a wall of text, and persisted they pollute the next turn's + // LLM history with unanswered chain-of-thought. + // + // Publishing lags one narration behind: the newest one is only + // staged (visible live in the bubble, not yet finalized) so + // processStream can still drop it if the final answer turns out + // to be the same text. Without the lag the user reads the same + // paragraph in two adjacent bubbles. + String narration = delta.content() != null ? delta.content().trim() : ""; + if (!narration.isEmpty()) { + String previous = pendingNarration.getAndSet(narration); + progress.onNarration(narration); + if (previous != null) { + WeComReplyContext ctx = liveCtx.get(); + if (replyContexts.get(replyTarget) == ctx) { + liveCtx.set(rollProgressBubble(replyTarget, ctx, previous, progress)); + } else { + // Bubble already force-finished (180s ceiling) — the + // narration still goes out as a plain message. + sendMessage(replyTarget, previous); + } + } + flushNow = true; + } + } else { + if (delta.thinking() != null) { + progress.onThinkingDelta(delta.thinking()); + } + if (delta.content() != null) { + contentAccumulator.append(delta.content()); + progress.onContentDelta(delta.content()); + } + } + long now = System.currentTimeMillis(); + if (!flushNow && now - lastFlushAt[0] < minIntervalMs) { + return; + } + WeComReplyContext ctx = liveCtx.get(); + // The keepalive force-finish (180s ceiling) evicts the reply + // context; once that happens the stream slot is closed and + // further overwrites would be silently rejected — stop pushing. + if (replyContexts.get(replyTarget) != ctx) { + return; + } + lastFlushAt[0] = now; + try { + replyStream(ctx.frameReqId(), ctx.processingStreamId(), progress.snapshot(), false); + } catch (Exception e) { + // Reset the throttle window so the next delta retries the + // overwrite immediately instead of waiting out the min + // interval — a failed push means the bubble is stale. + lastFlushAt[0] = 0L; + log.debug("[wecom] progress overwrite failed: {}", e.getMessage()); + } + }).blockLast(Duration.ofMinutes(10)); + + return new StreamOutcome(pendingNarration.get(), progress.isApprovalPending()); + } + + /** + * Finalize the current progress bubble with a stage narration and open a + * fresh stream as the next progress bubble. + *

    + * The narration goes through the channel renderer (thinking/tool-tag + * filters, table formatting, length split): the first segment overwrites + * the current bubble with {@code finish=true}, overflow segments ride + * plain messages. The replacement context is registered in + * {@link #replyContexts} so {@code renderAndSend} / approval cards keep + * working against the newest bubble, and keepalive restarts on the new + * stream with the live progress snapshot. + * + * @param progress live progress renderer, or {@code null} when the stream + * has already finished (the replacement bubble is then a + * bare slot for {@code renderAndSend}, with no keepalive) + */ + private WeComReplyContext rollProgressBubble(String replyTarget, WeComReplyContext ctx, + String narration, WeComProgressRenderer progress) { + boolean filterThinking = getConfigBoolean("filter_thinking", true); + boolean filterToolMessages = getConfigBoolean("filter_tool_messages", true); + String format = getConfigString("message_format", "auto"); + int maxLen = vip.mate.channel.ChannelMessageRenderer.PLATFORM_LIMITS + .getOrDefault(getChannelType(), 2048); + List segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel( + narration, filterThinking, filterToolMessages, format, maxLen); + if (segments.isEmpty()) { + // Narration entirely filtered away — keep the current bubble. + return ctx; + } + if (keepaliveScheduler != null) { + // Stop before the finish chunk so a refresh tick can't race it + // on the same stream. + keepaliveScheduler.stop(ctx.processingStreamId()); + } + boolean first = true; + for (String rawSegment : segments) { + String segment = formatMarkdownTables(rawSegment); + if (first) { + first = false; + try { + replyStream(ctx.frameReqId(), ctx.processingStreamId(), segment, true); + } catch (Exception e) { + log.debug("[wecom] stage bubble finalize failed: {}", e.getMessage()); + } + } else { + sendMessage(replyTarget, segment); + } + } + + String nextStreamId = generateReqId("stream"); + WeComReplyContext next = new WeComReplyContext(ctx.frameReqId(), nextStreamId); + replyContexts.put(replyTarget, next); + try { + replyStream(ctx.frameReqId(), nextStreamId, + progress != null ? progress.snapshot() : "✍️ 正在整理…", false); + } catch (Exception e) { + log.debug("[wecom] next progress bubble open failed: {}", e.getMessage()); + } + if (progress != null && keepaliveScheduler != null) { + try { + keepaliveScheduler.start(this, ctx.frameReqId(), nextStreamId, replyTarget); + keepaliveScheduler.attachTextSupplier(nextStreamId, progress::snapshot); + } catch (Exception e) { + log.debug("[wecom] keepalive restart failed: {}", e.getMessage()); + } + } + return next; + } + + /** + * Standalone tool-call trace messages, sent only when the channel's + * {@code filter_tool_messages} toggle is off: the user opted into seeing + * the tool trail as persistent bubbles (the progress bubble alone is + * transient — the final answer overwrites it). + */ + private void maybeSendToolEventMessage(String replyTarget, String eventType, + Map data) { + if (data == null || eventType == null) { + return; + } + Object toolName = data.get("toolName"); + if (toolName == null) { + return; + } + try { + switch (eventType) { + case "tool_call_started" -> { + String args = summarizeToolArgs(data.get("arguments")); + sendMessage(replyTarget, "🔧 调用工具 `" + toolName + "`" + + (args.isEmpty() ? "" : "\n> " + args)); + } + case "tool_call_completed" -> { + boolean success = !Boolean.FALSE.equals(data.get("success")); + sendMessage(replyTarget, (success ? "✅ `" : "❌ `") + toolName + + (success ? "` 完成" : "` 失败")); + } + default -> { + // Other events carry no standalone tool trace. + } + } + } catch (Exception e) { + log.debug("[wecom] tool trace message failed: {}", e.getMessage()); + } + } + + private static String summarizeToolArgs(Object arguments) { + if (arguments == null) { + return ""; + } + String text = arguments.toString().replaceAll("\\s+", " ").trim(); + if (text.isEmpty() || "{}".equals(text)) { + return ""; + } + return text.length() > TOOL_ARGS_SUMMARY_MAX + ? text.substring(0, TOOL_ARGS_SUMMARY_MAX) + "…" + : text; + } + @Override public void sendMessage(String targetId, String content) { if (webSocket == null) { @@ -1352,10 +1717,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { return; } - // 检查是否有 pending frame(用于 reply_stream 覆盖"思考中...") - // sendMessage 被 renderAndSend 调用时,尝试用 reply_stream 覆盖 - // 但由于 rawPayload 信息在 ChannelMessageRouter 层已丢失, - // 这里走 send_message 主动推送路径 + // 无 frame 上下文的通用发送入口(cron/异步通知等主动推送场景)。 + // 带 WeComReplyContext 的回复路径(renderAndSend / sendContentParts) + // 已直接绑定入站 frame 发送,不再落到这里;此处保留 + // send_message 主动推送 + 群聊缓存 reqId 兜底。 sendMessageToChat(targetId, content); } @@ -1491,6 +1856,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { && !ctx.processingStreamId().isBlank()) { replyStream(ctx.frameReqId(), ctx.processingStreamId(), segment, true); first = false; + } else if (ctx != null && ctx.frameReqId() != null && !ctx.frameReqId().isBlank()) { + // 后续分段绑定同一入站 frame 回复——群聊拒收主动推送, + // 走 frame 回复在群聊/单聊都可达且保持顺序 + replyMarkdown(ctx.frameReqId(), segment); } else { sendMessage(targetId, segment); } @@ -1609,6 +1978,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { && !ctx.processingStreamId().isBlank()) { replyStream(ctx.frameReqId(), ctx.processingStreamId(), rewritten, true); firstText = false; + } else if (ctx != null && ctx.frameReqId() != null + && !ctx.frameReqId().isBlank()) { + // 后续文本绑定同一入站 frame 回复(群聊拒收主动推送) + replyMarkdown(ctx.frameReqId(), rewritten); } else { sendMessage(targetId, rewritten); } @@ -1911,6 +2284,42 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { */ private final ConcurrentHashMap streamLastContent = new ConcurrentHashMap<>(); + /** + * Send a markdown bubble bound to an inbound frame via + * {@code aibot_respond_msg}. + * + *

    Used for reply segments after the first when a live + * {@link WeComReplyContext} exists: the platform rejects + * {@code aibot_send_msg} in group chats, so segments pushed actively + * would silently vanish there. Riding the inbound frame's reply slot + * works in both group and single chats, and keeps segment ordering + * behind the stream bubble (same per-reqId serial worker queue). + * + *

    Failures are logged, not propagated — one bad segment must not + * abort the remaining segments of a long reply. + */ + private void replyMarkdown(String frameReqId, String content) { + if (frameReqId == null || frameReqId.isBlank() + || content == null || content.isBlank()) { + return; + } + try { + Map body = Map.of( + "msgtype", "markdown", + "markdown", Map.of("content", content) + ); + Map frame = Map.of( + "cmd", CMD_RESPONSE, + "headers", Map.of("req_id", frameReqId), + "body", body + ); + sendFrameWithAck(frameReqId, frame); + } catch (Exception e) { + log.error("[wecom] Failed to send reply segment via frame {}: {}", + frameReqId, e.getMessage()); + } + } + // ==================== 上传大小预校验(WeCom 平台限制) ==================== /** WeCom hard limits — verified empirically; sources differ slightly. */ @@ -2088,6 +2497,16 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { sendFrameWithAck(eventReqId, frame); } + /** + * URL for the resolved approval card's mandatory {@code card_action} + * link (WeCom rejects {@code text_notice} cards without a type-1/2 + * action). Reads channel config {@code card_action_url}; public so the + * card handler (different package) can pass it to the renderer. + */ + public String resolvedCardActionUrl() { + return getConfigString("card_action_url", ToolGuardCardRenderer.DEFAULT_CARD_ACTION_URL); + } + /** * Keepalive refresh tick (called by {@link WeComKeepaliveScheduler} * every 20s). Sends {@code finish=false} on the existing stream so @@ -2098,6 +2517,13 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { * not be triggering refresh ticks. */ public void replyStreamRefreshForKeepalive(String reqId, String streamId, String text) { + // Bypass chunk dedup: a refresh whose text equals the previous chunk + // (e.g. the static placeholder when no progress supplier is attached) + // would otherwise be swallowed by replyStream's dedup guard — no + // network frame goes out, the server-side TTL is NOT reset, and the + // slot dies exactly the way this keepalive exists to prevent. Clearing + // the dedup slot first guarantees every tick produces a real frame. + streamLastContent.remove(streamId); replyStream(reqId, streamId, text, false); } @@ -2599,9 +3025,15 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { * matching row, returned 403, and the IM client rendered every * group-quoted image as a broken icon. */ - private static String inboundConversationId(String senderId, String chatId, String chatType) { + private static String inboundConversationId(String senderId, String chatId, String chatType, + Long channelId) { boolean isGroup = "group".equals(chatType); - return isGroup ? "wecom:" + chatId : "wecom:" + senderId; + String identifier = isGroup ? chatId : senderId; + // Mirror ChannelMessageRouter#buildConversationId: scope the id by channelId so the + // same sender on two workspaces' wecom channels never shares a conversation. channelId + // is the ChannelEntity primary key; a null (unreachable for a persisted channel) keeps + // the legacy two-segment form so nothing NPEs. + return channelId != null ? "wecom:" + channelId + ":" + identifier : "wecom:" + identifier; } // ==================== 引用消息(quote)解析 ==================== @@ -2657,7 +3089,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { items = List.of(quote); } - String inboundConvId = inboundConversationId(senderId, chatId, chatType); + String inboundConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null); StringBuilder summary = new StringBuilder(); List attached = new ArrayList<>(); @@ -2769,7 +3201,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { String title = ((String) appmsg.getOrDefault("title", "")).trim(); String desc = ((String) appmsg.getOrDefault("description", "")).trim(); String linkUrl = ((String) appmsg.getOrDefault("url", "")).trim(); - String inboundConvId = inboundConversationId(senderId, chatId, chatType); + String inboundConvId = inboundConversationId(senderId, chatId, chatType, channelEntity != null ? channelEntity.getId() : null); Object fileObj = appmsg.get("file"); Object imageObj = appmsg.get("image"); @@ -2997,63 +3429,12 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { } /** - * 下载并解密企业微信媒体文件(旧版本,保留给 outbound / 其他场景使用) + * AES-256-CBC decryption for WeCom media payloads. *

    - * AES-256-CBC 解密:base64 decode aesKey → IV = 前 16 字节 → PKCS#7 去填充 - * - * @param url 文件下载 URL - * @param aesKey Base64 编码的 AES-256 密钥 - * @param msgId 消息 ID(用于生成文件名) - * @param fileNameHint 文件名提示 - * @return 本地文件路径,失败返回 null - */ - private String downloadAndDecryptMedia(String url, String aesKey, String msgId, String fileNameHint) { - try { - String mediaDir = getConfigString("media_dir", "data/media"); - Path mediaDirPath = Path.of(mediaDir); - Files.createDirectories(mediaDirPath); - - // 1. HTTP GET 下载文件 - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(30)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - byte[] encryptedData = response.body().readAllBytes(); - - byte[] fileData; - // 2. AES 解密(如果提供了 aesKey) - if (aesKey != null && !aesKey.isBlank()) { - fileData = decryptAes256Cbc(encryptedData, aesKey); - } else { - fileData = encryptedData; - } - - // 3. 保存到本地 - String urlHash = md5Hex(url).substring(0, 8); - String safeName = fileNameHint.replaceAll("[^a-zA-Z0-9._-]", "_"); - if (safeName.isBlank()) safeName = "media"; - Path filePath = mediaDirPath.resolve("wecom_" + urlHash + "_" + safeName); - Files.write(filePath, fileData); - - log.info("[wecom] Media downloaded: {} ({} bytes)", filePath, fileData.length); - return filePath.toAbsolutePath().toString(); - - } catch (Exception e) { - log.error("[wecom] Failed to download media: {}", e.getMessage(), e); - return null; - } - } - - /** - * AES-256-CBC 解密(对齐 wecom-aibot-python-sdk crypto_utils.py) - *

    - * 1. Base64 decode aesKey(自动补齐 padding) - * 2. IV = decoded key 前 16 字节 - * 3. AES-256-CBC 解密 - * 4. PKCS#7 去填充 + * 1. Base64 decode aesKey (auto-fix missing padding) + * 2. IV = first 16 bytes of the decoded key + * 3. AES-256-CBC decrypt + * 4. Strip PKCS#7 padding */ private byte[] decryptAes256Cbc(byte[] encryptedData, String aesKeyBase64) throws Exception { // 补齐 Base64 padding @@ -3131,19 +3512,6 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { return prefix + "_" + System.currentTimeMillis() + "_" + reqIdCounter.incrementAndGet(); } - /** - * MD5 哈希(hex 字符串) - */ - private String md5Hex(String input) { - try { - MessageDigest md = MessageDigest.getInstance("MD5"); - byte[] hash = md.digest(input.getBytes()); - return bytesToHex(hash); - } catch (Exception e) { - return Integer.toHexString(input.hashCode()); - } - } - /** * MD5 哈希(字节数组输入) */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java index b53e6db6..7724d836 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java @@ -9,6 +9,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; /** * Periodically refreshes a WeCom AI Bot {@code stream} reply with the @@ -47,9 +48,17 @@ public class WeComKeepaliveScheduler { /** Hard ceiling — after this many seconds, force-finish the stream. */ static final long MAX_DURATION_SECONDS = 180; - /** Placeholder text written on every refresh tick + on force-finish. */ + /** Placeholder text written on every refresh tick (when no live progress supplier is attached). */ static final String PROCESSING_TEXT = "🤔 思考中..."; + /** + * Text written when the 180s ceiling force-finishes the stream. The real + * answer will arrive later as a separate pushed bubble (the reply context + * is invalidated below), so the sealed bubble must tell the user the + * reply is still coming — freezing it on "思考中..." reads as a hang. + */ + static final String FORCE_FINISH_TEXT = "⏳ 任务耗时较长,仍在处理中,结果稍后送达"; + /** One-shot state per active stream. Held by reference inside the scheduled task. */ private static final class StreamState { final WeComChannelAdapter adapter; @@ -58,6 +67,10 @@ public class WeComKeepaliveScheduler { final String replyToken; final long startedAt; volatile ScheduledFuture future; + /** Optional live progress text source; when set, refresh ticks write + * its current snapshot instead of the static placeholder so the + * bubble keeps showing elapsed time / tool state between events. */ + volatile Supplier textSupplier; StreamState(WeComChannelAdapter a, String r, String s, String t) { this.adapter = a; this.reqId = r; this.streamId = s; this.replyToken = t; this.startedAt = System.currentTimeMillis(); @@ -105,6 +118,22 @@ public class WeComKeepaliveScheduler { log.debug("[wecom-keepalive] started for stream={} reqId={}", streamId, reqId); } + /** + * Attach a live progress text source to an already-tracked stream. + * Subsequent refresh ticks write the supplier's snapshot instead of the + * static placeholder. No-op when the stream is not tracked (already + * stopped or force-finished). + */ + public void attachTextSupplier(String streamId, Supplier supplier) { + if (streamId == null || streamId.isBlank()) { + return; + } + StreamState st = states.get(streamId); + if (st != null) { + st.textSupplier = supplier; + } + } + /** * Stop keepalive for a stream — call this immediately before sending * the real reply so the next refresh tick doesn't race the @@ -138,7 +167,7 @@ public class WeComKeepaliveScheduler { // replyContext entry so the eventual real reply takes the // fresh-stream path. try { - st.adapter.replyStreamFinishForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT); + st.adapter.replyStreamFinishForKeepalive(st.reqId, st.streamId, FORCE_FINISH_TEXT); } catch (Exception e) { log.debug("[wecom-keepalive] force-finish replyStream failed for {}: {}", st.streamId, e.getMessage()); @@ -155,12 +184,29 @@ public class WeComKeepaliveScheduler { return; } try { - st.adapter.replyStreamRefreshForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT); + st.adapter.replyStreamRefreshForKeepalive(st.reqId, st.streamId, refreshText(st)); } catch (Exception e) { log.debug("[wecom-keepalive] refresh failed for {}: {}", st.streamId, e.getMessage()); } } + /** Current refresh text: live progress snapshot when attached, static placeholder otherwise. */ + private String refreshText(StreamState st) { + Supplier supplier = st.textSupplier; + if (supplier != null) { + try { + String text = supplier.get(); + if (text != null && !text.isBlank()) { + return text; + } + } catch (Exception e) { + log.debug("[wecom-keepalive] progress supplier failed for {}: {}", + st.streamId, e.getMessage()); + } + } + return PROCESSING_TEXT; + } + // ---- Test hooks ---- int activeStreamCount() { return states.size(); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComProgressRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComProgressRenderer.java new file mode 100644 index 00000000..7989437b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComProgressRenderer.java @@ -0,0 +1,280 @@ +package vip.mate.channel.wecom; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; + +/** + * Builds the live progress text shown in the WeCom stream bubble while an + * agent turn is running: a status header (thinking / tool call / replying, + * with elapsed time), the most recent tool-call lines, an optional rolling + * window of reasoning text, and the tail of the answer produced so far. + *

    + * Mutations arrive from the stream-consuming thread; {@link #snapshot()} may + * also be called from the keepalive scheduler thread, so all state access is + * synchronized on this instance. + */ +final class WeComProgressRenderer { + + /** Max completed/running tool lines rendered before collapsing to a counter. */ + private static final int MAX_TOOL_LINES = 3; + + /** Rolling window (chars) of reasoning text when thinking display is on. */ + private static final int THINKING_WINDOW = 500; + + /** Tail window (chars) of the streamed answer kept inside the bubble. + * The full answer is delivered by the final render pass; the bubble only + * needs enough to show live progress while staying under the 2048 limit. */ + private static final int ANSWER_WINDOW = 1200; + + private record ToolLine(String callId, String name, long startedAt, + Long finishedAt, boolean success) { + } + + private final long startedAtMillis; + private final boolean showThinking; + private final boolean showToolTrace; + + private final Deque toolLines = new ArrayDeque<>(); + private int collapsedToolCount; + private final StringBuilder thinkingTail = new StringBuilder(); + private final StringBuilder answerTail = new StringBuilder(); + private boolean thinkingSeen; + private boolean contentSeen; + private boolean approvalPending; + private String planStepLine; + private String narration; + + /** + * @param startedAtMillis turn start, for the elapsed-time counter + * @param showThinking render the rolling reasoning window + * ({@code filter_thinking=false}) + * @param showToolTrace render tool names and per-tool completion lines + * ({@code filter_tool_messages=false}). When off, + * the bubble only says that a tool is running — + * the whole point of the toggle is that the tool + * trail stays out of the user's view, and the + * progress bubble is as user-visible as a + * standalone trace message. + */ + WeComProgressRenderer(long startedAtMillis, boolean showThinking, boolean showToolTrace) { + this.startedAtMillis = startedAtMillis; + this.showThinking = showThinking; + this.showToolTrace = showToolTrace; + } + + synchronized void onThinkingDelta(String delta) { + thinkingSeen = true; + if (showThinking && delta != null && !delta.isEmpty()) { + thinkingTail.append(delta); + trimLeading(thinkingTail, THINKING_WINDOW); + } + } + + synchronized void onContentDelta(String delta) { + contentSeen = true; + if (delta != null && !delta.isEmpty()) { + answerTail.append(delta); + trimLeading(answerTail, ANSWER_WINDOW); + } + } + + /** + * Consume a graph event. Returns true when the event changes what the + * bubble shows in a way worth flushing immediately (tool transitions, + * plan steps, approval waits) rather than waiting for the throttle tick. + */ + synchronized boolean onEvent(String eventType, Map data) { + if (eventType == null) { + return false; + } + switch (eventType) { + case "tool_call_started" -> { + toolLines.addLast(new ToolLine( + stringField(data, "toolCallId"), + stringField(data, "toolName"), + System.currentTimeMillis(), null, false)); + compactToolLines(); + return true; + } + case "tool_call_completed" -> { + String callId = stringField(data, "toolCallId"); + boolean success = data == null || !Boolean.FALSE.equals(data.get("success")); + markToolCompleted(callId, stringField(data, "toolName"), success); + return true; + } + case "plan_step_started" -> { + Object index = data != null ? data.get("index") : null; + String title = stringField(data, "title"); + planStepLine = "📋 步骤" + (index != null ? " " + index : "") + + (title != null && !title.isBlank() ? ": " + title : ""); + return true; + } + case "tool_approval_requested" -> { + approvalPending = true; + return true; + } + default -> { + return false; + } + } + } + + /** + * Stage the newest per-stage narration so it shows in the live bubble + * straight away. Only the newest one is kept — the previous narration has + * already been published as its own bubble by then. + */ + synchronized void onNarration(String text) { + narration = (text == null || text.isBlank()) ? null : text.trim(); + } + + /** True once a tool call has asked for human approval this turn. */ + synchronized boolean isApprovalPending() { + return approvalPending; + } + + /** Render the current progress text for the stream bubble. */ + synchronized String snapshot() { + StringBuilder sb = new StringBuilder(); + sb.append(statusLine()); + if (planStepLine != null) { + sb.append('\n').append(planStepLine); + } + appendToolLines(sb); + if (showThinking && !contentSeen && thinkingTail.length() > 0) { + sb.append("\n\n> 💭 ").append(thinkingTail.toString().replace("\n", "\n> ")); + } + if (narration != null) { + sb.append("\n\n").append(narration); + } + if (answerTail.length() > 0) { + sb.append("\n\n").append(answerTail); + } + return sb.toString(); + } + + private String statusLine() { + if (approvalPending) { + return "⏸️ 等待工具审批…(" + elapsed() + ")"; + } + if (contentSeen) { + return "✍️ 正在回复…(" + elapsed() + ")"; + } + ToolLine running = lastRunningTool(); + if (running != null) { + return showToolTrace + ? "🔧 正在调用 " + displayName(running) + "…(" + elapsed() + ")" + : "🔧 正在执行工具…(" + elapsed() + ")"; + } + if (thinkingSeen) { + return "💭 思考中…(" + elapsed() + ")"; + } + return "🤔 思考中…(" + elapsed() + ")"; + } + + private void appendToolLines(StringBuilder sb) { + if (!showToolTrace) { + // filter_tool_messages=true — the tool trail is suppressed + // everywhere the user can see it, progress bubble included. + return; + } + if (collapsedToolCount > 0) { + sb.append("\n…等 ").append(collapsedToolCount).append(" 项已完成"); + } + for (ToolLine line : toolLines) { + if (line.finishedAt() == null) { + // The running tool is already the status header — skip here + // unless content started (header shows "正在回复" instead). + if (contentSeen || approvalPending) { + sb.append("\n🔧 ").append(displayName(line)).append(" 运行中…"); + } + } else { + long seconds = Math.max(0, (line.finishedAt() - line.startedAt()) / 1000); + sb.append('\n').append(line.success() ? "✅ " : "❌ ") + .append(displayName(line)) + .append(line.success() ? " 完成" : " 失败") + .append("(").append(seconds).append(" 秒)"); + } + } + } + + private ToolLine lastRunningTool() { + ToolLine running = null; + for (ToolLine line : toolLines) { + if (line.finishedAt() == null) { + running = line; + } + } + return running; + } + + private void markToolCompleted(String callId, String toolName, boolean success) { + ToolLine match = null; + for (ToolLine line : toolLines) { + if (line.finishedAt() != null) { + continue; + } + boolean idMatch = callId != null && callId.equals(line.callId()); + boolean nameMatch = callId == null && toolName != null && toolName.equals(line.name()); + if (idMatch || nameMatch) { + match = line; + } + } + if (match == null) { + toolLines.addLast(new ToolLine(callId, toolName, + System.currentTimeMillis(), System.currentTimeMillis(), success)); + } else { + ToolLine done = new ToolLine(match.callId(), match.name(), + match.startedAt(), System.currentTimeMillis(), success); + replaceLine(match, done); + } + compactToolLines(); + } + + private void replaceLine(ToolLine oldLine, ToolLine newLine) { + Deque rebuilt = new ArrayDeque<>(toolLines.size()); + for (ToolLine line : toolLines) { + rebuilt.addLast(line == oldLine ? newLine : line); + } + toolLines.clear(); + toolLines.addAll(rebuilt); + } + + /** Keep at most {@link #MAX_TOOL_LINES}; older completed lines collapse into a counter. */ + private void compactToolLines() { + while (toolLines.size() > MAX_TOOL_LINES) { + ToolLine oldest = toolLines.peekFirst(); + if (oldest != null && oldest.finishedAt() == null) { + // Never collapse a still-running tool line. + break; + } + toolLines.pollFirst(); + collapsedToolCount++; + } + } + + private String elapsed() { + long seconds = Math.max(0, (System.currentTimeMillis() - startedAtMillis) / 1000); + if (seconds < 60) { + return "已 " + seconds + " 秒"; + } + return "已 " + (seconds / 60) + " 分 " + (seconds % 60) + " 秒"; + } + + private static String displayName(ToolLine line) { + return line.name() != null && !line.name().isBlank() ? line.name() : "工具"; + } + + private static String stringField(Map data, String key) { + Object value = data != null ? data.get(key) : null; + return value != null ? value.toString() : null; + } + + private static void trimLeading(StringBuilder sb, int maxLen) { + int excess = sb.length() - maxLen; + if (excess > 0) { + sb.delete(0, excess); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java index ac28885d..6f3d8202 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java @@ -199,7 +199,8 @@ public class ToolGuardCardHandler implements WeComCardHandler { : "Tool " + toolName + " 已拒绝"; try { adapter.updateTemplateCard(eventReqId, - ToolGuardCardRenderer.buildResolvedCard(taskId, title, desc)); + ToolGuardCardRenderer.buildResolvedCard(taskId, title, desc, + adapter.resolvedCardActionUrl())); } catch (Exception e) { log.warn("[wecom-toolguard] update_template_card (resolved) failed: {}", e.getMessage()); } @@ -212,7 +213,8 @@ public class ToolGuardCardHandler implements WeComCardHandler { adapter.updateTemplateCard(eventReqId, ToolGuardCardRenderer.buildResolvedCard(taskId, "❌ 仅原请求者可审批", - "请由 " + requesterLabel + " 操作")); + "请由 " + requesterLabel + " 操作", + adapter.resolvedCardActionUrl())); } catch (Exception e) { log.warn("[wecom-toolguard] update_template_card (unauthorised) failed: {}", e.getMessage()); } @@ -224,7 +226,8 @@ public class ToolGuardCardHandler implements WeComCardHandler { adapter.updateTemplateCard(eventReqId, ToolGuardCardRenderer.buildResolvedCard(taskId, "⌛ 审批已过期", - "Tool " + toolName + " 的审批已过期或被处理")); + "Tool " + toolName + " 的审批已过期或被处理", + adapter.resolvedCardActionUrl())); } catch (Exception e) { log.warn("[wecom-toolguard] update_template_card (expired) failed: {}", e.getMessage()); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRenderer.java index 6498c3e4..8f502e8c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRenderer.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRenderer.java @@ -99,13 +99,29 @@ public class ToolGuardCardRenderer implements WeComCardRenderer { * to stay inside WeCom's main_title.desc limit */ public static Map buildResolvedCard(String taskId, String title, String desc) { + return buildResolvedCard(taskId, title, desc, DEFAULT_CARD_ACTION_URL); + } + + /** + * Fallback for the mandatory {@code card_action} link when the channel + * config doesn't provide one ({@code card_action_url}). + */ + public static final String DEFAULT_CARD_ACTION_URL = "https://mateclaw.vip"; + + /** + * Same as {@link #buildResolvedCard(String, String, String)} but with an + * explicit {@code card_action} URL (per-channel configurable). + */ + public static Map buildResolvedCard(String taskId, String title, String desc, + String actionUrl) { Map mainTitle = new LinkedHashMap<>(); mainTitle.put("title", title == null ? "" : title); mainTitle.put("desc", truncate(desc == null ? "" : desc, 30)); Map cardAction = new LinkedHashMap<>(); cardAction.put("type", 1); - cardAction.put("url", "https://mateclaw.vip"); + cardAction.put("url", (actionUrl == null || actionUrl.isBlank()) + ? DEFAULT_CARD_ACTION_URL : actionUrl); Map card = new LinkedHashMap<>(); card.put("card_type", "text_notice"); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java index 8ae53315..be3a6e1e 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java @@ -44,7 +44,8 @@ import java.util.*; public class ILinkClient { public static final String DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"; - private static final String CHANNEL_VERSION = "2.0.1"; + private static final String CHANNEL_VERSION = "1.0.2"; + private static final ObjectMapper WIRE_OBJECT_MAPPER = new ObjectMapper(); /** 长轮询超时(服务端最长 35s,客户端设 45s) */ private static final Duration GETUPDATES_TIMEOUT = Duration.ofSeconds(45); @@ -226,14 +227,30 @@ public class ILinkClient { body.put("msg", msg); body.put("base_info", Map.of("channel_version", CHANNEL_VERSION)); + String requestJson = WIRE_OBJECT_MAPPER.writeValueAsString(body); + log.info("[weixin] sendMessage request: toUser={}, contextTokenPresent={}, itemSummary={}, payload={}", + maskId(String.valueOf(msg.get("to_user_id"))), + String.valueOf(msg.getOrDefault("context_token", "")).length() > 0, + summarizeItems(msg.get("item_list")), redactSendMessagePayload(body)); + HttpRequest request = applyHeaders(HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/ilink/bot/sendmessage")) - .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))) + .POST(HttpRequest.BodyPublishers.ofString(requestJson))) .timeout(DEFAULT_TIMEOUT) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + log.info("[weixin] sendMessage response: status={}, body={}", response.statusCode(), response.body()); ensureOk(response, "sendMessage"); - return objectMapper.readValue(response.body(), new TypeReference<>() {}); + Map result = objectMapper.readValue(response.body(), new TypeReference<>() {}); + Object ret = result.get("ret"); + if (ret instanceof Number n && n.intValue() != 0) { + log.error("[weixin] sendMessage business error: ret={}, errmsg={}, toUser={}, itemSummary={}, body={}", + ret, result.get("errmsg"), maskId(String.valueOf(msg.get("to_user_id"))), + summarizeItems(msg.get("item_list")), response.body()); + throw new RuntimeException("sendMessage business error: ret=" + ret + + ", errmsg=" + result.get("errmsg")); + } + return result; } /** @@ -376,14 +393,42 @@ public class ILinkClient { body.put("no_need_thumb", true); body.put("base_info", Map.of("channel_version", CHANNEL_VERSION)); + String requestJson = WIRE_OBJECT_MAPPER.writeValueAsString(body); + log.info("[weixin] getUploadUrl request: mediaType={}, toUser={}, rawSize={}({}), encryptedSize={}({}), " + + "rawMd5={}, aesKeyHexLen={}, noNeedThumb={}, channelVersion={}, payload={}", + mediaType, maskId(toUserId), rawSize, typeName(body.get("rawsize")), + fileSize, typeName(body.get("filesize")), rawFileMd5, + aesKeyHex == null ? 0 : aesKeyHex.length(), true, CHANNEL_VERSION, + redactUploadUrlPayload(body)); + HttpRequest request = applyHeaders(HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/ilink/bot/getuploadurl")) - .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))) + .POST(HttpRequest.BodyPublishers.ofString(requestJson))) .timeout(DEFAULT_TIMEOUT) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + log.info("[weixin] getUploadUrl response: status={}, body={}", + response.statusCode(), response.body()); ensureOk(response, "getUploadUrl"); - return objectMapper.readValue(response.body(), new TypeReference<>() {}); + Map result = objectMapper.readValue(response.body(), new TypeReference<>() {}); + Object ret = result.get("ret"); + if (ret instanceof Number n && n.intValue() != 0) { + log.error("[weixin] getUploadUrl business error: ret={}, errmsg={}, mediaType={}, toUser={}, " + + "rawSize={}, encryptedSize={}, rawMd5={}, filekeyPrefix={}, body={}", + ret, result.get("errmsg"), mediaType, maskId(toUserId), rawSize, fileSize, + rawFileMd5, prefix(filekey, 8), response.body()); + throw new RuntimeException("getUploadUrl business error: ret=" + ret + + ", errmsg=" + result.get("errmsg")); + } else if (!result.containsKey("upload_full_url") && !result.containsKey("upload_param")) { + log.error("[weixin] getUploadUrl: missing upload_full_url and upload_param. Full response: {}", + response.body()); + } else { + log.info("[weixin] getUploadUrl ok: mediaType={}, rawSize={}, hasFullUrl={}, hasUploadParam={}, " + + "uploadParamLen={}", + mediaType, rawSize, result.containsKey("upload_full_url"), result.containsKey("upload_param"), + String.valueOf(result.getOrDefault("upload_param", "")).length()); + } + return result; } /** @@ -406,6 +451,8 @@ public class ILinkClient { // 1. 原始文件元数据 long rawSize = fileBytes.length; String rawFileMd5 = md5Hex(fileBytes); + log.info("[weixin] uploadMedia begin: mediaType={}, fileName={}, rawSize={}, rawMd5={}, toUser={}", + mediaType, fileName, rawSize, rawFileMd5, maskId(toUserId)); // 2. 生成 AES key 并加密 SecureRandom random = new SecureRandom(); @@ -418,11 +465,16 @@ public class ILinkClient { byte[] encryptedData = WeixinAesUtil.aesEcbEncrypt(fileBytes, aesKeyB64ForEncrypt); long encryptedSize = encryptedData.length; + log.info("[weixin] uploadMedia encrypted: mediaType={}, rawSize={}, encryptedSize={}, paddingBytes={}, " + + "aesKeyHexLen={}", + mediaType, rawSize, encryptedSize, encryptedSize - rawSize, aesKeyHex.length()); // 3. 生成 filekey(16 字节随机 hex) byte[] filekeyBytes = new byte[16]; random.nextBytes(filekeyBytes); String filekey = bytesToHex(filekeyBytes); + log.info("[weixin] uploadMedia filekey generated: mediaType={}, filekeyPrefix={}, filekeyLen={}", + mediaType, prefix(filekey, 8), filekey.length()); // 4. 获取上传 URL Map uploadUrlResp = getUploadUrl(filekey, mediaType, toUserId, @@ -440,6 +492,13 @@ public class ILinkClient { String encParam = URLEncoder.encode(uploadParam, StandardCharsets.UTF_8); uploadUrl = CDN_BASE_URL + "/upload?encrypted_query_param=" + encParam + "&filekey=" + filekey; } + log.info("[weixin] CDN upload target resolved: mediaType={}, mode={}, uploadParamPresent={}, uploadParamLen={}, " + + "uploadFullUrlPresent={}, uploadHost={}, uploadPath={}, filekeyPrefix={}", + mediaType, (fullUrl != null && !fullUrl.isBlank()) ? "upload_full_url" : "upload_param", + uploadUrlResp.get("upload_param") != null, + String.valueOf(uploadUrlResp.getOrDefault("upload_param", "")).length(), + fullUrl != null && !fullUrl.isBlank(), + URI.create(uploadUrl).getHost(), URI.create(uploadUrl).getPath(), prefix(filekey, 8)); // 5. POST 加密数据到 CDN(注意:使用 upload_param 时不需要 Authorization 头) HttpRequest.Builder cdnBuilder = HttpRequest.newBuilder() @@ -453,6 +512,12 @@ public class ILinkClient { } HttpResponse cdnResponse = httpClient.send(cdnBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + log.info("[weixin] CDN upload response: status={}, bodyBytes={}, hasXEncryptedParam={}, headers={}", + cdnResponse.statusCode(), + cdnResponse.body() == null ? 0 : cdnResponse.body().length, + cdnResponse.headers().firstValue("x-encrypted-param") + .or(() -> cdnResponse.headers().firstValue("X-Encrypted-Param")).isPresent(), + cdnResponse.headers().map().keySet()); ensureOk(cdnResponse, "CDN upload"); // 6. 从响应头提取 encrypt_query_param @@ -469,7 +534,7 @@ public class ILinkClient { log.info("[weixin] Media uploaded: type={}, size={}KB, encryptedSize={}KB", mediaType, rawSize / 1024, encryptedSize / 1024); - return new UploadResult(encryptQueryParam, aesKeyB64ForMsg, encryptedSize); + return new UploadResult(encryptQueryParam, aesKeyB64ForMsg, encryptedSize, rawFileMd5, rawSize); } /** @@ -480,6 +545,9 @@ public class ILinkClient { * @param contextToken 上下文 token */ public void sendImage(String toUserId, byte[] imageBytes, String contextToken) throws Exception { + log.info("[weixin] sendImage begin: toUser={}, imageBytes={}, contextTokenPresent={}", + maskId(toUserId), imageBytes == null ? 0 : imageBytes.length, + contextToken != null && !contextToken.isBlank()); UploadResult result = uploadMedia(imageBytes, "image.jpg", 1, toUserId); Map imageItem = new LinkedHashMap<>(); @@ -488,6 +556,7 @@ public class ILinkClient { "media", Map.of( "encrypt_query_param", result.encryptQueryParam(), "aes_key", result.aesKeyB64(), + "encrypt_type", 1, "mid_size", result.fileSize() ) )); @@ -517,16 +586,21 @@ public class ILinkClient { * @param contextToken 上下文 token */ public void sendFile(String toUserId, byte[] fileBytes, String fileName, String contextToken) throws Exception { + log.info("[weixin] sendFile begin: toUser={}, fileName={}, fileBytes={}, contextTokenPresent={}", + maskId(toUserId), fileName, fileBytes == null ? 0 : fileBytes.length, + contextToken != null && !contextToken.isBlank()); UploadResult result = uploadMedia(fileBytes, fileName, 3, toUserId); Map fileItem = new LinkedHashMap<>(); fileItem.put("type", 4); fileItem.put("file_item", Map.of( "file_name", fileName, - "len", (long) fileBytes.length, + "md5", result.rawFileMd5(), + "len", String.valueOf(result.rawSize()), "media", Map.of( "encrypt_query_param", result.encryptQueryParam(), - "aes_key", result.aesKeyB64() + "aes_key", result.aesKeyB64(), + "encrypt_type", 1 ) )); @@ -539,6 +613,10 @@ public class ILinkClient { msg.put("context_token", contextToken); msg.put("item_list", List.of(fileItem)); + log.info("[weixin] sendFile message prepared: toUser={}, fileName={}, len={}, encryptParamLen={}, aesKeyLen={}", + maskId(toUserId), fileName, fileBytes.length, + result.encryptQueryParam() == null ? 0 : result.encryptQueryParam().length(), + result.aesKeyB64() == null ? 0 : result.aesKeyB64().length()); sendMessage(msg); } @@ -557,7 +635,8 @@ public class ILinkClient { videoItem.put("video_item", Map.of( "media", Map.of( "encrypt_query_param", result.encryptQueryParam(), - "aes_key", result.aesKeyB64() + "aes_key", result.aesKeyB64(), + "encrypt_type", 1 ) )); @@ -610,6 +689,128 @@ public class ILinkClient { return sb.toString(); } + private static String maskId(String value) { + if (value == null || value.isBlank()) { + return ""; + } + int head = Math.min(12, value.length()); + return value.substring(0, head) + "..."; + } + + private static String prefix(String value, int length) { + if (value == null || value.isBlank()) { + return ""; + } + return value.substring(0, Math.min(length, value.length())); + } + + private static String typeName(Object value) { + return value == null ? "null" : value.getClass().getSimpleName(); + } + + private static String redactUploadUrlPayload(Map body) { + try { + Map redacted = new LinkedHashMap<>(body); + redacted.put("to_user_id", maskId(String.valueOf(body.get("to_user_id")))); + redacted.put("filekey", prefix(String.valueOf(body.get("filekey")), 8) + "..."); + redacted.put("aeskey", "len:" + String.valueOf(body.get("aeskey")).length()); + return WIRE_OBJECT_MAPPER.writeValueAsString(redacted); + } catch (Exception e) { + return ""; + } + } + + private static String redactSendMessagePayload(Map body) { + try { + Map redacted = new LinkedHashMap<>(body); + Object msgObj = redacted.get("msg"); + if (msgObj instanceof Map msgMap) { + Map msg = new LinkedHashMap<>(); + msgMap.forEach((k, v) -> msg.put(String.valueOf(k), v)); + msg.put("to_user_id", maskId(String.valueOf(msg.get("to_user_id")))); + if (msg.containsKey("context_token")) { + msg.put("context_token", "present:" + !String.valueOf(msg.get("context_token")).isBlank()); + } + msg.put("item_list", redactItems(msg.get("item_list"))); + redacted.put("msg", msg); + } + return WIRE_OBJECT_MAPPER.writeValueAsString(redacted); + } catch (Exception e) { + return ""; + } + } + + private static Object redactItems(Object itemListObj) { + if (!(itemListObj instanceof List items)) { + return itemListObj; + } + List redacted = new ArrayList<>(); + for (Object itemObj : items) { + if (!(itemObj instanceof Map itemMap)) { + redacted.add(itemObj); + continue; + } + Map item = new LinkedHashMap<>(); + itemMap.forEach((k, v) -> item.put(String.valueOf(k), v)); + Object fileObj = item.get("file_item"); + if (fileObj instanceof Map fileMap) { + Map file = new LinkedHashMap<>(); + fileMap.forEach((k, v) -> file.put(String.valueOf(k), v)); + file.put("media", redactMedia(file.get("media"))); + item.put("file_item", file); + } + Object imageObj = item.get("image_item"); + if (imageObj instanceof Map imageMap) { + Map image = new LinkedHashMap<>(); + imageMap.forEach((k, v) -> image.put(String.valueOf(k), v)); + image.put("media", redactMedia(image.get("media"))); + item.put("image_item", image); + } + redacted.add(item); + } + return redacted; + } + + private static Object redactMedia(Object mediaObj) { + if (!(mediaObj instanceof Map mediaMap)) { + return mediaObj; + } + Map media = new LinkedHashMap<>(); + mediaMap.forEach((k, v) -> media.put(String.valueOf(k), v)); + media.put("encrypt_query_param", "len:" + String.valueOf(media.get("encrypt_query_param")).length()); + media.put("aes_key", "len:" + String.valueOf(media.get("aes_key")).length()); + return media; + } + + private static String summarizeItems(Object itemListObj) { + if (!(itemListObj instanceof List items)) { + return "not-list"; + } + List summaries = new ArrayList<>(); + for (Object itemObj : items) { + if (!(itemObj instanceof Map itemMap)) { + summaries.add("unknown"); + continue; + } + Object type = itemMap.get("type"); + Object fileObj = itemMap.get("file_item"); + if (fileObj instanceof Map fileMap) { + Object len = fileMap.get("len"); + summaries.add("type=" + type + ",file,len=" + len + "(" + typeName(len) + ")"); + continue; + } + Object imageObj = itemMap.get("image_item"); + if (imageObj instanceof Map imageMap) { + Object mediaObj = imageMap.get("media"); + Object midSize = mediaObj instanceof Map mediaMap ? mediaMap.get("mid_size") : null; + summaries.add("type=" + type + ",image,midSize=" + midSize + "(" + typeName(midSize) + ")"); + continue; + } + summaries.add("type=" + type); + } + return String.join(";", summaries); + } + // ==================== 内部模型 ==================== /** @@ -619,7 +820,8 @@ public class ILinkClient { * @param aesKeyB64 AES key 的 base64(hex) 编码(用于 media.aes_key) * @param fileSize 加密后文件大小 */ - public record UploadResult(String encryptQueryParam, String aesKeyB64, long fileSize) {} + public record UploadResult(String encryptQueryParam, String aesKeyB64, long fileSize, + String rawFileMd5, long rawSize) {} /** * QR 码登录结果 diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java index d7050524..e415d4d3 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java @@ -60,9 +60,6 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { public static final String CHANNEL_TYPE = "weixin"; - /** 消息去重最大记录数 */ - private static final int PROCESSED_IDS_MAX = 2000; - // ==================== 运行时状态 ==================== private ILinkClient client; @@ -99,14 +96,6 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { private static final long POLL_STUCK_THRESHOLD_MS = 90_000; private static final long WATCHDOG_INTERVAL_MS = 30_000; - /** 消息去重集合(LRU) */ - private final LinkedHashMap processedIds = new LinkedHashMap<>(256, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > PROCESSED_IDS_MAX; - } - }; - /** 用户最新 context_token 缓存(用于主动推送) */ private final ConcurrentHashMap userContextTokens = new ConcurrentHashMap<>(); @@ -155,24 +144,37 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { */ private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; + /** + * Channel-shared scrubber that converts agent-emitted + * {@code /api/v1/files/generated/{id}} URLs into native WeChat attachments. + * Nullable for legacy callers / unit tests — when null, the URL passes + * through unchanged (legacy text-only behaviour). + */ + private final vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber; + public WeixinChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { super(channelEntity, messageRouter, objectMapper); + this.generatedFileScrubber = null; } /** * Full constructor used by the production factory (ChannelManager). The * trailing {@code chatUploadLocationResolver} enables workspace/agent-aware * attachment storage; {@code null} keeps the legacy {@code data/chat-uploads} - * behaviour. + * behaviour. The {@code generatedFileScrubber} upgrades + * {@code /api/v1/files/generated/{id}} URLs in agent replies into native + * WeChat file/image attachments. */ public WeixinChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper, - vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) { + vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver, + vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber) { super(channelEntity, messageRouter, objectMapper); this.chatUploadLocationResolver = chatUploadLocationResolver; + this.generatedFileScrubber = generatedFileScrubber; } @Override @@ -409,15 +411,18 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { return; } - // 去重 + // Stable inbound identity for this channel: context_token when the + // platform supplies one (it survives redelivery where msg_id does not), + // else sender + msg_id. Carried on the ChannelMessage as messageId so + // the router claims exactly this key — see inboundIdentity(). String dedupKey = !contextToken.isBlank() ? contextToken : fromUserId + "_" + getStr(msg, "msg_id"); - synchronized (processedIds) { - if (processedIds.containsKey(dedupKey)) { - log.debug("[weixin] Duplicate message skipped: {}", dedupKey.substring(0, Math.min(40, dedupKey.length()))); - return; - } - processedIds.put(dedupKey, Boolean.TRUE); + // Early duplicate gate: the authoritative claim happens once in + // ChannelMessageRouter.enqueue, but parsing below downloads media, so + // a known redelivery is dropped before paying for that. + if (messageRouter.isDuplicateInbound(channelEntity.getId(), dedupKey)) { + log.debug("[weixin] Duplicate message skipped: {}", dedupKey.substring(0, Math.min(40, dedupKey.length()))); + return; } // 解析消息内容 @@ -631,7 +636,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { String replyToken = contextToken + "|" + fromUserId; ChannelMessage channelMessage = ChannelMessage.builder() - .messageId(getStr(msg, "msg_id")) + // dedupKey, not the raw msg_id: it is this channel's stable + // inbound identity and the router claims exactly this value. + .messageId(dedupKey) .channelType(CHANNEL_TYPE) .senderId(fromUserId) .senderName(fromUserId) // iLink API 不提供昵称 @@ -744,13 +751,27 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { // 停止输入提示 stopTyping(toUserId); + // 文本 part 里可能携带 /api/v1/files/generated/{id} URL,需先 scrub。 + // 收集所有命中的附件,待文本全部发完后再统一推送(保持"文本在前,附件在后"的顺序) + List deferredAttachments = + new java.util.ArrayList<>(); + for (MessageContentPart part : parts) { if (part == null) continue; try { switch (part.getType()) { case "text" -> { if (part.getText() != null && !part.getText().isBlank()) { - client.sendText(toUserId, part.getText(), contextToken); + String textToSend = part.getText(); + if (generatedFileScrubber != null) { + vip.mate.channel.media.GeneratedFileScrubber.ScrubResult scrubbed = + generatedFileScrubber.scrub(part.getText()); + textToSend = scrubbed.rewrittenText(); + deferredAttachments.addAll(scrubbed.attachments()); + } + if (!textToSend.isBlank()) { + client.sendText(toUserId, textToSend, contextToken); + } } } case "image" -> sendImagePart(toUserId, contextToken, part); @@ -769,17 +790,36 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { sendFallbackText(targetId, part); } } + + // 推送文本 part 里 scrub 出来的原生附件 + sendAttachmentHits(toUserId, contextToken, deferredAttachments); } @Override public void renderAndSend(String targetId, String content) { // 停止输入提示 String[] split = targetId.split("\\|", 2); + String contextToken = split.length > 0 ? split[0] : ""; String toUserId = split.length > 1 ? split[1] : ""; if (!toUserId.isBlank()) { stopTyping(toUserId); } + // 扫描 /api/v1/files/generated/{id} URL → 替换为 "📎 filename" 标记 + 收集附件字节 + // 缺失此步会让 LLM 回复里的 URL 作为纯文本发到微信,用户无法点击下载 + List attachments = List.of(); + String rewrittenContent = content; + if (generatedFileScrubber != null && content != null && !content.isBlank()) { + vip.mate.channel.media.GeneratedFileScrubber.ScrubResult scrubbed = + generatedFileScrubber.scrub(content); + rewrittenContent = scrubbed.rewrittenText(); + attachments = scrubbed.attachments(); + if (!attachments.isEmpty()) { + log.info("[weixin] renderAndSend: scrubbed {} attachment(s) from content", + attachments.size()); + } + } + // 调用父类默认渲染逻辑 boolean filterThinking = getConfigBoolean("filter_thinking", true); boolean filterToolMessages = getConfigBoolean("filter_tool_messages", true); @@ -787,10 +827,57 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { int maxLen = vip.mate.channel.ChannelMessageRenderer.PLATFORM_LIMITS.getOrDefault(getChannelType(), 2048); List segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel( - content, filterThinking, filterToolMessages, format, maxLen); + rewrittenContent, filterThinking, filterToolMessages, format, maxLen); for (String segment : segments) { sendMessage(targetId, segment); } + + // 文本发完后,把缓存里的字节作为原生附件推送(与 WeCom/Feishu 行为对齐) + sendAttachmentHits(toUserId, contextToken, attachments); + } + + /** + * 把 {@link GeneratedFileScrubber} 抓取到的附件字节,通过 iLink 原生 + * file/image 通道发送给微信用户。图片走 {@link ILinkClient#sendImage}, + * 其他类型走 {@link ILinkClient#sendFile}(保留原始 fileName)。 + * 单个附件失败不阻断后续附件,仅记录 error 日志。 + */ + private void sendAttachmentHits(String toUserId, String contextToken, + List attachments) { + if (attachments == null || attachments.isEmpty() || client == null + || toUserId == null || toUserId.isBlank() + || contextToken == null || contextToken.isBlank()) { + log.info("[weixin] sendAttachmentHits skipped: attachments={}, clientReady={}, toUserPresent={}, contextTokenPresent={}", + attachments == null ? 0 : attachments.size(), client != null, + toUserId != null && !toUserId.isBlank(), + contextToken != null && !contextToken.isBlank()); + return; + } + log.info("[weixin] sendAttachmentHits begin: count={}, toUser={}, contextTokenPresent={}", + attachments.size(), toUserId.substring(0, Math.min(12, toUserId.length())), + !contextToken.isBlank()); + for (vip.mate.channel.media.GeneratedFileScrubber.AttachmentHit hit : attachments) { + try { + log.info("[weixin] sendAttachmentHit: mediaType={}, fileName={}, mimeType={}, bytes={}", + hit.mediaType(), hit.fileName(), hit.mimeType(), + hit.bytes() == null ? 0 : hit.bytes().length); + if ("image".equals(hit.mediaType())) { + client.sendImage(toUserId, hit.bytes(), contextToken); + log.info("[weixin] Generated image sent to {}: {} ({}bytes)", + toUserId.substring(0, Math.min(12, toUserId.length())), + hit.fileName(), hit.bytes().length); + } else { + client.sendFile(toUserId, hit.bytes(), hit.fileName(), contextToken); + log.info("[weixin] Generated file sent to {}: {} ({}bytes)", + toUserId.substring(0, Math.min(12, toUserId.length())), + hit.fileName(), hit.bytes().length); + } + } catch (Exception e) { + log.error("[weixin] Failed to send generated attachment {} to {}: {}", + hit.fileName(), toUserId.substring(0, Math.min(12, toUserId.length())), + e.getMessage(), e); + } + } } // ==================== 媒体上传发送 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/datasource/service/DatasourceService.java b/mateclaw-server/src/main/java/vip/mate/datasource/service/DatasourceService.java index 1c72747d..01295d34 100644 --- a/mateclaw-server/src/main/java/vip/mate/datasource/service/DatasourceService.java +++ b/mateclaw-server/src/main/java/vip/mate/datasource/service/DatasourceService.java @@ -109,6 +109,7 @@ public class DatasourceService { // 更新测试结果 entity.setLastTestTime(LocalDateTime.now()); entity.setLastTestOk(ok); + encryptPassword(entity); datasourceMapper.updateById(entity); return ok; } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiModelsPath.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiModelsPath.java new file mode 100644 index 00000000..17eae75a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiModelsPath.java @@ -0,0 +1,50 @@ +package vip.mate.llm.chatmodel; + +import org.springframework.util.StringUtils; + +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Single source of truth for the OpenAI-compatible models-listing path. + * + *

    Both the discovery flow ({@code ModelDiscoveryService}) and the failover + * liveness probe ({@code OpenAiCompatibleListModelsProbe}) list a provider's + * models to do their jobs. Keeping the path resolution here means an operator's + * {@code modelsPath} override is honored identically by both — otherwise a + * self-hosted endpoint behind a non-standard prefix could be discoverable yet + * still marked unhealthy by a probe hitting the wrong hard-coded path. + * + *

    Resolution order: + *

      + *
    1. an explicit {@code modelsPath} in {@code generateKwargs} — used verbatim + * (leading slash added if missing), for reverse-proxy / gateway prefixes + * such as {@code /openai/v1/models};
    2. + *
    3. otherwise {@code /v1/models}, collapsed to {@code /models} when the base + * URL already ends in a {@code /v{N}} segment (LM Studio {@code /v1}, + * Zhipu {@code /v4}, Volcano Ark {@code /api/v3}) to avoid {@code /vN/v1/models}.
    4. + *
    + * Mirrors the sibling {@code completionsPath} override on the chat path. + */ +public final class OpenAiModelsPath { + + /** Trailing {@code /v{N}} segment on a base URL (any numeric major version). */ + private static final Pattern VERSION_SUFFIX = Pattern.compile(".*/v\\d+$"); + + private OpenAiModelsPath() {} + + public static String resolve(String baseUrl, Map kwargs) { + if (kwargs != null) { + Object raw = kwargs.get("modelsPath"); + if (raw instanceof String value && StringUtils.hasText(value)) { + String path = value.trim(); + return path.startsWith("/") ? path : "/" + path; + } + } + String path = "/v1/models"; + if (baseUrl != null && VERSION_SUFFIX.matcher(baseUrl).matches()) { + path = "/models"; + } + return path; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/AvailableProviderPool.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/AvailableProviderPool.java index 0d7c4ae9..c6fe6864 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/AvailableProviderPool.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/AvailableProviderPool.java @@ -1,6 +1,7 @@ package vip.mate.llm.failover; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.time.Instant; @@ -17,17 +18,28 @@ import java.util.concurrent.ConcurrentHashMap; *

    Two state transitions:

    *
      *
    • Add — at startup ({@code ProviderInitProbe}), on user-triggered - * reprobe, or after a {@code ModelConfigChangedEvent}.
    • + * reprobe, after a {@code ModelConfigChangedEvent}, or lazily when a + * TTL'd removal expires (see below). *
    • Remove — when a request hits a provider-wide HARD error - * (AUTH_ERROR / BILLING) — these don't self-heal, so retrying on - * every subsequent call wastes the user's time. SOFT errors - * (RATE_LIMIT / SERVER_ERROR / EMPTY_RESPONSE) keep the provider in - * the pool and are handled by {@link ProviderHealthTracker}'s short - * cooldown instead. A rejected model id (MODEL_NOT_FOUND) is - * model-scoped, not provider-scoped, and never evicts the provider — - * its sibling models stay usable.
    • + * (AUTH_ERROR / BILLING) — retrying on every subsequent call wastes + * the user's time. SOFT errors (RATE_LIMIT / SERVER_ERROR / + * EMPTY_RESPONSE) keep the provider in the pool and are handled by + * {@link ProviderHealthTracker}'s short cooldown instead. A rejected + * model id (MODEL_NOT_FOUND) is model-scoped, not provider-scoped, and + * never evicts the provider — its sibling models stay usable. *
    * + *

    TTL readmission: AUTH_ERROR and BILLING removals are not + * permanent. Users top up balances, aggregator quotas refresh, and providers + * have transient 401 flaps — none of which the process can observe. Each + * removal carries a {@code readmitAtMs} deadline (from + * {@link ProviderHealthProperties}); once it passes, the next + * {@link #contains} check lazily readmits the provider. No pre-readmission + * probe: the first real call is the probe, and a still-broken provider is + * simply re-evicted (self-correcting). INIT_PROBE and MANUAL removals never + * auto-readmit — the former means the configuration itself is broken, the + * latter is explicit operator intent.

    + * *

    State is process-local; a restart re-runs the init probe. That's * intentional — full distributed coordination is out of scope for v1 * (single-node and desktop deployments are the primary targets).

    @@ -46,6 +58,18 @@ public class AvailableProviderPool { */ private final Map removalReasons = new ConcurrentHashMap<>(); + private final ProviderHealthProperties props; + + @Autowired + public AvailableProviderPool(ProviderHealthProperties props) { + this.props = props != null ? props : new ProviderHealthProperties(); + } + + /** Convenience constructor with default readmission TTLs. Used by tests. */ + public AvailableProviderPool() { + this(new ProviderHealthProperties()); + } + /** Add (or re-add) a provider to the pool. Clears any prior removal reason. */ public void add(String providerId) { if (providerId == null || providerId.isEmpty()) return; @@ -60,24 +84,53 @@ public class AvailableProviderPool { /** * Remove a provider from the pool with a reason. Idempotent — calling - * twice updates the reason (so the latest cause wins) but doesn't double-log. + * twice updates the reason (so the latest cause wins and the readmission + * TTL restarts from the latest incident) but doesn't double-log. */ public void remove(String providerId, RemovalSource source, String message) { if (providerId == null || providerId.isEmpty()) return; boolean wasMember = members.remove(providerId); - RemovalReason reason = new RemovalReason(source, message, Instant.now().toEpochMilli()); + long now = Instant.now().toEpochMilli(); + long ttl = readmitDelayMs(source); + RemovalReason reason = new RemovalReason(source, message, now, ttl > 0 ? now + ttl : 0); removalReasons.put(providerId, reason); if (wasMember) { - log.warn("[Pool] removing provider={} due to {} ({})", providerId, source, message); + log.warn("[Pool] removing provider={} due to {} ({}){}", providerId, source, message, + ttl > 0 ? " — auto-readmission in " + (ttl / 1000) + "s" : ""); } else { log.debug("[Pool] removal reason updated for already-out provider={}: {} ({})", providerId, source, message); } } - /** Membership check — the walker / primary short-circuit consults this on every entry. */ + /** + * Membership check — the walker / primary short-circuit consults this on + * every entry. Lazily readmits a provider whose removal TTL has expired, + * so recovery needs no scheduler thread and no user action. + */ public boolean contains(String providerId) { - return providerId != null && members.contains(providerId); + if (providerId == null) return false; + if (members.contains(providerId)) return true; + RemovalReason reason = removalReasons.get(providerId); + if (reason != null && reason.readmitAtMs() > 0 + && Instant.now().toEpochMilli() >= reason.readmitAtMs()) { + log.info("[Pool] readmitting provider={} — {} removal TTL expired (removed {}s ago)", + providerId, reason.source(), + (Instant.now().toEpochMilli() - reason.removedAtMs()) / 1000); + add(providerId); + return true; + } + return false; + } + + /** Readmission TTL for a removal source; 0 = never auto-readmit. */ + private long readmitDelayMs(RemovalSource source) { + if (source == null) return 0; + return switch (source) { + case BILLING -> props.getBillingReadmitMs(); + case AUTH_ERROR -> props.getAuthReadmitMs(); + case INIT_PROBE, MANUAL -> 0; + }; } /** @@ -105,8 +158,13 @@ public class AvailableProviderPool { // Records // ============================================================ - /** Why a provider was removed from the pool. {@code removedAtMs} is epoch milliseconds. */ - public record RemovalReason(RemovalSource source, String message, long removedAtMs) {} + /** + * Why a provider was removed from the pool. {@code removedAtMs} is epoch + * milliseconds; {@code readmitAtMs} is the epoch-millisecond deadline + * after which {@link #contains} lazily readmits the provider ({@code 0} + * = never auto-readmitted). + */ + public record RemovalReason(RemovalSource source, String message, long removedAtMs, long readmitAtMs) {} /** * Categorical source of a pool removal. Covers the provider-wide HARD diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderHealthProperties.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderHealthProperties.java index 3b84f2b6..5b4ea1b3 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderHealthProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderHealthProperties.java @@ -13,6 +13,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * enabled: true * failure-threshold: 3 # consecutive failures before cooldown * cooldown-ms: 300000 # 5 minutes + * billing-readmit-ms: 3600000 # auto-readmit a BILLING-evicted provider after 1h (0 = never) + * auth-readmit-ms: 1800000 # auto-readmit an AUTH-evicted provider after 30min (0 = never) * */ @ConfigurationProperties(prefix = "mateclaw.llm.failover.health") @@ -27,6 +29,22 @@ public class ProviderHealthProperties { /** Cooldown window in milliseconds. */ private long cooldownMs = 300_000L; + /** + * TTL after which a provider HARD-removed for BILLING is lazily readmitted + * to the available pool. Users top up balances and aggregator quotas + * refresh hourly — without this, recovery requires a manual reprobe or a + * process restart. {@code 0} disables auto-readmission. + */ + private long billingReadmitMs = 3_600_000L; + + /** + * TTL after which a provider HARD-removed for AUTH_ERROR is lazily + * readmitted. Bounds the damage of provider-side 401 flaps; a genuinely + * bad key just gets re-evicted by its first post-readmission call. + * {@code 0} disables auto-readmission. + */ + private long authReadmitMs = 1_800_000L; + public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } @@ -39,4 +57,14 @@ public class ProviderHealthProperties { public void setCooldownMs(long cooldownMs) { this.cooldownMs = Math.max(1000, cooldownMs); } + + public long getBillingReadmitMs() { return billingReadmitMs; } + public void setBillingReadmitMs(long billingReadmitMs) { + this.billingReadmitMs = Math.max(0, billingReadmitMs); + } + + public long getAuthReadmitMs() { return authReadmitMs; } + public void setAuthReadmitMs(long authReadmitMs) { + this.authReadmitMs = Math.max(0, authReadmitMs); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderHealthTracker.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderHealthTracker.java index b0dd24ee..eb1a5e2a 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderHealthTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderHealthTracker.java @@ -62,14 +62,46 @@ public class ProviderHealthTracker { return true; } + /** + * Upper bound for a provider-supplied cooldown override. Guards against + * a provider returning an absurd {@code Retry-After} (misconfigured proxy, + * clock-skewed reset timestamp) locking a provider out for days. + */ + static final long MAX_COOLDOWN_OVERRIDE_MS = 2 * 60 * 60 * 1000L; + /** * Record a single failure against {@code providerId}. When the counter * reaches the configured threshold, the provider enters cooldown. */ public void recordFailure(String providerId) { + recordFailure(providerId, 0); + } + + /** + * Record a failure with an optional provider-supplied cooldown override + * (milliseconds), typically parsed from a 429 response's + * {@code Retry-After} / rate-limit reset headers. + * + *

    When {@code cooldownOverrideMs > 0} the provider has stated exactly + * when capacity returns, so the cooldown starts immediately — + * waiting for {@link ProviderHealthProperties#getFailureThreshold} more + * consecutive failures would burn extra calls against a window the + * provider already announced. The override is clamped to + * {@link #MAX_COOLDOWN_OVERRIDE_MS} and never shortens an active + * cooldown.

    + */ + public void recordFailure(String providerId, long cooldownOverrideMs) { if (!props.isEnabled() || providerId == null) return; AtomicLong counter = consecutiveFailures.computeIfAbsent(providerId, k -> new AtomicLong()); long failures = counter.incrementAndGet(); + if (cooldownOverrideMs > 0) { + long clamped = Math.min(cooldownOverrideMs, MAX_COOLDOWN_OVERRIDE_MS); + long cooldownEnd = System.currentTimeMillis() + clamped; + cooldownUntilMs.merge(providerId, cooldownEnd, Math::max); + log.warn("[ProviderHealth] provider={} entering cooldown for {}s (provider-stated retry window)", + providerId, clamped / 1000); + return; + } if (failures >= props.getFailureThreshold()) { long cooldownEnd = System.currentTimeMillis() + props.getCooldownMs(); cooldownUntilMs.put(providerId, cooldownEnd); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbe.java index ad62ba2d..a66513d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbe.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbe.java @@ -1,5 +1,7 @@ package vip.mate.llm.failover.probe; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -9,6 +11,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.RestClient; +import vip.mate.llm.chatmodel.OpenAiModelsPath; import vip.mate.llm.failover.ProbeResult; import vip.mate.llm.failover.ProviderProbeStrategy; import vip.mate.llm.model.ModelProtocol; @@ -16,7 +19,7 @@ import vip.mate.llm.model.ModelProviderEntity; import java.net.http.HttpClient; import java.time.Duration; -import java.util.regex.Pattern; +import java.util.Map; /** * Probes an OpenAI-compatible provider by listing its models. @@ -24,14 +27,11 @@ import java.util.regex.Pattern; *

    Two non-trivial things this implementation handles:

    * *
      - *
    1. Path construction. Different vendors set Base URL to different - * depths: OpenAI/DeepSeek/Kimi point at the API root - * ({@code https://api.openai.com}) while LMStudio / ZhipuAI bake the - * version segment in ({@code http://localhost:1234/v1}, - * {@code https://open.bigmodel.cn/api/paas/v4}). We append {@code /models} - * when the URL already ends with a {@code /vN} segment, otherwise - * {@code /v1/models}. Without this we'd hit {@code /v1/v1/models} on - * LMStudio and {@code /v4/v1/models} on Zhipu — both 404.
    2. + *
    3. Path construction. Delegated to {@link OpenAiModelsPath} so the + * probe lists the exact same endpoint discovery does — including an + * operator's {@code modelsPath} override for non-standard gateway prefixes. + * Without sharing, a provider could be discoverable yet still marked + * unhealthy here by a probe hitting the wrong hard-coded path.
    4. * *
    5. Permissive 4xx/5xx handling. Not every OpenAI-compatible * vendor implements {@code /models}. Kimi for Coding returns a @@ -49,8 +49,11 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy { /** Conservative HTTP timeout — keeps a stalled provider from holding up the parallel batch. */ private static final Duration TIMEOUT = Duration.ofSeconds(5); - /** Matches a trailing {@code /v1}, {@code /v2}, ..., {@code /v99} segment on the base URL. */ - private static final Pattern VERSION_SUFFIX = Pattern.compile("/v\\d{1,2}$"); + private final ObjectMapper objectMapper; + + public OpenAiCompatibleListModelsProbe(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } @Override public ModelProtocol supportedProtocol() { @@ -63,7 +66,7 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy { return ProbeResult.fail(0, "base URL not configured"); } String baseUrl = stripTrailingSlash(provider.getBaseUrl().trim()); - String modelsPath = resolveModelsPath(baseUrl); + String modelsPath = OpenAiModelsPath.resolve(baseUrl, parseKwargs(provider.getGenerateKwargs())); long start = System.currentTimeMillis(); try { HttpClient httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); @@ -124,15 +127,21 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy { } /** - * Pick the right path to append. If the base URL already ends in a {@code /vN} - * version segment, append only {@code /models}. Otherwise append {@code /v1/models}. - * Package-private so the unit test can exercise it directly. + * Parse the provider's {@code generateKwargs} JSON into a map so a + * {@code modelsPath} override is visible to {@link OpenAiModelsPath}. Returns + * an empty map on null / blank / malformed JSON — a bad kwargs blob must not + * knock a provider out of the pool; it simply falls back to the default path. */ - static String resolveModelsPath(String baseUrl) { - if (baseUrl != null && VERSION_SUFFIX.matcher(baseUrl).find()) { - return "/models"; + private Map parseKwargs(String json) { + if (!StringUtils.hasText(json)) { + return Map.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.debug("[Probe] ignoring unparseable generateKwargs: {}", e.getMessage()); + return Map.of(); } - return "/v1/models"; } private static String stripTrailingSlash(String url) { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java index 8d9017dd..471fa050 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java @@ -4,24 +4,30 @@ import java.util.Arrays; public enum ModelProtocol { - OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel"), - OPENAI_CHATGPT("openai-chatgpt", "ChatGPTChatModel"), - ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel"), + OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel", true), + // OAuth-based: discovery relies on a separately established OAuth session + // (stored, auto-refreshed access token) rather than the provider row's + // baseUrl/apiKey, so a self-configured custom provider cannot drive it. + OPENAI_CHATGPT("openai-chatgpt", "ChatGPTChatModel", false), + ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel", true), /** * RFC-062: same Anthropic Messages API but authenticated with the user's * Claude Code OAuth token (Pro/Max subscription) instead of an API key. - * Routed by {@code ClaudeCodeChatModelBuilder}. + * Routed by {@code ClaudeCodeChatModelBuilder}. Has no discovery endpoint + * (fixed, Flyway-seeded catalog), so discovery is unsupported. */ - ANTHROPIC_CLAUDE_CODE("anthropic-claude-code", "ClaudeCodeChatModel"), - GEMINI_NATIVE("gemini-native", "GeminiChatModel"), - DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel"); + ANTHROPIC_CLAUDE_CODE("anthropic-claude-code", "ClaudeCodeChatModel", false), + GEMINI_NATIVE("gemini-native", "GeminiChatModel", true), + DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel", true); private final String id; private final String chatModelClass; + private final boolean supportsSelfConfiguredDiscovery; - ModelProtocol(String id, String chatModelClass) { + ModelProtocol(String id, String chatModelClass, boolean supportsSelfConfiguredDiscovery) { this.id = id; this.chatModelClass = chatModelClass; + this.supportsSelfConfiguredDiscovery = supportsSelfConfiguredDiscovery; } public String getId() { @@ -32,6 +38,23 @@ public enum ModelProtocol { return chatModelClass; } + /** + * Whether a self-configured provider (baseUrl + apiKey) of this protocol can + * drive model discovery. Used to decide the default {@code supportModelDiscovery} + * flag for user-created custom providers. OAuth-based protocols return false: + * their discovery hangs off a separately established OAuth session, not the + * provider row's baseUrl/apiKey. + * + *

      Note: this is narrower than "can this protocol ever discover" — + * the built-in ChatGPT-OAuth provider does discover (via its OAuth + * session) yet this returns false for {@code OPENAI_CHATGPT}. Do not reuse + * this to gate the discover button in general; it answers only the custom- + * provider default. + */ + public boolean supportsSelfConfiguredDiscovery() { + return supportsSelfConfiguredDiscovery; + } + public static ModelProtocol fromChatModel(String chatModel) { if (chatModel == null || chatModel.isBlank()) { return OPENAI_COMPATIBLE; @@ -52,13 +75,24 @@ public enum ModelProtocol { .orElse(OPENAI_COMPATIBLE); } - public static String resolveChatModel(String protocolId, String chatModel) { + /** + * Resolve the effective protocol from an explicit protocol id, falling back + * to inference from the chat-model class, and finally to + * {@link #OPENAI_COMPATIBLE}. Single source of truth so callers can derive + * both the chat-model class and capability flags (e.g. {@link #supportsSelfConfiguredDiscovery()}) + * from one consistent resolution. + */ + public static ModelProtocol resolve(String protocolId, String chatModel) { if (protocolId != null && !protocolId.isBlank()) { - return fromId(protocolId).getChatModelClass(); + return fromId(protocolId); } if (chatModel != null && !chatModel.isBlank()) { - return fromChatModel(chatModel).getChatModelClass(); + return fromChatModel(chatModel); } - return OPENAI_COMPATIBLE.getChatModelClass(); + return OPENAI_COMPATIBLE; + } + + public static String resolveChatModel(String protocolId, String chatModel) { + return resolve(protocolId, chatModel).getChatModelClass(); } } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java index df18b4ec..ca6ebf2f 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; import vip.mate.exception.MateClawException; +import vip.mate.llm.chatmodel.OpenAiModelsPath; import vip.mate.llm.model.*; import vip.mate.llm.oauth.OpenAIOAuthService; @@ -461,12 +462,12 @@ public class ModelDiscoveryService { .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .build(); - RestClient.RequestHeadersSpec spec = client.get().uri(resolveModelsPath(baseUrl)); + Map kwargs = modelProviderService.readProviderGenerateKwargs(provider); + RestClient.RequestHeadersSpec spec = client.get().uri(OpenAiModelsPath.resolve(baseUrl, kwargs)); if (modelProviderService.hasUsableApiKey(apiKey)) { spec = spec.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim()); } // Apply any custom headers declared in generateKwargs. - Map kwargs = modelProviderService.readProviderGenerateKwargs(provider); applyCustomHeaders(spec, kwargs); String body = spec.retrieve().body(String.class); @@ -931,19 +932,6 @@ public class ModelDiscoveryService { return path; } - /** - * Resolve the OpenAI-compatible {@code /v1/models} path against a base URL, - * stripping the {@code /v1} prefix when the base already carries a {@code /v{N}} - * suffix (Volcano Engine Ark, etc.). - */ - private String resolveModelsPath(String baseUrl) { - String path = "/v1/models"; - if (baseUrl != null && BASE_URL_VERSION_SUFFIX.matcher(baseUrl).matches()) { - path = "/models"; - } - return path; - } - private String normalizeBaseUrl(String baseUrl) { if (!StringUtils.hasText(baseUrl)) { return null; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 03ed03b1..8b05972b 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -157,11 +157,12 @@ public class ModelProviderService { if (modelProviderMapper.selectById(request.getId()) != null) { throw new MateClawException("err.llm.provider_exists", "Provider 已存在: " + request.getId()); } + ModelProtocol protocol = ModelProtocol.resolve(request.getProtocol(), request.getChatModel()); ModelProviderEntity provider = new ModelProviderEntity(); provider.setProviderId(request.getId()); provider.setName(request.getName()); provider.setApiKeyPrefix(request.getApiKeyPrefix()); - provider.setChatModel(ModelProtocol.resolveChatModel(request.getProtocol(), request.getChatModel())); + provider.setChatModel(protocol.getChatModelClass()); provider.setBaseUrl(request.getDefaultBaseUrl()); provider.setGenerateKwargs("{}"); provider.setIsCustom(true); @@ -169,7 +170,12 @@ public class ModelProviderService { // RFC-074: custom providers are user-created, so opt them in by default // — the user just made the row, no need to make them flip a second toggle. provider.setEnabled(true); - provider.setSupportModelDiscovery(false); + // Default model discovery on for protocols whose discovery works from a + // self-configured baseUrl+apiKey (OpenAI-compatible, DashScope, Gemini, + // Anthropic). Previously hard-coded false, which left self-hosted + // OpenAI-compatible endpoints (vLLM/Xinference/LocalAI/…) unable to + // surface the "discover models" button at all. OAuth protocols stay off. + provider.setSupportModelDiscovery(protocol.supportsSelfConfiguredDiscovery()); provider.setSupportConnectionCheck(false); provider.setFreezeUrl(false); provider.setRequireApiKey(request.getRequireApiKey() == null || Boolean.TRUE.equals(request.getRequireApiKey())); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactContradictionEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactContradictionEntity.java index b2e8b6c0..0a12ee29 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactContradictionEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactContradictionEntity.java @@ -14,7 +14,7 @@ import java.time.LocalDateTime; @TableName("mate_fact_contradiction") public class FactContradictionEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long agentId; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntity.java index 0fb38a1e..6ed22308 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntity.java @@ -17,7 +17,7 @@ import java.time.LocalDateTime; @TableName("mate_fact") public class FactEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long agentId; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java index f52f28a6..c9625b09 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java @@ -61,7 +61,7 @@ public class MemoryLifecycleMediator { public void afterLlmCall(TurnContext ctx, String assistantReply) { try { memoryManager.syncAll(ctx.agentId(), ctx.conversationId(), - ctx.userQuery(), assistantReply); + ctx.userQuery(), assistantReply, ctx.ownerKey()); events.publishEvent(new TurnCompletedEvent(ctx, assistantReply)); log.debug("[Memory] afterLlmCall: agent={}, conv={}, replyLen={}", ctx.agentId(), ctx.conversationId(), assistantReply != null ? assistantReply.length() : 0); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java index df388dfe..62ad1c24 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java @@ -14,7 +14,7 @@ import java.time.LocalDateTime; @TableName("mate_morning_card_seen") public class MorningCardSeenEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long userId; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java index bbbcf0b7..c5c8e302 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java @@ -189,9 +189,19 @@ public class MemoryManager { */ public void syncAll(Long agentId, String conversationId, String userMessage, String assistantReply) { + syncAll(agentId, conversationId, userMessage, assistantReply, null); + } + + /** + * Owner-scoped post-turn sync. Passes the same resolved memory + * {@code ownerKey} that prefetch used, so owner-aware providers persist + * the turn under the identifier their recall path queries by. + */ + public void syncAll(Long agentId, String conversationId, + String userMessage, String assistantReply, String ownerKey) { for (MemoryProvider provider : providers) { try { - provider.syncTurn(agentId, conversationId, userMessage, assistantReply); + provider.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey); } catch (Exception e) { log.warn("[MemoryManager] Provider '{}' syncTurn failed: {}", provider.id(), e.getMessage()); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java index 00595074..262c0c32 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java @@ -87,6 +87,24 @@ public interface MemoryProvider { String userMessage, String assistantReply) { } + /** + * Owner-scoped post-turn sync. Providers that isolate memory per end-user + * override this to persist the turn under the same {@code ownerKey} that + * owner-scoped prefetch recalls by. Default delegates to + * {@link #syncTurn(Long, String, String, String)} for providers that are + * not owner-aware. + * + * @param agentId the agent ID + * @param conversationId the conversation ID + * @param userMessage user's message text + * @param assistantReply assistant's reply text + * @param ownerKey resolved memory owner key (e.g. "user:42"); may be null + */ + default void syncTurn(Long agentId, String conversationId, + String userMessage, String assistantReply, String ownerKey) { + syncTurn(agentId, conversationId, userMessage, assistantReply); + } + /** * Spring AI @Tool beans this provider wants to expose to the agent. * These are collected by MemoryManager and added to the tool set. diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java index 1617c0be..d7480fa0 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java @@ -28,6 +28,9 @@ public abstract class MemoryProviderDecorator implements MemoryProvider { @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); } + @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply, String ownerKey) { + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey); + } @Override public List getToolBeans() { return delegate.getToolBeans(); } @Override public void onSessionEnd(Long agentId, String conversationId) { delegate.onSessionEnd(agentId, conversationId); } @Override public String onPreCompress(Long agentId, List messages) { return delegate.onPreCompress(agentId, messages); } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java index 04d76382..56ce7f98 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java @@ -58,9 +58,14 @@ public class MetricsMemoryProvider extends MemoryProviderDecorator { @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { + syncTurn(agentId, conversationId, userMessage, assistantReply, null); + } + + @Override + public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply, String ownerKey) { syncTimer.record(() -> { try { - delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey); } catch (Exception e) { meterRegistry.counter("memory.sync.failures", "provider", delegate.id()).increment(); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java index fd99dd1c..ac584215 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java @@ -45,10 +45,15 @@ public class RetryableMemoryProvider extends MemoryProviderDecorator { @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { + syncTurn(agentId, conversationId, userMessage, assistantReply, null); + } + + @Override + public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply, String ownerKey) { Exception lastException = null; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { - delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey); return; } catch (Exception e) { lastException = e; diff --git a/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java b/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java index fac3ac82..ab550b1c 100644 --- a/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java +++ b/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java @@ -127,6 +127,35 @@ public class PlanningService { } } + /** + * Park a plan whose steps were handed off to a team task board. The plan + * stays in this status while board tasks execute; any later inbound + * message resumes it through the delegated-plan gate. + */ + public void markPlanDelegated(Long planId) { + PlanEntity plan = planMapper.selectById(planId); + if (plan != null) { + plan.setStatus("delegated"); + planMapper.updateById(plan); + } + } + + /** + * Latest board-delegated plan parked on this conversation, or null. The + * counterpart of {@link #findAwaitingApprovalContext} for the team + * hand-off flow: park in the DB, resume from the DB. + */ + public PlanEntity findDelegatedPlan(String conversationId) { + if (conversationId == null || conversationId.isBlank()) { + return null; + } + return planMapper.selectOne(new LambdaQueryWrapper() + .eq(PlanEntity::getConversationId, conversationId) + .eq(PlanEntity::getStatus, "delegated") + .orderByDesc(PlanEntity::getCreateTime) + .last("LIMIT 1")); + } + /** * 完成计划 */ diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java index 23ce8c45..b6c9d581 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/bridge/PluginMemoryBridge.java @@ -45,12 +45,27 @@ public class PluginMemoryBridge implements MemoryProvider { return delegate.prefetch(agentId, userQuery); } + @Override + public String prefetch(Long agentId, String userQuery, String ownerKey) { + // Forward ownerKey to the plugin provider; plugins that don't override the + // three-arg variant fall back to the two-arg default (ownerKey dropped). + return delegate.prefetch(agentId, userQuery, ownerKey); + } + @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); } + @Override + public void syncTurn(Long agentId, String conversationId, + String userMessage, String assistantReply, String ownerKey) { + // Forward ownerKey to the plugin provider; plugins that don't override the + // five-arg variant fall back to the four-arg default (ownerKey dropped). + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey); + } + @Override public List getToolBeans() { List beans = delegate.getToolBeans(); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index bbee1d22..a6981e99 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -17,8 +17,11 @@ import vip.mate.skill.lessons.SkillLessonsService; import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.runtime.SkillDependencyChecker; +import vip.mate.skill.runtime.SkillPackageResolver; import vip.mate.skill.runtime.SkillCatalogSort; import vip.mate.skill.runtime.SkillCatalogSorter; +import vip.mate.skill.model.SkillFileEntity; +import vip.mate.skill.service.SkillFileService; import vip.mate.skill.service.SkillService; import vip.mate.skill.synthesis.SkillSynthesisService; import vip.mate.skill.runtime.SkillRuntimeService; @@ -26,6 +29,7 @@ import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.BundledSkillSyncer; import vip.mate.skill.workspace.SkillFileSyncer; import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.skill.workspace.bundle.SkillBundleFiles; import vip.mate.exception.MateClawException; import vip.mate.skill.lifecycle.ConfirmRequiredException; import vip.mate.skill.lifecycle.LifecycleTransition; @@ -57,6 +61,7 @@ public class SkillController { private final SkillService skillService; private final SkillRuntimeService skillRuntimeService; + private final SkillPackageResolver skillPackageResolver; private final SkillWorkspaceManager workspaceManager; private final BundledSkillSyncer bundledSkillSyncer; private final SkillFileSyncer skillFileSyncer; @@ -71,6 +76,7 @@ public class SkillController { private final SkillLifecycleService skillLifecycleService; private final SkillCuratorJob skillCuratorJob; private final SkillCuratorReportStore skillCuratorReportStore; + private final SkillFileService skillFileService; @Operation(summary = "获取技能分页列表(RFC-042 §2.1)") @GetMapping @@ -334,6 +340,157 @@ public class SkillController { return R.ok(body); } + // ==================== Bundle files (scripts/ + references/) ==================== + + /** Per-file content ceiling for the admin editor — matches the installer's per-file bound. */ + private static final int MAX_BUNDLE_FILE_CHARS = 1_000_000; + + @Operation(summary = "List a skill's bundle files (scripts/ + references/), without content") + @GetMapping("/{id}/files") + @RequireWorkspaceRole("member") + public R>> listBundleFiles(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + // Virtual MCP/ACP skills mirror live servers and own no bundle files. + if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id) + || vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) { + return R.ok(List.of()); + } + SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); + List rows = skillFileService.listBySkillId(id); + if (rows.isEmpty()) { + // Self-heal: ingest pre-canonical on-disk files into the DB store + // so legacy directory-based skills list their files too. + skillFileSyncer.syncOne(skill); + rows = skillFileService.listBySkillId(id); + } + List> out = new ArrayList<>(rows.size()); + rows.stream() + .sorted(java.util.Comparator.comparing(SkillFileEntity::getFilePath, + java.util.Comparator.nullsLast(String::compareTo))) + .forEach(row -> { + Map item = new LinkedHashMap<>(); + item.put("path", row.getFilePath()); + item.put("size", row.getContentSize()); + item.put("sha256", row.getSha256()); + item.put("updateTime", row.getUpdateTime()); + out.add(item); + }); + return R.ok(out); + } + + @Operation(summary = "Read one bundle file's content") + @GetMapping("/{id}/files/content") + @RequireWorkspaceRole("member") + public R> getBundleFileContent(@PathVariable Long id, + @RequestParam String path, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); + String normalized = normalizeBundlePath(path); + if (normalized == null) { + return R.fail("Invalid file path: " + path); + } + SkillFileEntity row = skillFileService.getFile(id, normalized); + if (row == null) { + return R.fail("File not found: " + normalized); + } + Map body = new LinkedHashMap<>(); + body.put("path", row.getFilePath()); + body.put("content", row.getContent() == null ? "" : row.getContent()); + body.put("size", row.getContentSize()); + body.put("sha256", row.getSha256()); + body.put("updateTime", row.getUpdateTime()); + return R.ok(body); + } + + @Operation(summary = "Create or update one bundle file", + description = "Writes the canonical mate_skill_file row, materializes the workspace cache, " + + "and re-resolves the skill so the runtime file tree updates immediately.") + @PutMapping("/{id}/files/content") + @RequireWorkspaceRole("admin") + public R> putBundleFileContent(@PathVariable Long id, + @RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + rejectVirtualSkillMutation(id); + SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); + if (Boolean.TRUE.equals(skill.getBuiltin())) { + return R.fail("Builtin skill files are read-only — they are restored from the shipped bundle on upgrade."); + } + String normalized = normalizeBundlePath(body.get("path")); + if (normalized == null) { + return R.fail("Invalid file path — must be under scripts/, references/ or templates/, no '..'."); + } + String content = body.get("content"); + if (content == null) { + return R.fail("content is required (use the delete endpoint to remove a file)."); + } + if (content.length() > MAX_BUNDLE_FILE_CHARS) { + return R.fail("Content too large (" + content.length() + " chars, max " + MAX_BUNDLE_FILE_CHARS + ")."); + } + + SkillFileEntity row = skillFileService.upsertFile(id, normalized, content); + try { + workspaceManager.writeWorkspaceFile(skill.getName(), normalized, content, skill.getWorkspaceId()); + } catch (Exception e) { + // Canonical store is updated; the syncer heals the cache later. + } + skillRuntimeService.rescanSingle(skill); + + Map out = new LinkedHashMap<>(); + out.put("path", row.getFilePath()); + out.put("size", row.getContentSize()); + out.put("sha256", row.getSha256()); + out.put("updateTime", row.getUpdateTime()); + return R.ok(out); + } + + @Operation(summary = "Delete one bundle file") + @DeleteMapping("/{id}/files") + @RequireWorkspaceRole("admin") + public R> deleteBundleFile(@PathVariable Long id, + @RequestParam String path, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + rejectVirtualSkillMutation(id); + SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); + if (Boolean.TRUE.equals(skill.getBuiltin())) { + return R.fail("Builtin skill files are read-only — they are restored from the shipped bundle on upgrade."); + } + String normalized = normalizeBundlePath(path); + if (normalized == null) { + return R.fail("Invalid file path: " + path); + } + boolean removed = skillFileService.deleteFile(id, normalized); + try { + workspaceManager.deleteWorkspaceFile(skill.getName(), normalized, skill.getWorkspaceId()); + } catch (Exception e) { + // Cache cleanup is best-effort; the canonical row is gone. + } + skillRuntimeService.rescanSingle(skill); + return R.ok(Map.of("path", normalized, "removed", removed)); + } + + /** + * Normalize a bundle-relative path and enforce the same envelope the + * store and workspace cache use: forward slashes, must sit under a + * DB-persisted bucket ({@code scripts/}, {@code references/} or + * {@code templates/}), no traversal, no absolute paths, and a + * non-empty file name. + * + * @return normalized path, or {@code null} when rejected + */ + static String normalizeBundlePath(String path) { + if (path == null || path.isBlank()) return null; + String p = path.strip().replace('\\', '/'); + if (p.startsWith("/") || p.contains("..") || p.contains("//") || p.endsWith("/")) return null; + if (!SkillBundleFiles.isDbEligible(p)) return null; + int slash = p.indexOf('/'); + if (slash == p.length() - 1) return null; + return p; + } + /** * Mutation paths refuse virtual MCP/ACP skill ids upfront. The bridge * synthesizes those rows on the fly from the upstream connection @@ -445,6 +602,20 @@ public class SkillController { } SkillEntity skill = skillService.getSkill(id); verifyResourceWorkspace(skill, workspaceId); + // Read-time store reconciliation: the workspace SKILL.md may have + // been edited out-of-band (agent shell tools in a chat session) + // with no refresh in between. One file read + hash compare in the + // steady state; when the file side actually changed, the content + // is ingested and a single-skill rescan re-projects manifest + // columns and drops stale runtime caches — so the detail view + // always shows what the runtime executes. + try { + if (skillPackageResolver.reconcileEntityContent(skill)) { + skillRuntimeService.rescanSingle(skill); + } + } catch (Exception ignored) { + // A reconcile failure must not break the detail view. + } return R.ok(skill); } @@ -785,7 +956,7 @@ public class SkillController { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { SkillEntity skill = skillService.getSkill(id); verifyResourceWorkspace(skill, workspaceId); - var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent()); + var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent(), skill.getWorkspaceId()); if (path == null) { return R.ok(Map.of("success", false, "message", "Failed to export workspace")); } @@ -799,7 +970,7 @@ public class SkillController { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { SkillEntity skill = skillService.getSkill(id); verifyResourceWorkspace(skill, workspaceId); - return R.ok(workspaceManager.getWorkspaceInfo(skill.getName())); + return R.ok(workspaceManager.getWorkspaceInfo(skill.getName(), skill.getWorkspaceId())); } // ==================== Skill lifecycle & curator ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java index a3dee481..2a4a6a96 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java @@ -82,7 +82,10 @@ public class SkillInstaller { * {@code SkillService.hardDeleteSkill} via {@code DELETE /skills/{id}}. */ public void uninstall(String skillName, Long workspaceId) { - List skills = skillService.listSkills(); + // Scope the lookup to this workspace (+ builtin) so a same-named skill in + // another workspace is never picked up (which would wrongly 403 below even + // though the current workspace has its own skill to uninstall). + List skills = skillService.listSkills(workspaceId); SkillEntity target = skills.stream() .filter(s -> s.getName().equals(skillName)) .findFirst() @@ -147,8 +150,9 @@ public class SkillInstaller { return CompletableFuture.completedFuture(null); } - // 3. 检查是否已存在 - boolean exists = skillService.listSkills().stream() + // 3. 检查是否已存在(按工作区隔离:只在本工作区 + builtin 范围内查重, + // 不同工作区的同名技能可以各自独立安装) + boolean exists = skillService.listSkills(request.getWorkspaceId()).stream() .anyMatch(s -> s.getName().equals(skillName)); if (exists && !Boolean.TRUE.equals(request.getOverwrite())) { task.markFailed("Skill '" + skillName + "' already exists. Set overwrite=true to replace."); @@ -161,10 +165,10 @@ public class SkillInstaller { } // 4. Materialize SKILL.md (overwrite on reinstall, keep on first create). - workspaceManager.initWorkspace(skillName, bundle.content(), exists); + workspaceManager.initWorkspace(skillName, bundle.content(), exists, request.getWorkspaceId()); if (task.isCancelRequested()) { - workspaceManager.archiveWorkspace(skillName); + workspaceManager.archiveWorkspace(skillName, request.getWorkspaceId()); task.markCancelled(); return CompletableFuture.completedFuture(null); } @@ -182,12 +186,12 @@ public class SkillInstaller { // Empty-bundle guard protects both sides from a malformed bundle // silently wiping pre-existing scripts/references. boolean force = Boolean.TRUE.equals(request.getForcePrune()); - persistBundleFiles(skillEntity, bundle, force, "url"); + persistBundleFiles(skillEntity, bundle, force, "url", request.getWorkspaceId()); // 7. Publish event for runtime refresh / sibling-node materialization. eventPublisher.publishEvent(new SkillWorkspaceEvent( skillName, SkillWorkspaceEvent.Type.INSTALLED, - workspaceManager.resolveConventionPath(skillName))); + workspaceManager.resolveConventionPath(skillName, request.getWorkspaceId()))); task.markCompleted(InstallResult.builder() .name(skillName) @@ -218,7 +222,8 @@ public class SkillInstaller { throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle"); } - boolean exists = skillService.listSkills().stream() + // Workspace-scoped dedup: same-named skills in different workspaces coexist. + boolean exists = skillService.listSkills(workspaceId).stream() .anyMatch(s -> s.getName().equals(skillName)); if (exists && !overwrite) { throw new vip.mate.exception.MateClawException("err.skill.name_exists", @@ -226,17 +231,17 @@ public class SkillInstaller { } // Materialize SKILL.md (always overwrite on reinstall path). - workspaceManager.initWorkspace(skillName, bundle.content(), exists); + workspaceManager.initWorkspace(skillName, bundle.content(), exists, workspaceId); // Register/update skill row first so we have an id to anchor the file rows. SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable, workspaceId); // DB-canonical, FS-cache. Empty-bundle guard on both sides. - persistBundleFiles(skillEntity, bundle, false, "zip"); + persistBundleFiles(skillEntity, bundle, false, "zip", workspaceId); eventPublisher.publishEvent(new SkillWorkspaceEvent( skillName, SkillWorkspaceEvent.Type.INSTALLED, - workspaceManager.resolveConventionPath(skillName))); + workspaceManager.resolveConventionPath(skillName, workspaceId))); int filesCount = (bundle.references() != null ? bundle.references().size() : 0) + (bundle.scripts() != null ? bundle.scripts().size() : 0) + 1; @@ -263,7 +268,9 @@ public class SkillInstaller { boolean enable, Long workspaceId) { SkillEntity skillEntity; if (exists) { - skillEntity = skillService.listSkills().stream() + // Locate the row to update within THIS workspace (+ builtin), so a + // reinstall never grabs a same-named skill owned by another workspace. + skillEntity = skillService.listSkills(workspaceId).stream() .filter(s -> s.getName().equals(skillName)) .findFirst().orElseThrow(); skillEntity.setSkillContent(bundle.content()); @@ -296,7 +303,8 @@ public class SkillInstaller { * same prefixed-key map. Logs a single combined summary so multi-instance * deployments can see what each node persisted vs preserved. */ - private void persistBundleFiles(SkillEntity skillEntity, SkillBundle bundle, boolean force, String origin) { + private void persistBundleFiles(SkillEntity skillEntity, SkillBundle bundle, boolean force, String origin, + Long workspaceId) { Map combined = new LinkedHashMap<>(); if (bundle.references() != null) { for (var e : bundle.references().entrySet()) { @@ -313,7 +321,7 @@ public class SkillInstaller { var dbApply = skillFileService.applyBundleFiles(skillEntity.getId(), combined, force); var fsApply = workspaceManager.applyBundleFiles(skillEntity.getName(), - bundle.references(), bundle.scripts(), force); + bundle.references(), bundle.scripts(), force, workspaceId); log.info("Persisted bundle for '{}' ({}): db(write={}, prune={}, preservedScripts={}, preservedRefs={}) " + "fs(refs write={}, prune={}, preserved={} | scripts write={}, prune={}, preserved={})", diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java index d176f0ed..50a6b5e4 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java @@ -8,8 +8,10 @@ import vip.mate.skill.runtime.SkillFrontmatterParser; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.ByteBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; @@ -179,11 +181,17 @@ public class ZipSkillFetcher { private static final Charset GBK = Charset.isSupported("GBK") ? Charset.forName("GBK") : null; /** - * Decompress raw ZIP bytes, trying UTF-8 first and falling back to GBK when - * an entry name fails to decode as UTF-8 — the common failure mode for zips - * packaged on Chinese Windows, where filenames are GBK and UTF-8 decoding - * throws a {@link CharacterCodingException}. Buffering the bytes (rather than - * a one-shot stream) is what makes the retry possible. + * Decompress raw ZIP bytes into in-memory SKILL.md + references + scripts. + * + *

      Entry names and entry content are each decoded independently — UTF-8 + * first, falling back to GBK — rather than picking one charset for the + * whole archive. Zips packaged on Windows commonly mix encodings: the OS + * zip tool writes entry names in the local codepage (GBK) because it + * never sets the ZIP "language encoding flag", while file content + * authored in a UTF-8 text editor stays UTF-8. Deciding the charset once + * for the entire archive means a single GBK-named entry forces every + * already-correct UTF-8 file to be mis-decoded as GBK, turning valid + * Chinese text into mojibake. */ public static ExtractedSkill extract(byte[] zipBytes) throws IOException { return extract(zipBytes, Limits.DEFAULT); @@ -191,41 +199,17 @@ public class ZipSkillFetcher { /** Variant of {@link #extract(byte[])} with explicit size caps. */ public static ExtractedSkill extract(byte[] zipBytes, Limits limits) throws IOException { - try { - return extract(zipBytes, StandardCharsets.UTF_8, limits); - } catch (IOException | RuntimeException e) { - if (GBK != null && isCharsetError(e)) { - log.warn("[ZipSkillFetcher] UTF-8 entry decode failed, retrying with GBK (Windows-authored archive?)"); - return extract(zipBytes, GBK, limits); - } - throw e; - } - } - - /** True if {@code t} (or any cause) is a charset-decode failure, vs a genuine "no SKILL.md" error. */ - private static boolean isCharsetError(Throwable t) { - for (Throwable c = t; c != null; c = c.getCause()) { - if (c instanceof CharacterCodingException) { - return true; - } - String m = c.getMessage(); - if (m != null && m.toLowerCase().contains("malformed")) { - return true; - } - if (c.getCause() == c) { - break; - } - } - return false; - } - - private static ExtractedSkill extract(byte[] zipBytes, Charset charset, Limits limits) throws IOException { List raws = new ArrayList<>(); String skillMdContent = null; String skillMdPrefix = ""; long totalSize = 0; - try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) { + // ISO-8859-1 maps every byte value 0-255 to a distinct character, so + // ZipInputStream never throws while decoding a name with it — even a + // raw GBK-encoded name comes back as reversible mojibake that + // fixEntryName() below can re-decode itself, instead of aborting the + // whole archive read the way a strict UTF-8/GBK charset would. + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), StandardCharsets.ISO_8859_1)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { if (entry.isDirectory()) { @@ -233,7 +217,7 @@ public class ZipSkillFetcher { continue; } - String entryName = entry.getName(); + String entryName = fixEntryName(entry.getName()); // Skip macOS archive cruft so it doesn't surface as "ignored" noise. if (entryName.startsWith("__MACOSX/") || entryName.equals(".DS_Store") @@ -286,7 +270,7 @@ public class ZipSkillFetcher { continue; } - String content = new String(bytes, charset); + String content = decodeBytes(bytes); String normalizedName = entryPath.toString().replace('\\', '/'); String fileName = entryPath.getFileName().toString(); @@ -346,6 +330,64 @@ public class ZipSkillFetcher { return new ExtractedSkill(skillMdContent, references, scripts); } + /** + * Re-decode a ZIP entry name that {@link ZipInputStream} returned via the + * byte-preserving ISO-8859-1 read. If the archive's language-encoding + * flag was actually set for this entry, the JDK already forced a real + * UTF-8 decode and the name contains genuine non-Latin-1 characters — + * left untouched. Otherwise the name is just the raw bytes reinterpreted + * as Latin-1 (fully reversible), so the original bytes are recovered and + * decoded again, trying UTF-8 then falling back to GBK. + */ + private static String fixEntryName(String name) { + if (!isLatin1(name)) { + return name; + } + return decodeBytes(name.getBytes(StandardCharsets.ISO_8859_1)); + } + + private static boolean isLatin1(String s) { + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) > 0xFF) { + return false; + } + } + return true; + } + + /** + * Decode raw bytes as text, trying UTF-8 first and falling back to GBK — + * per entry, not per archive, so one mis-encoded name or file doesn't + * force a lossy re-decode of everything else that was already correct. + * Falls back to a lossy UTF-8 decode (replacement characters) only if + * neither charset parses cleanly. + */ + private static String decodeBytes(byte[] bytes) { + String utf8 = tryDecodeStrict(bytes, StandardCharsets.UTF_8); + if (utf8 != null) { + return utf8; + } + if (GBK != null) { + String gbk = tryDecodeStrict(bytes, GBK); + if (gbk != null) { + return gbk; + } + } + return new String(bytes, StandardCharsets.UTF_8); + } + + private static String tryDecodeStrict(byte[] bytes, Charset charset) { + try { + return charset.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException e) { + return null; + } + } + /** * Heuristic binary detector: an entry is treated as binary if a NUL byte * (0x00) appears within the inspected prefix. UTF-8 and GBK text never diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsService.java b/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsService.java index 1a1e88e8..14c382e9 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lessons/SkillLessonsService.java @@ -205,7 +205,7 @@ public class SkillLessonsService { private Path resolveWorkspace(ResolvedSkill resolved) { if (resolved == null || resolved.getName() == null) return null; if (resolved.getSkillDir() != null) return resolved.getSkillDir(); - Path convention = workspaceManager.resolveConventionPath(resolved.getName()); + Path convention = workspaceManager.resolveConventionPath(resolved.getName(), resolved.getWorkspaceId()); return Files.exists(convention) && Files.isDirectory(convention) ? convention : null; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java index 882e2895..a5bb7c8f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java @@ -269,7 +269,7 @@ public class SkillCuratorJob { List archived = skillMapper.selectList(new LambdaQueryWrapper() .eq(SkillEntity::getLifecycleState, "archived")); for (SkillEntity skill : archived) { - if (skill.getName() == null || !workspaceManager.conventionWorkspaceExists(skill.getName())) { + if (skill.getName() == null || !workspaceManager.conventionWorkspaceExists(skill.getName(), skill.getWorkspaceId())) { continue; } report.reconciliation("skill '" + skill.getName() + "' (id=" + skill.getId() diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java index be149025..b8515474 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java @@ -166,7 +166,7 @@ public class SkillLifecycleService { "Skill is not archived: " + skill.getName()); } - SkillWorkspaceManager.RestoreResult fs = workspaceManager.restoreWorkspace(skill.getName()); + SkillWorkspaceManager.RestoreResult fs = workspaceManager.restoreWorkspace(skill.getName(), skill.getWorkspaceId()); switch (fs) { case MOVED -> { /* normal path */ } case MISSING -> { @@ -266,7 +266,7 @@ public class SkillLifecycleService { // FAILED defers the whole transition. SkillWorkspaceManager.ArchiveResult fsResult = SkillWorkspaceManager.ArchiveResult.MISSING; if ("archive".equals(workspaceProperties.getDeletePolicy())) { - fsResult = workspaceManager.archiveWorkspace(skill.getName()); + fsResult = workspaceManager.archiveWorkspace(skill.getName(), skill.getWorkspaceId()); } if (fsResult == SkillWorkspaceManager.ArchiveResult.FAILED) { log.warn("Skill '{}' workspace archive failed; deferring DB transition", skill.getName()); @@ -288,7 +288,7 @@ public class SkillLifecycleService { if (rows == 0) { log.warn("Skill '{}' DB archive update touched 0 rows; compensating workspace", skill.getName()); if (fsResult == SkillWorkspaceManager.ArchiveResult.MOVED) { - workspaceManager.restoreWorkspace(skill.getName()); + workspaceManager.restoreWorkspace(skill.getName(), skill.getWorkspaceId()); } return false; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java index db83b392..749a4259 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java @@ -140,9 +140,13 @@ public class SkillManifest { public static class RequirementDef { /** Required: stable identifier referenced by {@code features[*].requires}. */ private String key; - /** binary | env_var | api_key */ + /** binary | env_var | api_key | endpoint */ private String type; - /** Probe target — for binary, the executable name; for env_var, the env name. */ + /** + * Probe target — for binary, the executable name; for env_var, the + * env name; for endpoint, the service address to TCP-probe + * ({@code http(s)://host[:port][/path]} or {@code host[:port]}). + */ private String check; /** Optional means it only blocks features that reference it explicitly. */ @Builder.Default diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillContentReconciler.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillContentReconciler.java new file mode 100644 index 00000000..7a37a4a6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillContentReconciler.java @@ -0,0 +1,258 @@ +package vip.mate.skill.runtime; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; + +/** + * Reconciles a skill's SKILL.md between its two stores: the canonical + * {@code mate_skill.skill_content} column and the convention-workspace + * file cache ({@code {workspace-root}/{name}/SKILL.md}). + * + *

      The database column is the single source of truth; the workspace file + * is a materialized cache kept for script execution and direct-file + * tooling. Because agents (via shell tools) and operators may still edit + * the file in place, reconciliation is a three-way sync anchored on a + * sidecar marker ({@value #SYNC_MARKER}) that records the SHA-256 of the + * content at the last successful sync: + * + *

        + *
      • DB == file → in sync; heal a missing/stale marker.
      • + *
      • File missing/blank, DB has content → materialize DB → file. + * A blank file is never ingested over non-blank DB content.
      • + *
      • DB blank, file has content → backfill file → DB (covers installs + * that predate the canonical column).
      • + *
      • File changed since last sync, DB unchanged → ingest file → DB. + * This is what makes shell/agent edits to the file visible to + * DB-reading consumers (admin console, API).
      • + *
      • DB changed since last sync, file unchanged → materialize DB → file. + * This heals nodes whose workspace export was missed or failed.
      • + *
      • No marker and both sides non-blank but different (legacy state) → + * the file wins once: prior releases resolved runtime content from + * the directory, so the file reflects what was actually in effect.
      • + *
      • Both sides changed since last sync → DB wins; the losing file is + * kept as {@code SKILL.md.bak} before being overwritten.
      • + *
      + * + *

      All writes are idempotent and failure-tolerant: an IO or DB error + * logs a warning and leaves the marker untouched, so the next resolve + * pass retries the same reconciliation. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillContentReconciler { + + /** Sidecar file holding the SHA-256 of SKILL.md at the last sync. */ + public static final String SYNC_MARKER = ".skillmd.sha256"; + + /** Backup name for a file-side edit that loses a two-sided conflict. */ + static final String CONFLICT_BACKUP = "SKILL.md.bak"; + + private final SkillMapper skillMapper; + + /** What the reconciliation pass did. */ + public enum Action { + /** Both stores already held the same content. */ + IN_SYNC, + /** DB was blank; the workspace file was ingested into the DB. */ + BACKFILLED_TO_DB, + /** The workspace file changed; its content was written to the DB. */ + INGESTED_TO_DB, + /** The DB changed; its content was written to the workspace file. */ + MATERIALIZED_TO_FS, + /** Both changed; DB won and the file edit was backed up. */ + CONFLICT_DB_WON, + /** A store write failed; stores may still diverge. Retried next pass. */ + FAILED + } + + /** Reconciled content (the value both stores now agree on) + what happened. */ + public record Outcome(String content, Action action) {} + + /** + * Reconcile {@code entity}'s skill_content with the SKILL.md inside + * {@code workspaceDir}. On an ingest/backfill the passed entity's + * in-memory {@code skillContent} is updated too, so downstream resolve + * stages and diff-based write-backs see the merged value. + */ + public Outcome reconcile(SkillEntity entity, Path workspaceDir) { + String dbContent = entity.getSkillContent() == null ? "" : entity.getSkillContent(); + Path skillMd = workspaceDir.resolve("SKILL.md"); + String fsContent = readFileQuietly(skillMd); + + String dbHash = sha256(dbContent); + String fsHash = sha256(fsContent); + Path marker = workspaceDir.resolve(SYNC_MARKER); + String syncedHash = readMarker(marker); + + if (dbHash.equals(fsHash)) { + if (!dbHash.equals(syncedHash)) { + writeMarkerQuietly(marker, dbHash); + } + return new Outcome(dbContent, Action.IN_SYNC); + } + + if (fsContent.isBlank()) { + // File missing or empty while the DB has content: always + // materialize. Blank file content is never treated as an edit — + // that guard blocks the same wipe-on-empty scenario the bundle + // apply path protects against. + return materialize(entity, skillMd, marker, dbContent, dbHash, Action.MATERIALIZED_TO_FS); + } + + if (dbContent.isBlank()) { + return ingest(entity, marker, fsContent, fsHash, Action.BACKFILLED_TO_DB); + } + + // Both sides non-blank and different — use the marker to decide + // which side moved since the last sync. + if (syncedHash == null || syncedHash.equals(dbHash)) { + // DB unchanged since last sync (or legacy pre-marker state, + // where the directory was the effective runtime source): + // the file edit is the newer fact — ingest it. + return ingest(entity, marker, fsContent, fsHash, Action.INGESTED_TO_DB); + } + if (syncedHash.equals(fsHash)) { + // File unchanged since last sync; the DB moved — materialize. + return materialize(entity, skillMd, marker, dbContent, dbHash, Action.MATERIALIZED_TO_FS); + } + + // Both sides changed since the last sync. The DB is canonical, so + // it wins; keep the losing file edit next to the file for manual + // recovery instead of silently discarding it. + backupQuietly(skillMd, workspaceDir.resolve(CONFLICT_BACKUP)); + log.warn("SKILL.md conflict for skill '{}': both DB and workspace file changed since last sync; " + + "DB content wins, file edit saved as {}", entity.getName(), CONFLICT_BACKUP); + return materialize(entity, skillMd, marker, dbContent, dbHash, Action.CONFLICT_DB_WON); + } + + /** + * Mirror a file-authoritative skill's content into the DB column so + * DB-reading consumers (admin console, API) see what the runtime + * actually executes. Used for skills with an explicitly configured + * {@code skillDir}, where the user-managed directory — not the DB — + * is the source of truth and no marker/backfill dance applies. + */ + public void mirrorToDb(SkillEntity entity, String fsContent) { + if (fsContent == null || fsContent.isBlank()) return; + String dbContent = entity.getSkillContent() == null ? "" : entity.getSkillContent(); + if (fsContent.equals(dbContent)) return; + if (writeDb(entity, fsContent)) { + log.info("Mirrored directory SKILL.md into skill_content for skill '{}' (explicit skillDir)", + entity.getName()); + } + } + + private Outcome ingest(SkillEntity entity, Path marker, String fsContent, String fsHash, Action action) { + if (!writeDb(entity, fsContent)) { + return new Outcome(fsContent, Action.FAILED); + } + writeMarkerQuietly(marker, fsHash); + log.info("Ingested workspace SKILL.md into skill_content for skill '{}' ({})", + entity.getName(), action); + return new Outcome(fsContent, action); + } + + private Outcome materialize(SkillEntity entity, Path skillMd, Path marker, + String dbContent, String dbHash, Action action) { + try { + Files.createDirectories(skillMd.getParent()); + Files.writeString(skillMd, dbContent, StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("Failed to materialize SKILL.md for skill '{}' → {}: {}", + entity.getName(), skillMd, e.getMessage()); + return new Outcome(dbContent, Action.FAILED); + } + writeMarkerQuietly(marker, dbHash); + log.info("Materialized skill_content to workspace SKILL.md for skill '{}' ({})", + entity.getName(), action); + return new Outcome(dbContent, action); + } + + /** + * Column-whitelisted DB write. {@code SkillEntity} declares several + * {@code FieldStrategy.ALWAYS} columns, so a partial + * {@code updateById} would null them out — the update wrapper touches + * only {@code skill_content} and {@code update_time}. + */ + private boolean writeDb(SkillEntity entity, String content) { + if (entity.getId() == null) return false; + try { + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, entity.getId()) + .set(SkillEntity::getSkillContent, content) + .set(SkillEntity::getUpdateTime, LocalDateTime.now())); + entity.setSkillContent(content); + return true; + } catch (Exception e) { + log.warn("Failed to write skill_content for skill '{}': {}", entity.getName(), e.getMessage()); + return false; + } + } + + private String readFileQuietly(Path file) { + if (!Files.exists(file)) return ""; + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("Failed to read {}: {}", file, e.getMessage()); + return ""; + } + } + + private String readMarker(Path marker) { + if (!Files.exists(marker)) return null; + try { + String value = Files.readString(marker, StandardCharsets.UTF_8).strip(); + return value.isEmpty() ? null : value; + } catch (IOException e) { + log.warn("Failed to read sync marker {}: {}", marker, e.getMessage()); + return null; + } + } + + private void writeMarkerQuietly(Path marker, String hash) { + try { + Files.createDirectories(marker.getParent()); + Files.writeString(marker, hash, StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("Failed to write sync marker {}: {}", marker, e.getMessage()); + } + } + + private void backupQuietly(Path source, Path backup) { + try { + if (Files.exists(source)) { + Files.copy(source, backup, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + log.warn("Failed to back up {} → {}: {}", source, backup, e.getMessage()); + } + } + + static String sha256(String content) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex( + md.digest((content == null ? "" : content).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable on this JVM", e); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java index eb8ec366..3f746514 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java @@ -16,6 +16,9 @@ import vip.mate.tool.repository.ToolMapper; import java.io.BufferedReader; import java.io.InputStreamReader; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -26,7 +29,7 @@ import java.util.concurrent.TimeUnit; /** * 技能依赖检查器 - * 检查 commands / env / tools / platforms 依赖是否满足 + * 检查 commands / env / tools / platforms / endpoints 依赖是否满足 */ @Slf4j @Service @@ -59,6 +62,23 @@ public class SkillDependencyChecker { .maximumSize(256) .build(); + /** Timeout for a single endpoint reachability probe (TCP connect). */ + private static final int ENDPOINT_PROBE_TIMEOUT_MS = 1500; + + /** + * 60s cache for endpoint reachability probes, keyed by {@code host:port}. + * Same rhythm as {@link #commandAvailability}: every active-skills + * refresh re-evaluates each feature requirement, and without caching, + * N skills declaring the same service address would each open a socket + * (blocking up to the probe timeout) per pass. Network state is also + * the most volatile requirement class, so a short TTL keeps the + * "service unreachable" verdict from going stale after a VPN connect. + */ + private final Cache endpointReachability = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(60)) + .maximumSize(256) + .build(); + /** * 检查依赖 */ @@ -195,6 +215,71 @@ public class SkillDependencyChecker { } } + // ==================== Endpoint reachability ==================== + + /** Parsed {@code host:port} target of an endpoint requirement. */ + record EndpointTarget(String host, int port) {} + + /** + * Parse an endpoint requirement's check target into {@code host:port}. + * Accepted forms: + *

        + *
      • {@code http(s)://host[:port][/path]} — port defaults to the + * scheme's standard port when omitted
      • + *
      • {@code host:port}
      • + *
      • {@code host} — port defaults to 80
      • + *
      + * Returns {@code null} when the target is blank or unparseable, which + * the caller maps to {@code UNKNOWN} — a misdeclared manifest must not + * flip a skill to setup-needed. + */ + static EndpointTarget parseEndpointTarget(String target) { + if (target == null || target.isBlank()) return null; + String t = target.strip(); + try { + if (t.contains("://")) { + URI uri = URI.create(t); + String host = uri.getHost(); + if (host == null || host.isBlank()) return null; + int port = uri.getPort(); + if (port <= 0) { + port = "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + return new EndpointTarget(host, port); + } + int colon = t.lastIndexOf(':'); + if (colon > 0 && colon < t.length() - 1) { + String maybePort = t.substring(colon + 1); + if (!maybePort.isEmpty() && maybePort.chars().allMatch(Character::isDigit)) { + return new EndpointTarget(t.substring(0, colon), Integer.parseInt(maybePort)); + } + } + if (t.contains("/") || t.contains(":")) return null; + return new EndpointTarget(t, 80); + } catch (Exception e) { + return null; + } + } + + private boolean isEndpointReachable(EndpointTarget target) { + String key = target.host() + ":" + target.port(); + Boolean cached = endpointReachability.getIfPresent(key); + if (cached != null) return cached; + boolean reachable = probeEndpoint(target); + endpointReachability.put(key, reachable); + return reachable; + } + + private boolean probeEndpoint(EndpointTarget target) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(target.host(), target.port()), ENDPOINT_PROBE_TIMEOUT_MS); + return true; + } catch (Exception e) { + log.debug("Endpoint probe failed for {}:{} — {}", target.host(), target.port(), e.getMessage()); + return false; + } + } + private static boolean isWindows() { return CURRENT_OS.equals("windows"); } @@ -221,7 +306,7 @@ public class SkillDependencyChecker { * probe regardless of how the manifest expressed it. {@code ANY} is the * fallback when the manifest doesn't declare a type — we infer. */ - public enum RequirementType { BINARY, ENV_VAR, API_KEY, ANY } + public enum RequirementType { BINARY, ENV_VAR, API_KEY, ENDPOINT, ANY } /** * Status for a single requirement after probing. @@ -263,6 +348,16 @@ public class SkillDependencyChecker { String value = System.getenv(target); yield (value != null && !value.isBlank()) ? RequirementStatus.SATISFIED : RequirementStatus.MISSING; } + case ENDPOINT -> { + // Reachability, not liveness: a TCP connect proves the + // current deployment can route to the service (intranet + // address + wrong network segment is the classic failure), + // without depending on the service answering a particular + // HTTP verb on its base path. + EndpointTarget ep = parseEndpointTarget(target); + if (ep == null) yield RequirementStatus.UNKNOWN; + yield isEndpointReachable(ep) ? RequirementStatus.SATISFIED : RequirementStatus.MISSING; + } case ANY -> RequirementStatus.UNKNOWN; }; } catch (Exception e) { @@ -278,6 +373,7 @@ public class SkillDependencyChecker { case "binary" -> RequirementType.BINARY; case "env_var", "env" -> RequirementType.ENV_VAR; case "api_key", "key" -> RequirementType.API_KEY; + case "endpoint", "url", "service" -> RequirementType.ENDPOINT; default -> RequirementType.ANY; }; } @@ -289,6 +385,11 @@ public class SkillDependencyChecker { if (k.startsWith("env:")) return RequirementType.ENV_VAR; if (k.endsWith("_api_key") || k.endsWith("_key")) return RequirementType.API_KEY; } + // A check target that looks like a URL is an endpoint probe even + // without a declared type. + if (req.getCheck() != null && req.getCheck().contains("://")) { + return RequirementType.ENDPOINT; + } return RequirementType.ANY; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index 25903b0b..af823a94 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -59,6 +59,7 @@ public class SkillPackageResolver { private final ObjectMapper objectMapper; private final SkillWorkspaceManager workspaceManager; private final SkillMapper skillMapper; + private final SkillContentReconciler contentReconciler; /** * RFC-090 §14.4 — knowledge-skill wrapper tool factory. * {@code @Lazy} because WikiSkillWrapperToolFactory pulls in Wiki @@ -113,6 +114,7 @@ public class SkillPackageResolver { ObjectMapper objectMapper, SkillWorkspaceManager workspaceManager, SkillMapper skillMapper, + SkillContentReconciler contentReconciler, @Lazy WikiSkillWrapperToolFactory wikiWrapperFactory, @Lazy AcpSkillWrapperToolFactory acpWrapperFactory, @Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory, @@ -125,6 +127,7 @@ public class SkillPackageResolver { this.objectMapper = objectMapper; this.workspaceManager = workspaceManager; this.skillMapper = skillMapper; + this.contentReconciler = contentReconciler; this.wikiWrapperFactory = wikiWrapperFactory; this.acpWrapperFactory = acpWrapperFactory; this.scriptWrapperFactory = scriptWrapperFactory; @@ -146,7 +149,7 @@ public class SkillPackageResolver { resolved = resolveFromDirectory(entity, skillDir, configuredDir, "directory"); } else { // 2. 约定路径 {workspace-root}/{skillName}/ - Path conventionPath = workspaceManager.resolveConventionPath(entity.getName()); + Path conventionPath = workspaceManager.resolveConventionPath(entity.getName(), entity.getWorkspaceId()); if (Files.exists(conventionPath) && Files.isDirectory(conventionPath)) { resolved = resolveFromDirectory(entity, conventionPath, conventionPath.toString(), "convention"); } else { @@ -175,6 +178,48 @@ public class SkillPackageResolver { return resolved; } + /** + * Content-store-only reconciliation for read paths (e.g. the admin + * detail view). Runs the same SKILL.md store sync a full resolve + * performs — without the scan / dependency / manifest stages — so a + * detail query always returns the content the runtime would execute, + * even when the workspace file was edited out-of-band (agent shell + * tools, manual edits) and no refresh has run yet. + * + *

      Mutates the passed entity's {@code skillContent} when the file + * side wins. + * + * @return {@code true} when the DB side changed — callers should then + * trigger a single-skill rescan so caches and manifest + * projections catch up. + */ + public boolean reconcileEntityContent(SkillEntity entity) { + if (entity == null || entity.getId() == null || entity.getName() == null) return false; + + String configuredDir = extractSkillDirString(entity); + if (configuredDir != null) { + Path explicit = Paths.get(configuredDir); + if (Files.exists(explicit) && Files.isDirectory(explicit)) { + Path skillMd = explicit.resolve("SKILL.md"); + if (!Files.exists(skillMd)) return false; + try { + String before = entity.getSkillContent(); + contentReconciler.mirrorToDb(entity, Files.readString(skillMd)); + return !Objects.equals(before, entity.getSkillContent()); + } catch (Exception e) { + log.warn("Read-time mirror failed for skill '{}': {}", entity.getName(), e.getMessage()); + return false; + } + } + } + + Path convention = workspaceManager.resolveConventionPath(entity.getName(), entity.getWorkspaceId()); + if (!Files.exists(convention) || !Files.isDirectory(convention)) return false; + SkillContentReconciler.Outcome outcome = contentReconciler.reconcile(entity, convention); + return outcome.action() == SkillContentReconciler.Action.INGESTED_TO_DB + || outcome.action() == SkillContentReconciler.Action.BACKFILLED_TO_DB; + } + /** * Write back the latest scan status / findings JSON / timestamp when * they differ from what's already on the row. Keeps the DB in sync @@ -308,20 +353,35 @@ public class SkillPackageResolver { // ==================== 阶段 1:内容解析 ==================== private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) { - Path skillMd = skillDir.resolve("SKILL.md"); - - String content = ""; - String description = entity.getDescription(); - - if (Files.exists(skillMd)) { - try { - content = Files.readString(skillMd); - SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content); - if (!parsed.getDescription().isBlank()) { - description = parsed.getDescription(); + String content; + if ("convention".equals(source)) { + // Convention workspace: the DB column is canonical and the + // directory is a materialized cache. Reconcile both stores so + // runtime content and DB-reading consumers (admin console, API) + // can never diverge — file edits made by agents/shell are + // ingested into the DB, DB edits are materialized to the file. + content = contentReconciler.reconcile(entity, skillDir).content(); + } else { + // Explicit skillDir: the user-managed directory is the source + // of truth. Never write into it; mirror its content into the + // DB column so the console shows what actually executes. + Path skillMd = skillDir.resolve("SKILL.md"); + content = ""; + if (Files.exists(skillMd)) { + try { + content = Files.readString(skillMd); + contentReconciler.mirrorToDb(entity, content); + } catch (Exception e) { + log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage()); } - } catch (Exception e) { - log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage()); + } + } + + String description = entity.getDescription(); + if (!content.isBlank()) { + SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content); + if (!parsed.getDescription().isBlank()) { + description = parsed.getDescription(); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 4ba52cc9..b7e42f40 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -202,6 +202,19 @@ public class SkillRuntimeService { return refreshActiveSkills(); } + /** + * Workspace-scoped view of the active skills: builtin + global (virtual) + * skills plus only the skills owned by {@code workspaceId}. Reads the same + * process-global cache and filters at read time (no per-workspace cache), + * so an agent in one workspace never sees another workspace's same-named + * skill. Used by the agent runtime execution path. + */ + public List getActiveSkills(Long workspaceId) { + return getActiveSkills().stream() + .filter(s -> matchesWorkspace(s, workspaceId)) + .collect(Collectors.toList()); + } + /** * 刷新 active skills 缓存 * 进入 active set 的 skill 必须同时满足: @@ -328,6 +341,20 @@ public class SkillRuntimeService { .orElse(null); } + /** + * Workspace-scoped {@link #findActiveSkill(String)}: resolves {@code name} + * only among skills visible to {@code workspaceId} (builtin + global + + * that workspace's own), so a same-named skill in another workspace is + * never returned to an agent's load / read / execute path. + */ + public ResolvedSkill findActiveSkill(String name, Long workspaceId) { + return getActiveSkills().stream() + .filter(s -> s.getName().equals(name)) + .filter(s -> matchesWorkspace(s, workspaceId)) + .findFirst() + .orElse(null); + } + /** * 构建技能 prompt 增强片段(全局,向后兼容) */ @@ -739,9 +766,20 @@ public class SkillRuntimeService { * visible only inside its owning workspace. */ static boolean matchesWorkspace(ResolvedSkill skill, long agentWorkspaceId) { + return matchesWorkspace(skill, Long.valueOf(agentWorkspaceId)); + } + + /** + * Nullable-workspace variant used by the execution-side overloads: a + * {@code null} execution workspace (unresolved) sees only builtin and + * global ({@code null}-workspace, e.g. MCP virtual) skills — never another + * workspace's owned skills, so an unresolved context can never escalate + * into another tenant's skills. + */ + public static boolean matchesWorkspace(ResolvedSkill skill, Long workspaceId) { if (skill.isBuiltin()) return true; Long skillWs = skill.getWorkspaceId(); if (skillWs == null) return true; - return skillWs == agentWorkspaceId; + return workspaceId != null && skillWs.equals(workspaceId); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java index 267db5a6..3cea4cec 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java @@ -80,13 +80,16 @@ public class SkillFileService { Map incoming = newFiles == null ? Map.of() : newFiles; boolean newHasScripts = bucketHasEntries(incoming, "scripts/"); boolean newHasRefs = bucketHasEntries(incoming, "references/"); + boolean newHasTemplates = bucketHasEntries(incoming, "templates/"); List existing = listBySkillId(skillId); boolean existingHasScripts = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("scripts/")); boolean existingHasRefs = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("references/")); + boolean existingHasTemplates = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("templates/")); boolean preserveScripts = !newHasScripts && existingHasScripts && !force; boolean preserveRefs = !newHasRefs && existingHasRefs && !force; + boolean preserveTemplates = !newHasTemplates && existingHasTemplates && !force; Map existingByPath = new HashMap<>(); for (SkillFileEntity e : existing) existingByPath.put(e.getFilePath(), e); @@ -106,6 +109,13 @@ public class SkillFileService { } } } + if (preserveTemplates) { + for (SkillFileEntity e : existing) { + if (e.getFilePath() != null && e.getFilePath().startsWith("templates/")) { + keepPaths.add(e.getFilePath()); + } + } + } keepPaths.addAll(incoming.keySet()); int written = 0; @@ -152,6 +162,9 @@ public class SkillFileService { if (preserveRefs) { log.warn("Refused to prune references/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId); } + if (preserveTemplates) { + log.warn("Refused to prune templates/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId); + } return new ApplyResult(written, pruned, preserveScripts, preserveRefs); } @@ -163,6 +176,61 @@ public class SkillFileService { return mapper.deleteBySkillId(skillId); } + /** One file row by exact path, or {@code null}. */ + public SkillFileEntity getFile(Long skillId, String filePath) { + if (skillId == null || filePath == null || filePath.isBlank()) return null; + QueryWrapper q = new QueryWrapper<>(); + q.eq("skill_id", skillId).eq("file_path", filePath); + return mapper.selectOne(q); + } + + /** + * Create or update a single file row. Content hash and size are + * recomputed; a same-hash write is a no-op so idempotent saves don't + * churn {@code update_time}. + * + * @return the persisted row (existing row instance on no-op) + */ + @Transactional + public SkillFileEntity upsertFile(Long skillId, String filePath, String content) { + String safeContent = content == null ? "" : content; + String hash = sha256Hex(safeContent); + LocalDateTime now = LocalDateTime.now(); + + SkillFileEntity existing = getFile(skillId, filePath); + if (existing != null) { + if (hash.equals(existing.getSha256())) { + return existing; + } + existing.setContent(safeContent); + existing.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length); + existing.setSha256(hash); + existing.setUpdateTime(now); + mapper.updateById(existing); + return existing; + } + + SkillFileEntity row = new SkillFileEntity(); + row.setSkillId(skillId); + row.setFilePath(filePath); + row.setContent(safeContent); + row.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length); + row.setSha256(hash); + row.setCreateTime(now); + row.setUpdateTime(now); + mapper.insert(row); + return row; + } + + /** Delete a single file row. Returns {@code true} when a row was removed. */ + @Transactional + public boolean deleteFile(Long skillId, String filePath) { + SkillFileEntity existing = getFile(skillId, filePath); + if (existing == null) return false; + mapper.deleteById(existing.getId()); + return true; + } + private boolean bucketHasEntries(Map files, String prefix) { for (String key : files.keySet()) { if (key != null && key.startsWith(prefix)) return true; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index 7294cfd9..49d0b809 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -274,7 +274,8 @@ public class SkillService { } /** - * 按名称查找技能(RFC-023:SkillManageTool 重名检查用) + * 按名称查找技能(全局,跨所有工作区)。仅用于内置技能同步等全局语义场景; + * 工作区相关的重名检查请用 {@link #findByName(String, Long)}。 */ public SkillEntity findByName(String name) { return skillMapper.selectOne(new LambdaQueryWrapper() @@ -282,6 +283,17 @@ public class SkillService { .last("LIMIT 1")); } + /** + * 按名称在指定工作区内查找技能(含 builtin 全局可见)。用于工作区隔离的 + * 重名/存在性检查,避免跨工作区错误命中别的工作区的同名技能。 + */ + public SkillEntity findByName(String name, Long workspaceId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SkillEntity::getName, name); + applyWorkspaceScope(wrapper, workspaceId); + return skillMapper.selectOne(wrapper.last("LIMIT 1")); + } + /** * 按类型获取技能列表 */ @@ -323,9 +335,12 @@ public class SkillService { throw new MateClawException("err.skill.name_required", "技能名称不能为空"); } - // 检查名称唯一性 - Long count = skillMapper.selectCount(new LambdaQueryWrapper() - .eq(SkillEntity::getName, skill.getName())); + // 名称唯一性按工作区隔离:同名技能只要不在同一工作区(且都不是 builtin)即可共存。 + // 撞 builtin 名仍视为冲突(builtin 全局可见)。 + LambdaQueryWrapper dupCheck = new LambdaQueryWrapper() + .eq(SkillEntity::getName, skill.getName()); + applyWorkspaceScope(dupCheck, skill.getWorkspaceId()); + Long count = skillMapper.selectCount(dupCheck); if (count > 0) { throw new MateClawException("err.skill.name_exists", "技能名称已存在: " + skill.getName()); } @@ -361,7 +376,7 @@ public class SkillService { // 自动初始化工作区目录 if (workspaceProperties.isAutoInit() && !hasExplicitSkillDir(skill)) { - workspaceManager.initWorkspace(skill.getName(), skill.getSkillContent()); + workspaceManager.initWorkspace(skill.getName(), skill.getSkillContent(), skill.getWorkspaceId()); } // 刷新 runtime cache @@ -514,7 +529,7 @@ public class SkillService { eventPublisher.publishEvent(new SkillRemovedEvent(id, skill.getName())); if ("archive".equals(workspaceProperties.getDeletePolicy())) { - workspaceManager.archiveWorkspace(skill.getName()); + workspaceManager.archiveWorkspace(skill.getName(), skill.getWorkspaceId()); } // RFC-090 review #3 — refresh won't deregister wrappers for a // soft-deleted row (it only resolves rows still in @@ -562,7 +577,7 @@ public class SkillService { log.warn("Failed to purge secrets for skill {}: {}", skill.getName(), e.getMessage()); } - workspaceManager.purgeWorkspace(skill.getName()); + workspaceManager.purgeWorkspace(skill.getName(), skill.getWorkspaceId()); // RFC-090 review #3 — same explicit deregister as uninstall. if (runtimeService != null) { @@ -786,8 +801,8 @@ public class SkillService { if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) { return; } - if (workspaceManager.conventionWorkspaceExists(skill.getName())) { - Path workspaceDir = workspaceManager.resolveConventionPath(skill.getName()); + if (workspaceManager.conventionWorkspaceExists(skill.getName(), skill.getWorkspaceId())) { + Path workspaceDir = workspaceManager.resolveConventionPath(skill.getName(), skill.getWorkspaceId()); Path skillMd = workspaceDir.resolve("SKILL.md"); try { Files.writeString(skillMd, skill.getSkillContent()); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java index 61fc5f6b..245570be 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java @@ -95,8 +95,9 @@ public class SkillSynthesisService { } name = name.strip().toLowerCase().replaceAll("[^a-z0-9._-]", "-"); - // 去重 - SkillEntity existing = skillService.findByName(name); + // 去重(按工作区隔离:只与本工作区 + builtin 的同名技能避让, + // 不同工作区的同名技能不应互相影响命名) + SkillEntity existing = skillService.findByName(name, workspaceId); if (existing != null) { name = name + "-" + (System.currentTimeMillis() % 10000); } @@ -127,7 +128,7 @@ public class SkillSynthesisService { skillService.createSkill(skill); try { - workspaceManager.exportToWorkspace(name, skillMd); + workspaceManager.exportToWorkspace(name, skillMd, workspaceId); } catch (Exception e) { log.warn("[SkillSynthesis] Workspace export failed for '{}': {}", name, e.getMessage()); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java index 6d261a64..9c70ff2f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java @@ -106,7 +106,7 @@ public class SkillTemplateService { // classpath:{bundlePath}/** — scripts, references, fonts, etc. // Top-level SKILL.md in the bundle is skipped automatically so // the rendered manifest from step 2 stays authoritative. - overlayBundle(template, created.getName()); + overlayBundle(template, created.getName(), created.getWorkspaceId()); // 5. Persist any `secret` field values into mate_skill_secret so // the runtime can decrypt + inject them as env vars at exec @@ -138,13 +138,13 @@ public class SkillTemplateService { } } - private void overlayBundle(SkillTemplate template, String skillName) { + private void overlayBundle(SkillTemplate template, String skillName, Long workspaceId) { String bundlePath = template.getBundlePath(); if (bundlePath == null || bundlePath.isBlank()) { return; } SkillBundleSource source = new ClasspathBundleSource(resourceResolver, bundlePath); - Path workspaceDir = workspaceManager.resolveConventionPath(skillName); + Path workspaceDir = workspaceManager.resolveConventionPath(skillName, workspaceId); try { SkillBundleMaterializer.Result result = bundleMaterializer.materialize( source, workspaceDir, MaterializeOptions.templateOverlay()); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java index 1e28c896..f427a4b5 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java @@ -7,8 +7,12 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.stereotype.Component; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillFileService; +import vip.mate.skill.service.SkillService; import vip.mate.skill.workspace.bundle.ClasspathBundleSource; import vip.mate.skill.workspace.bundle.MaterializeOptions; +import vip.mate.skill.workspace.bundle.SkillBundleFiles; import vip.mate.skill.workspace.bundle.SkillBundleMaterializer; import vip.mate.skill.workspace.bundle.SkillBundleSource; @@ -19,6 +23,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -36,9 +41,19 @@ import java.util.regex.Pattern; *

    6. Re-install: only when the classpath SKILL.md frontmatter * {@code version} is strictly newer than the workspace copy. The * existing workspace is archived (not deleted) before overwrite.
    7. - *
    8. Same / older / unparseable version: leave the workspace alone so - * user edits aren't clobbered.
    9. + *
    10. Self-heal: when the bundle ships {@code scripts/} or + * {@code references/} but the workspace lacks that directory entirely + * (an install performed from a build whose jar was missing those + * folders), the workspace is archived and re-copied even though the + * version is unchanged.
    11. + *
    12. Same / older / unparseable version with all buckets present: leave + * the workspace alone so user edits aren't clobbered.
    13. * + * + *

      Whenever a bundle is copied to disk (install, upgrade, or self-heal), + * its {@code scripts/} and {@code references/} files are also persisted to + * the canonical {@code mate_skill_file} store so multi-instance deployments + * see the same content regardless of which node performed the sync. */ @Slf4j @Component @@ -48,10 +63,15 @@ public class BundledSkillSyncer { private static final Pattern VERSION_PATTERN = Pattern.compile("^version:\\s*[\"']?([^\"'\\s]+)[\"']?", Pattern.MULTILINE); + /** Builtin/bundled skills are global and materialized under the default workspace. */ + private static final Long BUILTIN_WORKSPACE_ID = 1L; + private final SkillWorkspaceProperties properties; private final SkillWorkspaceManager workspaceManager; private final SkillBundleMaterializer bundleMaterializer; private final ApplicationEventPublisher eventPublisher; + private final SkillService skillService; + private final SkillFileService skillFileService; /** * Run a full sync pass. Idempotent — safe to call from both startup @@ -87,42 +107,93 @@ public class BundledSkillSyncer { /** * Sync a single bundled skill. Returns true if the workspace was - * created or upgraded. Same/older versions are no-ops. + * created, upgraded, or self-healed. Same/older versions with all + * bundle buckets present on disk are no-ops. */ private boolean syncOne(ResourcePatternResolver resolver, String bundledPath, String skillName, Resource manifest) { - Path targetDir = workspaceManager.resolveConventionPath(skillName); + // Builtin/bundled skills are global and seeded into workspace 1. + Path targetDir = workspaceManager.resolveConventionPath(skillName, BUILTIN_WORKSPACE_ID); boolean firstInstall = !Files.exists(targetDir); + SkillBundleSource source = new ClasspathBundleSource(resolver, + bundledPath + "/" + skillName); + if (!firstInstall) { String bundledVersion = readVersion(manifest); String workspaceVersion = readVersion(targetDir.resolve("SKILL.md")); - if (bundledVersion == null || !isNewerVersion(bundledVersion, workspaceVersion)) { - log.debug("Bundled skill '{}' workspace is current (bundled={}, workspace={}), skipping", + boolean upgrade = bundledVersion != null && isNewerVersion(bundledVersion, workspaceVersion); + if (upgrade) { + log.info("Bundled skill '{}' version {} > workspace version {}, upgrading", skillName, bundledVersion, workspaceVersion); - return false; + } else { + List missing = missingBucketsOnDisk(source, targetDir); + if (missing.isEmpty()) { + log.debug("Bundled skill '{}' workspace is current (bundled={}, workspace={}), skipping", + skillName, bundledVersion, workspaceVersion); + return false; + } + log.info("Bundled skill '{}' is missing {} on disk, restoring from bundle", + skillName, missing); } - log.info("Bundled skill '{}' version {} > workspace version {}, upgrading", - skillName, bundledVersion, workspaceVersion); - workspaceManager.archiveWorkspace(skillName); + // Archive (never overwrite in place) so local edits stay recoverable. + workspaceManager.archiveWorkspace(skillName, BUILTIN_WORKSPACE_ID); } - copyBundle(resolver, bundledPath, skillName, targetDir); + copyBundle(source, targetDir); + syncBundleFilesToDb(skillName, source); eventPublisher.publishEvent( new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.CREATED, targetDir)); log.info("{} bundled skill '{}' → {}", firstInstall ? "Synced" : "Upgraded", skillName, targetDir); return true; } - private void copyBundle(ResourcePatternResolver resolver, String bundledPath, - String skillName, Path targetDir) { - SkillBundleSource source = new ClasspathBundleSource(resolver, - bundledPath + "/" + skillName); + /** + * Buckets ({@code scripts/}, {@code references/}) that ship in the + * bundle but are absent from the workspace directory — the fingerprint + * of an install performed from a build whose jar lacked those folders. + */ + private List missingBucketsOnDisk(SkillBundleSource source, Path targetDir) { + List missing = new ArrayList<>(); + try { + List assets = source.assets(); + for (String prefix : SkillBundleFiles.DB_BUCKET_PREFIXES) { + boolean inBundle = assets.stream().anyMatch(a -> a.relativePath().startsWith(prefix)); + String dirName = prefix.substring(0, prefix.length() - 1); + if (inBundle && !Files.isDirectory(targetDir.resolve(dirName))) { + missing.add(dirName); + } + } + } catch (IOException e) { + log.warn("Failed to enumerate bundle assets from {}: {}", source.origin(), e.getMessage()); + } + return missing; + } + + /** + * Mirror the bundle's DB-persisted buckets into {@code mate_skill_file}. + * Skipped silently when the skill row doesn't exist yet (first boot + * before seeding) — the skill file syncer's disk backfill covers that + * case once the row appears. + */ + private void syncBundleFilesToDb(String skillName, SkillBundleSource source) { + SkillEntity skill = skillService.findByName(skillName); + if (skill == null || skill.getId() == null) return; + try { + Map bundleFiles = SkillBundleFiles.readDbEligible(source); + if (bundleFiles.isEmpty()) return; + skillFileService.applyBundleFiles(skill.getId(), bundleFiles, false); + } catch (IOException e) { + log.warn("Failed to load bundle files from {}: {}", source.origin(), e.getMessage()); + } + } + + private void copyBundle(SkillBundleSource source, Path targetDir) { try { bundleMaterializer.materialize(source, targetDir, MaterializeOptions.verbatim()); } catch (IOException e) { - log.warn("Failed to copy bundled skill '{}' from {}: {}", - skillName, source.origin(), e.getMessage()); + log.warn("Failed to copy bundled skill from {}: {}", + source.origin(), e.getMessage()); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java index 9acafb7a..488ca086 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java @@ -2,11 +2,16 @@ package vip.mate.skill.workspace; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.stereotype.Component; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.model.SkillFileEntity; import vip.mate.skill.service.SkillFileService; import vip.mate.skill.service.SkillService; +import vip.mate.skill.workspace.bundle.ClasspathBundleSource; +import vip.mate.skill.workspace.bundle.SkillBundleFiles; +import vip.mate.skill.workspace.bundle.SkillBundleSource; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -15,18 +20,22 @@ import java.nio.file.Path; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; /** * Mirrors canonical {@code mate_skill_file} rows down to each node's local - * workspace cache so {@code scripts/} and {@code references/} files exist - * on disk wherever the skill might run. + * workspace cache so {@code scripts/}, {@code references/} and + * {@code templates/} files exist on disk wherever the skill might run. * *

      Also runs a one-time backfill: for any skill that has on-disk files * but no DB rows (typically pre-V112 installs), the local files are read - * up into the DB so the canonical store catches up to reality. Backfill - * is content-hash idempotent and safe to invoke repeatedly. + * up into the DB so the canonical store catches up to reality. Builtin + * skills with neither DB rows nor on-disk files fall back to re-reading + * the classpath bundle. Backfill is content-hash idempotent and safe to + * invoke repeatedly. * *

      Triggered: *

        @@ -45,6 +54,7 @@ public class SkillFileSyncer { private final SkillService skillService; private final SkillFileService skillFileService; private final SkillWorkspaceManager workspaceManager; + private final SkillWorkspaceProperties workspaceProperties; /** Aggregate counters for one full sync pass. */ public record SyncReport(int skillsConsidered, @@ -92,13 +102,16 @@ public class SkillFileSyncer { * are restored. */ public PerSkillReport syncOne(SkillEntity skill) { - Path workspaceDir = workspaceManager.resolveConventionPath(skill.getName()); + Path workspaceDir = workspaceManager.resolveConventionPath(skill.getName(), skill.getWorkspaceId()); List dbFiles = skillFileService.listBySkillId(skill.getId()); boolean didBackfill = false; int backfilled = 0; if (dbFiles.isEmpty()) { backfilled = backfillFromDiskIfNeeded(skill, workspaceDir); + if (backfilled == 0 && Boolean.TRUE.equals(skill.getBuiltin())) { + backfilled = backfillFromClasspathIfNeeded(skill); + } if (backfilled > 0) { didBackfill = true; dbFiles = skillFileService.listBySkillId(skill.getId()); @@ -120,14 +133,42 @@ public class SkillFileSyncer { return new PerSkillReport(materialized, alreadyCurrent, backfilled, didBackfill); } + /** + * Backfills builtin skill bundle files from the classpath when both the + * DB and the local workspace are empty — the state left behind by an + * install whose jar shipped without {@code scripts/}/{@code references/}. + */ + private int backfillFromClasspathIfNeeded(SkillEntity skill) { + String bundledPath = workspaceProperties.getBundledSkillsPath(); + if (bundledPath == null || bundledPath.isBlank()) return 0; + + ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + SkillBundleSource source = new ClasspathBundleSource(resolver, + bundledPath + "/" + skill.getName()); + + Map ingested; + try { + ingested = SkillBundleFiles.readDbEligible(source); + } catch (IOException e) { + log.warn("Failed to backfill builtin skill '{}' from classpath: {}", skill.getName(), e.getMessage()); + return 0; + } + if (ingested.isEmpty()) return 0; + + skillFileService.applyBundleFiles(skill.getId(), ingested, false); + log.info("Backfilled {} bundle file(s) from classpath into mate_skill_file for builtin skill '{}' (id={})", + ingested.size(), skill.getName(), skill.getId()); + return ingested.size(); + } + private enum MaterializeOutcome { WROTE, CURRENT, SKIPPED } private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) { String relative = row.getFilePath(); if (relative == null || relative.isBlank()) return MaterializeOutcome.SKIPPED; - if (!relative.startsWith("references/") && !relative.startsWith("scripts/")) { - log.warn("Skipping skill_file row {} — path outside scripts/ or references/: {}", - row.getId(), relative); + if (!SkillBundleFiles.isDbEligible(relative)) { + log.warn("Skipping skill_file row {} — path outside the DB-persisted buckets ({}): {}", + row.getId(), SkillBundleFiles.DB_BUCKET_PREFIXES, relative); return MaterializeOutcome.SKIPPED; } if (relative.contains("..")) { @@ -167,14 +208,14 @@ public class SkillFileSyncer { private int backfillFromDiskIfNeeded(SkillEntity skill, Path workspaceDir) { if (!Files.exists(workspaceDir) || !Files.isDirectory(workspaceDir)) return 0; - List roots = new ArrayList<>(2); - Path scripts = workspaceDir.resolve("scripts"); - Path references = workspaceDir.resolve("references"); - if (Files.isDirectory(scripts)) roots.add(scripts); - if (Files.isDirectory(references)) roots.add(references); + List roots = new ArrayList<>(SkillBundleFiles.DB_BUCKET_PREFIXES.size()); + for (String prefix : SkillBundleFiles.DB_BUCKET_PREFIXES) { + Path root = workspaceDir.resolve(prefix.substring(0, prefix.length() - 1)); + if (Files.isDirectory(root)) roots.add(root); + } if (roots.isEmpty()) return 0; - java.util.Map ingested = new java.util.LinkedHashMap<>(); + Map ingested = new LinkedHashMap<>(); Set seen = new HashSet<>(); for (Path root : roots) { String prefix = workspaceDir.relativize(root).toString().replace('\\', '/') + "/"; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java index 029cda4a..a506863c 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java @@ -6,6 +6,8 @@ import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; import java.util.List; @@ -37,12 +39,20 @@ public class SkillWorkspaceBootstrapRunner implements ApplicationRunner { private final SkillWorkspaceManager workspaceManager; private final BundledSkillSyncer bundledSkillSyncer; private final SkillFileSyncer skillFileSyncer; + private final SkillService skillService; @Override public void run(ApplicationArguments args) { var root = workspaceManager.getWorkspaceRoot(); log.info("Skill workspace root ready: {}", root); + // Step 0 — one-time layout migration BEFORE any sync: move legacy flat + // {root}/{name} dirs into their workspace-scoped {root}/{workspaceId}/{name} + // location. Must run before skillFileSyncer.syncAll(), else the syncer + // would materialize DB content at the new scoped path first and leave the + // old flat dir (and any on-disk-only files it holds) orphaned. + migrateLegacyLayout(); + List synced = bundledSkillSyncer.sync(); if (!synced.isEmpty()) { log.info("Synced {} bundled skill(s) to workspace: {}", synced.size(), synced); @@ -57,4 +67,25 @@ public class SkillWorkspaceBootstrapRunner implements ApplicationRunner { report.filesAlreadyCurrent(), report.skillsBackfilled(), report.filesBackfilledFromDisk()); } + + /** + * Walk every persisted skill and migrate its legacy flat workspace directory + * into the workspace-scoped layout. Idempotent — once migrated, subsequent + * starts find nothing to move. + */ + private void migrateLegacyLayout() { + List skills = skillService.listSkills(); + int moved = 0; + for (SkillEntity skill : skills) { + if (skill.getName() == null) { + continue; + } + if (workspaceManager.migrateLegacyFlatDir(skill.getName(), skill.getWorkspaceId())) { + moved++; + } + } + if (moved > 0) { + log.info("Migrated {} legacy skill workspace dir(s) to the workspace-scoped layout", moved); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java index fdcd3417..17dfdeed 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java @@ -47,18 +47,34 @@ public class SkillWorkspaceManager { } /** - * Resolve the conventional skill workspace path: {@code {root}/{sanitizedName}}. + * Resolve the conventional skill workspace path: + * {@code {root}/{workspaceId}/{sanitizedName}}. *

        - * Deterministic in {@code skillName} alone (no filesystem-state dependency). The - * non-ASCII collision fixed in #254 comes from {@link #sanitizeNameForFs} preserving - * Unicode letters/digits, so distinct names already map to distinct directories. No - * {@code -hash} suffix is appended: skill names are charset-constrained, so two names - * sanitizing to the same string is not a real case, and keeping the bare name avoids - * changing the path scheme for every existing skill (which would orphan already-created - * workspaces with no migration). + * Scoping the path by {@code workspaceId} keeps same-named skills in different + * workspaces on disjoint disk directories, so they no longer share a workspace + * directory, overwrite each other, or cross script execution between tenants. + * A {@code null} workspaceId falls back to workspace {@code 1} (the default + * workspace) — this is a defensive fallback only; real call sites must pass the + * skill's true owning workspace. + *

        + * Deterministic in {@code (skillName, workspaceId)} alone (no filesystem-state + * dependency). The non-ASCII collision fixed in #254 comes from + * {@link #sanitizeNameForFs} preserving Unicode letters/digits, so distinct names + * already map to distinct directories. No {@code -hash} suffix is appended: skill + * names are charset-constrained, so two names sanitizing to the same string is not + * a real case. */ - public Path resolveConventionPath(String skillName) { - return getWorkspaceRoot().resolve(sanitizeNameForFs(skillName)); + public Path resolveConventionPath(String skillName, Long workspaceId) { + return workspaceScopedRoot(workspaceId).resolve(sanitizeNameForFs(skillName)); + } + + /** + * Per-workspace root: {@code {root}/{workspaceId}}. A {@code null} workspaceId + * maps to workspace {@code 1} so a missing scope never resolves to the bare root + * (which would reintroduce the cross-workspace collision this scheme prevents). + */ + private Path workspaceScopedRoot(Long workspaceId) { + return getWorkspaceRoot().resolve(String.valueOf(workspaceId == null ? 1L : workspaceId)); } /** @@ -87,7 +103,7 @@ public class SkillWorkspaceManager { *

      • null(无目录,回退数据库)
      • * */ - public Path resolveEffectivePath(String skillName, String configuredDir) { + public Path resolveEffectivePath(String skillName, String configuredDir, Long workspaceId) { // 1. 显式配置 if (configuredDir != null && !configuredDir.isBlank()) { Path explicit = Paths.get(configuredDir); @@ -96,7 +112,7 @@ public class SkillWorkspaceManager { } } // 2. 约定路径 - Path convention = resolveConventionPath(skillName); + Path convention = resolveConventionPath(skillName, workspaceId); if (Files.exists(convention) && Files.isDirectory(convention)) { return convention; } @@ -107,11 +123,53 @@ public class SkillWorkspaceManager { /** * 检查约定路径的 workspace 是否存在 */ - public boolean conventionWorkspaceExists(String skillName) { - Path convention = resolveConventionPath(skillName); + public boolean conventionWorkspaceExists(String skillName, Long workspaceId) { + Path convention = resolveConventionPath(skillName, workspaceId); return Files.exists(convention) && Files.isDirectory(convention); } + /** + * One-time layout migration: the legacy scheme stored every skill flat at + * {@code {root}/{name}}; the workspace-scoped scheme stores it at + * {@code {root}/{workspaceId}/{name}}. Move a pre-existing flat directory + * into its scoped location so the skill's scripts/references survive the + * scheme change instead of being silently orphaned (the runtime would + * otherwise fall back to DB content and lose on-disk-only files). + * + *

        Idempotent and safe to run on every startup: + *

          + *
        • no-op if the flat directory is absent (already migrated, or new install);
        • + *
        • no-op if the scoped target already exists (never overwrites);
        • + *
        • skips anything without a top-level {@code SKILL.md} so a workspace-scoped + * root like {@code {root}/1} (whose children are skills, not a skill itself) + * is never mistaken for a legacy flat skill directory.
        • + *
        + * + * @return {@code true} only when a move actually happened. + */ + public boolean migrateLegacyFlatDir(String skillName, Long workspaceId) { + Path legacy = getWorkspaceRoot().resolve(sanitizeNameForFs(skillName)); + Path scoped = resolveConventionPath(skillName, workspaceId); + if (legacy.equals(scoped)) { + return false; // scoped always adds a {workspaceId} segment; defensive only + } + if (!Files.isDirectory(legacy) || !Files.exists(legacy.resolve("SKILL.md"))) { + return false; // absent, or not a real flat skill workspace + } + if (Files.exists(scoped)) { + return false; // already migrated / target present — never overwrite + } + try { + Files.createDirectories(scoped.getParent()); + Files.move(legacy, scoped, StandardCopyOption.ATOMIC_MOVE); + log.info("Migrated legacy skill workspace {} -> {}", legacy, scoped); + return true; + } catch (IOException e) { + log.warn("Failed to migrate legacy skill workspace {} -> {}: {}", legacy, scoped, e.getMessage()); + return false; + } + } + // ==================== 生命周期操作 ==================== /** @@ -121,8 +179,8 @@ public class SkillWorkspaceManager { * @param initialContent SKILL.md 初始内容(可为 null) * @return 创建的工作区路径 */ - public Path initWorkspace(String skillName, String initialContent) { - return initWorkspace(skillName, initialContent, false); + public Path initWorkspace(String skillName, String initialContent, Long workspaceId) { + return initWorkspace(skillName, initialContent, false, workspaceId); } /** @@ -134,8 +192,8 @@ public class SkillWorkspaceManager { * false 时仅在 SKILL.md 不存在时写入(用于首次创建) * @return 创建的工作区路径 */ - public Path initWorkspace(String skillName, String initialContent, boolean overwrite) { - Path workspaceDir = resolveConventionPath(skillName); + public Path initWorkspace(String skillName, String initialContent, boolean overwrite, Long workspaceId) { + Path workspaceDir = resolveConventionPath(skillName, workspaceId); try { Files.createDirectories(workspaceDir); Files.createDirectories(workspaceDir.resolve("references")); @@ -170,8 +228,8 @@ public class SkillWorkspaceManager { * by hard-delete only; uninstall still calls * {@link #archiveWorkspace} so users can recover by re-installing. */ - public void purgeWorkspace(String skillName) { - Path workspaceDir = resolveConventionPath(skillName); + public void purgeWorkspace(String skillName, Long workspaceId) { + Path workspaceDir = resolveConventionPath(skillName, workspaceId); if (!Files.exists(workspaceDir)) return; try { // Walk and delete bottom-up so non-empty dirs go away too. @@ -218,7 +276,8 @@ public class SkillWorkspaceManager { } /** - * Move {@code {root}/{name}/} to {@code {root}/.archived/{name}-{ts}/}. + * Move {@code {root}/{workspaceId}/{name}/} to + * {@code {root}/{workspaceId}/.archived/{name}-{ts}/}. * *

        Returns {@link ArchiveResult#MISSING} when the workspace doesn't * exist — callers may treat this as a successful no-op since the runtime @@ -228,14 +287,14 @@ public class SkillWorkspaceManager { * {@link ArchiveResult#MOVED} on success, having already published * {@link SkillWorkspaceEvent.Type#ARCHIVED}. */ - public ArchiveResult archiveWorkspace(String skillName) { - Path workspaceDir = resolveConventionPath(skillName); + public ArchiveResult archiveWorkspace(String skillName, Long workspaceId) { + Path workspaceDir = resolveConventionPath(skillName, workspaceId); if (!Files.exists(workspaceDir)) { return ArchiveResult.MISSING; } try { - Path archiveRoot = getWorkspaceRoot().resolve(".archived"); + Path archiveRoot = workspaceScopedRoot(workspaceId).resolve(".archived"); Files.createDirectories(archiveRoot); String archiveName = sanitizeName(skillName) + "-" + LocalDateTime.now().format(ARCHIVE_TS); @@ -261,13 +320,13 @@ public class SkillWorkspaceManager { * {@link RestoreResult#FAILED} when an archive directory exists but the * move-back fails. */ - public RestoreResult restoreWorkspace(String skillName) { - Path target = resolveConventionPath(skillName); + public RestoreResult restoreWorkspace(String skillName, Long workspaceId) { + Path target = resolveConventionPath(skillName, workspaceId); if (Files.exists(target)) { log.warn("restoreWorkspace skipped: target {} already exists", target); return RestoreResult.MISSING; } - Path archiveRoot = getWorkspaceRoot().resolve(".archived"); + Path archiveRoot = workspaceScopedRoot(workspaceId).resolve(".archived"); if (!Files.exists(archiveRoot)) { return RestoreResult.MISSING; } @@ -313,9 +372,9 @@ public class SkillWorkspaceManager { /** * 将数据库 skill 内容导出到工作区目录 */ - public Path exportToWorkspace(String skillName, String skillContent) { + public Path exportToWorkspace(String skillName, String skillContent, Long workspaceId) { // 始终覆写 SKILL.md(initWorkspace 内部已写入),无需再做一次冗余 IO - Path workspaceDir = initWorkspace(skillName, skillContent, true); + Path workspaceDir = initWorkspace(skillName, skillContent, true, workspaceId); if (workspaceDir != null) { log.info("Exported skill '{}' to workspace: {}", skillName, workspaceDir); eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.EXPORTED, workspaceDir)); @@ -338,8 +397,8 @@ public class SkillWorkspaceManager { * @param content 文件内容 * @throws IllegalArgumentException 如果路径不安全 */ - public void writeWorkspaceFile(String skillName, String relativePath, String content) { - Path workspaceDir = resolveConventionPath(skillName); + public void writeWorkspaceFile(String skillName, String relativePath, String content, Long workspaceId) { + Path workspaceDir = resolveConventionPath(skillName, workspaceId); // 路径安全校验 Path safePath = validateWritePath(workspaceDir, relativePath); @@ -356,12 +415,46 @@ public class SkillWorkspaceManager { } } + /** + * 从 skill 工作区删除单个文件(与 {@link #writeWorkspaceFile} 相同的 + * 路径安全约束)。文件不存在时为 no-op。顺带清理删空的父目录, + * 避免目录树里残留空壳。 + * + * @throws IllegalArgumentException 如果路径不安全 + */ + public void deleteWorkspaceFile(String skillName, String relativePath, Long workspaceId) { + Path workspaceDir = resolveConventionPath(skillName, workspaceId); + Path safePath = validateWritePath(workspaceDir, relativePath); + if (safePath == null) { + throw new IllegalArgumentException("Unsafe file path rejected: " + relativePath); + } + try { + Files.deleteIfExists(safePath); + // Prune emptied parent dirs up to (not including) the bucket root. + Path parent = safePath.getParent(); + Path scriptsRoot = workspaceDir.resolve("scripts"); + Path referencesRoot = workspaceDir.resolve("references"); + Path templatesRoot = workspaceDir.resolve("templates"); + while (parent != null && !parent.equals(scriptsRoot) && !parent.equals(referencesRoot) + && !parent.equals(templatesRoot) + && parent.startsWith(workspaceDir) && !parent.equals(workspaceDir)) { + try (var children = Files.list(parent)) { + if (children.findAny().isPresent()) break; + } + Files.delete(parent); + parent = parent.getParent(); + } + } catch (IOException e) { + log.warn("Failed to delete workspace file {}/{}: {}", skillName, relativePath, e.getMessage()); + } + } + /** * 清空 skill 工作区中的 references/ 和 scripts/ 目录内容(保留目录本身) * 用于 overwrite 安装前清除旧版本残留文件 */ - public void cleanWorkspaceDataDirs(String skillName) { - Path workspaceDir = resolveConventionPath(skillName); + public void cleanWorkspaceDataDirs(String skillName, Long workspaceId) { + Path workspaceDir = resolveConventionPath(skillName, workspaceId); cleanDirectoryContents(workspaceDir.resolve("references")); cleanDirectoryContents(workspaceDir.resolve("scripts")); } @@ -405,8 +498,9 @@ public class SkillWorkspaceManager { public ApplyBundleResult applyBundleFiles(String skillName, Map references, Map scripts, - boolean force) { - Path workspaceDir = resolveConventionPath(skillName); + boolean force, + Long workspaceId) { + Path workspaceDir = resolveConventionPath(skillName, workspaceId); try { Files.createDirectories(workspaceDir.resolve("references")); Files.createDirectories(workspaceDir.resolve("scripts")); @@ -414,8 +508,8 @@ public class SkillWorkspaceManager { log.warn("Failed to ensure data dirs for skill '{}': {}", skillName, e.getMessage()); } - int refsWritten = applyBucket(skillName, "references/", references); - int scriptsWritten = applyBucket(skillName, "scripts/", scripts); + int refsWritten = applyBucket(skillName, "references/", references, workspaceId); + int scriptsWritten = applyBucket(skillName, "scripts/", scripts, workspaceId); var refsPrune = pruneBucket(workspaceDir.resolve("references"), normalizeKeys(references), force, skillName, "references"); @@ -428,14 +522,14 @@ public class SkillWorkspaceManager { ); } - private int applyBucket(String skillName, String bucketPrefix, Map entries) { + private int applyBucket(String skillName, String bucketPrefix, Map entries, Long workspaceId) { if (entries == null || entries.isEmpty()) return 0; int written = 0; for (var e : entries.entrySet()) { String key = e.getKey(); String relative = key.startsWith(bucketPrefix) ? key : (bucketPrefix + key); try { - writeWorkspaceFile(skillName, relative, e.getValue()); + writeWorkspaceFile(skillName, relative, e.getValue(), workspaceId); written++; } catch (RuntimeException ex) { log.warn("Failed to write {} for skill '{}': {}", relative, skillName, ex.getMessage()); @@ -531,8 +625,9 @@ public class SkillWorkspaceManager { // 归一化分隔符 String normalized = relativePath.replace("\\", "/"); - // 必须以 references/ 或 scripts/ 开头 - if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")) { + // 必须以 references/、scripts/ 或 templates/ 开头 + if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/") + && !normalized.startsWith("templates/")) { return null; } @@ -583,8 +678,8 @@ public class SkillWorkspaceManager { /** * 获取 skill 工作区信息 */ - public Map getWorkspaceInfo(String skillName) { - Path workspaceDir = resolveConventionPath(skillName); + public Map getWorkspaceInfo(String skillName, Long workspaceId) { + Path workspaceDir = resolveConventionPath(skillName, workspaceId); Map info = new LinkedHashMap<>(); info.put("skillName", skillName); info.put("conventionPath", workspaceDir.toString()); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/bundle/SkillBundleFiles.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/bundle/SkillBundleFiles.java new file mode 100644 index 00000000..17e453a3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/bundle/SkillBundleFiles.java @@ -0,0 +1,53 @@ +package vip.mate.skill.workspace.bundle; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Helpers for the bundle file buckets that mirror into the canonical + * {@code mate_skill_file} store ({@code scripts/}, {@code references/} + * and {@code templates/}). + * + *

        Shared by the bundled-skill startup sync, the skill file syncer's + * classpath backfill, and the admin file editor so all agree on which + * paths are DB-persisted and how bundle contents are read into memory. + */ +public final class SkillBundleFiles { + + /** Path prefixes of the buckets persisted to {@code mate_skill_file}. */ + public static final List DB_BUCKET_PREFIXES = List.of("scripts/", "references/", "templates/"); + + private SkillBundleFiles() { + } + + /** True when the workspace-relative path belongs to a DB-persisted bucket. */ + public static boolean isDbEligible(String relativePath) { + if (relativePath == null) return false; + for (String prefix : DB_BUCKET_PREFIXES) { + if (relativePath.startsWith(prefix)) return true; + } + return false; + } + + /** + * Read every DB-eligible bundle file ({@link #DB_BUCKET_PREFIXES}) + * into memory, keyed by workspace-relative path (the key shape + * {@code SkillFileService#applyBundleFiles} expects). Iteration order + * follows {@link SkillBundleSource#assets()} enumeration order. + */ + public static Map readDbEligible(SkillBundleSource source) throws IOException { + Map files = new LinkedHashMap<>(); + for (SkillBundleSource.BundleAsset asset : source.assets()) { + String path = asset.relativePath(); + if (!isDbEligible(path)) continue; + try (InputStream is = asset.open().get()) { + files.put(path, new String(is.readAllBytes(), StandardCharsets.UTF_8)); + } + } + return files; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java index 2687d01f..109b9de1 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java @@ -10,6 +10,13 @@ public class SystemSettingsDTO { private Boolean debugMode; private Boolean stateGraphEnabled; + /** + * Default workspace storage root (global fallback sandbox root). Empty + * string = not overridden, fall back to the yml/env configuration. Null on + * save = field not submitted (partial payloads keep the stored value). + */ + private String workspaceStorageRoot; + // ===== 搜索服务配置 ===== private Boolean searchEnabled; /** serper / tavily */ diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index ce85061c..16f58427 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -1,20 +1,31 @@ package vip.mate.system.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; +import vip.mate.exception.MateClawException; import vip.mate.plugin.PluginManager; import vip.mate.system.model.SearchProviderCatalogEntry; import vip.mate.system.model.SearchProviderCatalogResponse; import vip.mate.system.model.SystemSettingEntity; import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.repository.SystemSettingMapper; +import vip.mate.tool.guard.WorkspacePathGuard; import vip.mate.tool.search.SearchProvider; import vip.mate.tool.search.SearchProviderRegistry; +import vip.mate.workspace.core.config.WorkspaceSandboxProperties; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.Set; +@Slf4j @Service public class SystemSettingService { @@ -78,6 +89,14 @@ public class SystemSettingService { private static final String DEFAULT_VISION_MODEL_KEY = "default.vision_model"; private static final String DEFAULT_VIDEO_MODEL_KEY = "default.video_model"; + /** + * Default workspace storage root override. When set, it replaces the + * yml/env-configured {@code mateclaw.workspace.sandbox.root} as the global + * fallback sandbox root for conversations without a per-workspace base + * path. Empty string means "not overridden" (fall back to yml/env). + */ + private static final String WORKSPACE_STORAGE_ROOT_KEY = "workspace.storage_root"; + private static final String ZHIPU_API_KEY_KEY = "zhipuApiKey"; private static final String ZHIPU_BASE_URL_KEY = "zhipuBaseUrl"; private static final String FAL_API_KEY_KEY = "falApiKey"; @@ -100,6 +119,7 @@ public class SystemSettingService { private final SystemSettingMapper systemSettingMapper; private final SearchProviderRegistry searchProviderRegistry; private final SettingCrypto settingCrypto; + private final WorkspaceSandboxProperties workspaceSandboxProperties; /** * {@code PluginManager} is injected lazily because the bean graph is @@ -118,10 +138,12 @@ public class SystemSettingService { public SystemSettingService(SystemSettingMapper systemSettingMapper, SearchProviderRegistry searchProviderRegistry, SettingCrypto settingCrypto, + WorkspaceSandboxProperties workspaceSandboxProperties, @Lazy PluginManager pluginManager) { this.systemSettingMapper = systemSettingMapper; this.searchProviderRegistry = searchProviderRegistry; this.settingCrypto = settingCrypto; + this.workspaceSandboxProperties = workspaceSandboxProperties; this.pluginManager = pluginManager; } @@ -210,6 +232,9 @@ public class SystemSettingService { // Multimodal sidecar routing — empty string means "not configured" dto.setDefaultVisionModelId(parseIdOrNull(getValue(DEFAULT_VISION_MODEL_KEY, ""))); dto.setDefaultVideoModelId(parseIdOrNull(getValue(DEFAULT_VIDEO_MODEL_KEY, ""))); + + // Default workspace storage root — empty string means "not overridden" + dto.setWorkspaceStorageRoot(getValue(WORKSPACE_STORAGE_ROOT_KEY, "")); return dto; } @@ -461,9 +486,90 @@ public class SystemSettingService { String.valueOf(dto.getDefaultVideoModelId()), "Default video-capable model id (mate_model_config.id) for sidecar routing"); } + + // Default workspace storage root. null = field not submitted (partial + // save from an unrelated settings page); blank = explicit clear, fall + // back to the yml/env-configured sandbox root. Applied immediately — + // no restart required. Only affects newly created files; existing data + // is never migrated. + if (dto.getWorkspaceStorageRoot() != null) { + String root = dto.getWorkspaceStorageRoot().trim(); + if (!root.isEmpty()) { + validateWorkspaceStorageRoot(root); + } + saveValue(WORKSPACE_STORAGE_ROOT_KEY, root, "默认工作空间存储路径(全局兜底沙箱根,空=使用配置文件默认值)"); + applyWorkspaceStorageRoot(root); + } return getSettings(); } + /** + * Reject a storage root that could never work: relative paths (the guard + * needs a stable absolute boundary) and paths that cannot be created. + */ + private void validateWorkspaceStorageRoot(String root) { + Path path; + try { + path = Paths.get(root); + } catch (InvalidPathException e) { + throw new MateClawException("err.settings.storage_root_invalid", 400, + "Invalid storage path: " + e.getMessage()); + } + if (!path.isAbsolute()) { + throw new MateClawException("err.settings.storage_root_not_absolute", 400, + "Storage path must be absolute: " + root); + } + try { + Files.createDirectories(path); + } catch (Exception e) { + throw new MateClawException("err.settings.storage_root_create_failed", 400, + "Cannot create storage directory " + root + ": " + e.getMessage()); + } + } + + /** + * Register the effective global fallback sandbox root with + * {@link WorkspacePathGuard}. Priority: DB override > yml/env > built-in + * default. A blank override restores the yml/env-configured behaviour, + * including the {@code enabled=false} escape hatch. + */ + private void applyWorkspaceStorageRoot(String override) { + if (override == null || override.isBlank()) { + if (workspaceSandboxProperties.isEnabled()) { + Path root = Paths.get(workspaceSandboxProperties.getRoot()).toAbsolutePath().normalize(); + WorkspacePathGuard.setDefaultRoot(root.toString()); + } else { + WorkspacePathGuard.setDefaultRoot(null); + } + return; + } + Path root = Paths.get(override).toAbsolutePath().normalize(); + WorkspacePathGuard.setDefaultRoot(root.toString()); + } + + /** + * Apply a persisted storage-root override once the database is ready. + * Startup registration order: WorkspaceSandboxAutoConfiguration registers + * the yml/env root at context construction, then this listener overrides + * it with the DB value when one is set. + */ + @EventListener(ApplicationReadyEvent.class) + public void applyPersistedWorkspaceStorageRoot() { + String root = getValue(WORKSPACE_STORAGE_ROOT_KEY, ""); + if (root == null || root.isBlank()) { + return; + } + try { + Files.createDirectories(Paths.get(root)); + } catch (Exception e) { + // Registering the root still tightens the boundary even if the + // directory can't be pre-created; log and continue. + log.warn("[SystemSetting] Failed to create workspace storage root {}: {}", root, e.getMessage()); + } + applyWorkspaceStorageRoot(root); + log.info("[SystemSetting] Workspace storage root override applied: {}", root); + } + /** * Dedicated update path for the multimodal sidecar configuration. *

        diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java new file mode 100644 index 00000000..5bc0aded --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java @@ -0,0 +1,398 @@ +package vip.mate.team.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.common.result.R; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.AgentTeamMemberEntity; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import vip.mate.team.model.TeamTaskCommentEntity; +import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskEventEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.service.TeamAnnounceService; +import vip.mate.team.service.TeamDispatchService; +import vip.mate.team.service.TeamEventChannel; +import vip.mate.team.service.TeamService; +import vip.mate.team.service.TeamTaskService; + +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Admin REST surface for agent teams: team/membership CRUD, the shared task + * board, and the human-in-the-loop approve / reject / retry actions. + * + * All Long ids serialize as JSON strings (global Jackson config) and inbound + * bodies accept both string and numeric forms, keeping Snowflake ids intact + * across the JS frontend. + * + * @author MateClaw Team + */ +@Tag(name = "Agent 团队管理") +@RestController +@RequestMapping("/api/v1/teams") +@RequiredArgsConstructor +public class TeamController { + + private final TeamService teamService; + private final TeamTaskService taskService; + private final TeamDispatchService dispatchService; + private final TeamAnnounceService announceService; + private final TeamEventChannel eventChannel; + private final AgentMapper agentMapper; + + // ==================== team CRUD ==================== + + @Operation(summary = "团队列表") + @GetMapping + public R> list() { + return R.ok(teamService.listTeams().stream().map(this::toVO).toList()); + } + + @Operation(summary = "团队详情(含成员)") + @GetMapping("/{id}") + public R get(@PathVariable Long id) { + AgentTeamEntity team = teamService.getTeam(id); + if (team == null) { + return R.fail("team not found"); + } + List members = teamService.listMembers(id).stream() + .map(m -> { + AgentEntity agent = agentMapper.selectById(m.getAgentId()); + return new MemberVO(m.getAgentId(), + agent != null && agent.getName() != null ? agent.getName() + : String.valueOf(m.getAgentId()), + m.getRole(), + agent != null ? agent.getIcon() : null); + }) + .toList(); + return R.ok(new TeamDetailVO(toVO(team), members)); + } + + @Operation(summary = "创建团队") + @PostMapping + public R create(@RequestBody CreateTeamRequest req, Principal principal) { + return guarded(() -> { + AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(), + req.getLeadAgentId(), req.getMemberAgentIds(), + principal != null ? principal.getName() : "admin"); + return R.ok(toVO(team)); + }); + } + + @Operation(summary = "更新团队") + @PutMapping("/{id}") + public R update(@PathVariable Long id, @RequestBody UpdateTeamRequest req) { + return guarded(() -> R.ok(toVO(teamService.updateTeam(id, req.getName(), + req.getDescription(), req.getSettings())))); + } + + @Operation(summary = "删除团队") + @DeleteMapping("/{id}") + public R delete(@PathVariable Long id) { + return guarded(() -> { + teamService.deleteTeam(id); + return R.ok(null); + }); + } + + // ==================== membership ==================== + + @Operation(summary = "添加成员") + @PostMapping("/{id}/members") + public R addMember(@PathVariable Long id, @RequestBody MemberRequest req) { + return guarded(() -> { + teamService.addMember(id, req.getAgentId(), req.getRole()); + return R.ok(null); + }); + } + + @Operation(summary = "移除成员") + @DeleteMapping("/{id}/members/{agentId}") + public R removeMember(@PathVariable Long id, @PathVariable Long agentId) { + return guarded(() -> { + teamService.removeMember(id, agentId); + return R.ok(null); + }); + } + + // ==================== task board ==================== + + @Operation(summary = "任务板列表") + @GetMapping("/{id}/tasks") + public R> listTasks(@PathVariable Long id, + @RequestParam(required = false) List status, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Integer offset) { + return R.ok(taskService.listTasks(id, status, limit, offset).stream() + .map(this::toTaskVO).toList()); + } + + @Operation(summary = "任务详情(含评论)") + @GetMapping("/{id}/tasks/{taskId}") + public R getTask(@PathVariable Long id, @PathVariable Long taskId) { + return guarded(() -> { + TeamTaskEntity task = requireTask(id, taskId); + return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId))); + }); + } + + @Operation(summary = "手动创建任务") + @PostMapping("/{id}/tasks") + public R createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req, + Principal principal) { + return guarded(() -> { + TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() + .teamId(id) + .subject(req.getSubject()) + .description(req.getDescription()) + .assigneeAgentId(req.getAssigneeAgentId()) + .priority(req.getPriority()) + .blockedBy(req.getBlockedBy()) + .requireApproval(Boolean.TRUE.equals(req.getRequireApproval())) + .username(principal != null ? principal.getName() : null) + .channel("dashboard") + .build()); + eventChannel.publishTaskEvent(task, "team_task_created", Map.of()); + if (TeamTaskStatus.PENDING.equals(task.getStatus())) { + dispatchService.requestDispatch(id); + } + return R.ok(toTaskVO(task)); + }); + } + + @Operation(summary = "批准 in_review 任务") + @PostMapping("/{id}/tasks/{taskId}/approve") + public R approve(@PathVariable Long id, @PathVariable Long taskId, + Principal principal) { + return guarded(() -> { + requireTask(id, taskId); + List released = taskService.approveTask(taskId); + recordUserEvent(id, taskId, TeamTaskEventEntity.APPROVED, principal, null); + publishBoardEvent(taskId, "team_task_approved"); + if (!released.isEmpty()) { + dispatchService.requestDispatch(id); + } + return R.ok(toTaskVO(taskService.getTask(taskId))); + }); + } + + @Operation(summary = "驳回 in_review 任务") + @PostMapping("/{id}/tasks/{taskId}/reject") + public R reject(@PathVariable Long id, @PathVariable Long taskId, + @RequestBody(required = false) ReasonRequest req, + Principal principal) { + return guarded(() -> { + requireTask(id, taskId); + taskService.rejectTask(taskId, req == null ? null : req.getReason()); + recordUserEvent(id, taskId, TeamTaskEventEntity.REJECTED, principal, + req == null ? null : req.getReason()); + publishBoardEvent(taskId, "team_task_rejected"); + TeamTaskEntity task = taskService.getTask(taskId); + // The lead must hear about the rejection to re-plan or retry. + announceService.announceTaskSettled(task); + dispatchService.requestDispatch(id); + return R.ok(toTaskVO(task)); + }); + } + + @Operation(summary = "重试 failed/stale 任务") + @PostMapping("/{id}/tasks/{taskId}/retry") + public R retry(@PathVariable Long id, @PathVariable Long taskId, + Principal principal) { + return guarded(() -> { + requireTask(id, taskId); + if (!taskService.retryTask(taskId)) { + return R.fail("only failed or stale tasks can be retried"); + } + recordUserEvent(id, taskId, TeamTaskEventEntity.RETRIED, principal, null); + publishBoardEvent(taskId, "team_task_retried"); + dispatchService.requestDispatch(id); + return R.ok(toTaskVO(taskService.getTask(taskId))); + }); + } + + @Operation(summary = "取消任务") + @PostMapping("/{id}/tasks/{taskId}/cancel") + public R cancel(@PathVariable Long id, @PathVariable Long taskId, + @RequestBody(required = false) ReasonRequest req, + Principal principal) { + return guarded(() -> { + TeamTaskEntity task = requireTask(id, taskId); + List released = taskService.cancelTask(taskId, req == null ? null : req.getReason()); + recordUserEvent(id, taskId, TeamTaskEventEntity.CANCELLED, principal, + req == null ? null : req.getReason()); + publishBoardEvent(taskId, "team_task_cancelled"); + // Stop the member run mid-flight instead of letting it burn to the end. + dispatchService.interruptRun(task); + if (!released.isEmpty()) { + dispatchService.requestDispatch(id); + } + return R.ok(toTaskVO(taskService.getTask(taskId))); + }); + } + + @Operation(summary = "任务时间线") + @GetMapping("/{id}/tasks/{taskId}/events") + public R> taskEvents(@PathVariable Long id, @PathVariable Long taskId) { + return guarded(() -> { + requireTask(id, taskId); + return R.ok(taskService.listEvents(taskId)); + }); + } + + @Operation(summary = "团队事件流(SSE)") + @GetMapping("/{id}/events") + public SseEmitter events(@PathVariable Long id, + @RequestHeader(value = "Last-Event-ID", required = false) Long lastEventId) { + SseEmitter emitter = new SseEmitter(0L); + // A fresh subscription is an activity ticker, not a transcript: skip + // the ring-buffer replay (stale events would render as breaking news) + // and deliver live events only. A reconnect carrying Last-Event-ID + // keeps the resume-from-where-I-left semantics. + eventChannel.attach(id, emitter, lastEventId == null ? Long.MAX_VALUE : lastEventId); + return emitter; + } + + private void recordUserEvent(Long teamId, Long taskId, String eventType, + Principal principal, String detail) { + taskService.recordEvent(teamId, taskId, eventType, TeamTaskService.AUTHOR_USER, + principal != null ? principal.getName() : null, detail); + } + + private void publishBoardEvent(Long taskId, String event) { + eventChannel.publishTaskEvent(taskService.getTask(taskId), event, Map.of()); + } + + @Operation(summary = "添加评论") + @PostMapping("/{id}/tasks/{taskId}/comments") + public R comment(@PathVariable Long id, @PathVariable Long taskId, + @RequestBody CommentRequest req, Principal principal) { + return guarded(() -> { + requireTask(id, taskId); + taskService.addComment(taskId, TeamTaskService.AUTHOR_USER, + principal != null ? principal.getName() : "admin", + TeamTaskService.COMMENT_NOTE, req.getContent()); + return R.ok(null); + }); + } + + @Operation(summary = "任务状态统计(看板列头)") + @GetMapping("/{id}/tasks/stats") + public R> taskStats(@PathVariable Long id) { + return R.ok(taskService.countByStatus(id)); + } + + // ==================== helpers / DTOs ==================== + + /** + * Runs an endpoint body whose service layer reports validation verdicts + * (unknown assignee, wrong task status, cross-team task id…) via + * IllegalArgumentException / IllegalStateException. Those must reach the + * client as readable text in the R envelope, not the catch-all 500 handler. + */ + private R guarded(Supplier> action) { + try { + return action.get(); + } catch (IllegalArgumentException | IllegalStateException e) { + return R.fail(e.getMessage()); + } + } + + private TeamTaskEntity requireTask(Long teamId, Long taskId) { + TeamTaskEntity task = taskService.getTask(taskId); + if (task == null || !task.getTeamId().equals(teamId)) { + throw new IllegalArgumentException("task not found on this team's board"); + } + return task; + } + + private TeamVO toVO(AgentTeamEntity team) { + long memberCount = teamService.listMembers(team.getId()).size(); + AgentEntity lead = agentMapper.selectById(team.getLeadAgentId()); + return new TeamVO(team, + lead != null && lead.getName() != null ? lead.getName() + : String.valueOf(team.getLeadAgentId()), + lead != null ? lead.getIcon() : null, + memberCount); + } + + private TaskVO toTaskVO(TeamTaskEntity task) { + return new TaskVO(task, + agentName(task.getAssigneeAgentId()), + task.getOwnerAgentId() == null ? null : agentName(task.getOwnerAgentId())); + } + + private String agentName(Long agentId) { + if (agentId == null) { + return null; + } + AgentEntity agent = agentMapper.selectById(agentId); + return agent != null && agent.getName() != null ? agent.getName() : String.valueOf(agentId); + } + + public record TeamVO(AgentTeamEntity team, String leadName, String leadIcon, long memberCount) { + } + + public record TeamDetailVO(TeamVO team, List members) { + } + + public record MemberVO(Long agentId, String name, String role, String icon) { + } + + public record TaskVO(TeamTaskEntity task, String assigneeName, String ownerName) { + } + + public record TaskDetailVO(TaskVO task, List comments) { + } + + @Data + public static class CreateTeamRequest { + private String name; + private String description; + private Long leadAgentId; + private List memberAgentIds; + } + + @Data + public static class UpdateTeamRequest { + private String name; + private String description; + private String settings; + } + + @Data + public static class MemberRequest { + private Long agentId; + private String role; + } + + @Data + public static class CreateTaskRequest { + private String subject; + private String description; + private Long assigneeAgentId; + private Integer priority; + private List blockedBy; + private Boolean requireApproval; + } + + @Data + public static class ReasonRequest { + private String reason; + } + + @Data + public static class CommentRequest { + private String content; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/event/TeamChangedEvent.java b/mateclaw-server/src/main/java/vip/mate/team/event/TeamChangedEvent.java new file mode 100644 index 00000000..cdd241a6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/event/TeamChangedEvent.java @@ -0,0 +1,14 @@ +package vip.mate.team.event; + +import java.util.List; + +/** + * Published when a team's composition or configuration changes. Listeners + * evict the affected agents' cached runtime instances so the team context + * baked into their system prompts is rebuilt on the next turn. + * + * @param agentIds every agent whose prompt may embed this team's context + * @author MateClaw Team + */ +public record TeamChangedEvent(List agentIds) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/event/TeamTasksDelegatedEvent.java b/mateclaw-server/src/main/java/vip/mate/team/event/TeamTasksDelegatedEvent.java new file mode 100644 index 00000000..50c805c3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/event/TeamTasksDelegatedEvent.java @@ -0,0 +1,13 @@ +package vip.mate.team.event; + +/** + * Published after a plan's steps were handed off to a team's task board, so + * the dispatch layer sweeps immediately instead of waiting for the scheduled + * pass. An event (rather than a direct call) keeps the hand-off bridge free + * of the dispatch service — a direct dependency would close a bean cycle + * through the agent graph builder. + * + * @author MateClaw Team + */ +public record TeamTasksDelegatedEvent(Long teamId) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java new file mode 100644 index 00000000..5ab849b1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java @@ -0,0 +1,49 @@ +package vip.mate.team.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Agent team: one lead agent plus member agents collaborating through a + * shared task board. The lead orchestrates work by creating tasks assigned + * to members; members execute in isolated conversations and report results. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_agent_team") +public class AgentTeamEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private String name; + + private String description; + + /** Agent that orchestrates this team; exactly one per team. */ + private Long leadAgentId; + + /** Team lifecycle status: active / paused. */ + private String status; + + /** Monotonic per-team counter backing human-readable task numbers. */ + private Integer taskSeq; + + /** Team-level settings as a JSON object (notification switches, escalation, ...). */ + private String settings; + + /** Username of the admin who created the team. */ + private String createdBy; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamMemberEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamMemberEntity.java new file mode 100644 index 00000000..4bed621d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamMemberEntity.java @@ -0,0 +1,36 @@ +package vip.mate.team.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Team membership row linking an agent to a team with a role. + * An agent belongs to at most one active team (enforced in the service layer). + * + * @author MateClaw Team + */ +@Data +@TableName("mate_agent_team_member") +public class AgentTeamMemberEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long teamId; + + private Long agentId; + + /** Member role within the team: lead / member / reviewer. */ + private String role; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamRole.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRole.java new file mode 100644 index 00000000..611a385f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRole.java @@ -0,0 +1,21 @@ +package vip.mate.team.model; + +/** + * Team membership role constants. + * + * @author MateClaw Team + */ +public final class TeamRole { + + /** Orchestrates the team; receives the full task-board playbook. */ + public static final String LEAD = "lead"; + + /** Executes assigned tasks. */ + public static final String MEMBER = "member"; + + /** Reviews work submitted for approval. */ + public static final String REVIEWER = "reviewer"; + + private TeamRole() { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCommentEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCommentEntity.java new file mode 100644 index 00000000..31ed429e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCommentEntity.java @@ -0,0 +1,45 @@ +package vip.mate.team.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Comment on a team task, written by an agent, a human, or the system. + * A comment of type "blocker" auto-fails the task and escalates to the lead. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_team_task_comment") +public class TeamTaskCommentEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long taskId; + + /** Denormalized team id for board-level queries. */ + private Long teamId; + + /** Author kind: agent / user / system. */ + private String authorType; + + /** Agent id or username depending on authorType. */ + private String authorId; + + /** note / blocker. */ + private String commentType; + + private String content; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java new file mode 100644 index 00000000..7834abd0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskCreateCommand.java @@ -0,0 +1,52 @@ +package vip.mate.team.model; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * Input for creating a team task. The assignee is mandatory: every task must + * name the member expected to execute it, so each delegation is trackable on + * the board. + * + * @author MateClaw Team + */ +@Data +@Builder +public class TeamTaskCreateCommand { + + private Long teamId; + + private String subject; + + private String description; + + /** Required: the member agent expected to execute this task. */ + private Long assigneeAgentId; + + /** Creating agent id; NULL when a human creates the task from the board. */ + private Long createdByAgentId; + + /** Higher dispatches first; defaults to 0. */ + private Integer priority; + + /** general / request / note; defaults to general. */ + private String taskType; + + /** Prerequisite task ids; non-empty list creates the task in blocked status. */ + private List blockedBy; + + /** Park completion in in_review for human approval. */ + private boolean requireApproval; + + /** Lead conversation that originated the task (result routing). */ + private String leadConversationId; + + private String username; + + private String channel; + + /** Optional JSON metadata (attachments, origin routing, trace ids, ...). */ + private String metadata; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java new file mode 100644 index 00000000..bb79b0f1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEntity.java @@ -0,0 +1,97 @@ +package vip.mate.team.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * A task on a team's shared board. Created by the lead (or an admin) with a + * mandatory assignee, dispatched to that member for isolated execution, and + * completed with a result summary. Supports dependency blocking, progress + * reporting, an optional human-approval stage, and a dispatch circuit breaker. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_team_task") +public class TeamTaskEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long teamId; + + /** Human-readable sequential number, unique within the team. */ + private Integer taskNumber; + + private String subject; + + private String description; + + /** See {@link TeamTaskStatus} for the full state machine. */ + private String status; + + /** Higher value dispatches first among unblocked pending tasks. */ + private Integer priority; + + /** Task category: general / request / note. */ + private String taskType; + + /** Intended executor chosen at creation (required); never the team lead. */ + private Long assigneeAgentId; + + /** Agent currently executing; NULL until the task is claimed or assigned. */ + private Long ownerAgentId; + + /** Creating agent id when the task was created by an agent (NULL for humans). */ + private Long createdByAgentId; + + /** JSON array of prerequisite task ids (as strings). */ + private String blockedBy; + + /** When true, completion parks the task in in_review until a human approves. */ + private Boolean requireApproval; + + private Integer progressPercent; + + private String progressStep; + + /** Result summary set on completion. */ + @TableField(value = "result", updateStrategy = FieldStrategy.ALWAYS) + private String result; + + /** Failure / cancellation / rejection reason. */ + private String reason; + + /** Dispatch attempts; auto-fails past the circuit-breaker cap. */ + private Integer dispatchCount; + + /** Execution lease expiry; an expired in_progress task is recoverable as stale. */ + @TableField(value = "lock_expires_at", updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime lockExpiresAt; + + /** Conversation in which the member executes this task. */ + private String conversationId; + + /** Lead conversation that originated the task; used to route the result back. */ + private String leadConversationId; + + /** User whose request triggered the task (scoping / board filtering). */ + private String username; + + /** Origin channel of the triggering request (web / dingtalk / ...). */ + private String channel; + + /** Custom JSON payload (attachments, origin routing, trace ids, ...). */ + private String metadata; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEventEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEventEntity.java new file mode 100644 index 00000000..abb7a23e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEventEntity.java @@ -0,0 +1,64 @@ +package vip.mate.team.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * One moment in a team task's lifecycle (created, dispatched, progress, + * comment, deliverable, settlement, approval actions). Append-only side + * channel rendered as the task's collaboration timeline; recording failures + * never affect the task itself. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_team_task_event") +public class TeamTaskEventEntity { + + // event_type values; kept as plain constants (no enum) so new moments can + // be recorded without a schema or code migration. + public static final String CREATED = "created"; + public static final String DISPATCHED = "dispatched"; + public static final String PROGRESS = "progress"; + public static final String COMMENT = "comment"; + public static final String BLOCKER = "blocker"; + public static final String DELIVERABLE = "deliverable"; + public static final String COMPLETED = "completed"; + public static final String IN_REVIEW = "in_review"; + public static final String FAILED = "failed"; + public static final String CANCELLED = "cancelled"; + public static final String APPROVED = "approved"; + public static final String REJECTED = "rejected"; + public static final String RETRIED = "retried"; + public static final String STALE = "stale"; + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** Denormalized team id for team-level activity queries. */ + private Long teamId; + + private Long taskId; + + private String eventType; + + /** Actor kind: agent / user / system. */ + private String actorType; + + /** Agent id or username depending on actorType; null for system moments. */ + private String actorId; + + /** Human-readable one-liner: progress step, failure reason, file name… */ + private String detail; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskStatus.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskStatus.java new file mode 100644 index 00000000..97dc5c0e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskStatus.java @@ -0,0 +1,46 @@ +package vip.mate.team.model; + +import java.util.Set; + +/** + * Team task state machine constants. + * + *

        + * pending ──claim/assign──▶ in_progress ──complete──▶ completed
        + *    │                          │  (require_approval) ▶ in_review ──approve──▶ completed
        + *    │                          │                                └──reject───▶ cancelled
        + *    │                          ├──blocker/error──▶ failed ──retry──▶ pending
        + *    │                          └──lease expired──▶ stale  ──retry──▶ pending
        + *    ├──blocked_by set──▶ blocked ──all blockers released──▶ pending
        + *    └──cancel──▶ cancelled
        + * 
        + * + * @author MateClaw Team + */ +public final class TeamTaskStatus { + + public static final String PENDING = "pending"; + public static final String IN_PROGRESS = "in_progress"; + public static final String IN_REVIEW = "in_review"; + public static final String COMPLETED = "completed"; + public static final String FAILED = "failed"; + public static final String CANCELLED = "cancelled"; + public static final String BLOCKED = "blocked"; + public static final String STALE = "stale"; + + /** No further transitions except hard delete. */ + public static final Set TERMINAL = Set.of(COMPLETED, FAILED, CANCELLED); + + /** Statuses that release dependent (blocked) tasks. Failed does NOT release. */ + public static final Set RELEASES_DEPENDENTS = Set.of(COMPLETED, CANCELLED); + + /** Statuses eligible for a manual retry back to pending. */ + public static final Set RETRYABLE = Set.of(FAILED, STALE); + + private TeamTaskStatus() { + } + + public static boolean isTerminal(String status) { + return status != null && TERMINAL.contains(status); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMapper.java new file mode 100644 index 00000000..0fa1354a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMapper.java @@ -0,0 +1,14 @@ +package vip.mate.team.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.team.model.AgentTeamEntity; + +/** + * Agent team mapper. + * + * @author MateClaw Team + */ +@Mapper +public interface AgentTeamMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMemberMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMemberMapper.java new file mode 100644 index 00000000..73d05012 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/AgentTeamMemberMapper.java @@ -0,0 +1,14 @@ +package vip.mate.team.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.team.model.AgentTeamMemberEntity; + +/** + * Team membership mapper. + * + * @author MateClaw Team + */ +@Mapper +public interface AgentTeamMemberMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskCommentMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskCommentMapper.java new file mode 100644 index 00000000..f16f3f68 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskCommentMapper.java @@ -0,0 +1,14 @@ +package vip.mate.team.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.team.model.TeamTaskCommentEntity; + +/** + * Team task comment mapper. + * + * @author MateClaw Team + */ +@Mapper +public interface TeamTaskCommentMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskEventMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskEventMapper.java new file mode 100644 index 00000000..1b43aeb5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskEventMapper.java @@ -0,0 +1,12 @@ +package vip.mate.team.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import vip.mate.team.model.TeamTaskEventEntity; + +/** + * Mapper for the team task event timeline. + * + * @author MateClaw Team + */ +public interface TeamTaskEventMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskMapper.java new file mode 100644 index 00000000..c0bf9f33 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskMapper.java @@ -0,0 +1,14 @@ +package vip.mate.team.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.team.model.TeamTaskEntity; + +/** + * Team task board mapper. + * + * @author MateClaw Team + */ +@Mapper +public interface TeamTaskMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java new file mode 100644 index 00000000..8da88b08 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java @@ -0,0 +1,229 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.agent.runtime.RunningConversationRegistry; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Delivers settled task results back to the team lead. Results arriving close + * together are debounced per lead conversation and merged into ONE combined + * announcement, so parallel members finishing near-simultaneously wake the + * lead once instead of once per task. + * + * Delivery is guaranteed, not opportunistic: when the lead is mid-turn the + * announcement is NOT injected into the running turn (an in-turn notification + * is dropped if the turn ends before the next reasoning round — silent result + * loss). Instead delivery re-arms itself until the lead is idle, then starts a + * fresh lead turn in the originating conversation; the lead's synthesized + * reply is persisted there and pushed over SSE. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TeamAnnounceService { + + /** Collect window: results arriving within it join the same announcement. */ + static final long DEBOUNCE_MILLIS = 2000; + + /** A batch drains immediately once it reaches this size. */ + static final int MAX_BATCH = 20; + + /** Re-check interval while waiting for a busy lead to go idle. */ + static final long BUSY_RETRY_MILLIS = 2000; + + /** Give up waiting and wake the lead anyway after this many busy retries. */ + static final int MAX_BUSY_RETRIES = 900; // ~30 minutes + + private static final ScheduledExecutorService DEBOUNCE_SCHEDULER = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "team-announce-debounce"); + t.setDaemon(true); + return t; + }); + + /** One JDK 21 virtual thread per lead wake-up run. */ + private static final ExecutorService ANNOUNCE_EXECUTOR = + Executors.newVirtualThreadPerTaskExecutor(); + + private final TeamService teamService; + private final TeamTaskService taskService; + private final AgentService agentService; + private final AgentMapper agentMapper; + private final RunningConversationRegistry runningConversations; + private final ChatStreamTracker streamTracker; + private final ConversationService conversationService; + + /** Pending items per lead conversation; the first item arms the drain timer. */ + private final Map> pending = new ConcurrentHashMap<>(); + + record AnnounceItem(Long teamId, Integer taskNumber, String subject, String status, + String memberName, String detail) { + } + + /** + * Queue a settled task for announcement to its lead. Safe to call from any + * thread; no-op when the task has no originating lead conversation. + */ + public void announceTaskSettled(TeamTaskEntity task) { + if (task == null || task.getLeadConversationId() == null) { + return; + } + String detail = TeamTaskStatus.COMPLETED.equals(task.getStatus()) + || TeamTaskStatus.IN_REVIEW.equals(task.getStatus()) + ? task.getResult() : task.getReason(); + StringBuilder detailWithFiles = new StringBuilder(detail == null ? "" : detail); + List deliverables = taskService.listDeliverables(task); + if (!deliverables.isEmpty()) { + detailWithFiles.append("\nDeliverables (share these download links with the user):"); + for (TeamTaskService.Deliverable file : deliverables) { + detailWithFiles.append("\n- ").append(file.name()).append(" → ").append(file.url()); + } + } + AnnounceItem item = new AnnounceItem(task.getTeamId(), task.getTaskNumber(), + task.getSubject(), task.getStatus(), + agentName(task.getAssigneeAgentId()), + detailWithFiles.toString()); + + String key = task.getLeadConversationId(); + List drainNow = null; + synchronized (pending) { + List queue = pending.computeIfAbsent(key, k -> new ArrayList<>()); + queue.add(item); + if (queue.size() >= MAX_BATCH) { + drainNow = pending.remove(key); + } else if (queue.size() == 1) { + DEBOUNCE_SCHEDULER.schedule(() -> drain(key), DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS); + } + } + if (drainNow != null) { + deliver(key, drainNow); + } + } + + /** Timer callback: take whatever accumulated and deliver it. */ + void drain(String leadConversationId) { + List items; + synchronized (pending) { + items = pending.remove(leadConversationId); + } + if (items != null && !items.isEmpty()) { + deliver(leadConversationId, items); + } + } + + void deliver(String leadConversationId, List items) { + deliver(leadConversationId, items, 0); + } + + private void deliver(String leadConversationId, List items, int busyRetries) { + Long teamId = items.get(0).teamId(); + AgentTeamEntity team = teamService.getTeam(teamId); + if (team == null) { + log.warn("Announce dropped: team {} vanished", teamId); + return; + } + if (runningConversations.isActive(leadConversationId) && busyRetries < MAX_BUSY_RETRIES) { + // Lead is mid-turn. Late tasks settling meanwhile join this batch + // via the pending map, so re-queue and re-arm instead of injecting + // into the running turn (which can drop the message on turn end). + List merged = items; + synchronized (pending) { + List late = pending.remove(leadConversationId); + if (late != null) { + merged = new ArrayList<>(items); + merged.addAll(late); + } + } + List retryItems = merged; + DEBOUNCE_SCHEDULER.schedule(() -> deliver(leadConversationId, retryItems, busyRetries + 1), + BUSY_RETRY_MILLIS, TimeUnit.MILLISECONDS); + return; + } + String message = buildAnnouncement(items); + ANNOUNCE_EXECUTOR.submit(() -> wakeLead(team, leadConversationId, message, items.size())); + } + + /** Start a fresh lead turn carrying the merged results; its reply reaches the user. */ + private void wakeLead(AgentTeamEntity team, String leadConversationId, + String message, int taskCount) { + try { + streamTracker.broadcastObject(leadConversationId, "team_announce_start", + Map.of("teamId", String.valueOf(team.getId()), "tasks", taskCount)); + // Persist the announce turn: message persistence is the caller's + // contract, and without it the lead's synthesized reply would + // vanish from the conversation history on the next reload. + conversationService.saveMessage(leadConversationId, "user", message); + AgentService.ChatResult result = agentService.chatWithUsage( + team.getLeadAgentId(), message, leadConversationId); + String reply = result == null ? null : result.content(); + if (reply != null && !reply.isBlank()) { + conversationService.saveMessage(leadConversationId, "assistant", reply); + } + streamTracker.broadcastObject(leadConversationId, "team_announce_reply", + Map.of("teamId", String.valueOf(team.getId()), + "content", reply == null ? "" : reply)); + log.info("Team {} lead woken with {} task result(s)", team.getId(), taskCount); + } catch (Exception e) { + log.warn("Team {} lead wake-up failed: {}", team.getId(), e.getMessage()); + } + } + + /** Merged announcement text; single- and multi-result variants. */ + static String buildAnnouncement(List items) { + StringBuilder sb = new StringBuilder(512); + long failed = items.stream().filter(i -> TeamTaskStatus.FAILED.equals(i.status())).count(); + if (items.size() == 1) { + sb.append("[System Message] A delegated team task has settled.\n"); + } else { + sb.append("[System Message] ").append(items.size()) + .append(" delegated team tasks have settled"); + if (failed > 0) { + sb.append(" (").append(failed).append(" failed)"); + } + sb.append(".\n"); + } + for (AnnounceItem item : items) { + sb.append("\n--- Task #").append(item.taskNumber()) + .append(" \"").append(item.subject()).append("\" — ") + .append(item.status()) + .append(" (member: ").append(item.memberName()).append(") ---\n"); + if (!item.detail().isBlank()) { + sb.append(item.detail()).append('\n'); + } + } + sb.append(""" + + Review these results against the original request, then reply to the user with ONE synthesized answer. \ + For failed tasks, fix the missing input and re-dispatch with team_tasks(action="retry", taskId=...), or cancel them. \ + Tasks in in_review await human approval — mention that instead of treating them as done."""); + return sb.toString(); + } + + private String agentName(Long agentId) { + if (agentId == null) { + return "-"; + } + AgentEntity agent = agentMapper.selectById(agentId); + return agent != null && agent.getName() != null ? agent.getName() : String.valueOf(agentId); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java new file mode 100644 index 00000000..b019d698 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java @@ -0,0 +1,188 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.AgentTeamMemberEntity; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Renders the team context block ("TEAM.md") appended to an agent's system + * prompt. The lead receives the full orchestration playbook; members receive + * execution-focused instructions; agents outside any team receive a one-line + * negative notice so the model never probes the team_tasks tool speculatively. + * + * The block is baked into the cached agent instance; {@code TeamChangedEvent} + * evicts affected agents so composition changes surface on the next turn. + * + * @author MateClaw Team + */ +@Component +@RequiredArgsConstructor +public class TeamContextBuilder { + + static final String NO_TEAM_NOTICE = """ + + ## Team + You are not part of any agent team. Do NOT call the team_tasks tool. + """; + + /** Snapshot line cap; larger boards are folded behind a "…and N more" line. */ + static final int SNAPSHOT_MAX_LINES = 15; + + private final TeamService teamService; + private final TeamTaskService taskService; + private final AgentMapper agentMapper; + + /** Build the team context block for the given agent; never returns null. */ + public String buildTeamContext(Long agentId) { + Optional teamOpt = teamService.getTeamForAgent(agentId); + if (teamOpt.isEmpty()) { + return NO_TEAM_NOTICE; + } + AgentTeamEntity team = teamOpt.get(); + List members = teamService.listMembers(team.getId()); + boolean isLead = teamService.isLead(team, agentId); + + StringBuilder sb = new StringBuilder(2048); + sb.append("\n\n## Team: ").append(team.getName()).append('\n'); + if (team.getDescription() != null && !team.getDescription().isBlank()) { + sb.append(team.getDescription()).append('\n'); + } + sb.append("Your role: ").append(isLead ? "LEAD — you orchestrate this team." : "MEMBER.") + .append('\n'); + + sb.append(""" + + ### Members + This is the complete and authoritative list of your team. Do NOT use tools to verify it. + """); + for (AgentTeamMemberEntity member : members) { + AgentEntity agent = agentMapper.selectById(member.getAgentId()); + String name = agent != null && agent.getName() != null ? agent.getName() + : String.valueOf(member.getAgentId()); + sb.append("- **").append(name).append("** (agentId: ").append(member.getAgentId()) + .append(", ").append(member.getRole()).append(')'); + if (member.getAgentId().equals(agentId)) { + sb.append(" — you"); + } else if (agent != null && agent.getDescription() != null + && !agent.getDescription().isBlank()) { + sb.append(": ").append(agent.getDescription().strip()); + } + sb.append('\n'); + } + + sb.append(isLead ? leadPlaybook() : memberPlaybook()); + return sb.toString(); + } + + /** + * Render a live snapshot of the team's non-terminal tasks for the lead's + * per-turn runtime context, so the lead never duplicates in-flight work or + * declares it finished. Returns null for non-leads, agents outside any + * team, and boards with no active tasks — callers inject nothing in those + * cases. Injected as a meta user message, never the system prompt, so the + * per-turn variation cannot break the system prompt cache. + */ + public String buildBoardSnapshot(Long agentId) { + Optional teamOpt = teamService.getTeamForAgent(agentId); + if (teamOpt.isEmpty() || !teamService.isLead(teamOpt.get(), agentId)) { + return null; + } + AgentTeamEntity team = teamOpt.get(); + List all = taskService.listTasks(team.getId(), null); + List active = all.stream() + .filter(t -> !TeamTaskStatus.isTerminal(t.getStatus())) + .toList(); + if (active.isEmpty()) { + return null; + } + Map numberById = new HashMap<>(); + for (TeamTaskEntity task : all) { + numberById.put(task.getId(), task.getTaskNumber()); + } + + StringBuilder sb = new StringBuilder(512); + sb.append("[team-board] Live board of team \"").append(team.getName()) + .append("\" — tasks currently in flight:\n"); + for (TeamTaskEntity task : active.subList(0, Math.min(active.size(), SNAPSHOT_MAX_LINES))) { + sb.append("- #").append(task.getTaskNumber()) + .append(" [").append(task.getStatus()).append("] ") + .append(task.getSubject()) + .append(" (assignee: ").append(agentName(task.getAssigneeAgentId())); + if (task.getProgressPercent() != null + && TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) { + sb.append(", ").append(task.getProgressPercent()).append('%'); + } + if (TeamTaskStatus.BLOCKED.equals(task.getStatus())) { + List blockers = TeamTaskService.parseIdArray(task.getBlockedBy()); + if (!blockers.isEmpty()) { + sb.append(", waits on"); + for (Long blockerId : blockers) { + Integer number = numberById.get(blockerId); + sb.append(" #").append(number != null ? number : blockerId); + } + } + } + sb.append(")\n"); + } + if (active.size() > SNAPSHOT_MAX_LINES) { + sb.append("- …and ").append(active.size() - SNAPSHOT_MAX_LINES) + .append(" more (call team_tasks list for the full board)\n"); + } + sb.append("Do NOT create a task duplicating any of the above, " + + "and do NOT claim in-flight work is finished."); + return sb.toString(); + } + + private String agentName(Long agentId) { + if (agentId == null) { + return "-"; + } + AgentEntity agent = agentMapper.selectById(agentId); + return agent != null && agent.getName() != null ? agent.getName() + : String.valueOf(agentId); + } + + private static String leadPlaybook() { + return """ + + ### Delegation workflow (mandatory) + - Delegate work by creating tasks on the team board: `team_tasks(action="create", subject=..., description=..., assigneeAgentId=...)`. Every delegation MUST go through the board — never pretend a teammate did something without a task backing it. + - Check the board FIRST: a live board snapshot is injected into your context whenever tasks are in flight; consult it (or call `team_tasks(action="list")`) before creating tasks so you never create duplicates. + - When a task's outcome needs a human decision before it counts as done (publishing something, destructive changes), create it with `requireApproval=true`; it will park in review for sign-off instead of completing automatically. + - Create ALL tasks for the request up front in one batch. Order dependent work with `blockedBy` (ids of prerequisite tasks). Then announce the assignments to the user and STOP — do not keep reasoning while members work. + - Delegation is NOT completion. After creating tasks, never say the work is "done" or "finished"; say it has been assigned and results will follow. + - Never assign a task to yourself — the lead orchestrates, members execute. + - Task sizing: one task = one specific action producing one output. Split a task if it needs two different skills; do not over-split mechanical steps. + - If a prerequisite task is already completed, pass its result inside the new task's description instead of blocking on it. + + ### When results arrive + Member results are delivered to you as system messages in this conversation. Review them, cross-check against the original request, then synthesize ONE coherent reply for the user. Do not forward raw member output unedited. + + ### Handling blockers + When a member reports a blocker the task auto-fails and you are notified with the reason. Resolve the missing input (provide context, adjust the description), then re-dispatch it with `team_tasks(action="retry", taskId=...)`, or cancel it with `action="cancel"` if it is no longer needed. + """; + } + + private static String memberPlaybook() { + return """ + + ### Working on assigned tasks + - When a task is dispatched to you, focus entirely on executing it. Your final reply becomes the task result and is reported back to the lead automatically. + - Report meaningful milestones with `team_tasks(action="progress", taskId=..., percent=..., step=...)`. The taskId is included in the dispatch message. + - When the output is a document, spreadsheet or presentation, produce a real file (renderDocx / renderXlsx / renderPptx, or the docx/pptx/xlsx skills), then register it with `team_tasks(action="attach", taskId=..., name="report.docx", url=)`. Keep your final reply a summary — never paste the file's full content as the result. + - Leave findings other teammates may need as comments: `team_tasks(action="comment", taskId=..., text=...)`. + - If you cannot proceed (missing input, unclear scope, failed dependency), report it with `team_tasks(action="comment", taskId=..., type="blocker", text="what you need")`. This fails the task and notifies the lead — do NOT silently improvise around a blocker. + - You may inspect the board with `action="list"` or `action="get"` for context, but do not create or cancel tasks. + """; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java new file mode 100644 index 00000000..984a82a2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -0,0 +1,345 @@ +package vip.mate.team.service; + +import cn.hutool.core.util.IdUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import vip.mate.team.event.TeamTasksDelegatedEvent; +import vip.mate.agent.AgentService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskEventEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Dispatches board tasks to their assigned member agents and closes the + * execution loop: run the member in an isolated child conversation, complete + * (or fail) the task from the run outcome, then re-sweep so released + * dependents and the now-idle member pick up follow-up work. + * + * Concurrency model: the sweep itself takes no locks — {@code assignTask}'s + * conditional UPDATE (pending → in_progress) is the single arbiter, so + * overlapping sweeps can never double-dispatch a task. One member executes at + * most one task at a time. A scheduled sweep self-heals anything a + * notification-path dispatch missed (releases, retries, restarts). + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TeamDispatchService { + + /** Result summaries are capped before persisting to keep the board readable. */ + static final int MAX_RESULT_CHARS = 8000; + + /** One JDK 21 virtual thread per member-agent run. */ + private static final ExecutorService DISPATCH_EXECUTOR = + Executors.newVirtualThreadPerTaskExecutor(); + + /** + * Lease-renewal cadence while a member run is in flight: a third of the + * lease keeps two renewal chances in hand even if one write is lost, so a + * long-running member is never reclaimed as stale while still working. + */ + private static final long HEARTBEAT_MINUTES = TeamTaskService.LOCK_MINUTES / 3; + + /** Single daemon thread firing lease-renewal heartbeats for all running tasks. */ + private static final ScheduledExecutorService HEARTBEAT_SCHEDULER = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "team-task-heartbeat"); + t.setDaemon(true); + return t; + }); + + private final TeamService teamService; + private final TeamTaskService taskService; + private final AgentService agentService; + private final ConversationService conversationService; + private final ChatStreamTracker streamTracker; + private final TeamAnnounceService announceService; + private final TeamEventChannel eventChannel; + + /** Members with a run currently in flight in this JVM (belt-and-braces on top of hasActiveTask). */ + private final Set runningMembers = ConcurrentHashMap.newKeySet(); + + /** + * Plan hand-off notification: sweep the board as soon as a delegated + * plan's tasks land. Event-driven because the hand-off bridge cannot + * depend on this service directly (bean cycle through the graph builder). + */ + @EventListener + public void onTeamTasksDelegated(TeamTasksDelegatedEvent event) { + requestDispatch(event.teamId()); + } + + /** Asynchronously sweep the team's board and dispatch whatever is eligible. */ + public void requestDispatch(Long teamId) { + DISPATCH_EXECUTOR.submit(() -> { + try { + sweep(teamId); + } catch (Exception e) { + log.warn("Team {} dispatch sweep failed: {}", teamId, e.getMessage()); + } + }); + } + + /** + * Periodic self-heal: mark expired leases stale, then sweep every active + * team so released dependents, manual retries and work orphaned by a + * restart are dispatched even when no tool-path notification fired. + */ + @Scheduled(fixedDelay = 30_000, initialDelay = 30_000) + public void scheduledSweep() { + taskService.recoverStaleTasks(); + for (AgentTeamEntity team : teamService.listTeams()) { + if (TeamService.STATUS_ACTIVE.equals(team.getStatus())) { + try { + sweep(team.getId()); + } catch (Exception e) { + log.warn("Scheduled sweep failed for team {}: {}", team.getId(), e.getMessage()); + } + } + } + } + + /** + * Dispatch at most one eligible pending task per assignee. Priority order + * comes from the query; the conditional assign makes the winner unique. + */ + void sweep(Long teamId) { + List candidates = taskService.findDispatchable(teamId); + if (candidates.isEmpty()) { + return; + } + Set dispatchedThisRound = new HashSet<>(); + for (TeamTaskEntity task : candidates) { + Long assignee = task.getAssigneeAgentId(); + if (dispatchedThisRound.contains(assignee) + || runningMembers.contains(assignee) + || taskService.hasActiveTask(teamId, assignee)) { + continue; + } + if (!taskService.assignTask(task.getId(), assignee)) { + continue; // another sweep won the race, or status moved on + } + taskService.recordEvent(teamId, task.getId(), TeamTaskEventEntity.DISPATCHED, + TeamTaskService.AUTHOR_SYSTEM, null, "agent " + assignee); + if (!taskService.tryAcquireDispatch(task.getId())) { + // Circuit breaker tripped; the task was auto-failed — the lead + // must hear about it or the work silently disappears. + announceService.announceTaskSettled(taskService.getTask(task.getId())); + continue; + } + dispatchedThisRound.add(assignee); + TeamTaskEntity assigned = taskService.getTask(task.getId()); + DISPATCH_EXECUTOR.submit(() -> runTask(teamId, assigned)); + } + } + + /** Execute one dispatched task on its member agent, then settle the outcome. */ + void runTask(Long teamId, TeamTaskEntity task) { + Long memberId = task.getAssigneeAgentId(); + if (!runningMembers.add(memberId)) { + // Same member picked up concurrently in this JVM; put the task back. + taskService.retryTask(task.getId()); + return; + } + String childConvId = "team-task-" + IdUtil.fastSimpleUUID(); + ScheduledFuture heartbeat = null; + try { + conversationService.createChildConversation(childConvId, memberId, "system", + null, task.getLeadConversationId()); + taskService.attachConversation(task.getId(), childConvId); + // Track the child run so graph nodes honor requestStop() — without a + // registered RunState, cancelling the task could never interrupt the + // member mid-run. + streamTracker.register(childConvId); + streamTracker.incrementFlux(childConvId); + // Renew the execution lease while the member works; the conditional + // UPDATE inside renewLock makes this a no-op once the task settles. + heartbeat = HEARTBEAT_SCHEDULER.scheduleAtFixedRate( + () -> taskService.renewLock(task.getId()), + HEARTBEAT_MINUTES, HEARTBEAT_MINUTES, TimeUnit.MINUTES); + broadcast(task, "team_task_dispatched", Map.of()); + log.info("Team {} task #{} dispatched to agent {} (conv {})", + teamId, task.getTaskNumber(), memberId, childConvId); + + // Message persistence is the caller's contract (the graph expects the + // current user message to already be the conversation's last row), + // and the persisted pair is what makes the run's transcript + // reviewable from the task card. + String dispatchContent = buildDispatchContent(task); + conversationService.saveMessage(childConvId, "user", dispatchContent); + AgentService.ChatResult result = agentService.chatWithUsage( + memberId, dispatchContent, childConvId); + String reply = result == null ? null : result.content(); + if (reply != null && !reply.isBlank()) { + conversationService.saveMessage(childConvId, "assistant", reply); + } + + settleOutcome(task, reply); + } catch (Exception e) { + log.warn("Team {} task #{} member run ended exceptionally: {}", teamId, + task.getTaskNumber(), e.getMessage()); + // Only report a failure the guarded transition actually applied — an + // interrupted run whose task is already cancelled must not produce a + // misleading failed event on top of the terminal state. + boolean failed = taskService.failTask(task.getId(), + truncate("member run error: " + e.getMessage(), 1000)); + if (failed) { + broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(e.getMessage()))); + announceService.announceTaskSettled(taskService.getTask(task.getId())); + } + } finally { + if (heartbeat != null) { + heartbeat.cancel(false); + } + streamTracker.complete(childConvId); + runningMembers.remove(memberId); + // Chain: dispatch released dependents and the member's next task. + requestDispatch(teamId); + } + } + + /** + * Ask the member conversation executing this task to stop at the next graph + * node boundary (cancel path). No-op when the task never dispatched or the + * run already ended. + */ + public void interruptRun(TeamTaskEntity task) { + if (task == null || task.getConversationId() == null) { + return; + } + if (streamTracker.requestStop(task.getConversationId())) { + log.info("Team task #{} member run interrupted (conv {})", + task.getTaskNumber(), task.getConversationId()); + } + } + + /** + * Settle a finished member run. If the member already moved the task + * (explicit complete, blocker fail) the run outcome is not applied on top; + * otherwise the final reply becomes the task result (auto-completion). + */ + void settleOutcome(TeamTaskEntity task, String reply) { + TeamTaskEntity current = taskService.getTask(task.getId()); + if (current == null) { + return; + } + if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) { + List released = taskService.completeTask(task.getId(), null, + truncate(reply == null || reply.isBlank() ? "(no output)" : reply, + MAX_RESULT_CHARS)); + current = taskService.getTask(task.getId()); + log.info("Team task #{} auto-completed ({} dependents released)", + task.getTaskNumber(), released.size()); + } + String event = switch (current.getStatus()) { + case TeamTaskStatus.FAILED -> "team_task_failed"; + case TeamTaskStatus.IN_REVIEW -> "team_task_in_review"; + default -> "team_task_completed"; + }; + Map payload = new HashMap<>(); + payload.put("status", current.getStatus()); + if (current.getResult() != null) { + payload.put("resultPreview", truncate(current.getResult(), 200)); + } + if (current.getReason() != null) { + payload.put("reason", current.getReason()); + } + broadcast(task, event, payload); + announceService.announceTaskSettled(current); + } + + /** Per-prerequisite and whole-section caps keeping the envelope bounded. */ + static final int MAX_PREREQ_RESULT_CHARS = 1500; + static final int MAX_PREREQ_SECTION_CHARS = 6000; + + /** The full instruction envelope the member receives; it cannot see the lead's conversation. */ + private String buildDispatchContent(TeamTaskEntity task) { + StringBuilder sb = new StringBuilder(1024); + sb.append("[Assigned team task #").append(task.getTaskNumber()) + .append(" (taskId: ").append(task.getId()).append(")]\n") + .append("Subject: ").append(task.getSubject()).append('\n'); + if (task.getDescription() != null && !task.getDescription().isBlank()) { + sb.append("\n").append(task.getDescription()).append('\n'); + } + appendPrerequisiteResults(sb, task); + sb.append(""" + + [Instructions] + - Execute this task now. Your final reply becomes the task result reported to the team lead, so end with a complete, self-contained summary of what you produced. + - Report milestones with team_tasks(action="progress", taskId=%s, percent=..., step=...). + - If the output is a document, spreadsheet or presentation, generate a real file (renderDocx / renderXlsx / renderPptx or the docx/pptx/xlsx skills) and register it with team_tasks(action="attach", taskId=%s, name="", url=). Keep the result a summary — do not paste file contents. + - If you are missing an input you cannot obtain yourself, call team_tasks(action="comment", taskId=%s, type="blocker", text="what you need") and stop. + """.formatted(task.getId(), task.getId(), task.getId())); + return sb.toString(); + } + + /** + * Hand the member everything its prerequisites produced: result summaries + * and deliverable links, so upstream output flows downstream without the + * lead re-typing it. Bounded by per-item and whole-section caps — the + * member can fetch the full record with team_tasks(action="get"). + */ + void appendPrerequisiteResults(StringBuilder sb, TeamTaskEntity task) { + List blockerIds = TeamTaskService.parseIdArray(task.getBlockedBy()); + if (blockerIds.isEmpty()) { + return; + } + StringBuilder section = new StringBuilder(); + for (Long blockerId : blockerIds) { + TeamTaskEntity blocker = taskService.getTask(blockerId); + if (blocker == null) { + continue; + } + section.append("- #").append(blocker.getTaskNumber()) + .append(" \"").append(blocker.getSubject()).append("\" (") + .append(blocker.getStatus()).append(')'); + if (blocker.getResult() != null && !blocker.getResult().isBlank()) { + section.append(": ").append(truncate(blocker.getResult().strip(), + MAX_PREREQ_RESULT_CHARS)); + } + section.append('\n'); + for (TeamTaskService.Deliverable file : taskService.listDeliverables(blocker)) { + section.append(" File: ").append(file.name()).append(" → ") + .append(file.url()).append('\n'); + } + } + if (section.isEmpty()) { + return; + } + sb.append("\n[Prerequisite results]\n") + .append(truncate(section.toString(), MAX_PREREQ_SECTION_CHARS)) + .append("Use team_tasks(action=\"get\", taskId=...) for any full record.\n"); + } + + /** Push a task event onto the team channel and the lead conversation's stream. */ + private void broadcast(TeamTaskEntity task, String event, Map extra) { + eventChannel.publishTaskEvent(task, event, extra); + } + + private static String truncate(String s, int max) { + if (s == null || s.length() <= max) { + return s; + } + return s.substring(0, max) + "\n...(truncated)"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java new file mode 100644 index 00000000..9c726fde --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java @@ -0,0 +1,71 @@ +package vip.mate.team.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.model.TeamTaskEntity; + +import java.util.HashMap; +import java.util.Map; + +/** + * Team-scoped SSE event channel, backed by a synthetic conversation id on the + * existing stream tracker so registration, ring buffering, replay-by-last-id + * and heartbeats are all inherited instead of re-invented. One channel per + * team, alive for the application's lifetime; publishing lazily (re)registers, + * so a recycled channel heals on the next event and subscribers simply + * reconnect. + * + * Task events are additionally mirrored onto the originating lead + * conversation's stream (when the task has one) for in-chat observability. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TeamEventChannel { + + static final String CHANNEL_PREFIX = "team-events-"; + + private final ChatStreamTracker streamTracker; + + /** Publish a task lifecycle event to the team channel (+ lead stream if any). */ + public void publishTaskEvent(TeamTaskEntity task, String event, Map extra) { + if (task == null) { + return; + } + try { + Map payload = new HashMap<>(extra == null ? Map.of() : extra); + payload.put("taskId", String.valueOf(task.getId())); + payload.put("taskNumber", task.getTaskNumber()); + payload.put("subject", task.getSubject()); + payload.put("teamId", String.valueOf(task.getTeamId())); + payload.put("assigneeAgentId", String.valueOf(task.getAssigneeAgentId())); + + String channelId = channelId(task.getTeamId()); + streamTracker.register(channelId); + streamTracker.broadcastObject(channelId, event, payload); + + if (task.getLeadConversationId() != null) { + streamTracker.broadcastObject(task.getLeadConversationId(), event, payload); + } + } catch (Exception e) { + // Events are a side channel — never let them affect the task flow. + log.debug("Team event '{}' broadcast skipped: {}", event, e.getMessage()); + } + } + + /** Attach a subscriber, replaying buffered events newer than lastEventId. */ + public boolean attach(Long teamId, SseEmitter emitter, long lastEventId) { + String channelId = channelId(teamId); + streamTracker.register(channelId); + return streamTracker.attach(channelId, emitter, lastEventId); + } + + static String channelId(Long teamId) { + return CHANNEL_PREFIX + teamId; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java new file mode 100644 index 00000000..2b824ddf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java @@ -0,0 +1,304 @@ +package vip.mate.team.service; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.planning.model.PlanEntity; +import vip.mate.planning.service.PlanningService; +import vip.mate.team.event.TeamTasksDelegatedEvent; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.AgentTeamMemberEntity; +import vip.mate.team.model.TeamRole; +import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Bridges the Plan-Execute graph onto the team task board. When a plan's + * lead-of-team owner assigns every step to a team member, the steps become + * board tasks (dependencies mapped to blockedBy), the plan parks in the + * "delegated" status and the lead's turn ends — execution then runs through + * the board's dispatch/announce machinery instead of the serial per-step + * delegation pipeline. Any later inbound message resumes through + * {@link #checkParkedPlan}: settled boards feed the plan summary, in-flight + * boards produce a progress answer. + * + * Deliberately does NOT depend on the dispatch service (bean cycle through + * the agent graph builder) — a {@link TeamTasksDelegatedEvent} triggers the + * immediate sweep instead. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TeamPlanBridge { + + /** Task subject cap; the full step text rides in the description. */ + static final int SUBJECT_MAX_CHARS = 120; + + private final TeamService teamService; + private final TeamTaskService taskService; + private final PlanningService planningService; + private final AgentMapper agentMapper; + private final ApplicationEventPublisher eventPublisher; + + // ==================== triage support ==================== + + /** The team this agent leads, if any. */ + public Optional leadTeam(Long agentId) { + if (agentId == null) { + return Optional.empty(); + } + return teamService.getTeamForAgent(agentId) + .filter(team -> teamService.isLead(team, agentId)); + } + + /** Assignable members (lead excluded), for the planner's roster message. */ + public List roster(AgentTeamEntity team) { + List members = new ArrayList<>(); + for (AgentTeamMemberEntity member : teamService.listMembers(team.getId())) { + if (TeamRole.LEAD.equals(member.getRole())) { + continue; + } + AgentEntity agent = agentMapper.selectById(member.getAgentId()); + if (agent != null) { + members.add(agent); + } + } + return members; + } + + /** + * Map the planner's step_agents names onto team member ids. Returns null + * unless EVERY step resolves to a member — the hand-off is all-or-nothing + * (mixed local/board plans are out of scope), and a null keeps the plan + * on the legacy serial pipeline. + */ + public List resolveMembers(AgentTeamEntity team, List steps, + List stepAgents) { + if (steps == null || steps.isEmpty() || stepAgents == null) { + return null; + } + Map byName = new HashMap<>(); + for (AgentEntity member : roster(team)) { + if (member.getName() != null) { + byName.put(member.getName().trim().toLowerCase(), member.getId()); + } + } + List ids = new ArrayList<>(); + for (int i = 0; i < steps.size(); i++) { + String name = i < stepAgents.size() ? stepAgents.get(i) : null; + Long id = (name == null || name.isBlank()) ? null + : byName.get(name.trim().toLowerCase()); + if (id == null) { + return null; + } + ids.add(id); + } + return ids; + } + + // ==================== hand-off ==================== + + /** + * Create one board task per step (dependencies → blockedBy), park the plan + * as "delegated" and nudge the dispatcher. Returns the announcement text + * the lead streams to the user before ending its turn. + * + * @param stepDeps per-step prerequisite step indices (0-based, each + * referencing an earlier step); the caller guarantees + * validity via its sequential-chain fallback + */ + public String delegatePlan(AgentTeamEntity team, Long planId, String goal, + List steps, List> stepDeps, + List memberIds, String leadConversationId) { + List created = new ArrayList<>(); + for (int i = 0; i < steps.size(); i++) { + String step = steps.get(i); + List blockedBy = new ArrayList<>(); + for (Integer depIndex : stepDeps.get(i)) { + blockedBy.add(created.get(depIndex).getId()); + } + TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() + .teamId(team.getId()) + .subject(subjectOf(step)) + .description(step + "\n\n[Plan context]\nOverall request: " + goal) + .assigneeAgentId(memberIds.get(i)) + .createdByAgentId(team.getLeadAgentId()) + .blockedBy(blockedBy.isEmpty() ? null : blockedBy) + .leadConversationId(leadConversationId) + .channel("plan") + .metadata(new JSONObject() + .set("planId", String.valueOf(planId)) + .set("stepIndex", i) + .toString()) + .build()); + created.add(task); + } + planningService.markPlanDelegated(planId); + eventPublisher.publishEvent(new TeamTasksDelegatedEvent(team.getId())); + log.info("Plan {} delegated to team {} board as {} task(s)", planId, team.getId(), + created.size()); + return buildAnnouncement(created, stepDeps); + } + + // ==================== resume gate ==================== + + /** Outcome of the parked-plan check on an inbound message. */ + public sealed interface ParkedPlanState permits None, Settled, InFlight { + } + + public record None() implements ParkedPlanState { + } + + /** All board tasks terminal — resume into the plan summary. */ + public record Settled(Long planId, String goal, List steps, + List completedResults) implements ParkedPlanState { + } + + /** Board still working — answer with live progress, stay parked. */ + public record InFlight(String progressText) implements ParkedPlanState { + } + + /** + * Inspect the conversation's parked plan, if any. Settled boards sync the + * sub-plan mirror and return the step results (with deliverable links) in + * the summary node's expected format; in-flight boards return a rendered + * progress snapshot. + */ + public ParkedPlanState checkParkedPlan(String conversationId) { + PlanEntity plan = planningService.findDelegatedPlan(conversationId); + if (plan == null) { + return new None(); + } + Optional teamOpt = leadTeam(parseAgentId(plan.getAgentId())); + if (teamOpt.isEmpty()) { + // Team dissolved or lead reassigned while parked — nothing to wait + // for; fail the plan so the conversation is not wedged forever. + planningService.markPlanFailed(plan.getId(), "team no longer available"); + return new None(); + } + List tasks = taskService.listTasksByPlan(teamOpt.get().getId(), plan.getId()); + if (tasks.isEmpty()) { + planningService.markPlanFailed(plan.getId(), "board tasks vanished"); + return new None(); + } + boolean allTerminal = tasks.stream() + .allMatch(task -> TeamTaskStatus.isTerminal(task.getStatus())); + List steps = planningService.getSubPlans(plan.getId()).stream() + .map(sub -> sub.getDescription()) + .toList(); + if (!allTerminal) { + return new InFlight(buildProgressText(tasks)); + } + return new Settled(plan.getId(), plan.getGoal(), steps, settle(plan.getId(), tasks)); + } + + /** Sync the sub-plan mirror from terminal tasks and render step results. */ + private List settle(Long planId, List tasks) { + List results = new ArrayList<>(); + for (TeamTaskEntity task : tasks) { + int stepIndex = stepIndexOf(task); + StringBuilder line = new StringBuilder(); + if (TeamTaskStatus.COMPLETED.equals(task.getStatus())) { + planningService.updateSubPlanResult(planId, stepIndex, + task.getResult() == null ? "" : task.getResult()); + line.append(String.format("步骤%d结果:%s", stepIndex + 1, + task.getResult() == null ? "(无输出)" : task.getResult())); + } else { + String reason = task.getReason() == null ? task.getStatus() : task.getReason(); + planningService.updateSubPlanFailure(planId, stepIndex, reason); + line.append(String.format("步骤%d未完成(%s):%s", stepIndex + 1, + task.getStatus(), reason)); + } + for (TeamTaskService.Deliverable file : taskService.listDeliverables(task)) { + line.append("\n交付物:").append(file.name()).append(" → ").append(file.url()); + } + results.add(line.toString()); + } + return results; + } + + // ==================== rendering ==================== + + private String buildAnnouncement(List tasks, List> stepDeps) { + StringBuilder sb = new StringBuilder("已将计划分派到团队任务板并行执行:\n"); + for (int i = 0; i < tasks.size(); i++) { + TeamTaskEntity task = tasks.get(i); + sb.append("- #").append(task.getTaskNumber()).append(' ') + .append(task.getSubject()) + .append("(").append(agentName(task.getAssigneeAgentId())).append(")"); + if (!stepDeps.get(i).isEmpty()) { + sb.append(" — 前置:"); + for (Integer depIndex : stepDeps.get(i)) { + sb.append('#').append(tasks.get(depIndex).getTaskNumber()).append(' '); + } + } + sb.append('\n'); + } + sb.append("成员完成后我会汇总结果给你。"); + return sb.toString(); + } + + private String buildProgressText(List tasks) { + StringBuilder sb = new StringBuilder("计划仍在团队任务板上执行中:\n"); + for (TeamTaskEntity task : tasks) { + sb.append("- #").append(task.getTaskNumber()).append(' ') + .append(task.getSubject()) + .append(":").append(task.getStatus()); + if (task.getProgressPercent() != null + && TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) { + sb.append("(").append(task.getProgressPercent()).append('%'); + if (task.getProgressStep() != null) { + sb.append(" — ").append(task.getProgressStep()); + } + sb.append(')'); + } + sb.append('\n'); + } + sb.append("全部完成后我会汇总;如需调整可在团队任务板上操作。"); + return sb.toString(); + } + + // ==================== helpers ==================== + + private static String subjectOf(String step) { + String firstLine = step.strip().lines().findFirst().orElse(step.strip()); + return firstLine.length() <= SUBJECT_MAX_CHARS ? firstLine + : firstLine.substring(0, SUBJECT_MAX_CHARS); + } + + private static int stepIndexOf(TeamTaskEntity task) { + try { + return JSONUtil.parseObj(task.getMetadata()).getInt("stepIndex", 0); + } catch (Exception e) { + return 0; + } + } + + private static Long parseAgentId(String agentId) { + try { + return Long.valueOf(agentId); + } catch (Exception e) { + return null; + } + } + + private String agentName(Long agentId) { + AgentEntity agent = agentId == null ? null : agentMapper.selectById(agentId); + return agent != null && agent.getName() != null ? agent.getName() + : String.valueOf(agentId); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java new file mode 100644 index 00000000..539938a4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java @@ -0,0 +1,239 @@ +package vip.mate.team.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.team.event.TeamChangedEvent; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.AgentTeamMemberEntity; +import vip.mate.team.model.TeamRole; +import vip.mate.team.repository.AgentTeamMapper; +import vip.mate.team.repository.AgentTeamMemberMapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Team registry: create/update/delete teams and manage membership. + * + * Invariants enforced here: + * - every team has exactly one lead (the creating lead is auto-added with the lead role); + * - an agent belongs to at most one active team (keeps system-prompt team context unambiguous); + * - the lead cannot be removed or demoted while the team exists. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TeamService { + + public static final String STATUS_ACTIVE = "active"; + public static final String STATUS_PAUSED = "paused"; + + private final AgentTeamMapper teamMapper; + private final AgentTeamMemberMapper memberMapper; + private final AgentMapper agentMapper; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public AgentTeamEntity createTeam(String name, String description, Long leadAgentId, + List memberAgentIds, String createdBy) { + requireAgentExists(leadAgentId, "lead"); + requireNotInAnyTeam(leadAgentId); + if (memberAgentIds != null) { + for (Long memberId : memberAgentIds) { + if (memberId.equals(leadAgentId)) { + throw new IllegalArgumentException("lead agent cannot also be listed as a member"); + } + requireAgentExists(memberId, "member"); + requireNotInAnyTeam(memberId); + } + } + + AgentTeamEntity team = new AgentTeamEntity(); + team.setName(name); + team.setDescription(description); + team.setLeadAgentId(leadAgentId); + team.setStatus(STATUS_ACTIVE); + team.setTaskSeq(0); + team.setCreatedBy(createdBy); + teamMapper.insert(team); + + insertMember(team.getId(), leadAgentId, TeamRole.LEAD); + if (memberAgentIds != null) { + memberAgentIds.forEach(id -> insertMember(team.getId(), id, TeamRole.MEMBER)); + } + log.info("Created agent team {} ({}) lead={} members={}", team.getId(), name, + leadAgentId, memberAgentIds == null ? 0 : memberAgentIds.size()); + notifyTeamChanged(team.getId()); + return team; + } + + @Transactional + public void addMember(Long teamId, Long agentId, String role) { + AgentTeamEntity team = requireTeam(teamId); + if (TeamRole.LEAD.equals(role)) { + throw new IllegalArgumentException("a team has exactly one lead; role must be member or reviewer"); + } + if (agentId.equals(team.getLeadAgentId())) { + throw new IllegalArgumentException("agent is already the team lead"); + } + requireAgentExists(agentId, "member"); + requireNotInAnyTeam(agentId); + insertMember(teamId, agentId, role == null ? TeamRole.MEMBER : role); + notifyTeamChanged(teamId); + } + + @Transactional + public void removeMember(Long teamId, Long agentId) { + AgentTeamEntity team = requireTeam(teamId); + if (agentId.equals(team.getLeadAgentId())) { + throw new IllegalArgumentException("cannot remove the team lead; delete the team instead"); + } + memberMapper.delete(Wrappers.lambdaQuery() + .eq(AgentTeamMemberEntity::getTeamId, teamId) + .eq(AgentTeamMemberEntity::getAgentId, agentId)); + // The removed agent's prompt must drop the team block too. + eventPublisher.publishEvent(new TeamChangedEvent(List.of(agentId))); + notifyTeamChanged(teamId); + } + + @Transactional + public void deleteTeam(Long teamId) { + requireTeam(teamId); + // Capture membership before it is wiped so every agent gets evicted. + List agentIds = listMembers(teamId).stream() + .map(AgentTeamMemberEntity::getAgentId).toList(); + memberMapper.delete(Wrappers.lambdaQuery() + .eq(AgentTeamMemberEntity::getTeamId, teamId)); + teamMapper.deleteById(teamId); + eventPublisher.publishEvent(new TeamChangedEvent(agentIds)); + } + + @Transactional + public AgentTeamEntity updateTeam(Long teamId, String name, String description, String settings) { + AgentTeamEntity team = requireTeam(teamId); + if (name != null) { + team.setName(name); + } + if (description != null) { + team.setDescription(description); + } + if (settings != null) { + team.setSettings(settings); + } + teamMapper.updateById(team); + notifyTeamChanged(teamId); + return team; + } + + public List listTeams() { + return teamMapper.selectList(Wrappers.lambdaQuery() + .orderByDesc(AgentTeamEntity::getCreateTime)); + } + + public AgentTeamEntity getTeam(Long teamId) { + return teamMapper.selectById(teamId); + } + + public List listMembers(Long teamId) { + return memberMapper.selectList(Wrappers.lambdaQuery() + .eq(AgentTeamMemberEntity::getTeamId, teamId) + .orderByAsc(AgentTeamMemberEntity::getCreateTime)); + } + + /** + * Resolve the (single) active team an agent belongs to. Used by the prompt + * builder to inject team context and by the task tool to scope board access. + */ + public Optional getTeamForAgent(Long agentId) { + AgentTeamMemberEntity member = memberMapper.selectOne(Wrappers.lambdaQuery() + .eq(AgentTeamMemberEntity::getAgentId, agentId) + .last("LIMIT 1")); + if (member == null) { + return Optional.empty(); + } + AgentTeamEntity team = teamMapper.selectById(member.getTeamId()); + if (team == null || !STATUS_ACTIVE.equals(team.getStatus())) { + return Optional.empty(); + } + return Optional.of(team); + } + + public boolean isMember(Long teamId, Long agentId) { + return memberMapper.selectCount(Wrappers.lambdaQuery() + .eq(AgentTeamMemberEntity::getTeamId, teamId) + .eq(AgentTeamMemberEntity::getAgentId, agentId)) > 0; + } + + public boolean isLead(AgentTeamEntity team, Long agentId) { + return team != null && agentId != null && agentId.equals(team.getLeadAgentId()); + } + + /** + * Atomically advance and return the team's task counter. The UPDATE takes a + * row lock so concurrent creators serialize on the counter instead of racing. + */ + @Transactional + public int nextTaskNumber(Long teamId) { + int rows = teamMapper.update(null, Wrappers.lambdaUpdate() + .eq(AgentTeamEntity::getId, teamId) + .setSql("task_seq = task_seq + 1")); + if (rows != 1) { + throw new IllegalStateException("team not found: " + teamId); + } + return teamMapper.selectById(teamId).getTaskSeq(); + } + + /** Evict every current member's cached agent so team context rebuilds next turn. */ + private void notifyTeamChanged(Long teamId) { + List agentIds = new ArrayList<>(listMembers(teamId).stream() + .map(AgentTeamMemberEntity::getAgentId).toList()); + if (!agentIds.isEmpty()) { + eventPublisher.publishEvent(new TeamChangedEvent(agentIds)); + } + } + + private void insertMember(Long teamId, Long agentId, String role) { + AgentTeamMemberEntity member = new AgentTeamMemberEntity(); + member.setTeamId(teamId); + member.setAgentId(agentId); + member.setRole(role); + memberMapper.insert(member); + } + + private AgentTeamEntity requireTeam(Long teamId) { + AgentTeamEntity team = teamMapper.selectById(teamId); + if (team == null) { + throw new IllegalArgumentException("team not found: " + teamId); + } + return team; + } + + private void requireAgentExists(Long agentId, String roleLabel) { + AgentEntity agent = agentMapper.selectById(agentId); + if (agent == null) { + throw new IllegalArgumentException(roleLabel + " agent not found: " + agentId); + } + } + + + private void requireNotInAnyTeam(Long agentId) { + // Membership check ignores team status on purpose: an agent parked in a + // paused team must not silently join a second one. + AgentTeamMemberEntity member = memberMapper.selectOne(Wrappers.lambdaQuery() + .eq(AgentTeamMemberEntity::getAgentId, agentId) + .last("LIMIT 1")); + if (member != null) { + throw new IllegalStateException("agent " + agentId + " already belongs to team " + + member.getTeamId() + "; an agent can join only one team"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java new file mode 100644 index 00000000..5b64e7a8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -0,0 +1,702 @@ +package vip.mate.team.service; + +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamTaskCommentEntity; +import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.model.TeamTaskEventEntity; +import vip.mate.team.repository.TeamTaskCommentMapper; +import vip.mate.team.repository.TeamTaskEventMapper; +import vip.mate.team.repository.TeamTaskMapper; + +import java.net.URI; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Shared task board service. All status transitions are guarded conditional + * updates (state checked in the WHERE clause, success judged by affected-row + * count), so concurrent agents cannot double-claim or double-complete a task — + * the database is the arbiter, no in-process locking involved. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TeamTaskService { + + /** Execution lease length; renewed by the runner while the member works. */ + static final int LOCK_MINUTES = 60; + + /** Dispatch attempts before the circuit breaker auto-fails the task. */ + static final int MAX_DISPATCHES = 3; + + public static final String AUTHOR_AGENT = "agent"; + public static final String AUTHOR_USER = "user"; + public static final String AUTHOR_SYSTEM = "system"; + + public static final String COMMENT_NOTE = "note"; + public static final String COMMENT_BLOCKER = "blocker"; + + private final TeamTaskMapper taskMapper; + private final TeamTaskCommentMapper commentMapper; + private final TeamTaskEventMapper eventMapper; + private final TeamService teamService; + + // ==================== creation ==================== + + @Transactional + public TeamTaskEntity createTask(TeamTaskCreateCommand cmd) { + AgentTeamEntity team = teamService.getTeam(cmd.getTeamId()); + if (team == null || !TeamService.STATUS_ACTIVE.equals(team.getStatus())) { + throw new IllegalArgumentException("team not found or not active: " + cmd.getTeamId()); + } + if (cmd.getSubject() == null || cmd.getSubject().isBlank()) { + throw new IllegalArgumentException("subject is required"); + } + Long assignee = cmd.getAssigneeAgentId(); + if (assignee == null) { + throw new IllegalArgumentException( + "assignee is required — specify which team member should handle this task"); + } + if (assignee.equals(team.getLeadAgentId())) { + throw new IllegalArgumentException( + "cannot assign a task to the team lead; the lead orchestrates, members execute"); + } + if (!teamService.isMember(cmd.getTeamId(), assignee)) { + throw new IllegalArgumentException("assignee " + assignee + " is not a member of this team"); + } + + // Dependency edges can only reference pre-existing tasks and blockedBy is + // immutable after creation, so the dependency graph is acyclic by + // construction — adding an edit path for blockedBy would break this + // invariant and require real cycle detection. + List blockers = cmd.getBlockedBy() == null ? List.of() : cmd.getBlockedBy(); + for (Long blockerId : blockers) { + TeamTaskEntity blocker = taskMapper.selectById(blockerId); + if (blocker == null || !blocker.getTeamId().equals(cmd.getTeamId())) { + throw new IllegalArgumentException("blocking task not found in this team: " + blockerId); + } + if (TeamTaskStatus.isTerminal(blocker.getStatus())) { + throw new IllegalArgumentException("blocking task " + blockerId + + " is already " + blocker.getStatus() + + "; pass its result in the description instead of blocking on it"); + } + } + + TeamTaskEntity task = new TeamTaskEntity(); + task.setTeamId(cmd.getTeamId()); + task.setTaskNumber(teamService.nextTaskNumber(cmd.getTeamId())); + task.setSubject(cmd.getSubject()); + task.setDescription(cmd.getDescription()); + task.setStatus(blockers.isEmpty() ? TeamTaskStatus.PENDING : TeamTaskStatus.BLOCKED); + task.setPriority(cmd.getPriority() == null ? 0 : cmd.getPriority()); + task.setTaskType(cmd.getTaskType() == null ? "general" : cmd.getTaskType()); + task.setAssigneeAgentId(assignee); + task.setCreatedByAgentId(cmd.getCreatedByAgentId()); + task.setBlockedBy(blockers.isEmpty() ? null : toJsonIdArray(blockers)); + task.setRequireApproval(cmd.isRequireApproval()); + task.setDispatchCount(0); + task.setLeadConversationId(cmd.getLeadConversationId()); + task.setUsername(cmd.getUsername()); + task.setChannel(cmd.getChannel()); + task.setMetadata(cmd.getMetadata()); + taskMapper.insert(task); + recordEvent(cmd.getTeamId(), task.getId(), TeamTaskEventEntity.CREATED, + cmd.getCreatedByAgentId() != null ? AUTHOR_AGENT + : cmd.getUsername() != null ? AUTHOR_USER : AUTHOR_SYSTEM, + cmd.getCreatedByAgentId() != null ? String.valueOf(cmd.getCreatedByAgentId()) + : cmd.getUsername(), + "assignee: agent " + assignee); + log.info("Team {} task #{} created ({}), assignee={} status={}", + cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus()); + return task; + } + + // ==================== claim / assign ==================== + + /** + * Atomically claim a pending, unowned task. Exactly one caller wins; losers + * get false. The WHERE clause is the mutex. + */ + public boolean claimTask(Long taskId, Long agentId) { + return taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) + .isNull(TeamTaskEntity::getOwnerAgentId) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) + .set(TeamTaskEntity::getOwnerAgentId, agentId) + .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; + } + + /** + * Assign a pending task to an agent (dispatch / admin path). Unlike claim, + * this overrides a previously set owner but still requires pending status. + */ + public boolean assignTask(Long taskId, Long agentId) { + return taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) + .set(TeamTaskEntity::getOwnerAgentId, agentId) + .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; + } + + /** Record the member conversation executing the task. */ + public void attachConversation(Long taskId, String conversationId) { + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .set(TeamTaskEntity::getConversationId, conversationId)); + } + + // ==================== completion lifecycle ==================== + + /** + * Complete a task with a result summary. A pending task is auto-claimed + * first (single-call convenience; safe because the claim is atomic), but + * only by its assignee — otherwise any team member could complete another + * member's not-yet-dispatched task. When the task requires approval it + * parks in in_review instead of completed. + * + * @return ids of dependent tasks released to pending by this completion + */ + @Transactional + public List completeTask(Long taskId, Long agentId, String result) { + TeamTaskEntity task = requireTask(taskId); + if (TeamTaskStatus.PENDING.equals(task.getStatus()) && agentId != null) { + if (task.getAssigneeAgentId() != null && !agentId.equals(task.getAssigneeAgentId())) { + throw new IllegalStateException("task #" + task.getTaskNumber() + + " is assigned to another agent; only the assignee can claim and complete it"); + } + claimTask(taskId, agentId); + task = requireTask(taskId); + } + if (agentId != null && task.getOwnerAgentId() != null && !agentId.equals(task.getOwnerAgentId())) { + throw new IllegalStateException("task #" + task.getTaskNumber() + + " is owned by another agent; only the owner can complete it"); + } + boolean toReview = Boolean.TRUE.equals(task.getRequireApproval()); + String target = toReview ? TeamTaskStatus.IN_REVIEW : TeamTaskStatus.COMPLETED; + int rows = taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) + .set(TeamTaskEntity::getStatus, target) + .set(TeamTaskEntity::getResult, result) + .set(TeamTaskEntity::getLockExpiresAt, null) + .set(TeamTaskEntity::getProgressPercent, 100)); + if (rows != 1) { + throw new IllegalStateException("task #" + task.getTaskNumber() + + " is " + task.getStatus() + " and cannot be completed"); + } + recordEvent(task.getTeamId(), taskId, + toReview ? TeamTaskEventEntity.IN_REVIEW : TeamTaskEventEntity.COMPLETED, + agentId != null ? AUTHOR_AGENT : AUTHOR_SYSTEM, + agentId != null ? String.valueOf(agentId) : null, null); + return toReview ? List.of() : releaseDependents(task); + } + + /** Human approval of an in_review task; releases dependents. */ + @Transactional + public List approveTask(Long taskId) { + TeamTaskEntity task = requireTask(taskId); + int rows = taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_REVIEW) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.COMPLETED)); + if (rows != 1) { + throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review"); + } + return releaseDependents(task); + } + + /** Human rejection of an in_review task; cancels it and releases dependents. */ + @Transactional + public List rejectTask(Long taskId, String reason) { + TeamTaskEntity task = requireTask(taskId); + int rows = taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_REVIEW) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.CANCELLED) + .set(TeamTaskEntity::getReason, reason)); + if (rows != 1) { + throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review"); + } + return releaseDependents(task); + } + + /** Fail a task (blocker escalation, runner error, circuit breaker). Does NOT release dependents. */ + public boolean failTask(Long taskId, String reason) { + boolean failed = taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .in(TeamTaskEntity::getStatus, + TeamTaskStatus.PENDING, TeamTaskStatus.IN_PROGRESS, TeamTaskStatus.STALE) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED) + .set(TeamTaskEntity::getReason, reason) + .set(TeamTaskEntity::getLockExpiresAt, null)) == 1; + if (failed) { + TeamTaskEntity task = taskMapper.selectById(taskId); + recordEvent(task == null ? null : task.getTeamId(), taskId, + TeamTaskEventEntity.FAILED, AUTHOR_SYSTEM, null, reason); + } + return failed; + } + + /** Cancel a non-terminal task; releases dependents so siblings are not deadlocked. */ + @Transactional + public List cancelTask(Long taskId, String reason) { + TeamTaskEntity task = requireTask(taskId); + int rows = taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .notIn(TeamTaskEntity::getStatus, + TeamTaskStatus.COMPLETED, TeamTaskStatus.FAILED, TeamTaskStatus.CANCELLED) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.CANCELLED) + .set(TeamTaskEntity::getReason, reason) + .set(TeamTaskEntity::getLockExpiresAt, null)); + if (rows != 1) { + throw new IllegalStateException("task #" + task.getTaskNumber() + " is already terminal"); + } + return releaseDependents(task); + } + + /** Manual retry of a failed/stale task: back to pending, owner and breaker reset. */ + public boolean retryTask(Long taskId) { + return taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .in(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED, TeamTaskStatus.STALE) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) + .set(TeamTaskEntity::getOwnerAgentId, null) + .set(TeamTaskEntity::getLockExpiresAt, null) + .set(TeamTaskEntity::getReason, null) + .set(TeamTaskEntity::getDispatchCount, 0)) == 1; + } + + // ==================== progress / comments ==================== + + /** Update progress and renew the execution lease in one shot. */ + public boolean updateProgress(Long taskId, Long agentId, Integer percent, String step) { + boolean updated = taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) + .eq(agentId != null, TeamTaskEntity::getOwnerAgentId, agentId) + .set(percent != null, TeamTaskEntity::getProgressPercent, percent) + .set(step != null, TeamTaskEntity::getProgressStep, step) + .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; + if (updated) { + TeamTaskEntity task = taskMapper.selectById(taskId); + recordEvent(task == null ? null : task.getTeamId(), taskId, + TeamTaskEventEntity.PROGRESS, AUTHOR_AGENT, + agentId != null ? String.valueOf(agentId) : null, + (percent != null ? percent + "%" : "") + (step != null ? " — " + step : "")); + } + return updated; + } + + /** Extend the execution lease (runner heartbeat). */ + public void renewLock(Long taskId) { + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) + .set(TeamTaskEntity::getLockExpiresAt, newLease())); + } + + /** + * Add a comment. A blocker comment on an in_progress task auto-fails the + * task; the caller (dispatch layer) is responsible for escalating to the + * lead when this returns true. + * + * @return true when the comment was a blocker that failed the task + */ + @Transactional + public boolean addComment(Long taskId, String authorType, String authorId, + String commentType, String content) { + TeamTaskEntity task = requireTask(taskId); + TeamTaskCommentEntity comment = new TeamTaskCommentEntity(); + comment.setTaskId(taskId); + comment.setTeamId(task.getTeamId()); + comment.setAuthorType(authorType); + comment.setAuthorId(authorId); + comment.setCommentType(commentType == null ? COMMENT_NOTE : commentType); + comment.setContent(content); + commentMapper.insert(comment); + recordEvent(task.getTeamId(), taskId, + COMMENT_BLOCKER.equals(comment.getCommentType()) + ? TeamTaskEventEntity.BLOCKER : TeamTaskEventEntity.COMMENT, + authorType, authorId, content); + + if (COMMENT_BLOCKER.equals(comment.getCommentType())) { + boolean failed = failTask(taskId, "blocked: " + content); + if (failed) { + log.info("Team task {} auto-failed by blocker comment from {}:{}", + taskId, authorType, authorId); + } + return failed; + } + return false; + } + + public List listComments(Long taskId) { + return commentMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskCommentEntity::getTaskId, taskId) + .orderByAsc(TeamTaskCommentEntity::getCreateTime)); + } + + // ==================== timeline events ==================== + + /** Timeline detail cap, matching the column width. */ + static final int MAX_EVENT_DETAIL_CHARS = 1000; + + /** + * Record a lifecycle moment on the task's timeline. Best-effort side + * channel: any failure is logged and swallowed — a missing timeline row + * is acceptable, a task transition broken by the audit trail is not. + */ + public void recordEvent(Long teamId, Long taskId, String eventType, + String actorType, String actorId, String detail) { + try { + TeamTaskEventEntity event = new TeamTaskEventEntity(); + event.setTeamId(teamId); + event.setTaskId(taskId); + event.setEventType(eventType); + event.setActorType(actorType); + event.setActorId(actorId); + event.setDetail(detail == null || detail.length() <= MAX_EVENT_DETAIL_CHARS + ? detail : detail.substring(0, MAX_EVENT_DETAIL_CHARS)); + eventMapper.insert(event); + } catch (Exception e) { + log.warn("Team task {} timeline event '{}' not recorded: {}", + taskId, eventType, e.getMessage()); + } + } + + /** The task's timeline, oldest first. */ + public List listEvents(Long taskId) { + return eventMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEventEntity::getTaskId, taskId) + .orderByAsc(TeamTaskEventEntity::getCreateTime) + .orderByAsc(TeamTaskEventEntity::getId)); + } + + // ==================== deliverables ==================== + + /** Maximum deliverables per task; the board is a summary surface, not a file store. */ + static final int MAX_DELIVERABLES = 10; + + /** Download-path prefix of the generated-file cache — the only accepted deliverable URL form. */ + static final String GENERATED_FILE_PATH = "/api/v1/files/generated/"; + + /** A produced-file reference surfaced on the task card. */ + public record Deliverable(String name, String url, String time) { + } + + /** + * Attach a produced-file reference to the task, stored under the + * "deliverables" key of the task's metadata JSON. + * + * Single-writer assumption: only the task owner's run thread calls this + * (tool-side gating) and no other code path writes metadata, so a plain + * read-modify-write is safe. If a second metadata writer ever appears, + * switch to a SQL-level JSON merge or optimistic locking. + */ + @Transactional + public void addDeliverable(Long taskId, Long agentId, String name, String url) { + TeamTaskEntity task = requireTask(taskId); + if (TeamTaskStatus.isTerminal(task.getStatus())) { + throw new IllegalStateException("task #" + task.getTaskNumber() + " is " + + task.getStatus() + "; deliverables can only be attached while it is active"); + } + if (agentId != null && task.getOwnerAgentId() != null + && !agentId.equals(task.getOwnerAgentId())) { + throw new IllegalStateException("task #" + task.getTaskNumber() + + " is owned by another agent; only the owner can attach deliverables"); + } + if (name == null || name.isBlank() || url == null || url.isBlank()) { + throw new IllegalArgumentException("both name and url are required for a deliverable"); + } + String trimmedUrl = url.trim(); + if (!isGeneratedFileUrl(trimmedUrl)) { + throw new IllegalArgumentException("url must be a " + GENERATED_FILE_PATH + + " download link produced by a render tool; external links are not accepted"); + } + + JSONObject metadata = task.getMetadata() == null || task.getMetadata().isBlank() + ? new JSONObject() + : JSONUtil.parseObj(task.getMetadata()); + JSONArray deliverables = metadata.getJSONArray("deliverables"); + if (deliverables == null) { + deliverables = new JSONArray(); + } + if (deliverables.size() >= MAX_DELIVERABLES) { + throw new IllegalStateException("task #" + task.getTaskNumber() + " already has " + + MAX_DELIVERABLES + " deliverables; consolidate outputs instead of adding more"); + } + deliverables.add(new JSONObject() + .set("name", name.trim()) + .set("url", trimmedUrl) + .set("time", LocalDateTime.now().toString())); + metadata.set("deliverables", deliverables); + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .set(TeamTaskEntity::getMetadata, metadata.toString())); + recordEvent(task.getTeamId(), taskId, TeamTaskEventEntity.DELIVERABLE, + AUTHOR_AGENT, agentId != null ? String.valueOf(agentId) : null, name.trim()); + log.info("Team task {} deliverable attached: {}", taskId, name.trim()); + } + + /** Parse the task's deliverable list; empty on missing/malformed metadata. */ + public List listDeliverables(TeamTaskEntity task) { + if (task == null || task.getMetadata() == null || task.getMetadata().isBlank()) { + return List.of(); + } + try { + JSONArray arr = JSONUtil.parseObj(task.getMetadata()) + .getJSONArray("deliverables"); + if (arr == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (Object entry : arr) { + JSONObject obj = (JSONObject) entry; + result.add(new Deliverable(obj.getStr("name"), obj.getStr("url"), obj.getStr("time"))); + } + return result; + } catch (Exception e) { + return List.of(); + } + } + + /** Accept the cache's relative download path, or an absolute URL whose path is one. */ + private static boolean isGeneratedFileUrl(String url) { + if (url.startsWith(GENERATED_FILE_PATH)) { + return true; + } + if (url.startsWith("http://") || url.startsWith("https://")) { + try { + String path = URI.create(url).getPath(); + return path != null && path.startsWith(GENERATED_FILE_PATH); + } catch (Exception e) { + return false; + } + } + return false; + } + + // ==================== dispatch support ==================== + + /** + * Reserve one dispatch attempt. Returns false — and auto-fails the task — + * once the circuit-breaker cap is exhausted, so a task that keeps bouncing + * cannot loop forever. + */ + @Transactional + public boolean tryAcquireDispatch(Long taskId) { + int rows = taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .lt(TeamTaskEntity::getDispatchCount, MAX_DISPATCHES) + .setSql("dispatch_count = dispatch_count + 1")); + if (rows == 1) { + return true; + } + boolean failed = failTask(taskId, "dispatch circuit breaker: exceeded " + + MAX_DISPATCHES + " attempts"); + if (failed) { + log.warn("Team task {} auto-failed by dispatch circuit breaker", taskId); + } + return false; + } + + /** + * Pending tasks eligible for dispatch, priority first. The dispatch layer + * picks at most one per assignee so a member never runs two tasks at once. + */ + public List findDispatchable(Long teamId) { + return taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getTeamId, teamId) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) + .isNotNull(TeamTaskEntity::getAssigneeAgentId) + .orderByDesc(TeamTaskEntity::getPriority) + .orderByAsc(TeamTaskEntity::getCreateTime)); + } + + /** Whether the agent is already executing a task in this team. */ + public boolean hasActiveTask(Long teamId, Long agentId) { + return taskMapper.selectCount(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getTeamId, teamId) + .eq(TeamTaskEntity::getOwnerAgentId, agentId) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)) > 0; + } + + /** + * Mark in_progress tasks whose lease expired as stale. Returns the affected + * tasks so a scheduler can escalate or retry them. + */ + @Transactional + public List recoverStaleTasks() { + List expired = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) + .isNotNull(TeamTaskEntity::getLockExpiresAt) + .lt(TeamTaskEntity::getLockExpiresAt, LocalDateTime.now())); + for (TeamTaskEntity task : expired) { + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, task.getId()) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE) + .set(TeamTaskEntity::getReason, "execution lease expired")); + recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE, + AUTHOR_SYSTEM, null, "execution lease expired"); + } + if (!expired.isEmpty()) { + log.warn("Marked {} team task(s) stale after lease expiry", expired.size()); + } + return expired; + } + + // ==================== queries ==================== + + public TeamTaskEntity getTask(Long taskId) { + return taskMapper.selectById(taskId); + } + + /** + * Tasks created from a delegated plan's steps, ordered by creation. The + * plan linkage lives in the task metadata JSON ({@code "planId"} written + * as a string), matched with a LIKE — team boards are small and the + * pattern includes the quoted key, so false positives are not a concern. + */ + public List listTasksByPlan(Long teamId, Long planId) { + return taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getTeamId, teamId) + .like(TeamTaskEntity::getMetadata, "\"planId\":\"" + planId + "\"") + .orderByAsc(TeamTaskEntity::getCreateTime)); + } + + public List listTasks(Long teamId, List statuses) { + return listTasks(teamId, statuses, null, null); + } + + /** + * Board query with optional windowing. Terminal columns grow without + * bound on long-lived teams, so the UI pages them (newest first) while + * active columns stay unwindowed. LIMIT/OFFSET is valid across all three + * supported dialects. + */ + public List listTasks(Long teamId, List statuses, + Integer limit, Integer offset) { + return taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getTeamId, teamId) + .in(statuses != null && !statuses.isEmpty(), TeamTaskEntity::getStatus, statuses) + .orderByDesc(TeamTaskEntity::getPriority) + .orderByDesc(TeamTaskEntity::getCreateTime) + .last(limit != null, + "LIMIT " + (limit == null ? 0 : Math.max(1, limit)) + + " OFFSET " + (offset == null ? 0 : Math.max(0, offset)))); + } + + /** Per-status task counts for the board header, computed in the database. */ + public Map countByStatus(Long teamId) { + Map counts = new HashMap<>(); + taskMapper.selectMaps(Wrappers.query() + .select("status", "count(*) as cnt") + .eq("team_id", teamId) + .eq("deleted", 0) + .groupBy("status")) + .forEach(row -> counts.put(String.valueOf(row.get("status")), + ((Number) row.get("cnt")).longValue())); + return counts; + } + + // ==================== dependency release ==================== + + /** + * Release tasks blocked on the given task once ALL of their blockers have + * reached a releasing status (completed / cancelled). Failed blockers keep + * dependents blocked — a retry may still succeed. + * + * @return ids of tasks transitioned from blocked to pending + */ + List releaseDependents(TeamTaskEntity finished) { + List blocked = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getTeamId, finished.getTeamId()) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.BLOCKED)); + if (blocked.isEmpty()) { + return List.of(); + } + List released = new ArrayList<>(); + for (TeamTaskEntity candidate : blocked) { + List blockerIds = parseIdArray(candidate.getBlockedBy()); + if (!blockerIds.contains(finished.getId())) { + continue; + } + boolean allReleased = blockerIds.stream().allMatch(id -> { + if (Objects.equals(id, finished.getId())) { + return true; + } + TeamTaskEntity blocker = taskMapper.selectById(id); + // A vanished blocker must not deadlock its dependents forever. + return blocker == null + || TeamTaskStatus.RELEASES_DEPENDENTS.contains(blocker.getStatus()); + }); + if (!allReleased) { + continue; + } + int rows = taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, candidate.getId()) + .eq(TeamTaskEntity::getStatus, TeamTaskStatus.BLOCKED) + .set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)); + if (rows == 1) { + released.add(candidate.getId()); + } + } + if (!released.isEmpty()) { + log.info("Task {} released {} dependent task(s): {}", + finished.getId(), released.size(), released); + } + return released; + } + + // ==================== helpers ==================== + + private TeamTaskEntity requireTask(Long taskId) { + TeamTaskEntity task = taskMapper.selectById(taskId); + if (task == null) { + throw new IllegalArgumentException("team task not found: " + taskId); + } + return task; + } + + private static LocalDateTime newLease() { + return LocalDateTime.now().plusMinutes(LOCK_MINUTES); + } + + /** Ids are serialized as JSON strings to stay safe across the JS frontend. */ + private static String toJsonIdArray(List ids) { + return JSONUtil.toJsonStr(ids.stream().map(String::valueOf).toList()); + } + + static List parseIdArray(String json) { + if (json == null || json.isBlank()) { + return Collections.emptyList(); + } + try { + return JSONUtil.toList(json, String.class).stream() + .map(Long::valueOf) + .toList(); + } catch (Exception e) { + return Collections.emptyList(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java new file mode 100644 index 00000000..90e1fb29 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java @@ -0,0 +1,367 @@ +package vip.mate.team.tool; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamTaskCommentEntity; +import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskEventEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.service.TeamDispatchService; +import vip.mate.team.service.TeamEventChannel; +import vip.mate.team.service.TeamService; +import vip.mate.team.service.TeamTaskService; +import vip.mate.tool.builtin.ToolExecutionContext; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Shared team task board exposed to the LLM. One multi-action tool (rather + * than one tool per action) keeps the schema compact and mirrors how the + * model already phrases board operations as an action verb plus fields. + * + * Role gating: only the lead creates/cancels/retries tasks; members complete, + * report progress, and comment; everyone reads. All errors return structured + * strings written for LLM self-correction, never exceptions. + * + * @author MateClaw Team + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TeamTasksTool { + + private final TeamService teamService; + private final TeamTaskService taskService; + private final TeamDispatchService dispatchService; + private final TeamEventChannel eventChannel; + private final ConversationService conversationService; + private final AgentMapper agentMapper; + + @Tool(description = "Operate your team's shared task board. Actions: " + + "'list' all tasks; 'get' one task with comments (taskId); " + + "'create' a task (lead only; subject, description, assigneeAgentId required, " + + "optional blockedBy comma-separated prerequisite task ids, priority, higher first, " + + "requireApproval=true to park the finished task for human sign-off); " + + "'complete' a task with its result summary (taskId, result); " + + "'progress' to report execution progress (taskId, percent 0-100, step); " + + "'comment' to leave a note, or type='blocker' when you are stuck and need the lead " + + "(taskId, text); 'attach' to register a produced file on the task " + + "(taskId, name, url — the download link returned by a render tool); " + + "'cancel' (lead only; taskId, text as reason); " + + "'retry' a failed/stale task back to pending (lead only; taskId). " + + "Only usable when you belong to an agent team.") + public String team_tasks( + @ToolParam(description = "One of: list, get, create, complete, progress, comment, attach, cancel, retry") + String action, + @ToolParam(description = "Task id (string form is fine) — required by every action except list/create", required = false) + String taskId, + @ToolParam(description = "create: short task title", required = false) + String subject, + @ToolParam(description = "create: full task instructions; include every input the member needs — members do not see this conversation", required = false) + String description, + @ToolParam(description = "create: agentId of the member who should execute the task", required = false) + String assigneeAgentId, + @ToolParam(description = "create: comma-separated ids of tasks that must finish first", required = false) + String blockedBy, + @ToolParam(description = "create: priority, higher dispatches first (default 0)", required = false) + Integer priority, + @ToolParam(description = "create: true to require human approval before the finished task counts as done", required = false) + Boolean requireApproval, + @ToolParam(description = "complete: result summary reported back to the lead", required = false) + String result, + @ToolParam(description = "progress: completion percent 0-100", required = false) + Integer percent, + @ToolParam(description = "progress: one-line description of the current step", required = false) + String step, + @ToolParam(description = "comment/cancel: comment text or cancellation reason", required = false) + String text, + @ToolParam(description = "comment: 'note' (default) or 'blocker' to escalate to the lead", required = false) + String type, + @ToolParam(description = "attach: display file name of the deliverable, e.g. report.docx", required = false) + String name, + @ToolParam(description = "attach: the /api/v1/files/generated/... download link returned by the render tool", required = false) + String url, + @Nullable ToolContext ctx) { + + String conversationId = ToolExecutionContext.conversationId(ctx); + if (conversationId == null || conversationId.isBlank()) { + return "Error: no conversation context bound to this call."; + } + ConversationEntity conversation = conversationService.findByConversationId(conversationId); + if (conversation == null || conversation.getAgentId() == null) { + return "Error: cannot resolve the calling agent for this conversation."; + } + Long agentId = conversation.getAgentId(); + Optional teamOpt = teamService.getTeamForAgent(agentId); + if (teamOpt.isEmpty()) { + return "Error: you are not part of any agent team; team_tasks is unavailable."; + } + AgentTeamEntity team = teamOpt.get(); + boolean isLead = teamService.isLead(team, agentId); + + try { + return switch (action == null ? "" : action) { + case "list" -> renderBoard(team); + case "get" -> renderDetail(team, parseId(taskId, "taskId")); + case "create" -> createTask(team, agentId, isLead, subject, description, + assigneeAgentId, blockedBy, priority, requireApproval, conversationId); + case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result); + case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step); + case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text); + case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url); + case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text); + case "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId")); + default -> "Error: unknown action '" + action + + "'. Use one of: list, get, create, complete, progress, comment, attach, cancel, retry."; + }; + } catch (IllegalArgumentException | IllegalStateException e) { + return "Error: " + e.getMessage(); + } catch (Exception e) { + log.warn("team_tasks {} failed for team={} agent={}: {}", + action, team.getId(), agentId, e.getMessage()); + return "Error: team_tasks failed — " + e.getMessage(); + } + } + + // ==================== actions ==================== + + private String createTask(AgentTeamEntity team, Long agentId, boolean isLead, + String subject, String description, String assigneeAgentId, + String blockedBy, Integer priority, Boolean requireApproval, + String conversationId) { + if (!isLead) { + return "Error: only the team lead can create tasks. Report blockers or ask the " + + "lead via a comment on your current task instead."; + } + TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() + .teamId(team.getId()) + .subject(subject) + .description(description) + .assigneeAgentId(parseId(assigneeAgentId, "assigneeAgentId")) + .createdByAgentId(agentId) + .priority(priority) + .blockedBy(parseIdList(blockedBy)) + .requireApproval(Boolean.TRUE.equals(requireApproval)) + .leadConversationId(conversationId) + .build()); + eventChannel.publishTaskEvent(task, "team_task_created", Map.of()); + if (TeamTaskStatus.PENDING.equals(task.getStatus())) { + dispatchService.requestDispatch(team.getId()); + } + return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId() + + ") \"" + task.getSubject() + "\" assigned to " + agentName(task.getAssigneeAgentId()) + + ". Status: " + task.getStatus() + + (TeamTaskStatus.BLOCKED.equals(task.getStatus()) + ? " (starts automatically once its prerequisites finish)." : ".") + + " Members are dispatched automatically — do not wait in this turn."; + } + + private String completeTask(AgentTeamEntity team, Long agentId, Long taskId, String result) { + requireTaskInTeam(team, taskId); + if (result == null || result.isBlank()) { + return "Error: result is required — summarize what was produced."; + } + List released = taskService.completeTask(taskId, agentId, result); + if (!released.isEmpty()) { + dispatchService.requestDispatch(team.getId()); + } + TeamTaskEntity task = taskService.getTask(taskId); + StringBuilder sb = new StringBuilder("✓ Task #" + task.getTaskNumber() + " " + + task.getStatus() + "."); + if (TeamTaskStatus.IN_REVIEW.equals(task.getStatus())) { + sb.append(" It awaits human approval before counting as done."); + } + if (!released.isEmpty()) { + sb.append(" Released ").append(released.size()).append(" dependent task(s)."); + } + return sb.toString(); + } + + private String progress(AgentTeamEntity team, Long agentId, Long taskId, + Integer percent, String step) { + requireTaskInTeam(team, taskId); + if (percent != null && (percent < 0 || percent > 100)) { + return "Error: percent must be between 0 and 100."; + } + boolean ok = taskService.updateProgress(taskId, agentId, percent, step); + if (ok) { + Map extra = new HashMap<>(); + if (percent != null) { + extra.put("progressPercent", percent); + } + if (step != null) { + extra.put("progressStep", step); + } + eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_progress", extra); + } + return ok ? "✓ Progress recorded." + : "Error: task is not in progress under your ownership; progress not recorded."; + } + + private String comment(AgentTeamEntity team, Long agentId, Long taskId, + String type, String text) { + requireTaskInTeam(team, taskId); + if (text == null || text.isBlank()) { + return "Error: text is required for a comment."; + } + boolean escalated = taskService.addComment(taskId, TeamTaskService.AUTHOR_AGENT, + String.valueOf(agentId), type, text); + return escalated + ? "✓ Blocker recorded. The task is now failed and the lead has been notified — stop working on it." + : "✓ Comment added."; + } + + private String attach(AgentTeamEntity team, Long agentId, Long taskId, String name, String url) { + requireTaskInTeam(team, taskId); + taskService.addDeliverable(taskId, agentId, name, url); + return "✓ Deliverable attached: " + name.trim() + + ". It now shows on the task card; keep your result a summary instead of pasting file contents."; + } + + private String cancel(AgentTeamEntity team, Long agentId, boolean isLead, + Long taskId, String reason) { + if (!isLead) { + return "Error: only the team lead can cancel tasks."; + } + TeamTaskEntity task = requireTaskInTeam(team, taskId); + List released = taskService.cancelTask(taskId, reason); + taskService.recordEvent(team.getId(), taskId, TeamTaskEventEntity.CANCELLED, + TeamTaskService.AUTHOR_AGENT, String.valueOf(agentId), reason); + eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_cancelled", Map.of()); + // Stop the member run mid-flight instead of letting it burn to the end. + dispatchService.interruptRun(task); + if (!released.isEmpty()) { + dispatchService.requestDispatch(team.getId()); + } + return "✓ Task cancelled."; + } + + private String retry(AgentTeamEntity team, Long agentId, boolean isLead, Long taskId) { + if (!isLead) { + return "Error: only the team lead can retry tasks."; + } + requireTaskInTeam(team, taskId); + if (!taskService.retryTask(taskId)) { + return "Error: only failed or stale tasks can be retried."; + } + taskService.recordEvent(team.getId(), taskId, TeamTaskEventEntity.RETRIED, + TeamTaskService.AUTHOR_AGENT, String.valueOf(agentId), null); + eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_retried", Map.of()); + dispatchService.requestDispatch(team.getId()); + return "✓ Task reset to pending; it will be re-dispatched."; + } + + // ==================== rendering ==================== + + private String renderBoard(AgentTeamEntity team) { + List tasks = taskService.listTasks(team.getId(), null); + if (tasks.isEmpty()) { + return "The task board is empty."; + } + StringBuilder sb = new StringBuilder("Task board for team \"") + .append(team.getName()).append("\" (").append(tasks.size()).append(" tasks):\n"); + for (TeamTaskEntity task : tasks) { + sb.append("- #").append(task.getTaskNumber()) + .append(" [").append(task.getStatus()).append("] ") + .append(task.getSubject()) + .append(" (id: ").append(task.getId()) + .append(", assignee: ").append(agentName(task.getAssigneeAgentId())); + if (task.getProgressPercent() != null + && TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) { + sb.append(", ").append(task.getProgressPercent()).append('%'); + } + sb.append(")\n"); + } + return sb.toString(); + } + + private String renderDetail(AgentTeamEntity team, Long taskId) { + TeamTaskEntity task = requireTaskInTeam(team, taskId); + StringBuilder sb = new StringBuilder(512); + sb.append("Task #").append(task.getTaskNumber()) + .append(" (id: ").append(task.getId()).append(")\n") + .append("Subject: ").append(task.getSubject()).append('\n') + .append("Status: ").append(task.getStatus()).append('\n') + .append("Assignee: ").append(agentName(task.getAssigneeAgentId())).append('\n'); + if (task.getDescription() != null && !task.getDescription().isBlank()) { + sb.append("Description: ").append(task.getDescription()).append('\n'); + } + if (task.getProgressStep() != null) { + sb.append("Progress: ").append(task.getProgressPercent() == null ? "?" + : task.getProgressPercent()).append("% — ").append(task.getProgressStep()).append('\n'); + } + if (task.getResult() != null && !task.getResult().isBlank()) { + sb.append("Result: ").append(task.getResult()).append('\n'); + } + if (task.getReason() != null && !task.getReason().isBlank()) { + sb.append("Reason: ").append(task.getReason()).append('\n'); + } + List comments = taskService.listComments(taskId); + if (!comments.isEmpty()) { + sb.append("Comments:\n"); + for (TeamTaskCommentEntity comment : comments) { + sb.append("- [").append(comment.getCommentType()).append("] ") + .append(comment.getAuthorType()).append(' ').append(comment.getAuthorId()) + .append(": ").append(comment.getContent()).append('\n'); + } + } + return sb.toString(); + } + + // ==================== helpers ==================== + + private TeamTaskEntity requireTaskInTeam(AgentTeamEntity team, Long taskId) { + TeamTaskEntity task = taskService.getTask(taskId); + if (task == null || !task.getTeamId().equals(team.getId())) { + throw new IllegalArgumentException("task " + taskId + " not found on this team's board"); + } + return task; + } + + private String agentName(Long agentId) { + if (agentId == null) { + return "-"; + } + AgentEntity agent = agentMapper.selectById(agentId); + return agent != null && agent.getName() != null ? agent.getName() : String.valueOf(agentId); + } + + private static Long parseId(String raw, String field) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + try { + return Long.valueOf(raw.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(field + " must be a numeric id, got: " + raw); + } + } + + private static List parseIdList(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + List ids = new ArrayList<>(); + for (String part : raw.split(",")) { + if (!part.isBlank()) { + ids.add(parseId(part, "blockedBy entry")); + } + } + return ids; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java index 3aa174f4..1ecec6c8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java @@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.agent.context.ChatOrigin; import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillRuntimeService; @@ -57,6 +58,7 @@ import java.util.Set; public class CodeExecuteTool { private final SkillRuntimeService runtimeService; + private final AgentWorkspaceResolver workspaceResolver; private final SkillScriptExecutionService executionService; private final SkillSecretService skillSecretService; private final ObjectMapper objectMapper; @@ -119,8 +121,9 @@ public class CodeExecuteTool { Map envVars = Collections.emptyMap(); if (skillName != null && !skillName.isBlank()) { - // Skill-scoped run: validate binding + resolve the skill directory. - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + // Skill-scoped run: validate binding + resolve the skill directory, + // scoped to the conversation's workspace (+ builtin/global). + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(ChatOrigin.from(ctx))); if (skill == null) { return formatError("Skill '" + skillName + "' not found or not enabled"); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java index 9c464a7b..bc4bd650 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java @@ -110,9 +110,11 @@ public class DatasourceTool { "SELECT TABLE_NAME, TABLE_COMMENT, TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_SCHEMA = '%s' ORDER BY TABLE_NAME", sanitizeIdentifier(entity.getDatabaseName())); case "postgresql" -> String.format( - "SELECT tablename AS table_name, obj_description(c.oid) AS table_comment " + - "FROM pg_tables t LEFT JOIN pg_class c ON c.relname = t.tablename " + - "WHERE t.schemaname = '%s' ORDER BY tablename", + "SELECT t.table_name, obj_description(c.oid) AS table_comment " + + "FROM information_schema.tables t " + + "LEFT JOIN pg_namespace n ON n.nspname = t.table_schema " + + "LEFT JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = t.table_name " + + "WHERE t.table_schema = '%s' ORDER BY t.table_name", sanitizeIdentifier(entity.getSchemaName() != null ? entity.getSchemaName() : "public")); case "clickhouse" -> "SHOW TABLES"; default -> throw new IllegalArgumentException("不支持的数据库类型: " + dbType); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java index 8b4e2a47..618ae10d 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.TokenEstimator; import vip.mate.llm.routing.AgentBindingResolver; @@ -39,9 +40,25 @@ public class SkillFileTool { private static final int DEFAULT_MAX_LINES = 200; private static final int MAX_OUTPUT_CHARS = 8_000; + /** + * Ceiling for returning SKILL.md in one piece. Below it the full contract + * is returned verbatim — the common case, and the only way the model sees + * every mandatory section. Above it the read degrades to resumable + * pagination (page + "continue with startLine=N" banner) rather than an + * unbounded inline dump. + * + *

        Deliberately far above {@link #MAX_OUTPUT_CHARS} so ordinary skills + * (a few thousand chars) are never split: splitting a contract the model + * can silently under-read is the more expensive failure. It matches the + * per-turn aggregate budget, the point past which a single result would + * dominate the turn regardless. + */ + private static final int MAX_FULL_SKILL_CHARS = 32_000; + private final SkillRuntimeService runtimeService; private final SkillFileAccessPolicy accessPolicy; private final SkillUsageService usageService; + private final AgentWorkspaceResolver workspaceResolver; @Lazy @Autowired @@ -84,7 +101,7 @@ public class SkillFileTool { log.info("Reading skill file: skill={}, path={}", skillName, filePath); // 查找 active skill - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(ChatOrigin.from(ctx))); if (skill == null) { return "Error: Skill '" + skillName + "' not found or not enabled"; } @@ -105,8 +122,14 @@ public class SkillFileTool { // pagination via startLine or maxLines. References / scripts are // still paginated below because they can be large supplementary // material the model loads on demand. + // Safety valve: an outsized SKILL.md degrades to resumable + // pagination instead of an unbounded inline dump. Never a + // lossy middle-cut — a contract with its middle silently + // removed is what makes models fabricate the missing span; + // a page plus an explicit "continue with startLine=N" banner + // keeps the read complete-able. boolean paginationRequested = startLine != null || maxLines != null; - if (!paginationRequested) { + if (!paginationRequested && skill.getContent().length() <= MAX_FULL_SKILL_CHARS) { return skill.getContent(); } return paginateSkillContent(skillName, "SKILL.md", skill.getContent(), startLine, maxLines); @@ -245,7 +268,7 @@ public class SkillFileTool { ) { log.info("Listing skill files: skill={}", skillName); - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(ChatOrigin.from(ctx))); if (skill == null) { return "Error: Skill '" + skillName + "' not found or not enabled"; } @@ -341,7 +364,7 @@ public class SkillFileTool { // entries — no need to thread the (package-private) recommended // comparator back through here. List activeSkills = SkillCatalogSorter.sortResolved( - runtimeService.getActiveSkills().stream() + runtimeService.getActiveSkills(workspaceResolver.resolve(ChatOrigin.from(ctx))).stream() .filter(s -> SkillCatalogSorter.sourceMatches(s, source)) .filter(s -> SkillCatalogSorter.runtimeMatches(s, status)) .filter(s -> boundSkillIds == null diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java index 5409977c..1a6575d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.agent.context.ChatOrigin; import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillRuntimeService; @@ -37,6 +38,7 @@ public class SkillLoadTool { private final SkillRuntimeService runtimeService; private final SkillFileTool skillFileTool; + private final AgentWorkspaceResolver workspaceResolver; @Lazy @Autowired @@ -69,13 +71,16 @@ public class SkillLoadTool { if (skillName == null || skillName.isBlank()) { return "Error: skillName is required. Call listAvailableSkills() to see loadable skills."; } - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + ChatOrigin origin = ChatOrigin.from(ctx); + // Resolve only within the conversation's workspace (+ builtin/global), so + // an agent can never load another workspace's same-named skill. + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(origin)); if (skill == null) { log.info("load_skill: skill '{}' not found or not enabled", skillName); return "Error: Skill '" + skillName + "' not found or not enabled. " + "Call listAvailableSkills(keyword=\"" + skillName + "\") to find the correct name."; } - Long agentId = ChatOrigin.from(ctx).agentId(); + Long agentId = origin.agentId(); if (agentId != null) { Set boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId); if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java index 46bcbc2b..38e1e3a1 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java @@ -13,6 +13,7 @@ import vip.mate.skill.model.SkillEntity; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillSecurityService; import vip.mate.skill.runtime.SkillValidationResult; +import vip.mate.skill.service.SkillFileService; import vip.mate.skill.service.SkillService; import vip.mate.skill.workspace.SkillWorkspaceManager; @@ -39,6 +40,7 @@ import java.util.regex.Pattern; public class SkillManageTool { private final SkillService skillService; + private final SkillFileService skillFileService; private final SkillSecurityService securityService; private final SkillWorkspaceManager workspaceManager; private final SkillRuntimeService runtimeService; @@ -82,10 +84,10 @@ public class SkillManageTool { - create: Create a new skill with SKILL.md content (YAML frontmatter + markdown body) - edit: Replace entire skill content (for major rewrites; preferred when changing version + body together) - patch: Find-and-replace a specific section (for small targeted fixes) - - write_file: Write a supporting file under the skill's references/ or scripts/ directory - (e.g. a long reference doc the SKILL.md links to, or a re-runnable script). Put the - file body in 'content' and the path in 'filePath'. Keep SKILL.md itself lean and move - bulky detail into references/. + - write_file: Write a supporting file under the skill's references/, scripts/ or + templates/ directory (e.g. a long reference doc the SKILL.md links to, a re-runnable + script, or an output template). Put the file body in 'content' and the path in + 'filePath'. Keep SKILL.md itself lean and move bulky detail into references/. - delete: Remove a skill SKILL.md format example: @@ -130,7 +132,7 @@ public class SkillManageTool { String newText, @JsonProperty - @JsonPropertyDescription("For write_file action: relative path under references/ or scripts/ (e.g. 'references/api.md', 'scripts/run.sh'). No '..' allowed.") + @JsonPropertyDescription("For write_file action: relative path under references/, scripts/ or templates/ (e.g. 'references/api.md', 'scripts/run.sh', 'templates/report.html'). No '..' allowed.") String filePath, // RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden @@ -208,7 +210,7 @@ public class SkillManageTool { // 同步到 workspace 文件系统 try { - workspaceManager.exportToWorkspace(name, content); + workspaceManager.exportToWorkspace(name, content, skill.getWorkspaceId()); } catch (Exception e) { log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage()); } @@ -252,7 +254,7 @@ public class SkillManageTool { skillService.updateSkill(existing); try { - workspaceManager.exportToWorkspace(name, content); + workspaceManager.exportToWorkspace(name, content, existing.getWorkspaceId()); } catch (Exception e) { log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage()); } @@ -333,7 +335,7 @@ public class SkillManageTool { skillService.updateSkill(existing); try { - workspaceManager.exportToWorkspace(name, patchedContent); + workspaceManager.exportToWorkspace(name, patchedContent, existing.getWorkspaceId()); } catch (Exception e) { log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage()); } @@ -359,7 +361,7 @@ public class SkillManageTool { */ private String doWriteFile(String name, String filePath, String content) { if (filePath == null || filePath.isBlank()) { - return "Error: filePath is required for write_file (e.g. 'references/api.md' or 'scripts/run.sh')."; + return "Error: filePath is required for write_file (e.g. 'references/api.md', 'scripts/run.sh' or 'templates/report.html')."; } if (content == null) { return "Error: content is required for write_file action."; @@ -383,15 +385,27 @@ public class SkillManageTool { } try { - workspaceManager.writeWorkspaceFile(name, filePath, content); + workspaceManager.writeWorkspaceFile(name, filePath, content, existing.getWorkspaceId()); } catch (IllegalArgumentException e) { return "Error: " + e.getMessage() - + " (paths must start with references/ or scripts/, and may not contain '..')."; + + " (paths must start with references/, scripts/ or templates/, and may not contain '..')."; } catch (Exception e) { log.error("[SkillManage] Failed to write file '{}' for skill '{}': {}", filePath, name, e.getMessage(), e); return "Error writing skill file: " + e.getMessage(); } + // Mirror into the canonical mate_skill_file store so the file + // survives node changes and is visible to DB-reading consumers + // (admin file editor, multi-instance workspace sync). + try { + skillFileService.upsertFile(existing.getId(), filePath.replace('\\', '/'), content); + } catch (Exception e) { + log.warn("[SkillManage] Canonical store write failed for '{}' of skill '{}': {}", + filePath, name, e.getMessage()); + } + + rescanQuietly(existing); + log.info("[SkillManage] Agent wrote skill file: skill={}, path={}", name, filePath); return "File '" + filePath + "' written to skill '" + name + "' (security scan: PASSED)."; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java index ae3a5a49..7e54c1df 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java @@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.agent.context.ChatOrigin; import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillFileAccessPolicy; @@ -38,6 +39,7 @@ import java.util.Set; public class SkillScriptTool { private final SkillRuntimeService runtimeService; + private final AgentWorkspaceResolver workspaceResolver; private final SkillFileAccessPolicy accessPolicy; private final SkillScriptExecutionService executionService; private final SkillSecretService skillSecretService; @@ -84,8 +86,8 @@ public class SkillScriptTool { ) { log.info("Executing skill script: skill={}, script={}, args={}", skillName, scriptPath, args); - // Look up active skill. - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + // Look up active skill within the conversation's workspace (+ builtin/global). + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(ChatOrigin.from(ctx))); if (skill == null) { return formatError("Skill '" + skillName + "' not found or not enabled"); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java index 3c2e28c1..f6bce0ef 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java @@ -11,6 +11,7 @@ import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import vip.mate.agent.context.ChatOrigin; import vip.mate.memory.MemoryProperties; +import vip.mate.memory.identity.MemoryScope; import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.memory.service.MemoryRecallTracker; import vip.mate.workspace.document.MemorySearchHit; @@ -134,6 +135,8 @@ public class WorkspaceMemoryTool { 为避免覆盖有价值内容,通常应先调用 read_workspace_memory_file 再决定写入。 注意:新建文件的 enabled 字段默认为 false,表示该文件不会自动纳入系统提示词——这是正常行为,不代表写入失败。 PROFILE.md / MEMORY.md 等核心记忆文件在首次由种子数据创建时即为 enabled=true;daily note 文件按需读写即可。 + 返回值中的 scope 字段说明写入位置:PERSONAL 表示当前会话用户的私有记忆副本(仅对该用户后续会话生效, + 不会出现在管理页的共享文件列表);TEAM 表示所有使用该 Agent 的用户共享的文件。向用户说明写入结果时请如实区分。 """) public String write_workspace_memory_file( @ToolParam(description = "当前 Agent 的 ID") Long agentId, @@ -157,7 +160,10 @@ public class WorkspaceMemoryTool { result.set("overwritten", before != null); result.set("enabled", Boolean.TRUE.equals(saved.getEnabled())); result.set("bytesWritten", (content != null ? content : "").getBytes(StandardCharsets.UTF_8).length); - result.set("message", before == null ? "工作区记忆文件已创建" : "工作区记忆文件已覆写"); + result.set("scope", saved.getScope()); + result.set("ownerKey", saved.getOwnerKey()); + result.set("message", (before == null ? "工作区记忆文件已创建" : "工作区记忆文件已覆写") + + scopeHint(saved.getScope())); log.info("[WorkspaceMemoryTool] Saved workspace memory file: agentId={}, filename={}", agentId, filename); return JSONUtil.toJsonPrettyStr(result); } @@ -213,7 +219,7 @@ public class WorkspaceMemoryTool { replacements = 1; } - workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey); + WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey); JSONObject result = new JSONObject(); result.set("agentId", agentId); @@ -221,7 +227,9 @@ public class WorkspaceMemoryTool { result.set("replacements", replacements); result.set("replaceAll", replaceAllFlag); result.set("fileSizeAfter", updated.getBytes(StandardCharsets.UTF_8).length); - result.set("message", "工作区记忆文件编辑成功"); + result.set("scope", saved.getScope()); + result.set("ownerKey", saved.getOwnerKey()); + result.set("message", "工作区记忆文件编辑成功" + scopeHint(saved.getScope())); log.info("[WorkspaceMemoryTool] Edited workspace memory file: agentId={}, filename={}, replacements={}", agentId, filename, replacements); return JSONUtil.toJsonPrettyStr(result); @@ -315,6 +323,18 @@ public class WorkspaceMemoryTool { }; } + /** + * Human-readable suffix explaining where a write landed, so the agent can + * relay accurately whether the memory is a per-user private copy or the + * shared file every user of the agent sees. + */ + private static String scopeHint(String scope) { + if (MemoryScope.PERSONAL.equals(scope)) { + return "(写入的是当前会话用户的私有记忆副本,仅对该用户生效,不会出现在管理页的共享文件列表中)"; + } + return "(写入的是共享文件,对所有使用该 Agent 的用户可见)"; + } + private String validate(Long agentId, String filename) { if (agentId == null) { return "agentId 不能为空"; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/preview/OfficePreviewService.java b/mateclaw-server/src/main/java/vip/mate/tool/document/preview/OfficePreviewService.java new file mode 100644 index 00000000..c214443e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/preview/OfficePreviewService.java @@ -0,0 +1,194 @@ +package vip.mate.tool.document.preview; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.tool.document.pdf.PdfProperties; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Converts office documents (pptx / ppt / doc / xls / odt / ods / odp / rtf / …) + * to PDF for in-browser preview, using a {@code soffice --convert-to pdf} + * subprocess. Formats that the frontend can render directly (pdf / docx / xlsx / + * html / text) never reach this service — it is the fallback path for the ones + * no client-side library covers. + * + *

        Reuses the LibreOffice availability contract from {@link PdfProperties}: + * when {@code soffice} is absent or disabled, {@link #isAvailable()} returns + * false and the controller answers {@code 501}, letting the UI degrade to a + * download link. No LibreOffice install is required for the rest of preview to + * work. + * + *

        Converted PDFs are cached next to the source under a hidden + * {@code .preview/} directory, keyed by the source file's last-modified time, + * so repeated opens of the same attachment convert only once. The cache lives + * inside the conversation directory and is removed wholesale when the + * conversation's attachments are cleaned up. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OfficePreviewService { + + /** Hidden sub-directory (per conversation dir) holding converted preview PDFs. */ + public static final String PREVIEW_DIR = ".preview"; + + private static final long CONVERT_TIMEOUT_SECONDS = 90; + + /** + * Extensions this service will convert. Kept in sync with the frontend + * {@code OFFICE_CONVERT_EXTS} set in {@code previewKind.ts}. Formats the + * browser renders natively (pdf/docx/xlsx/csv/html/text) are intentionally + * excluded — they never hit this endpoint. + */ + private static final Set CONVERTIBLE_EXTS = Set.of( + "ppt", "pptx", "doc", "xls", "odt", "ods", "odp", "rtf", "wps"); + + private final PdfProperties properties; + + /** Whether a usable {@code soffice} binary is present (probed each call, cheap). */ + public boolean isAvailable() { + if (!properties.libreoffice().enabled()) return false; + try { + ProcessBuilder pb = new ProcessBuilder(properties.libreoffice().binary(), "--version"); + pb.redirectErrorStream(true); + Process p = pb.start(); + p.getInputStream().readAllBytes(); + boolean finished = p.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + p.destroyForcibly(); + return false; + } + return p.exitValue() == 0; + } catch (Exception e) { + log.debug("[OfficePreview] soffice probe failed: {}", e.getMessage()); + return false; + } + } + + /** Whether {@code filename}'s extension is one this service can convert. */ + public boolean isConvertible(String filename) { + return CONVERTIBLE_EXTS.contains(extensionOf(filename)); + } + + /** + * Return the preview PDF bytes for {@code source}, converting via soffice on + * a cache miss. Callers must have already verified {@link #isConvertible} + * and {@link #isAvailable}. + * + * @param source an existing, readable office document + * @return converted PDF bytes + * @throws IOException conversion failed or produced no output + */ + public byte[] renderPdf(Path source) throws IOException { + Path cached = cachePathFor(source); + if (isCacheFresh(cached, source)) { + return Files.readAllBytes(cached); + } + byte[] pdf = convert(source); + writeCache(cached, pdf); + return pdf; + } + + // ==================== internals ==================== + + private Path cachePathFor(Path source) { + Path dir = source.getParent().resolve(PREVIEW_DIR); + return dir.resolve(source.getFileName().toString() + ".pdf"); + } + + private boolean isCacheFresh(Path cached, Path source) { + try { + if (!Files.isRegularFile(cached)) return false; + // Fresh only when the cached PDF is at least as new as the source, + // so a re-uploaded/overwritten source invalidates the stale preview. + return Files.getLastModifiedTime(cached).toMillis() + >= Files.getLastModifiedTime(source).toMillis(); + } catch (IOException e) { + return false; + } + } + + private void writeCache(Path cached, byte[] pdf) { + try { + Files.createDirectories(cached.getParent()); + Files.write(cached, pdf); + } catch (IOException e) { + // Non-fatal: a failed cache write just means the next open reconverts. + log.debug("[OfficePreview] failed to cache preview {}: {}", cached, e.getMessage()); + } + } + + private byte[] convert(Path source) throws IOException { + Path tempDir = Files.createTempDirectory("mc_preview_"); + try { + ProcessBuilder pb = new ProcessBuilder( + properties.libreoffice().binary(), + "--headless", + "--convert-to", "pdf", + "--outdir", tempDir.toString(), + source.toString()); + pb.redirectErrorStream(true); + Process p = pb.start(); + byte[] stderr = p.getInputStream().readAllBytes(); + boolean finished; + try { + finished = p.waitFor(CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + p.destroyForcibly(); + Thread.currentThread().interrupt(); + throw new IOException("soffice conversion interrupted", e); + } + if (!finished) { + p.destroyForcibly(); + throw new IOException("soffice conversion timed out after " + CONVERT_TIMEOUT_SECONDS + "s"); + } + if (p.exitValue() != 0) { + throw new IOException("soffice exit " + p.exitValue() + ": " + new String(stderr).strip()); + } + // soffice names the output after the input basename, extension swapped to .pdf. + String base = stripExtension(source.getFileName().toString()); + Path pdfFile = tempDir.resolve(base + ".pdf"); + if (!Files.isRegularFile(pdfFile)) { + throw new IOException("soffice produced no PDF (stderr: " + new String(stderr).strip() + ")"); + } + return Files.readAllBytes(pdfFile); + } finally { + cleanup(tempDir); + } + } + + private void cleanup(Path tempDir) { + try (var stream = Files.walk(tempDir)) { + List entries = stream.sorted(Comparator.reverseOrder()).toList(); + for (Path entry : entries) { + try { + Files.deleteIfExists(entry); + } catch (IOException ignored) { + // Best-effort; the OS reclaims java.io.tmpdir on reboot. + } + } + } catch (IOException ignored) { + // ditto + } + } + + private static String extensionOf(String filename) { + if (filename == null) return ""; + int idx = filename.lastIndexOf('.'); + return idx >= 0 ? filename.substring(idx + 1).toLowerCase(Locale.ROOT) : ""; + } + + private static String stripExtension(String filename) { + int idx = filename.lastIndexOf('.'); + return idx >= 0 ? filename.substring(0, idx) : filename; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/ToolExecutionGuardHelper.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/ToolExecutionGuardHelper.java index 1a83c15b..2f86f1d6 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/ToolExecutionGuardHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/ToolExecutionGuardHelper.java @@ -27,7 +27,15 @@ public final class ToolExecutionGuardHelper { * * @return 审批提示文本,作为 tool response 返回给 LLM */ - public static String handleToolApproval( + /** + * Outcome of {@link #handleToolApproval}: the tool-response text handed back + * to the LLM plus the persisted pending-approval id. {@code pendingId} is + * null when the approval service is unavailable and the call degraded to a + * block-style message. + */ + public record ApprovalRequest(String response, String pendingId) {} + + public static ApprovalRequest handleToolApproval( AssistantMessage.ToolCall toolCall, String toolName, String arguments, GuardEvaluation evaluation, String conversationId, String agentId, String requesterId, @@ -39,8 +47,8 @@ public final class ToolExecutionGuardHelper { log.warn("[GuardHelper] ApprovalService not available, falling back to BLOCK for tool={}", toolName); events.add(GraphEventPublisher.toolComplete(toolName, evaluation.summary() != null ? evaluation.summary() : "需要审批", false)); - return "[安全拦截] " + (evaluation.summary() != null ? evaluation.summary() : "需要审批") - + "。审批服务不可用,请联系管理员。"; + return new ApprovalRequest("[安全拦截] " + (evaluation.summary() != null ? evaluation.summary() : "需要审批") + + "。审批服务不可用,请联系管理员。", null); } String toolCallPayload = serializeToolCall(toolCall); @@ -78,7 +86,8 @@ public final class ToolExecutionGuardHelper { log.info("[GuardHelper] Approval pending created: pendingId={}, tool={}, findings={}", pendingId, toolName, evaluation.hasFindings() ? evaluation.findings().size() : 0); - return "[APPROVAL_PENDING] tool=" + toolName + " awaiting user decision"; + return new ApprovalRequest( + "[APPROVAL_PENDING] tool=" + toolName + " awaiting user decision", pendingId); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java index debb6bf0..26c45468 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java @@ -27,6 +27,14 @@ public class ToolGuardAuditLogEntity { private String pendingId; private String replayPayloadHash; + /** + * Auto-approve resolution outcome for NEEDS_APPROVAL invocations: + * AUTO_GRANT / HARD_BLOCK / FORCE_HUMAN:<pattern> / SEVERITY_CRITICAL / + * SEVERITY_CEILING:<ceiling><<actual> / UNKNOWN_WORKSPACE / NO_GRANT. + * NULL when the invocation never reached the auto-grant decision layer. + */ + private String autoApproveOutcome; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardAuditService.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardAuditService.java index fa3d7e22..008907d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardAuditService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardAuditService.java @@ -43,6 +43,18 @@ public class ToolGuardAuditService { */ @Async public void record(ToolInvocationContext context, GuardEvaluation evaluation, String pendingId) { + record(context, evaluation, pendingId, null); + } + + /** + * 记录审计日志并附带自动批准决策结果。 + *

        + * {@code autoApproveOutcome} 为 NEEDS_APPROVAL 调用经过 auto-grant 决策层后的 + * 结果码(AUTO_GRANT / SEVERITY_CEILING:… / NO_GRANT 等),未经过该层时为 null。 + */ + @Async + public void record(ToolInvocationContext context, GuardEvaluation evaluation, + String pendingId, String autoApproveOutcome) { try { // 审计开关检查 if (!configService.isAuditEnabled()) { @@ -67,6 +79,7 @@ public class ToolGuardAuditService { entity.setDecision(evaluation.decision().name()); entity.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null); entity.setPendingId(pendingId); + entity.setAutoApproveOutcome(autoApproveOutcome); if (evaluation.hasFindings()) { entity.setFindingsJson(serializeFindings(evaluation)); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardService.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardService.java index b8a57a54..2e6c53a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardService.java @@ -32,6 +32,18 @@ public class ToolGuardService { * 通过后再委托 ToolGuardEngine 做 Guardian 规则评估。 */ public GuardEvaluation evaluate(ToolInvocationContext context) { + return evaluate(context, false); + } + + /** + * 评估工具调用,可选延迟 NEEDS_APPROVAL 行的审计记录。 + *

        + * {@code deferApprovalAudit=true} 时,NEEDS_APPROVAL 结果不在此处落审计—— + * 调用方在 auto-grant 决策完成后通过 + * {@link #recordApprovalAudit(ToolInvocationContext, GuardEvaluation, String, String)} + * 补记一行,行内带上决策结果码与 pendingId。ALLOW / BLOCK 行为不变。 + */ + public GuardEvaluation evaluate(ToolInvocationContext context, boolean deferApprovalAudit) { // 全局开关:guard 禁用时直接放行 if (!configService.isEnabled()) { return GuardEvaluation.allow(context.toolName()); @@ -47,16 +59,35 @@ public class ToolGuardService { GuardEvaluation evaluation = engine.evaluate(context); - // 异步审计记录 - try { - auditService.record(context, evaluation, null); - } catch (Exception e) { - log.warn("[ToolGuardService] Failed to record audit: {}", e.getMessage()); + // 异步审计记录(NEEDS_APPROVAL 且调用方要求延迟时跳过,由调用方补记) + if (!(deferApprovalAudit && evaluation.shouldRequireApproval())) { + try { + auditService.record(context, evaluation, null); + } catch (Exception e) { + log.warn("[ToolGuardService] Failed to record audit: {}", e.getMessage()); + } } return evaluation; } + /** + * 补记被 {@code evaluate(context, true)} 延迟的 NEEDS_APPROVAL 审计行。 + * + * @param autoApproveOutcome auto-grant 决策结果码(AUTO_GRANT / HARD_BLOCK / + * FORCE_HUMAN:xxx / SEVERITY_CRITICAL / SEVERITY_CEILING:xxx / + * UNKNOWN_WORKSPACE / NO_GRANT),未接线时为 null + * @param pendingId 人审路径创建的待批 id,无则为 null + */ + public void recordApprovalAudit(ToolInvocationContext context, GuardEvaluation evaluation, + String pendingId, String autoApproveOutcome) { + try { + auditService.record(context, evaluation, pendingId, autoApproveOutcome); + } catch (Exception e) { + log.warn("[ToolGuardService] Failed to record approval audit: {}", e.getMessage()); + } + } + /** * 便捷评估方法 */ diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java index e4c9c7fd..f9e0f63d 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java @@ -86,6 +86,7 @@ public class WikiTransformationController { WikiTransformationEntity existing = transformationService.getById(id); if (existing == null) return R.fail(404, "Transformation not found"); verifyTemplateWorkspace(existing, workspaceId); + rejectGlobalTemplateMutation(existing); return R.ok(transformationService.update(id, body)); } @@ -96,6 +97,7 @@ public class WikiTransformationController { WikiTransformationEntity existing = transformationService.getById(id); if (existing != null) { verifyTemplateWorkspace(existing, workspaceId); + rejectGlobalTemplateMutation(existing); transformationService.delete(id); } return R.ok(); @@ -273,4 +275,19 @@ public class WikiTransformationController { throw new MateClawException("err.common.wrong_workspace", 403, "Resource does not belong to current workspace"); } } + + /** + * Global templates ({@code workspace_id IS NULL}, e.g. the built-in starter + * pack) are shared across every workspace, so they must stay read-only on + * the write/delete paths — a mutation by one workspace would affect all of + * them, and a delete is unrecoverable (the Flyway seed runs once). + * {@link #verifyTemplateWorkspace} intentionally allows null-workspace on + * read/apply paths; this guard only covers mutation endpoints. + */ + private void rejectGlobalTemplateMutation(WikiTransformationEntity t) { + if (t.getWorkspaceId() == null) { + throw new MateClawException("err.wiki.global_template_readonly", 403, + "Built-in global templates are read-only"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java index e7dcada2..5c24dcfa 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java @@ -84,4 +84,23 @@ public class WikiKbConfig { * lets the extractor use its built-in default type set. */ private List entityTypes; + + /** + * Optional closed relation schema: a whitelist of + * (subjectType, predicate, objectType) triples. When non-empty, + * extraction is constrained to only these relations instead of freely + * inferring arbitrary ones, which keeps the resulting graph focused on + * the handful of relationships a KB actually cares about instead of + * diluting it with incidental entities. {@code null} or empty keeps the + * legacy open-vocabulary behaviour. + */ + private List relationSchema; + + /** One allowed relation triple in {@link #relationSchema}. */ + @Data + public static class RelationSchemaEntry { + private String subjectType; + private String predicate; + private String objectType; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/model/WikiProcessingJobEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/model/WikiProcessingJobEntity.java index 7be65cca..60bbf378 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/model/WikiProcessingJobEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/model/WikiProcessingJobEntity.java @@ -13,7 +13,7 @@ import java.time.LocalDateTime; @TableName("mate_wiki_processing_job") public class WikiProcessingJobEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long kbId; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiHotCacheEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiHotCacheEntity.java index d3ff6b3a..73f9d617 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiHotCacheEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiHotCacheEntity.java @@ -21,7 +21,7 @@ import java.time.LocalDateTime; @TableName("mate_wiki_hot_cache") public class WikiHotCacheEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long kbId; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiImageCaptionCacheEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiImageCaptionCacheEntity.java index 02a4ebc8..d3126d8f 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiImageCaptionCacheEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiImageCaptionCacheEntity.java @@ -23,7 +23,7 @@ import java.time.LocalDateTime; @TableName("mate_wiki_image_caption_cache") public class WikiImageCaptionCacheEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; /** SHA-256 hex digest (64 chars, lowercase) of the original image bytes. */ diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageCitationEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageCitationEntity.java index 8de827ad..f645654e 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageCitationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageCitationEntity.java @@ -13,7 +13,7 @@ import java.time.LocalDateTime; @TableName("mate_wiki_page_citation") public class WikiPageCitationEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long pageId; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRelationEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRelationEntity.java index 59d98310..1e0b7597 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRelationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRelationEntity.java @@ -29,7 +29,7 @@ import java.time.LocalDateTime; @TableName("mate_wiki_relation") public class WikiRelationEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long kbId; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java index 75a35914..ebb7af21 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java @@ -22,7 +22,7 @@ import java.time.LocalDateTime; @TableName("mate_wiki_transformation") public class WikiTransformationEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; /** diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationRunEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationRunEntity.java index 2f7dc63b..d3401ec8 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationRunEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationRunEntity.java @@ -19,7 +19,7 @@ import java.time.LocalDateTime; @TableName("mate_wiki_transformation_run") public class WikiTransformationRunEntity { - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long transformationId; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java index 29965c7b..f2c2adca 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java @@ -151,9 +151,24 @@ public class WikiContextService { * Build full wiki context for agent system prompt. */ public String buildWikiContext(Long agentId) { + return buildWikiContext(agentId, null); + } + + /** + * Budgeted variant of the system-prompt wiki listing. In addition to the + * absolute {@code maxContextChars} cap (sized for large cloud models), the + * enumerated page list may not exceed {@code budgetTokens} (estimated), so + * a large KB cannot consume a fixed ~{@code maxContextChars}-sized slice of + * a small local model's context window on every turn. A null budget keeps + * the previous chars-only behavior; a non-positive budget skips injection. + */ + public String buildWikiContext(Long agentId, Integer budgetTokens) { if (!properties.isEnabled()) { return ""; } + if (budgetTokens != null && budgetTokens <= 0) { + return ""; + } List kbs = kbService.listByAgentId(agentId); if (kbs.isEmpty()) { @@ -166,6 +181,9 @@ public class WikiContextService { int totalChars = 0; int maxChars = properties.getMaxContextChars(); + // Running estimate of what has been appended, so the page enumeration + // (the part that scales with KB file count) can respect budgetTokens. + int totalTokens = TokenEstimator.estimateTokens(sb.toString()); // Each KB renders as a HEADING-ONLY block (### ) followed by a // metadata line and its page list. The heading deliberately contains @@ -180,16 +198,20 @@ public class WikiContextService { List pages = pageService.listSummaries(kb.getId()); if (pages.isEmpty()) continue; - // Heading: pure KB name. This is what `kbName` expects verbatim. - sb.append("### ").append(kb.getName()).append("\n"); + // Heading: pure KB name (what `kbName` expects verbatim) plus a + // metadata line — built as one string so its tokens are budgeted too. + StringBuilder heading = new StringBuilder(); + heading.append("### ").append(kb.getName()).append("\n"); // Metadata line: page count first (easy to scan), then optional // description. Lives on its own line so it can't be confused for // part of the name. - sb.append(pages.size()).append(" pages"); + heading.append(pages.size()).append(" pages"); if (kb.getDescription() != null && !kb.getDescription().isBlank()) { - sb.append(" — ").append(kb.getDescription()); + heading.append(" — ").append(kb.getDescription()); } - sb.append("\n\n"); + heading.append("\n\n"); + sb.append(heading); + totalTokens += TokenEstimator.estimateTokens(heading.toString()); boolean compact = pages.size() > 20; @@ -204,12 +226,15 @@ public class WikiContextService { } line += "\n"; } - if (totalChars + line.length() > maxChars) { + int lineTokens = TokenEstimator.estimateTokens(line); + if (totalChars + line.length() > maxChars + || (budgetTokens != null && totalTokens + lineTokens > budgetTokens)) { sb.append("- ... and more (use wiki_list_pages to see all)\n"); break; } sb.append(line); totalChars += line.length(); + totalTokens += lineTokens; } sb.append("\n"); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java index 6bbdd007..2f1cd507 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java @@ -115,9 +115,10 @@ public class WikiEntityExtractionService { } List types = resolveEntityTypes(kb); + List relationSchema = resolveRelationSchema(kb); BeanOutputConverter converter = new BeanOutputConverter<>(EntityExtractionResult.class); - String systemPrompt = buildSystemPrompt(types); + String systemPrompt = buildSystemPrompt(types, relationSchema); // Per-run resolution cache: type+normalizedKey → entityId. Seeded lazily // from the DB so entities resolve consistently within and across chunks. @@ -144,7 +145,7 @@ public class WikiEntityExtractionService { if (alreadyProcessed) { clearChunkArtifacts(chunk.getId()); } - persistChunk(kbId, chunk, result, resolved, index); + persistChunk(kbId, chunk, result, resolved, index, relationSchema); } catch (Exception e) { log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}", chunk.getId(), kbId, e.getMessage()); @@ -180,27 +181,44 @@ public class WikiEntityExtractionService { } } - private String buildSystemPrompt(List types) { - return "You are a knowledge-graph entity extractor. From the given source text, " - + "extract named entities and the factual relations between them.\n" - + "Entity types to use: " + String.join(", ", types) + ".\n" - + "Rules:\n" - + "- Only extract entities explicitly named in the text; do not invent any.\n" - + "- Use the most complete surface form as the name; list shorter forms as aliases.\n" - + "- For each relation, subject and object must both appear in the entities list.\n" - + "- Keep predicates short and snake_case (e.g. works_for, located_in, founded).\n" - + "- Provide a short verbatim evidence quote for each entity and relation.\n" - + "- If nothing relevant is present, return empty lists."; + private String buildSystemPrompt(List types, List relationSchema) { + StringBuilder sb = new StringBuilder() + .append("You are a knowledge-graph entity extractor. From the given source text, ") + .append("extract named entities and the factual relations between them.\n") + .append("Entity types to use: ").append(String.join(", ", types)).append(".\n") + .append("Rules:\n") + .append("- Only extract entities explicitly named in the text; do not invent any.\n") + .append("- Use the most complete surface form as the name; list shorter forms as aliases.\n") + .append("- For each relation, subject and object must both appear in the entities list.\n") + .append("- Keep predicates short and snake_case (e.g. works_for, located_in, founded).\n") + .append("- Provide a short verbatim evidence quote for each entity and relation.\n") + .append("- If nothing relevant is present, return empty lists."); + if (relationSchema != null && !relationSchema.isEmpty()) { + sb.append("\n\nAllowed relations (ONLY extract these — ignore everything else):\n"); + for (WikiKbConfig.RelationSchemaEntry rule : relationSchema) { + if (rule == null) { + continue; + } + sb.append("- ").append(rule.getSubjectType()).append(' ') + .append(rule.getPredicate()).append(' ') + .append(rule.getObjectType()).append('\n'); + } + sb.append("Only extract named entities that participate in at least one relation above. ") + .append("Ignore all other named entities and relations, even if they fit one of the entity types."); + } + return sb.toString(); } // ---- persistence ------------------------------------------------------ private void persistChunk(Long kbId, WikiChunkEntity chunk, EntityExtractionResult result, - Map resolved, EntityIndex index) { + Map resolved, EntityIndex index, + List relationSchema) { Long pageId = firstCitingPage(chunk.getId()); // Resolve each entity to a canonical id, persist its mention for this chunk. Map localByName = new HashMap<>(); + Map localTypeByName = new HashMap<>(); if (result.getEntities() != null) { for (EntityExtractionResult.ExtractedEntity e : result.getEntities()) { if (e == null || e.getName() == null || e.getName().isBlank()) { @@ -212,10 +230,12 @@ public class WikiEntityExtractionService { continue; } localByName.put(normalize(e.getName()), entityId); + localTypeByName.put(normalize(e.getName()), type); if (e.getAliases() != null) { for (String alias : e.getAliases()) { if (alias != null && !alias.isBlank()) { localByName.put(normalize(alias), entityId); + localTypeByName.put(normalize(alias), type); } } } @@ -224,7 +244,10 @@ public class WikiEntityExtractionService { } } - // Persist relations whose endpoints both resolved. + // Persist relations whose endpoints both resolved and, when a relation + // schema is configured, whose (subjectType, predicate, objectType) + // matches an allowed triple — a hard backstop in case the model + // doesn't fully follow the prompt-level restriction. if (result.getRelations() != null) { for (EntityExtractionResult.ExtractedRelation r : result.getRelations()) { if (r == null || r.getSubject() == null || r.getObject() == null @@ -236,12 +259,49 @@ public class WikiEntityExtractionService { if (subjectId == null || objectId == null || subjectId.equals(objectId)) { continue; } - upsertRelation(kbId, subjectId, objectId, normalizePredicate(r.getPredicate()), - r.getEvidence(), chunk.getId()); + String predicate = normalizePredicate(r.getPredicate()); + String subjectType = localTypeByName.get(normalize(r.getSubject())); + String objectType = localTypeByName.get(normalize(r.getObject())); + if (!allowedBySchema(relationSchema, subjectType, predicate, objectType)) { + continue; + } + upsertRelation(kbId, subjectId, objectId, predicate, r.getEvidence(), chunk.getId()); } } } + /** + * True when {@code schema} is empty (legacy open behaviour) or contains a + * triple matching {@code subjectType}/{@code predicate}/{@code objectType}. + * {@code predicate} is expected to already be {@link #normalizePredicate}d; + * the rule's predicate is normalized the same way before comparing so + * e.g. a user-entered "works for" matches an extracted "works_for". + */ + private boolean allowedBySchema(List schema, + String subjectType, String predicate, String objectType) { + if (schema == null || schema.isEmpty()) { + return true; + } + for (WikiKbConfig.RelationSchemaEntry rule : schema) { + if (rule == null || rule.getPredicate() == null || rule.getPredicate().isBlank()) { + continue; + } + if (equalsNormalized(rule.getSubjectType(), subjectType) + && normalizePredicate(rule.getPredicate()).equals(predicate) + && equalsNormalized(rule.getObjectType(), objectType)) { + return true; + } + } + return false; + } + + private boolean equalsNormalized(String a, String b) { + if (a == null || b == null) { + return false; + } + return a.trim().equalsIgnoreCase(b.trim()); + } + /** * Resolve an extracted entity to a canonical node id: run cache → exact * key match in DB → embedding near-match → create new. @@ -457,6 +517,16 @@ public class WikiEntityExtractionService { return DEFAULT_ENTITY_TYPES; } + private List resolveRelationSchema(WikiKnowledgeBaseEntity kb) { + if (kb.getConfigContent() != null) { + WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent()); + if (config != null && config.getRelationSchema() != null && !config.getRelationSchema().isEmpty()) { + return config.getRelationSchema(); + } + } + return List.of(); + } + private String normalize(String s) { return s == null ? "" : s.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index 0e4e34a2..1c8aaa6a 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -127,13 +127,22 @@ public class WikiKnowledgeBaseService { * KB just drops out). An agent with no scope rows stays workspace-wide, * preserving the pre-scoping behavior for every existing agent. *

        + * An agent flagged {@code wiki_disabled=true} sees no KBs at all — the + * opt-out toggle wins over both workspace visibility and any leftover + * binding rows (the UI clears the rows when the flag is set, so the + * zero-row state must not fall through to "unrestricted"). + *

        * This is the single choke point for KB access: {@code wiki_list_kbs}, - * {@link #findVisibleById}, {@link #findAllByName} and - * {@link #resolvePrimaryKb} all read through here, so narrowing it scopes - * every wiki tool at once. + * {@link #findVisibleById}, {@link #findAllByName}, + * {@link #resolvePrimaryKb}, the system-prompt wiki context, and the + * per-turn relevant-page injection all read through here, so narrowing + * it scopes every wiki surface at once. */ public List listByAgentId(Long agentId) { AgentEntity agent = getAgentOrNull(agentId); + if (agent != null && Boolean.TRUE.equals(agent.getWikiDisabled())) { + return List.of(); + } List workspaceKbs = (agent == null || agent.getWorkspaceId() == null) ? listAll() : listByWorkspace(agent.getWorkspaceId()); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index 5fca7f82..1d8cc1e5 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -1173,7 +1173,7 @@ public class WikiProcessingService { int totalPlanned = createMetas.size() + updateSlugs.size(); // Chunk fallback: if route returned nothing for a non-trivial chunk, inject an overview page - // so no content is silently dropped (mirrors llm_wiki source-summary guarantee). + // so no content is silently dropped: every source chunk is represented by at least one page. if (totalPlanned == 0 && textContent.length() >= properties.getChunkFallbackMinChars()) { String overviewSlug = WikiPageService.toSlug(rawTitle) + "-overview"; ObjectNode fallbackMeta = diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java index af71db3b..baf1130d 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vip.mate.exception.MateClawException; import vip.mate.wiki.model.WikiTransformationEntity; import vip.mate.wiki.model.WikiTransformationRunEntity; import vip.mate.wiki.repository.WikiTransformationMapper; @@ -77,7 +78,10 @@ public class WikiTransformationService { .and(ws -> ws.eq(WikiTransformationEntity::getWorkspaceId, workspaceId) .or().isNull(WikiTransformationEntity::getWorkspaceId)) .eq(WikiTransformationEntity::getName, name) - .last("LIMIT 1")); + // Deterministic: prefer the workspace-local row over a same-named + // global one, then newer-first. (workspace_id IS NULL) ASC puts the + // non-null workspace-scoped row first — consistent across H2/MySQL/Kingbase. + .last("ORDER BY (workspace_id IS NULL) ASC, update_time DESC LIMIT 1")); return Optional.ofNullable(global); } @@ -135,6 +139,10 @@ public class WikiTransformationService { if (entity == null) { throw new IllegalArgumentException("Transformation not found: " + id); } + // Defense in depth: global templates are system-owned / read-only. + // The controller already rejects this, but this also guards callers that + // bypass the controller (e.g. the WikiTool LLM entry points). + rejectGlobalTemplateMutation(entity); if (patch.getTitle() != null) entity.setTitle(patch.getTitle()); if (patch.getDescription() != null) entity.setDescription(patch.getDescription()); if (patch.getPromptTemplate() != null) entity.setPromptTemplate(patch.getPromptTemplate()); @@ -219,6 +227,11 @@ public class WikiTransformationService { @Transactional public void delete(Long id) { + WikiTransformationEntity entity = transformationMapper.selectById(id); + if (entity == null) { + return; + } + rejectGlobalTemplateMutation(entity); transformationMapper.deleteById(id); } @@ -288,4 +301,16 @@ public class WikiTransformationService { "name must be 3-64 chars, lowercase letters / digits / hyphens (start and end alphanumeric)"); } } + + /** + * Global templates ({@code workspace_id IS NULL}) are shared across all + * workspaces and seeded once by Flyway, so they are read-only: a mutation + * by one workspace hits everyone, and a delete is unrecoverable. + */ + private static void rejectGlobalTemplateMutation(WikiTransformationEntity entity) { + if (entity.getWorkspaceId() == null) { + throw new MateClawException("err.wiki.global_template_readonly", 403, + "Built-in global templates are read-only"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 99df5997..4ad09d22 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -347,8 +347,22 @@ public class ConversationService { conv.setMessageCount(0); conv.setLastActiveTime(LocalDateTime.now()); conversationMapper.insert(conv); - } else if (!conv.getUsername().equals(username)) { - throw new IllegalArgumentException("无权操作该会话"); + } else { + // Defense-in-depth workspace isolation: an existing row whose owning + // workspace differs from the caller's means two workspaces resolved to + // the same conversationId. With channel-scoped ids this should no longer + // happen for channel traffic; refuse rather than silently write the + // caller's message into another workspace's conversation. Also closes the + // web-console bare-"default" cross-workspace edge case. + if (workspaceId != null && conv.getWorkspaceId() != null + && !conv.getWorkspaceId().equals(workspaceId)) { + log.warn("[Conversation] Cross-workspace conversationId collision: id={} owner={} requested={}", + conversationId, conv.getWorkspaceId(), workspaceId); + throw new IllegalArgumentException("会话不属于当前工作区"); + } + if (!conv.getUsername().equals(username)) { + throw new IllegalArgumentException("无权操作该会话"); + } } return conv; } @@ -757,6 +771,24 @@ public class ConversationService { conversationMapper.updateById(conv); } + /** + * Clear a conversation's pinned model so it falls back to the agent / + * global default. Counterpart of {@link #updateConversationModel}, which + * deliberately treats blank input as "no override supplied" — resetting + * therefore needs its own explicit entry point. The null-write goes + * through an update wrapper because {@code updateById} skips null fields. + */ + @Transactional + public void clearConversationModel(String conversationId) { + if (conversationId == null || conversationId.isBlank()) { + return; + } + conversationMapper.update(null, new LambdaUpdateWrapper() + .eq(ConversationEntity::getConversationId, conversationId) + .set(ConversationEntity::getModelProvider, null) + .set(ConversationEntity::getModelName, null)); + } + /** * Persist an assistant placeholder marker only when the last message is a * user turn (i.e., the assistant never got to reply). Used by the admin @@ -794,27 +826,112 @@ public class ConversationService { } /** - * Find the most recent message of a given role in a conversation. - * Used by the webchat regenerate flow to find the seed user message and - * locate the assistant reply to delete. Returns null if no match. + * Post-rewind snapshot handed back to the controller so the UI can sync + * the sidebar (message count + preview) without a follow-up query. */ - public MessageEntity findLastMessageByRole(String conversationId, String role) { - List msgs = messageMapper.selectList(new LambdaQueryWrapper() - .eq(MessageEntity::getConversationId, conversationId) - .eq(MessageEntity::getRole, role) - .orderByDesc(MessageEntity::getId) - .last("LIMIT 1")); - return msgs.isEmpty() ? null : msgs.get(0); + public record RewindResult(int deletedCount, int messageCount, String lastMessage) { } /** - * Delete a single message by its primary key. Used by the webchat - * regenerate flow to drop the last assistant reply before re-running. - * Does NOT touch the conversation's messageCount counter — that is - * rewritten when the new assistant message is persisted by saveMessage. + * Delete the given message and every message after it (compression + * boundary rows included), returning the conversation to the state just + * before that message. Aggregate counters ({@code messageCount}, + * {@code lastMessage}, {@code lastActiveTime}) are recomputed from the + * surviving rows. + * + *

        回退到指定消息:删除该消息及其之后的所有消息,并重算会话统计。 + * + * @return snapshot of the post-rewind state, or {@code null} when the + * message does not belong to the conversation */ - public void deleteMessageById(Long messageId) { - messageMapper.deleteById(messageId); + @Transactional + public RewindResult rewindToMessage(String conversationId, Long messageId) { + List all = listMessages(conversationId); + int index = -1; + for (int i = 0; i < all.size(); i++) { + if (messageId.equals(all.get(i).getId())) { + index = i; + break; + } + } + if (index < 0) { + return null; + } + List doomedIds = all.subList(index, all.size()).stream() + .map(MessageEntity::getId) + .toList(); + messageMapper.delete(new LambdaQueryWrapper() + .eq(MessageEntity::getConversationId, conversationId) + .in(MessageEntity::getId, doomedIds)); + + List remaining = all.subList(0, index); + String lastMessage = remaining.stream() + .filter(m -> "assistant".equals(m.getRole())) + .reduce((first, second) -> second) + .map(this::assistantPreview) + .orElse(null); + ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, conversationId)); + if (conv != null) { + conv.setMessageCount(remaining.size()); + conv.setLastMessage(lastMessage); + conv.setLastActiveTime(LocalDateTime.now()); + conversationMapper.updateById(conv); + } + log.info("[Conversation] Rewound conv={} to before message {}, deleted {} rows", + conversationId, messageId, doomedIds.size()); + return new RewindResult(doomedIds.size(), remaining.size(), lastMessage); + } + + /** + * Seed for a regenerate turn: the persisted user message whose reply is + * being regenerated. {@code parts} are already deserialized so the caller + * can rebuild the prompt exactly as the original turn saw it. + */ + public record RegenerateSeed(Long seedMessageId, String content, List parts) { + } + + /** + * Prepare a regenerate turn: locate the most recent user message, delete + * every row after it (the assistant reply block, including any trailing + * system/boundary rows), and return that user message as the new turn's + * input. The caller re-runs the agent WITHOUT persisting a new user row, + * so {@code mate_message} stays free of duplicates. + * + *

        When the user message is already the conversation tail (the reply + * never got persisted — stream died mid-turn), nothing is deleted and the + * seed is still returned, which doubles as the recovery path. + * + *

        准备重新生成:删除最近一条 user 消息之后的所有行并返回该消息作为种子。 + * + * @return the seed, or {@code null} when the conversation has no user + * message to regenerate from + */ + @Transactional + public RegenerateSeed prepareRegenerate(String conversationId) { + List all = listMessages(conversationId); + int i = all.size() - 1; + while (i >= 0 && !"user".equals(all.get(i).getRole())) { + i--; + } + if (i < 0) { + return null; + } + MessageEntity seed = all.get(i); + if (i + 1 < all.size()) { + rewindToMessage(conversationId, all.get(i + 1).getId()); + } + return new RegenerateSeed(seed.getId(), seed.getContent(), parseMessageParts(seed)); + } + + /** + * Sidebar preview of an assistant reply — same summarization and 50-char + * truncation that {@link #saveMessage} applies to the + * {@code last_message} column. + */ + private String assistantPreview(MessageEntity message) { + String summary = summarizeMessage(message.getContent(), parseMessageParts(message)); + return summary.length() > 50 ? summary.substring(0, 50) + "..." : summary; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java index 2aa7c498..291b543f 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java @@ -215,6 +215,30 @@ public class ConversationController { return R.ok(deleted); } + /** + * 回退到指定消息:删除该消息及其之后的所有消息,会话回到该消息之前的状态。 + * 生成中的会话拒绝回退(409),避免与在途流写入竞争。 + */ + @Operation(summary = "回退会话到指定消息之前") + @PostMapping("/{conversationId}/messages/{messageId}/rewind") + public R rewindToMessage(@PathVariable String conversationId, + @PathVariable Long messageId, + Authentication auth) { + String username = auth != null ? auth.getName() : "anonymous"; + if (!conversationService.isConversationOwner(conversationId, username)) { + return R.fail(403, "无权操作该会话"); + } + if (streamTracker.isRunning(conversationId)) { + return R.fail(409, "正在生成回复,请先停止再回退"); + } + ConversationService.RewindResult result = + conversationService.rewindToMessage(conversationId, messageId); + if (result == null) { + return R.fail(404, "消息不存在或不属于该会话"); + } + return R.ok(result); + } + /** * 清空会话消息(保留会话记录) */ diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java index b9fa712f..623ef124 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/WorkspaceFileService.java @@ -175,6 +175,24 @@ public class WorkspaceFileService { return files; } + /** + * List every owner's PERSONAL memory rows for an agent (metadata only, + * content stripped). Admin-surface listing so operators can see that + * per-user memory copies exist alongside the shared config files — + * reading a row's content goes through + * {@link #getMemoryFile(Long, String, String)}. + */ + public List listPersonalFiles(Long agentId) { + List files = fileMapper.selectList( + new LambdaQueryWrapper() + .eq(WorkspaceFileEntity::getAgentId, agentId) + .eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL) + .orderByAsc(WorkspaceFileEntity::getOwnerKey) + .orderByAsc(WorkspaceFileEntity::getFilename)); + files.forEach(f -> f.setContent(null)); + return files; + } + /** * Read a file visible to {@code ownerKey}: the owner's PERSONAL row when it * exists, otherwise the shared row. Null when neither exists. diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java index 70bf6d57..07e8d822 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/controller/WorkspaceFileController.java @@ -118,6 +118,40 @@ public class WorkspaceFileController { return R.ok(); } + // ==================== Per-owner PERSONAL memory (admin read-only) ==================== + + /** + * List every owner's PERSONAL memory rows (metadata only). These rows are + * written by agents during conversations and are scoped to a single end + * user, so they never appear in the shared file list above. Admin-gated: + * the listing exposes which subjects (owner keys) hold private memory. + */ + @Operation(summary = "列出各用户的私有记忆文件(仅元数据)") + @RequireWorkspaceRole("admin") + @GetMapping("/memory/personal-files") + public R> listPersonalFiles(@PathVariable Long agentId) { + return R.ok(workspaceFileService.listPersonalFiles(agentId)); + } + + /** + * Read one owner's PERSONAL memory file content. Query params (not path + * segments) because both the filename ({@code memory/2026-06-02.md}) and + * the owner key ({@code feishu:ou_xxx}) contain characters that clash with + * path mapping. + */ + @Operation(summary = "读取单个用户私有记忆文件内容") + @RequireWorkspaceRole("admin") + @GetMapping("/memory/personal-file") + public R getPersonalFile(@PathVariable Long agentId, + @RequestParam String filename, + @RequestParam String ownerKey) { + WorkspaceFileEntity file = workspaceFileService.getMemoryFile(agentId, filename, ownerKey); + if (file == null) { + return R.fail("文件不存在: " + filename); + } + return R.ok(file); + } + // ==================== Memory snapshot export / import ==================== /** diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 891e0eca..cd3d9082 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -290,6 +290,14 @@ mateclaw: # MateClaw Agent 配置 mate: + channel: + # 入站消息去重:IM 平台在 ack 迟到 / 丢失 / 非 200 时会重投同一条消息, + # 不去重则每次重投都会跑一轮完整 Agent 回合,用户看到重复答复。 + dedup: + enabled: true + # 需明显长于各平台重投窗口(秒级到分钟级) + ttl: 5m + max-size: 2000 agent: # Deterministic Markdown cleanup of the final answer (heading spaces, glued # ---, table pipe alignment) before persistence / channel delivery. Set to @@ -329,9 +337,16 @@ mate: # exceeds the threshold, gets spilled to a new path, agent reads that one, # ad infinitum until MAX_TOOL_CALLS_PER_STEP is hit. Add MCP-provided # readers here if they have the same role. + # readSkillFile / load_skill deliberately return the full SKILL.md so the + # model never misses mandatory sections (API parameter tables etc.); + # spilling them defeats that and leaves the model an 800-char preview, + # causing wrong-parameter tool calls. Their references/scripts reads are + # already self-paginated to 8000 chars, so excluding them stays bounded. excluded-tools: - read_file - read_workspace_memory_file + - readSkillFile + - load_skill # Spill files are deleted after this many days. Default 0 disables the # scheduled sweep entirely so a summary/preview that points at a spill # path stays valid for the whole life of the conversation. Files are diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V170__guard_audit_auto_approve_outcome.sql b/mateclaw-server/src/main/resources/db/migration/h2/V170__guard_audit_auto_approve_outcome.sql new file mode 100644 index 00000000..83045558 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V170__guard_audit_auto_approve_outcome.sql @@ -0,0 +1,7 @@ +-- V170: Tool-guard audit rows carry the auto-approve resolution outcome +-- (H2 dialect). NULL means the invocation never went through the auto-grant +-- decision layer (decision was ALLOW/BLOCK, or the row predates this column). +-- Values: AUTO_GRANT / HARD_BLOCK / FORCE_HUMAN: / SEVERITY_CRITICAL / +-- SEVERITY_CEILING:< / UNKNOWN_WORKSPACE / NO_GRANT. + +ALTER TABLE mate_tool_guard_audit_log ADD COLUMN IF NOT EXISTS auto_approve_outcome VARCHAR(64) NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V171__widen_conversation_id_for_channel_scoping.sql b/mateclaw-server/src/main/resources/db/migration/h2/V171__widen_conversation_id_for_channel_scoping.sql new file mode 100644 index 00000000..9f7e88c0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V171__widen_conversation_id_for_channel_scoping.sql @@ -0,0 +1,9 @@ +-- V171: widen conversation_id to hold the channel-scoped id format +-- (H2 dialect). Channel conversation ids now carry a channelId segment +-- ({channelType}:{channelId}:{sender|chat}), which can exceed the old +-- VARCHAR(64). Widen the two strongly-bound tables to VARCHAR(128), matching +-- mate_channel_session / audit tables. The UNIQUE index on +-- mate_conversation.conversation_id is preserved by the type change. + +ALTER TABLE mate_conversation ALTER COLUMN conversation_id SET DATA TYPE VARCHAR(128); +ALTER TABLE mate_message ALTER COLUMN conversation_id SET DATA TYPE VARCHAR(128); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V172__agent_team_foundation.sql b/mateclaw-server/src/main/resources/db/migration/h2/V172__agent_team_foundation.sql new file mode 100644 index 00000000..8eb1b376 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V172__agent_team_foundation.sql @@ -0,0 +1,89 @@ +-- V172: Agent team foundation — team registry, membership, and the shared task board. +-- A team groups one lead agent with member agents. The lead orchestrates work by +-- creating tasks on the shared board; tasks are dispatched to the assigned member, +-- executed in an isolated conversation, and completed with a result summary. +-- (H2 dialect) + +CREATE TABLE IF NOT EXISTS mate_agent_team ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description VARCHAR(1024), + lead_agent_id BIGINT NOT NULL, + status VARCHAR(16) DEFAULT 'active', + -- Monotonic per-team counter backing human-readable task numbers (#1, #2, ...). + task_seq INT DEFAULT 0, + settings TEXT, + created_by VARCHAR(64), + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_agent_team_lead ON mate_agent_team(lead_agent_id); + +CREATE TABLE IF NOT EXISTS mate_agent_team_member ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + role VARCHAR(16) DEFAULT 'member', + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_team_member_team ON mate_agent_team_member(team_id, agent_id); +CREATE INDEX IF NOT EXISTS idx_team_member_agent ON mate_agent_team_member(agent_id); + +CREATE TABLE IF NOT EXISTS mate_team_task ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + task_number INT, + subject VARCHAR(512) NOT NULL, + description TEXT, + status VARCHAR(16) DEFAULT 'pending', + priority INT DEFAULT 0, + task_type VARCHAR(16) DEFAULT 'general', + -- Intended executor chosen at creation time (required); dispatch turns it into owner. + assignee_agent_id BIGINT NULL, + -- Agent currently executing the task; NULL until claimed or assigned. + owner_agent_id BIGINT NULL, + created_by_agent_id BIGINT NULL, + -- JSON array of prerequisite task ids (as strings); task stays 'blocked' until all + -- prerequisites reach a releasing status (completed / cancelled). + blocked_by TEXT, + -- When TRUE, completion parks the task in 'in_review' until a human approves. + require_approval BOOLEAN DEFAULT FALSE, + progress_percent INT NULL, + progress_step VARCHAR(512), + result TEXT, + reason VARCHAR(1024), + -- Dispatch attempts; the dispatcher auto-fails the task past the circuit-breaker cap. + dispatch_count INT DEFAULT 0, + -- Execution lease; an in_progress task whose lease expired is recoverable as stale. + lock_expires_at TIMESTAMP NULL, + conversation_id VARCHAR(64), + lead_conversation_id VARCHAR(64), + username VARCHAR(64), + channel VARCHAR(32), + metadata TEXT, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_team_task_board ON mate_team_task(team_id, status); +CREATE INDEX IF NOT EXISTS idx_team_task_owner ON mate_team_task(team_id, owner_agent_id, status); +CREATE INDEX IF NOT EXISTS idx_team_task_number ON mate_team_task(team_id, task_number); + +CREATE TABLE IF NOT EXISTS mate_team_task_comment ( + id BIGINT NOT NULL PRIMARY KEY, + task_id BIGINT NOT NULL, + team_id BIGINT NOT NULL, + author_type VARCHAR(16), + author_id VARCHAR(64), + -- 'note' for regular comments; a 'blocker' comment auto-fails the task and + -- escalates to the team lead. + comment_type VARCHAR(16) DEFAULT 'note', + content TEXT, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_team_task_comment ON mate_team_task_comment(task_id, create_time); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V173__register_team_tasks_tool.sql b/mateclaw-server/src/main/resources/db/migration/h2/V173__register_team_tasks_tool.sql new file mode 100644 index 00000000..4d998e37 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V173__register_team_tasks_tool.sql @@ -0,0 +1,6 @@ +-- V173: Register the team_tasks built-in tool (shared team task board). +-- (H2 dialect) + +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板:lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言(type=blocker 时自动失败并升级给 lead)、complete 提交结果;list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V174__team_task_event_timeline.sql b/mateclaw-server/src/main/resources/db/migration/h2/V174__team_task_event_timeline.sql new file mode 100644 index 00000000..28f5da5d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V174__team_task_event_timeline.sql @@ -0,0 +1,20 @@ +-- V174: Team task event timeline — an append-only audit trail of task lifecycle +-- moments (created, dispatched, progress, comments, deliverables, settlement, +-- approval actions), rendered as the task's collaboration timeline in the UI. +-- Written as a side channel: failures to record never affect the task itself. +-- (H2 dialect) + +CREATE TABLE IF NOT EXISTS mate_team_task_event ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL, + actor_type VARCHAR(16), + actor_id VARCHAR(64), + detail VARCHAR(1000), + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_team_task_event_task ON mate_team_task_event(task_id); +CREATE INDEX IF NOT EXISTS idx_team_task_event_team ON mate_team_task_event(team_id, create_time); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V170__guard_audit_auto_approve_outcome.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V170__guard_audit_auto_approve_outcome.sql new file mode 100644 index 00000000..1284da8a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V170__guard_audit_auto_approve_outcome.sql @@ -0,0 +1,4 @@ +-- See the H2 file for context. KingbaseES (PostgreSQL-compatible) supports +-- ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_tool_guard_audit_log ADD COLUMN IF NOT EXISTS auto_approve_outcome VARCHAR(64) NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V171__widen_conversation_id_for_channel_scoping.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V171__widen_conversation_id_for_channel_scoping.sql new file mode 100644 index 00000000..929b4368 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V171__widen_conversation_id_for_channel_scoping.sql @@ -0,0 +1,6 @@ +-- See the H2 file for context. KingbaseES (PostgreSQL-compatible) uses +-- ALTER COLUMN ... TYPE. The NOT NULL and UNIQUE constraints on the column are +-- preserved by a type change. + +ALTER TABLE mate_conversation ALTER COLUMN conversation_id TYPE VARCHAR(128); +ALTER TABLE mate_message ALTER COLUMN conversation_id TYPE VARCHAR(128); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V172__agent_team_foundation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V172__agent_team_foundation.sql new file mode 100644 index 00000000..b4c1f602 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V172__agent_team_foundation.sql @@ -0,0 +1,76 @@ +-- V172: Agent team foundation — team registry, membership, and the shared task board. +-- (KingbaseES / PostgreSQL dialect). See h2/V172 for design notes. + +CREATE TABLE IF NOT EXISTS mate_agent_team ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description VARCHAR(1024), + lead_agent_id BIGINT NOT NULL, + status VARCHAR(16) DEFAULT 'active', + task_seq INT DEFAULT 0, + settings TEXT, + created_by VARCHAR(64), + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_agent_team_lead ON mate_agent_team(lead_agent_id); + +CREATE TABLE IF NOT EXISTS mate_agent_team_member ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + role VARCHAR(16) DEFAULT 'member', + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_team_member_team ON mate_agent_team_member(team_id, agent_id); +CREATE INDEX IF NOT EXISTS idx_team_member_agent ON mate_agent_team_member(agent_id); + +CREATE TABLE IF NOT EXISTS mate_team_task ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + task_number INT, + subject VARCHAR(512) NOT NULL, + description TEXT, + status VARCHAR(16) DEFAULT 'pending', + priority INT DEFAULT 0, + task_type VARCHAR(16) DEFAULT 'general', + assignee_agent_id BIGINT NULL, + owner_agent_id BIGINT NULL, + created_by_agent_id BIGINT NULL, + blocked_by TEXT, + require_approval BOOLEAN DEFAULT FALSE, + progress_percent INT NULL, + progress_step VARCHAR(512), + result TEXT, + reason VARCHAR(1024), + dispatch_count INT DEFAULT 0, + lock_expires_at TIMESTAMP NULL, + conversation_id VARCHAR(64), + lead_conversation_id VARCHAR(64), + username VARCHAR(64), + channel VARCHAR(32), + metadata TEXT, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_team_task_board ON mate_team_task(team_id, status); +CREATE INDEX IF NOT EXISTS idx_team_task_owner ON mate_team_task(team_id, owner_agent_id, status); +CREATE INDEX IF NOT EXISTS idx_team_task_number ON mate_team_task(team_id, task_number); + +CREATE TABLE IF NOT EXISTS mate_team_task_comment ( + id BIGINT NOT NULL PRIMARY KEY, + task_id BIGINT NOT NULL, + team_id BIGINT NOT NULL, + author_type VARCHAR(16), + author_id VARCHAR(64), + comment_type VARCHAR(16) DEFAULT 'note', + content TEXT, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_team_task_comment ON mate_team_task_comment(task_id, create_time); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V173__register_team_tasks_tool.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V173__register_team_tasks_tool.sql new file mode 100644 index 00000000..f81f4ffc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V173__register_team_tasks_tool.sql @@ -0,0 +1,6 @@ +-- V173: Register the team_tasks built-in tool (shared team task board). +-- (KingbaseES / PostgreSQL dialect) + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板:lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言(type=blocker 时自动失败并升级给 lead)、complete 提交结果;list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V174__team_task_event_timeline.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V174__team_task_event_timeline.sql new file mode 100644 index 00000000..4ea6e5fb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V174__team_task_event_timeline.sql @@ -0,0 +1,17 @@ +-- V174: Team task event timeline — an append-only audit trail of task lifecycle +-- moments. (KingbaseES / PostgreSQL dialect). See h2/V174 for design notes. + +CREATE TABLE IF NOT EXISTS mate_team_task_event ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL, + actor_type VARCHAR(16), + actor_id VARCHAR(64), + detail VARCHAR(1000), + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_team_task_event_task ON mate_team_task_event(task_id); +CREATE INDEX IF NOT EXISTS idx_team_task_event_team ON mate_team_task_event(team_id, create_time); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V170__guard_audit_auto_approve_outcome.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V170__guard_audit_auto_approve_outcome.sql new file mode 100644 index 00000000..bb42da97 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V170__guard_audit_auto_approve_outcome.sql @@ -0,0 +1,16 @@ +-- See the H2 file for context. MySQL 8.0 doesn't support +-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through +-- INFORMATION_SCHEMA + a prepared statement. + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_tool_guard_audit_log' + AND COLUMN_NAME = 'auto_approve_outcome' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_tool_guard_audit_log ADD COLUMN auto_approve_outcome VARCHAR(64) NULL', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V171__widen_conversation_id_for_channel_scoping.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V171__widen_conversation_id_for_channel_scoping.sql new file mode 100644 index 00000000..d32a62bc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V171__widen_conversation_id_for_channel_scoping.sql @@ -0,0 +1,8 @@ +-- See the H2 file for context. MySQL widens with MODIFY COLUMN; the full column +-- definition is restated so NOT NULL is preserved (MODIFY replaces the whole +-- definition). Widening the type keeps the existing UNIQUE index on +-- mate_conversation.conversation_id. Widening is idempotent enough that Flyway's +-- version tracking is the only re-run guard needed (no ADD COLUMN existence check). + +ALTER TABLE mate_conversation MODIFY COLUMN conversation_id VARCHAR(128) NOT NULL; +ALTER TABLE mate_message MODIFY COLUMN conversation_id VARCHAR(128) NOT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V172__agent_team_foundation.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V172__agent_team_foundation.sql new file mode 100644 index 00000000..e4f75c09 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V172__agent_team_foundation.sql @@ -0,0 +1,76 @@ +-- V172: Agent team foundation — team registry, membership, and the shared task board. +-- (MySQL dialect). See h2/V172 for design notes. + +CREATE TABLE IF NOT EXISTS mate_agent_team ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description VARCHAR(1024), + lead_agent_id BIGINT NOT NULL, + status VARCHAR(16) DEFAULT 'active', + task_seq INT DEFAULT 0, + settings TEXT, + created_by VARCHAR(64), + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted INT DEFAULT 0, + KEY idx_agent_team_lead (lead_agent_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mate_agent_team_member ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + role VARCHAR(16) DEFAULT 'member', + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted INT DEFAULT 0, + KEY idx_team_member_team (team_id, agent_id), + KEY idx_team_member_agent (agent_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mate_team_task ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + task_number INT, + subject VARCHAR(512) NOT NULL, + description TEXT, + status VARCHAR(16) DEFAULT 'pending', + priority INT DEFAULT 0, + task_type VARCHAR(16) DEFAULT 'general', + assignee_agent_id BIGINT NULL, + owner_agent_id BIGINT NULL, + created_by_agent_id BIGINT NULL, + blocked_by TEXT, + require_approval BOOLEAN DEFAULT FALSE, + progress_percent INT NULL, + progress_step VARCHAR(512), + result TEXT, + reason VARCHAR(1024), + dispatch_count INT DEFAULT 0, + lock_expires_at TIMESTAMP NULL, + conversation_id VARCHAR(64), + lead_conversation_id VARCHAR(64), + username VARCHAR(64), + channel VARCHAR(32), + metadata TEXT, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted INT DEFAULT 0, + KEY idx_team_task_board (team_id, status), + KEY idx_team_task_owner (team_id, owner_agent_id, status), + KEY idx_team_task_number (team_id, task_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mate_team_task_comment ( + id BIGINT NOT NULL PRIMARY KEY, + task_id BIGINT NOT NULL, + team_id BIGINT NOT NULL, + author_type VARCHAR(16), + author_id VARCHAR(64), + comment_type VARCHAR(16) DEFAULT 'note', + content TEXT, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted INT DEFAULT 0, + KEY idx_team_task_comment (task_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V173__register_team_tasks_tool.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V173__register_team_tasks_tool.sql new file mode 100644 index 00000000..2003f548 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V173__register_team_tasks_tool.sql @@ -0,0 +1,6 @@ +-- V173: Register the team_tasks built-in tool (shared team task board). +-- (MySQL dialect) + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板:lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言(type=blocker 时自动失败并升级给 lead)、complete 提交结果;list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V174__team_task_event_timeline.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V174__team_task_event_timeline.sql new file mode 100644 index 00000000..2a12c607 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V174__team_task_event_timeline.sql @@ -0,0 +1,17 @@ +-- V174: Team task event timeline — an append-only audit trail of task lifecycle +-- moments. (MySQL dialect). See h2/V174 for design notes. + +CREATE TABLE IF NOT EXISTS mate_team_task_event ( + id BIGINT NOT NULL PRIMARY KEY, + team_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL, + actor_type VARCHAR(16), + actor_id VARCHAR(64), + detail VARCHAR(1000), + create_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT DEFAULT 0, + KEY idx_team_task_event_task (task_id), + KEY idx_team_task_event_team (team_id, create_time) +); diff --git a/mateclaw-server/src/main/resources/docs/en/channels.md b/mateclaw-server/src/main/resources/docs/en/channels.md index 45287417..a6d77e31 100644 --- a/mateclaw-server/src/main/resources/docs/en/channels.md +++ b/mateclaw-server/src/main/resources/docs/en/channels.md @@ -650,7 +650,34 @@ IM channels (WeCom, WeChat, DingTalk) support voice input. Transcription via Das ## Per-conversation model selection (all IM channels) -As of 1.4.0, IM channel conversations **remember a per-conversation model**, just like web. Each IM conversation seeds a conversation-level model when it's created, and later replies respect that choice rather than always falling back to the agent's default model. See [Chat & Messaging](./chat) for the web-side switching detail. +As of 1.4.0, IM channel conversations **remember a per-conversation model**, just like web. Each IM conversation seeds a conversation-level model when it's created, and later replies respect that choice rather than always falling back to the agent's default model. See [Chat & Messaging](./chat) for the web-side switching detail. As of 2.0.0 you can also switch right inside the IM with the `/model` magic command (next section). + +--- + +## Channel magic commands (2.0.0+) + +In any IM channel, a message that is entirely a `/`-prefixed command is intercepted by a unified dispatcher **before it reaches the LLM** — commands register once and work on every channel, burn no tokens, and respond instantly: + +| Command | What it does | +|------|--------| +| `/new` | Start a fresh conversation (current context is archived) | +| `/clear` | Clear the current conversation's context (the conversation itself survives; the 1.8-era clear command folds into this framework) | +| `/status` | Show the conversation's state — bound employee, model, whether a task is running | +| `/stop` | Stop the running task — intercepted at the enqueue gate, so it preempts a long task mid-flight | +| `/model` | With no argument, list available models (current pin marked); `/model ` or `/model :` switches **this conversation's** model, effective from the next message; `/model reset` restores the default. Fuzzy names get suggestion lists | +| `/help` | List all commands with descriptions | + +Every command carries Chinese and English aliases (e.g. `清空` / `新会话` / `状态`) and is case-insensitive. Matching is two-layered: **bare aliases match only as the entire message** ("help me write a report" is a normal prompt, not `/help`); **the slash form matches on the first token with arguments passed through** (which is how `/model qwen-max` carries its argument). A normal message that merely contains `/stop` mid-sentence never misfires. Command confirmations go through the channel's normal render-and-send path, so an already-posted "thinking…" placeholder bubble is properly consumed instead of spinning forever. + +--- + +## Per-stage progress narration on sync IM channels (2.0.0+) + +The worst part of long tasks in IM is the "message dropped into a void" feeling. As of 2.0.0, IM channels on the synchronous path (WeCom, WeChat, …) no longer reply with only the final answer: + +- each **stage narration** of the agent's run (what it's doing, which tool it called) arrives as a standalone message — you see the task's footsteps on your phone; +- WeCom goes further with an **event-driven progress bubble** that updates in place — thinking state, live tool trace and elapsed time roll in real time, and the bubble morphs into the final answer when it arrives (details and tuning in [WeCom Deep Tuning](./wecom-tuning)); +- the web SSE and sync IM paths share one **per-turn stream accumulator**, so both sides see identical execution metadata (tool calls, token usage). --- @@ -659,6 +686,7 @@ As of 1.4.0, IM channel conversations **remember a per-conversation model**, jus - **Webhook mode needs HTTPS.** Production deployments should front MateClaw with Nginx + SSL. - **Long-connection modes need no public IP.** Telegram Long-Polling, DingTalk Stream, Feishu WebSocket, Discord Gateway, Slack Socket mode, WeCom Long connection — all run behind NAT. - **One channel, one agent.** Different channels can point at different agents. +- **Conversation ids are channel-scoped (2.0.0).** Conversation id generation now encodes the channel identity — two same-type channels created in different workspaces keep separate conversations even for the same external user, so two workspaces' chats can never bleed into one conversation row. - **Credentials are encrypted at rest** in `mate_channel`. - **China networks** often need `http_proxy` configured for Telegram and Discord. diff --git a/mateclaw-server/src/main/resources/docs/en/chat.md b/mateclaw-server/src/main/resources/docs/en/chat.md index 601b0a2d..5ed1c1b8 100644 --- a/mateclaw-server/src/main/resources/docs/en/chat.md +++ b/mateclaw-server/src/main/resources/docs/en/chat.md @@ -105,6 +105,15 @@ Images handed to a vision-capable model get attached for visual understanding. P Files a worker generates via tools (documents / images / audio…) are now **persisted to disk** under `data/generated-files/`, with a 7-day retention window + a 6-hour cleanup sweep and an in-memory LRU on top — download links keep working after a restart and are no longer bounded by the old 10-minute in-memory window. The frontend intercepts `/api/v1/files/generated/{id}` downloads via a global click delegator: success goes through an authenticated fetch → blob download; failure (404/410/expired) just shows a toast, **so a dead link no longer wedges the whole page**. ::: +### In-browser preview: Office / PDF / HTML / text without downloading (2.0.0+) + +Images, audio/video and 3D models always previewed inline — but a Word report the agent generated was just a download button: to glance at it you had to download, find the file, open a local app. Now **document attachments open right in the chat**: + +- **Click to preview**: pdf / docx / xlsx / html / markdown / txt / code files open in a glass-styled preview layer from the attachment card — uploaded and AI-generated alike. +- **Pure client-side rendering**: PDF, Word and Excel parse and render in the browser — nothing leaves your machine, no external preview service, the single-JAR and desktop packaging story is unchanged. +- **Server fallback for the stubborn formats**: pptx and legacy binary Office (doc / xls / ppt) are converted to PDF server-side before preview; if the converter (LibreOffice) isn't present, they degrade gracefully to download — no error, no hang. +- **Safe HTML preview**: rendered in a sandboxed iframe — interactive pages and charts fully work (scripts run), but the iframe sits in an opaque origin and cannot read the app's login state or local storage. + ### Primary model can't see images? "Multimodal sidecar" routing ::: tip Added in 1.3.0 @@ -190,6 +199,15 @@ A conversation is a sequence of messages scoped to a single agent and a single u The segment representation is what powers the progressive display. It also makes the database the source of truth — the UI can reconstruct any past response exactly as it looked while streaming. +### Rewind and regenerate (2.0.0+) + +Two high-frequency actions gained **server-side semantics** in 2.0.0 — no more frontend sleight of hand: + +- **Rewind to here**: truncate the conversation back to a message — everything after it is genuinely deleted in the database and the conversation's aggregates (message count, last-message summary) are recomputed. Refresh the page or open from another client and you see the rewound state; "deleted" answers don't resurrect. +- **Regenerate**: delete the trailing assistant answer and **reuse the original user message** for the re-run — no duplicate user row is inserted. Previously every regenerate added a duplicate question to the database while the old answer haunted the history; now the history stays clean and consistent across refreshes. + +The same semantics cover the admin console, the WebChat widget, and the API — all three entrances behave identically. A conversation with an in-flight stream refuses to rewind first, so an actively-writing turn is never truncated. + ### Per-conversation model selection ::: tip Added in 1.4.0 diff --git a/mateclaw-server/src/main/resources/docs/en/index.md b/mateclaw-server/src/main/resources/docs/en/index.md index 37ea8581..8e82d200 100644 --- a/mateclaw-server/src/main/resources/docs/en/index.md +++ b/mateclaw-server/src/main/resources/docs/en/index.md @@ -23,6 +23,9 @@ features: - icon: 🧑‍💼 title: Digital employees, not chatbots details: You hire coworkers, not a chat box. Each one has a role, a goal, a backstory, a pixel-art avatar, and a color of their own — five career templates ship ready to use. ReAct + Plan-and-Execute, parallel delegation between employees. + - icon: 🤝 + title: Teams, not lone wolves + details: Build a team — the lead breaks a goal into tasks on a shared board, members execute in parallel, dependencies orchestrate themselves, prerequisite results hand off automatically, and settled work announces back for synthesis. Leases, cancel-interrupt, approval gates, deliverables and timelines — as of 2.0.0, a crew works around one board. - icon: 🧩 title: Skills are the skeleton, not a plugin details: One SKILL.md plus one LESSONS.md that grows with use. Eight starter templates, a five-step creation wizard, pre-flight checks before install. MCP and ACP bridges — even Claude Code and Codex show up as employees. diff --git a/mateclaw-server/src/main/resources/docs/en/memory.md b/mateclaw-server/src/main/resources/docs/en/memory.md index 51b49544..ca93879b 100644 --- a/mateclaw-server/src/main/resources/docs/en/memory.md +++ b/mateclaw-server/src/main/resources/docs/en/memory.md @@ -569,6 +569,64 @@ For developers extending the memory layer, see [Architecture](./architecture). --- +## Mem0 Integration (Optional) + +::: warning Not in the default stack +Mem0 integration is an **optional community contribution** — it is NOT part of a default MateClaw install. It requires you to **self-host a Mem0 service** (FastAPI + pgvector + optional Neo4j). MateClaw's "local-first, zero external dependencies" stance is unchanged — this plugin just adds an **additive semantic recall channel** for people willing to run that extra service. +::: + +[Mem0](https://github.com/mem0ai/mem0) is a standalone memory service that handles LLM memory extraction, deduplication, and vector-based recall. MateClaw's `mateclaw-plugin-mem0` module plugs it in as a **plugin-style memory provider** — none of the 4 built-in providers (Builtin / Structured / Session / Fact) are touched. Mem0 stacks on top as a 5th, external provider. **They don't replace each other.** + +### What it does + +| Hook | Behavior | +|------|----------| +| `systemPromptBlock` | Returns empty — leaves the resident system prompt alone, avoids per-turn token bloat | +| `prefetch(agentId, query, ownerKey)` | When `searchEnabled=true` and `ownerKey` is non-blank, calls `POST {baseUrl}/memories/search/` and returns a `[Mem0 Recall]` block concatenated into the current turn's context | +| `syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey)` | When `syncEnabled=true` and `ownerKey` is non-blank, **asynchronously** pushes this turn's user/assistant messages to `POST {baseUrl}/memories/` under `user_id = ownerKey` — the same identifier recall queries by. Failures are logged only, never block the response | +| `getToolBeans` | Empty list — v1 exposes no agent-callable tools | + +**Fault isolation**: any exception in recall or sync is swallowed and logged by the plugin itself; the platform keeps going with the other providers. Mem0 being down does not affect MateClaw's local memory. + +### Per-owner isolation mapping + +Mem0 isolates by `user_id` + `agent_id`. MateClaw maps them as: + +| MateClaw field | Mem0 field | Notes | +|---|---|---| +| `ownerKey` (e.g. `user:42` / `feishu:sender_abc`) | `user_id` | Passed through verbatim | +| `agentId` | `agent_id` | The digital employee ID | + +Both `prefetch` and `syncTurn` receive `ownerKey` from the platform, so writes and recalls are keyed by the same `user_id`. The variants without `ownerKey` skip (empty recall / dropped write) — Mem0 requires `user_id`, without it isolation is impossible. + +### Installation + +1. **Deploy Mem0**: following Mem0's official docs, self-host an instance (FastAPI + pgvector + optional Neo4j). Note its base URL, e.g. `http://localhost:8080`. +2. **Build the plugin JAR**: from the MateClaw repo root, run `mvn -pl mateclaw-plugin-mem0 -am package` — the JAR lands at `mateclaw-plugin-mem0/target/mateclaw-plugin-mem0-*.jar`. +3. **Drop the JAR**: place it in MateClaw's `plugins/` directory. +4. **Configure**: in the plugin admin UI, set `baseUrl` (required) and optionally `apiKey` and other tunables. Restart or reload the plugin. + +### Configuration + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `baseUrl` | string | yes | — | Mem0 REST API base URL, e.g. `http://localhost:8080` | +| `apiKey` | string | no | — | Bearer token sent as the `Authorization` header to Mem0 | +| `searchEnabled` | boolean | no | `true` | Whether prefetch should call `/memories/search/` for semantic recall | +| `syncEnabled` | boolean | no | `true` | Whether syncTurn should push each turn to `/memories/` | +| `maxResults` | integer | no | `5` | Cap on memories returned per recall | +| `timeoutMs` | integer | no | `3000` | HTTP timeout in milliseconds, shared by recall and sync | + +Config is read once at plugin load — changes require a plugin reload to take effect. + +### Known limitations (v1) + +- **Turns without a resolved owner are not synced**: `syncTurn` requires `ownerKey`; turns where the platform cannot resolve one (e.g. system-triggered runs) are skipped rather than written under a fallback identifier that recall could never surface. +- **No token budget control**: the `[Mem0 Recall]` block returned by prefetch is concatenated into the context directly — it is NOT subject to the `system-block-max-chars` injection budget (that budget only governs `user`/`feedback` structured entries). `maxResults` is the only size knob. +- **No agent tools**: v1 does not expose `mem0_search` / `mem0_add` style tools for the agent to call proactively. The agent only passively receives prefetch results. + +--- + ## Next - [Agents](./agents) — how agents use memory during a turn diff --git a/mateclaw-server/src/main/resources/docs/en/models.md b/mateclaw-server/src/main/resources/docs/en/models.md index 33291b96..f815614b 100644 --- a/mateclaw-server/src/main/resources/docs/en/models.md +++ b/mateclaw-server/src/main/resources/docs/en/models.md @@ -198,6 +198,18 @@ Providers that expose a model list (OpenAI, Ollama, LM Studio, OpenRouter, etc.) For OpenRouter specifically, Model Discovery surfaces the **200+ free-tier models** — pick a free model and you have a working setup with zero cost. +### Custom (self-added) providers + +Compatible endpoints you create via "Add provider" (vLLM / Xinference / LocalAI / gateways) enable discovery by protocol: `openai-compatible`, `dashscope-native`, `gemini-native`, and `anthropic-messages` get the **Discover models** button by default; OAuth protocols (ChatGPT OAuth, Claude Code OAuth) do not — their discovery runs through a dedicated sign-in callback, unrelated to `baseUrl`. + +If the endpoint's model-listing path is not the standard `/v1/models` (e.g. a reverse proxy adds a `/openai/v1/models` prefix), override it with a `modelsPath` entry in the provider's Generate Kwargs (JSON): + +```json +{ "modelsPath": "/openai/v1/models" } +``` + +The sibling `completionsPath` key overrides the chat-completions path (default `/v1/chat/completions`); the two are independent. If the endpoint exposes no OpenAI-style listing at all, just use "Add model" to enter model ids manually. + ### Ollama auto-detection on startup No manual configuration needed. On startup: @@ -393,6 +405,15 @@ Every provider you add joins an `AvailableProviderPool` that's probed at startup - **Egress sanitizer** — provider-specific options (e.g., `reasoning_effort` for OpenAI reasoning models) are stripped at egress when failing over to a provider that doesn't support them, so leaked options can't 400 the fallback - **UI distinguishes 401 from session expiry** — provider auth errors and user session expiry now show different messages with different remediation +### Policy-driven error recovery and rate-limit-aware backoff (2.0.0+) + +Failover decides *who to switch to*; 2.0.0 also makes *how each error recovers* a matter of **classification-as-policy** — every error type carries its recovery attributes (retryable, compress context, rotate, fall back), and the retry loop consumes the policy instead of scattering if-chains. The key semantics: + +- **"Server overloaded" and "my key is rate-limited" are treated differently.** A new OVERLOADED class: 503/529-style **server overload** means everyone is queuing — switching providers just burns the whole chain for nothing (and single-key users have nowhere to switch) — so the right move is **back off on the same provider**; a 429 on **your own key** is what deserves a fast rotation. These used to be conflated with opposite policies. +- **When the provider says when it recovers, we believe it.** `Retry-After` / ratelimit-reset response headers used to go only to logs; they now **feed directly into backoff duration and health cooldown** — no more blind backoff against a known rate-limit window. +- **Eviction is a TTL cooldown, not a death sentence.** Providers hard-evicted for auth failures or billing now get TTL-based readmission (swap in a new key or top up the account and the system heals itself, no restart required); a provider-stated recovery time overrides the default. +- **Randomized jitter prevents retry storms.** Concurrent conversations hitting the same rate-limited provider back off with randomized jitter (±30% on the overload backoff tiers, exponential backoff plus a random component on the generic path) — no more lockstep mass retries that keep re-triggering the limit. + ### Preferred provider drives the primary model (1.5.0) Before 1.5.0, "per-agent priority" only affected the **failover order** — the primary model was still the global default. 1.5.0 makes that preference **actually decide primary-model selection**. The full precedence is: diff --git a/mateclaw-server/src/main/resources/docs/en/releases.md b/mateclaw-server/src/main/resources/docs/en/releases.md index fd1fd233..4f107648 100644 --- a/mateclaw-server/src/main/resources/docs/en/releases.md +++ b/mateclaw-server/src/main/resources/docs/en/releases.md @@ -10,6 +10,7 @@ For historical diffs, check the corresponding git tag. For the "why" behind a fe | Version | Date | Highlights | |---------|------|------------| +| [v2.0.0](./releases/2.0.0) | 2026-07-31 | **Agent Teams with a shared task board** — the lead decomposes, members execute in parallel (teams/roles · eight-status kanban · `blockedBy` dependency orchestration · automatic prerequisite hand-off · settled results wake the lead · deliverable registration & download · task timeline + team SSE live board · execution lease heartbeat + cancel-interrupt + `in_review` approval gates) · **Plan-Execute plans hand over to the board** (steps→tasks · dependencies→parallelism · parked-plan resume gate for deterministic synthesis) · Workspace isolation fully sealed (channel-scoped conversation ids · same-named skills coexist per workspace with conversation-scoped runtime resolution) · Channel magic commands (`/new` `/clear` `/status` `/stop` `/model` `/help`) + WeCom event-driven progress bubble (live tool trace · per-stage rolling narration) · Server-side rewind/regenerate semantics · Explainable auto-approval misses (reason codes on audit rows + one-click grant creation + anti-footgun forms) · Policy-driven LLM error recovery (overload vs rate-limit split · `Retry-After`-aware backoff · provider TTL readmission · jitter against retry storms) · In-chat attachment preview (pdf/docx/xlsx/html/text) · Single-source SKILL.md + console bundle-file management · Optional Mem0 plugin memory provider · Knowledge-graph relation schema whitelist | | [v1.8.0](./releases/1.8.0) | 2026-07-12 | Content Studio — one sentence to a publishable post (seeded "Content Studio" employee runs pick-topic → research → draft → illustrate → de-AI → layout → deliver) · **WeChat Official Account (公众号)** image-text articles (`gzh_article` · inline-style HTML · draft-box publish via `gzh_publish`) + **Xiaohongshu (小红书)** image-first notes (`xhs_note` · ≥3 vertical 3:4 cards · online preview) · Measurable **de-AI-ification** (heuristic AI-trace score → detect/rewrite/re-check loop, max 3 rounds) · Publish chain hardened (body images uploaded into WeChat · AES-GCM secret encryption · WeChat service+token reuse · retry + Chinese error hints · fallback cover) · **Content Calendar** (deliver = compliance-scan + auto-record · topic-fingerprint dedup · read-only page) · Browser agent **accessibility-tree ref interaction** + real-browser privacy guardrails + controlled CDP hatch · Attention anchoring + tool-call loop guard + post-mutation verify reminder · Fast-load (~78% smaller initial bundle) · Context-occupancy panel · Cross-KB wikilinks · MCP progress notifications · Volcano Engine provider · PostgreSQL 16 | | [v1.7.0](./releases/1.7.0) | 2026-07-04 | Productionization pass — all three approval paths close the loop (workflow approval channel notify + resolve→resume bridge · WebChat/API-Key channel approve+replay · Feishu/WeCom card-click resolves workflow approvals) · Long tasks are visible ("Run Overview" rail + per-turn token breakdown incl. cache hit/miss/write + sub-agent cost rolled up + one-click generated-file download) · Fits the real model window (local-model context-window probing + unified token budget for prefix injection + small-context degradation + tool-schema budget gate) · Opens up (KB / Deep Research open API with API-key+rate-limit+SSE · pluggable search Provider SPI · MCP identity forwarding) · Desktop remote-server connection + `mateclaw-desktop` source open-sourced + LAN deployment mode · One-click operational data export (Dashboard 9-sheet Excel + CLI) · Wiki processing-failure visibility · Per-employee model chain · Debuggable OpenAPI/Swagger | | [v1.6.0](./releases/1.6.0) | 2026-06-22 | Runs on domestic databases — KingbaseES (人大金仓) + PostgreSQL (one shared PostgreSQL-family migration tree · opt-in Kingbase driver · least-privilege Docker roles) · New senses & hands (image kept in context across turns + `image_analyze` · `execute_code` runs agent-authored code) · You shape the employee (AGENTS.md editor + About You identity + runtime model identity + KB-scope binding + roster tags) · Wiki Sources tab (raw materials + watcher unified, per-KB auto-sync, multi-path/glob, pageType form editor) · Global outbound HTTP/SOCKS proxy · Deterministic Markdown answers · Claude Fable 5 | diff --git a/mateclaw-server/src/main/resources/docs/en/roadmap.md b/mateclaw-server/src/main/resources/docs/en/roadmap.md index d07f6d3a..6c5e090c 100644 --- a/mateclaw-server/src/main/resources/docs/en/roadmap.md +++ b/mateclaw-server/src/main/resources/docs/en/roadmap.md @@ -130,36 +130,39 @@ The employee turns **outward and finishes a whole job** — from a one-sentence Full story: [v1.8.0 release notes](./releases/1.8.0.md). +### v2.0 — It leads a team ✅ Released (2026-07-26) + +From "one person who gets things done" to "a team that collaborates" — **Agent Teams** become a standing roster around a shared task board. + +- **Team entity and roles**: a team = name + lead + members + reviewers — persisted, reusable; the roster and collaboration playbook inject into member prompts +- **Shared task board**: eight-status kanban, `blockedBy` dependency orchestration, member-level parallel dispatch, automatic prerequisite hand-off, settled results waking the lead +- **Lead dispatch**: the lead decomposes, assigns, reviews; a Plan-Execute lead hands its **whole plan over to the board** — steps become tasks, dependencies become parallelism +- **Execution hardening**: lease heartbeats against double execution, cancel-interrupt, `in_review` human approval gates, retry for failed/stale +- **Deliverables and full observability**: output files register on tasks, task timelines, a team SSE live board, and jump-in access to any member's word-by-word run +- Plus: workspace isolation fully sealed, channel magic commands + the WeCom progress bubble, conversation rewind/regenerate, explainable auto-approval, policy-driven LLM error recovery, in-chat attachment preview, single-source SKILL.md, the Mem0 plugin provider + +Full story: [v2.0.0 release notes](./releases/2.0.0.md); user guide: [Agent Teams](./teams). + --- -## Next: Agent Team & Agent Loop +## Next: Agent Loop & Team follow-through > "Great things in business are never done by one person. They're done by a team of people." -Look back along the line: v1.2 gave employees an identity, v1.3 made flows orchestratable, v1.4 made employees follow goals and spin up delegation trees, v1.5 made autonomy verifiable, v1.7 made long tasks visible. +Look back along the line: v1.2 gave employees an identity, v1.3 made flows orchestratable, v1.4 made employees follow goals and spin up delegation trees, v1.7 made long tasks visible, and **v2.0 made teams a standing roster**. -But today's MateClaw still has two "stops": +One "stop" remains: **employees are reactive.** Goal auto-followup only lives **within a single run**; cron and triggers can wake an employee up, but every wake-up is an isolated response. No employee is truly **on duty** — continuously watching its area of responsibility and deciding for itself when to act. -**Collaboration is one-shot.** The v1.4 delegation tree is powerful, but it's **task-scoped** — parent delegates child, the task ends, the tree dissolves. The next task starts from zero. Teams have no name, no roster, no accumulated experience — like hiring a fresh batch of temps for every project. +### Agent Team follow-through — the roster exists; now it grows skills -**Employees are reactive.** Goal auto-followup only lives **within a single run**; cron and triggers can wake an employee up, but every wake-up is an isolated response. No employee is truly **on duty** — continuously watching its area of responsibility and deciding for itself when to act. +2.0 delivered the team entity, the task board, lead dispatch and the hardened execution chain (above). Still on the team track: -v1.9 turns both stops into continuity. - -### Agent Team — from "temp hires" to "standing roster" - -A team is no longer a tree that sprouts at delegation time and vanishes when the task ends. It becomes a **persistent organizational unit**: - -- [ ] **Team entity**: a team = name + leader + member roster + charter — persisted, reusable, exportable and shareable -- [ ] **Team charter (TEAM.md)**: division of labor, collaboration rules, escalation paths — shapes the team the way AGENTS.md shapes an individual -- [ ] **Leader dispatch**: tasks come in, the leader decomposes, assigns to the best-fit member, and reviews the result; what it can't handle gets escalated instead of improvised -- [ ] **Peer review**: critical deliverables can require another member's sign-off before shipping -- [ ] **Shared team memory**: built on v1.5's TEAM scope — members share team memory and a team file space while personal memories stay isolated -- [ ] **Team-level goals**: one goal decomposes into member sub-goals; the checklist aggregates across members — hover the leader's avatar to see what the whole team still owes -- [ ] **Team-to-channel binding**: bind a Feishu / DingTalk group to a team; @ the team in the group, the leader decides who takes it +- [ ] **Peer review**: critical deliverables can require another member's sign-off before shipping (2.0's `in_review` is human approval; member peer review is the next step) +- [ ] **Team-level goals**: one goal decomposes into member sub-goals; the checklist aggregates across members — hover the lead's avatar to see what the whole team still owes +- [ ] **Team-to-channel binding**: bind a Feishu / DingTalk group to a team; @ the team in the group, the lead decides who takes it - [ ] **Team retrospectives**: task wrap-up auto-generates a retrospective into the team's LESSONS.md — this team does better next time -- [ ] **Employee Builder upgrade**: v1.4 builds a batch of employees from one sentence; v1.9 makes it emit a **standing team with a charter** -- [ ] **Run Overview becomes a team view**: each member on-duty / busy / idle at a glance; click through to see what it's working on +- [ ] **Collaboration DAG / swimlane view**: draw task dependencies and member swimlanes on top of the timeline data +- [ ] **Employee Builder upgrade**: one sentence emits a **standing team with a roster** ### Agent Loop — from "answers then stops" to "on duty" @@ -220,8 +223,9 @@ A leader on a loop, members summoned on demand — that's a **self-running digit | **v1.5** | It's verifiable | Goal checklists + self-maintaining Wiki + owner-aware memory | ✅ Released | | **v1.6** | It meets you where you are | Domestic databases + persistent vision + code execution + identity shaping | ✅ Released | | **v1.7** | It's ready for production | Approval paths closed + Run Overview & cost visibility + context/token budgeting + open API/Deep Research + desktop remote/LAN + operational export | ✅ Released | -| **v1.8** | **It does a whole job** | **Content Studio — one sentence to a publishable 公众号 / 小红书 post + browser ref interaction** | ✅ Released | -| **v1.9** | **It's on duty** | **Agent Team standing rosters + Agent Loop resident cycles = a department that runs itself** | 📋 Planned | +| **v1.8** | It does a whole job | Content Studio — one sentence to a publishable 公众号 / 小红书 post + browser ref interaction | ✅ Released | +| **v2.0** | **It leads a team** | **Agent Teams + a shared task board — the lead decomposes and dispatches, members run in parallel, deliverables and full observability** | ✅ Released | +| **Next** | **It's on duty** | **Agent Loop resident cycles + team follow-through (peer review / team goals / group binding / retrospectives) = a department that runs itself** | 📋 Planned | --- diff --git a/mateclaw-server/src/main/resources/docs/en/security.md b/mateclaw-server/src/main/resources/docs/en/security.md index 2a564c2f..d0836eb0 100644 --- a/mateclaw-server/src/main/resources/docs/en/security.md +++ b/mateclaw-server/src/main/resources/docs/en/security.md @@ -308,6 +308,17 @@ curl http://localhost:18088/api/v1/approval/grants \ -H "Authorization: Bearer " ``` +### Auto-approval: hits visible, misses explainable (2.0.0+) + +The most maddening pre-2.0 scenario: you configured an auto-approval grant exactly as intuition suggested, and tool calls **still** went to human review — the grants page said "enabled", the audit log said only "needs approval", and nothing anywhere told you why the grant didn't fire. 2.0.0 makes the whole chain transparent: + +- **Miss reasons are classified.** The resolver no longer lumps every miss into "no grant": **a grant exists but its severity ceiling is too low** (e.g. a LOW ceiling blocking a HIGH call — the most common trap), no candidate grant at all, workspace mismatch, CRITICAL forced to human review… each gets its own reason code. +- **The outcome lands on the audit row.** Every guard audit row records the auto-approval outcome and reason — auto-approved calls no longer misleadingly show "needs approval", and calls that went to review show *why* at a glance. Audit rows also carry the real pending-approval id, so you can jump from audit straight to that approval. +- **One-click grant creation from the audit page.** When you see a "severity ceiling too low" miss, a **create grant** shortcut sits right on the audit row, pre-filled with the tool name, scope, and suggested ceiling — no re-configuring from memory. +- **Anti-footgun forms.** Scope IDs switch from free text to **scope-typed pickers** (pick an agent for AGENT scope, a conversation for CONVERSATION, a workspace for WORKSPACE), eradicating type-mismatched dead grants at the source; the severity ceiling carries semantic hints ("LOW only auto-approves low-severity calls"); and **cross-workspace dead configurations are rejected at creation** — a grant that could never fire is called out on the spot instead of leaving you guessing in the audit log. + +The hard floors are unchanged: CRITICAL always goes to a human, and safety-floor blocks stay non-negotiable. + --- ## File Guard diff --git a/mateclaw-server/src/main/resources/docs/en/skills.md b/mateclaw-server/src/main/resources/docs/en/skills.md index 026387ac..41b3265c 100644 --- a/mateclaw-server/src/main/resources/docs/en/skills.md +++ b/mateclaw-server/src/main/resources/docs/en/skills.md @@ -217,8 +217,8 @@ Think of it as "Maven Local Repository, but for skills" — except the local rep Two sync passes run at boot, so every node has the latest bundle: -1. `SkillWorkspaceBootstrapRunner` → `BundledSkillSyncer` scans the classpath `skills/` directory and syncs **bundled skills** into the workspace root. **Only syncs when the target directory doesn't exist**, so it never clobbers local modifications. -2. `SkillFileSyncer` diffs `mate_skill_file` (DB) against the local workspace (FS) by `sha256` and materializes anything missing or stale. +1. `SkillWorkspaceBootstrapRunner` → `BundledSkillSyncer` scans the classpath `skills/` directory, syncs **bundled skills** into the workspace root, and persists their `scripts/` and `references/` into `mate_skill_file`. Local modifications are normally left alone; but if the on-disk `scripts/` directory has vanished entirely (2.0.0 hardening), it is force-restored from the classpath — a built-in skill's scripts can't stay permanently maimed by one accidental delete. +2. `SkillFileSyncer` diffs `mate_skill_file` (DB) against the local workspace (FS) by `sha256` and materializes anything missing or stale; for built-in skills with script files in neither DB nor disk, it backfills from the classpath (2.0.0 self-heal path). **Why this matters for multi-instance deployments**: one node accepts the upload, the DB row + file rows are written, every other node either restarts or hits `POST /api/v1/skills/{id}/sync-files` to receive the full bundle. No NFS, no scp loop, even desktop clients can hand a skill off across machines. @@ -231,6 +231,7 @@ Third-party packagers package weirdly — some put `setup.sh` at the zip root, s - **Two-pass extraction** — the entire archive is buffered in memory first (cap-protected, 50 MB by default via `mateclaw.skill.upload.max-total-size-mb`), `SKILL.md` is located and the wrapper-dir prefix computed, then entries are classified. **Zip entry order no longer affects the result.** - **Root-level extension fallback** — files sitting next to `SKILL.md` that aren't already under a known bucket get classified by extension: `.sh / .py / .js / .rb / ...` → `scripts/`, `.md / .json / .yaml / .csv / ...` → `references/`. Unknown extensions are dropped with a `WARN` line so packaging mistakes surface instead of vanishing. - **Write-then-prune + empty-bundle guard** — reinstalls **write new files first, then prune anything in the bucket that's not in the new bundle**. If the new bundle has zero entries for a bucket (`scripts/` or `references/`), the disk copies for that bucket are **left alone** — a malformed re-extract can no longer wipe your scripts. Pass `forcePrune=true` if you really want to clear a bucket via an intentionally empty bundle. +- **No more mojibake in CJK file names** (2.0.0) — zip entry names and file content have their encodings **detected independently**: an archive built by a Windows packer with GBK entry names and UTF-8 content decodes each side correctly, so you no longer get "garbled names but readable content" (or the reverse) after install. > Real failure this catches: the official tencent-meeting-mcp zip puts `setup.sh` at the package root (not under `scripts/`). The old extractor silently dropped it; the new one auto-classifies it as `scripts/setup.sh` and the skill installs ready to run. @@ -248,6 +249,27 @@ mateclaw: --- +## Single-source SKILL.md (2.0.0+) + +Before 2.0.0 there was a hidden fork: the runtime read SKILL.md from the workspace directory while the admin console read the database column. An employee editing the file with shell tools in a chat session changed runtime behavior invisibly to the console; conversely, one failed export left agents executing stale content the console claimed was current. + +Now the two sides run a **three-way reconcile**, anchored on a sidecar recording the hash at last sync: file-side edits ingest into the DB, DB-side edits materialize to the file, and a two-sided conflict resolves **DB-wins** with the file side kept as a `SKILL.md.bak` backup. A blank file never overwrites non-blank DB content; a blank DB backfills from the file. The reconcile runs on every convention-path resolve, and opening a skill's detail in the console performs a read-time reconcile too — **what you see in the console is what the employee executes.** + +(Skills with an explicit `skillDir` stay file-authoritative, mirroring their content into the DB column for display.) + +--- + +## Bundle file management: scripts, references and templates editable from the console (2.0.0+) + +The detail drawer used to show and edit only SKILL.md; `scripts/` and `references/` had no console surface at all, and `templates/` wasn't even in the canonical store's bucket set. Now: + +- **`/api/v1/skills/{id}/files` admin endpoints**: list / read / upsert / delete a skill's bundle files. Writes land on the canonical `mate_skill_file` row, materialize the workspace cache, and re-resolve the skill immediately — **the employee's next call runs the new script**. +- **`templates/` becomes a first-class bucket**: DB-persisted like `scripts/` and `references/`, included in sync and backfill, protected by the empty-bundle prune guard. +- **Path envelope**: only the three convention buckets are allowed and traversal is blocked; built-in skill files stay read-only (restored from the shipped bundle on upgrade); virtual MCP / ACP skills own no files. +- **Agent-side writes persist too**: when a skill edits its own bundle files via `write_file` in a session, the change mirrors into the canonical store — console and runtime never disagree again. + +--- + ## Skill Market (and ClawHub) The **Skill Market** page (`/skills`) is where you browse, install, edit, and manage skills. Three sources: @@ -339,9 +361,17 @@ Generate a standup update by analyzing recent git activity. --- -## Workspace isolation +## Workspace isolation (fully sealed in 2.0.0) -Each workspace gets its own copy of skills. When you enable a skill for a workspace, its files are staged under that workspace's directory, the skill's tools are scoped to that workspace, and any file the skill writes stays inside the workspace boundary. As of v1.4 the skill **catalog and runtime are scoped per workspace** too, so each workspace sees and runs only its own skills. See [Workspaces](./workspaces). +Each workspace gets its own copy of skills. When you enable a skill for a workspace, its files are staged under that workspace's directory, the skill's tools are scoped to that workspace, and any file the skill writes stays inside the workspace boundary. + +2.0.0 seals the isolation through **every layer of storage and execution**: + +- **Same-named skills coexist across workspaces.** Install dedup, name uniqueness, and reinstall/uninstall lookups all filter by workspace — workspace A's "book-meeting" skill no longer blocks workspace B from installing its own. +- **Filesystem paths encode the workspace.** The skill directory scheme includes the workspaceId, so two same-named skills own separate directories — no more sharing one directory, overwriting each other, or scripts landing in the neighbor's house. +- **Runtime resolution is scoped to the conversation's workspace.** `load_skill`, skill file reads, script runs and auto-redirect all resolve only within "this conversation's workspace + builtin + global virtual" — an employee in one workspace cannot read or execute another workspace's same-named skill. + +See [Workspaces](./workspaces). --- diff --git a/mateclaw-server/src/main/resources/docs/en/teams.md b/mateclaw-server/src/main/resources/docs/en/teams.md new file mode 100644 index 00000000..0934154b --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/teams.md @@ -0,0 +1,161 @@ +--- +title: Agent Teams — one lead, a crew of digital employees, one shared task board +description: MateClaw agent teams let a lead employee break a complex goal into tasks, dispatch them to team members in parallel, with dependencies, approvals, deliverables and full observability on a shared board. +head: + - - meta + - name: keywords + content: agent teams,task board,kanban,multi-agent collaboration,dispatch,deliverables,MateClaw +--- + +# Agent Teams (2.0.0+) + +> **Before: one employee with sub-tasks. Now: a team around a shared task board.** + +Sub-agent delegation (`delegateToAgent`) solves "one person temporarily calls a helper": synchronous, one-to-one, black-box. But real complex delivery looks like a project: **break down tasks, declare dependencies, run in parallel, gate on approvals, archive deliverables, and see who is doing what at any time**. + +Agent Teams bring that project machinery into MateClaw: you create a **team**, assign one **lead** employee and several **members**; tell the lead a goal, it breaks the goal into tasks on a **shared task board**; the dispatch engine hands tasks to members and runs them **in parallel**; settled results are announced back to the lead, which reviews, re-dispatches, and drives the whole thing to done. You watch it all from the Teams page — or drop tasks onto the board yourself. + +--- + +## Core concepts + +| Concept | Description | +|------|------| +| **Team** | A group of employees plus one task board. An employee can belong to multiple teams. | +| **Role** | `lead` / `member` / `reviewer`. The lead decomposes and summarizes, members execute, reviewers review. | +| **Task** | One work item on the board: subject, description, assignee, dependencies (`blockedBy`), progress, result, deliverables, comments, timeline. | +| **Board** | A kanban grouped by status. The state machine is guarded by conditional database updates — under concurrency the first successful writer wins, so a task can never hold two states. | + +Eight task statuses: + +``` +pending → in_progress → completed / failed / cancelled + ↘ in_review (tasks that require human approval) +blocked (waiting on prerequisites) stale (lease expired, retryable) +``` + +`failed` and `stale` tasks can be retried; `completed` / `cancelled` release downstream tasks that depend on them. + +--- + +## What one collaboration run looks like + +1. **You give the lead a goal**: "Competitive analysis: research vendors A and B separately, then merge into one report." +2. **The lead puts tasks on the board**: three tasks via the `team_tasks` tool — "research A" and "research B" run in parallel, "merge report" declares `blockedBy` on both and enters `blocked`. +3. **The dispatch engine takes over**: a resident sweep (every 30 s, plus an immediate pass after tool/REST actions) assigns pending tasks to their members — each task gets its own child conversation where the member runs the full agent graph. Each member executes one task at a time; the rest queue. +4. **Prerequisite results are handed off automatically**: once "research A/B" complete, "merge report" is released and its dispatch envelope **automatically carries the results and deliverable links of both prerequisites** — member C doesn't need the lead to retell what member A did. +5. **Settled results wake the lead**: completions and failures are announced to the lead in debounced batches; the lead wakes up in a real new turn — reviews results, dispatches follow-ups, or declares the job done. +6. **You see everything**: the Teams board refreshes live (SSE event-driven, no polling), an activity banner streams "#3 dispatched to Content Studio"; open any task for its timeline, progress, comments, deliverables — and **jump into the member's child conversation to watch the run word by word** (a live typewriter while it's running). + +--- + +## The lead's tool: `team_tasks` + +The lead (and members) operate the board through the `team_tasks` tool, with role-gated actions: + +| Action | Who | What | +|------|--------|--------| +| `list` | any member | Render the current board (the lead also gets a live board snapshot injected every turn — see below) | +| `get` | any member | Task detail | +| `create` | lead | Create a task: subject, description, assignee, `blockedBy` dependencies, `requireApproval` for a human gate | +| `complete` | assignee | Submit the result (approval-gated tasks move to `in_review`) | +| `progress` | assignee | Report percent and current step (also renews the execution lease and broadcasts to the board) | +| `comment` | any member | Leave a comment | +| `attach` | assignee | Register a **deliverable** (file name + download URL) on the task | +| `cancel` | lead | Cancel — this **actually interrupts** a running member session, not just flips a status | +| `retry` | lead | Retry a `failed` / `stale` task | + +**Team context injection**: employees on a team get the team roster and collaboration playbook injected into their system prompt; the lead additionally receives a **live board snapshot every turn** — it never has to call `list` first to know what's on the board, and long conversations can't drift into duplicate task creation. + +--- + +## Execution hardening for long-running tasks + +Teams target deep research and long document work — tasks that run for tens of minutes. The execution path is hardened accordingly: + +- **Execution lease + runtime heartbeat.** A dispatched task holds a 60-minute lease that is renewed automatically while the member runs; only genuinely lost tasks (process crash, restart) expire to `stale` and can be safely re-dispatched — no more "task re-dispatched while still running, two instances overwriting each other". +- **Cancel means interrupt.** Member child conversations register with the stream tracker; graph nodes check the stop flag each round, so a cancelled member stops at the next node boundary — no more burning tokens to natural completion after a cancel. +- **Human approval gates.** Declare `requireApproval` at creation; the submitted task parks at `in_review` until you approve / reject on the Teams page — sensitive output passes a human before it leaves the team. +- **Manual task creation.** Tasks don't have to come from the lead: create one directly on the Teams page, assigned to any member; the result lands on the board for you (with no lead conversation to wake, announcement gracefully no-ops). + +--- + +## Deliverables and run visibility + +The right output shape for a complex task is **files + summary**, not one truncated blob of text: + +- Members produce files with the document render tools (docx / pptx / xlsx / pdf) or skills, then `attach` them to the task; the task detail renders a **downloadable attachment list** and the result announcement carries the attachments — links no longer drown in truncated text. +- Every task detail has a **"view run"** entry: the member child conversation's full transcript — dispatch envelope, round-by-round thinking, tool calls, intermediate output. Opened mid-run it's a live typewriter (reconnects replay the buffer). +- **Task timeline**: who created / dispatched / reported / attached / approved / cancelled, and when — each event lands in the `mate_team_task_event` audit table and renders as a timeline on the task detail. The collaboration has a historian. + +--- + +## Plan-Execute leads: the plan becomes the board + +Leads aren't restricted by agent type. A **ReAct lead** creates tasks one by one via `team_tasks`; a **Plan-Execute lead** goes further — the plan produced by its planning node is **handed over to the board wholesale**: + +- plan steps map to board tasks, step dependencies become `blockedBy` — a formerly strictly-serial plan now **parallelizes wherever it can**; +- after hand-off the plan parks (`delegated`) and the lead's turn ends normally; the dispatch/announce loop takes over; +- once all tasks settle, the wake-up passes a **parked-plan resume gate** that deterministically routes to the plan summary node, rebuilding context from task results and deliverables — the same "park in DB, resume in a fresh turn" shape the tool-approval flow already uses, with no checkpoint machinery. + +The hand-off is **all-or-nothing**: the plan goes to the board only when every step resolves to a team member; if any step can't be assigned, the whole plan falls back to the original serial delegation pipeline, behaving exactly as before. + +In short: **a lead that can plan turns its planning into team orchestration.** + +--- + +## The Teams page + +The admin console gains a **Teams** page (`/teams`): + +- **Team management**: create teams, add/remove members, assign roles; +- **Board**: status columns, event-driven live refresh, **paged columns** with **database-side true totals** in the headers — a thousand-task board won't drag the page down; +- **Activity banner**: streaming dispatch / completion / failure events; +- **Task detail**: timeline, progress, comments, deliverable downloads, run-transcript entry, approve / reject; +- **Manual task creation**: drop work onto the board directly. + +--- + +## REST API + +The admin API lives under `/api/v1/teams`: + +| Endpoint | Description | +|------|------| +| `GET / POST /api/v1/teams` | List / create teams | +| `GET / PUT / DELETE /api/v1/teams/{id}` | Team detail / update / delete | +| `POST /api/v1/teams/{id}/members` · `DELETE …/members/{agentId}` | Membership | +| `GET / POST /api/v1/teams/{id}/tasks` | Task list (windowed paging) / create task | +| `GET /api/v1/teams/{id}/tasks/stats` | Per-status counts (computed database-side) | +| `GET /api/v1/teams/{id}/tasks/{taskId}` | Task detail | +| `POST …/tasks/{taskId}/approve · reject · retry · cancel` | Approve / retry / cancel | +| `POST …/tasks/{taskId}/comments` | Comment | +| `GET …/tasks/{taskId}/events` | Task timeline | +| `GET /api/v1/teams/{id}/events` | Team-level SSE event stream (what drives the live board) | + +Every validation failure returns a **readable error** — never a bare 500. + +Data lives in five tables: `mate_agent_team`, `mate_agent_team_member`, `mate_team_task`, `mate_team_task_comment`, `mate_team_task_event`. + +--- + +## Teams vs. sub-agent delegation (`delegateToAgent`) + +| | `delegateToAgent` | Team task board | +|---|---|---| +| Shape | One-off helper call | Standing team + shared board | +| Parallelism | Single async call | Member-level parallelism + dependency orchestration | +| Visibility | Black box until done | Timeline + live spectating + deliverables | +| Interrupt/recover | Tied to parent turn | Leases, cancel-interrupt, retry | +| Fits | Outsourcing one sub-problem | Multi-role, multi-step project delivery | + +They coexist: a team member can still call `delegateToAgent` inside its own task. + +--- + +## Read next + +- [Agents](./agents) — how the ReAct and Plan-Execute graphs work +- [Persistent Goals](./goals) — cross-turn follow-through for a single employee +- [Workflow](./workflow) — deterministic step orchestration (use workflows when the process is fixed, teams when it must be decomposed on the spot) +- [Security & Approval](./security) — how tool approval relates to team task approval diff --git a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md index c6aa92f5..53330b1e 100644 --- a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md @@ -216,6 +216,21 @@ Files live at `data/chat-uploads/{conversationId}/` by default, but when the con --- +## The progress bubble: long tasks no longer look frozen (2.0.0+) + +Before 2.0.0, WeCom replied with one static "🤔 Thinking..." bubble that **never changed** until the final answer — a 30-second-to-3-minute task routinely read as a hang. The placeholder bubble is now **event-driven**: + +- **Live tool trace**: the bubble rolls with the agent's run — thinking state, the tool being called, completed calls with elapsed time, appended line by line; +- **Per-stage rolling**: each new stage (a new reasoning round, a new tool call) refreshes the bubble in place with the current stage narration — you can see exactly how far the task has advanced; +- **Morphs into the answer**: when the first real content chunk arrives, keepalive is cancelled and the **same stream slot is reused** — the progress bubble becomes the answer in place, leaving no orphan bubble; +- **Overwrite throttling**: refreshes carry a minimum interval plus a skip-if-previous-flush-pending guard, so WeCom's rate limits are never tripped. + +Whether thinking content and the tool trace are shown is still governed by the channel's "message filtering" switches — and as of 2.0.0 those switches genuinely control "should process messages be sent", not merely strip inline tags from the final answer. + +Alongside it, **streaming reply management is hardened**: stream slot lifecycles are centrally managed — keepalive, forced finish and context invalidation each in their place — so "the answer landed but the bubble keeps spinning" and "a dangling slot blocks the next message" pathologies are gone. Generated files (images, documents) are also actually delivered through the WeChat channel rather than left as a local-only link. + +--- + ## Model behavior: faking tool calls Observation: **qwen3.6-plus** sometimes "lazes out" in long-context, tool-call-heavy scenarios — it produces a Markdown code block that **mimics** a tool call, but `toolCallCount=0`: diff --git a/mateclaw-server/src/main/resources/docs/en/wiki.md b/mateclaw-server/src/main/resources/docs/en/wiki.md index 7374ab75..7e1cd3df 100644 --- a/mateclaw-server/src/main/resources/docs/en/wiki.md +++ b/mateclaw-server/src/main/resources/docs/en/wiki.md @@ -663,6 +663,12 @@ Extraction only runs when **entity extraction is enabled** in the KB configurati In `Wiki → Config → Entity Extraction`: toggling the switch on reveals a tag editor (multi-select, searchable, inline create). The six built-in types are suggested by default; you can type a custom type (e.g. `technology`, `law`) and press Enter to add it. Leaving the list empty falls back to the built-in six. The type list is stored in the KB's `configContent` JSON under the `entityTypes` key. +### Relation schema: a closed triple whitelist (2.0.0+) + +Entity types constrain *what entities* get extracted, but the relation layer used to be open — the model could invent any predicate between any two entities, and fringe entities got persisted as important merely for "participating in some relation", diluting the few definite relations you actually care about. + +Each KB can now declare an optional **relation schema**: a closed whitelist of `subjectType → predicate → objectType` triples (e.g. `person → works_at → organization`). With it enabled, extraction **keeps only relations matching the schema and the entities participating in them** — no more free-form invention; the graph contains only the relation shapes you defined. Leave it empty to keep the original open extraction. The config lives in the KB's `configContent`; no migration needed. + ### Exploring the graph The Wiki graph view toolbar gains a **Page graph / Entity graph** toggle. In entity graph mode: diff --git a/mateclaw-server/src/main/resources/docs/en/workspaces.md b/mateclaw-server/src/main/resources/docs/en/workspaces.md index fce3bf69..4421a7df 100644 --- a/mateclaw-server/src/main/resources/docs/en/workspaces.md +++ b/mateclaw-server/src/main/resources/docs/en/workspaces.md @@ -186,6 +186,12 @@ Workspace memory files (PROFILE.md, MEMORY.md, daily notes) live under `workspac Each channel binds to exactly one agent, so transitively to exactly one workspace. A DingTalk bot configured in workspace A is completely separate from a DingTalk bot configured in workspace B, even if they're configured to connect to the same DingTalk application (you probably don't want that, but it's technically allowed). +As of 2.0.0, **conversation id generation encodes the channel identity** — two same-type channels created in different workspaces keep separate conversation rows even for the same external user; two workspaces' chats can no longer land in one conversation. + +### Skills (2.0.0) + +Same-named skills coexist independently across workspaces: install dedup filters by workspace, disk directories encode the workspaceId, and runtime load / file reads / script runs resolve only within the conversation's workspace (+ builtin + global virtual). An employee in one workspace can neither read nor execute another workspace's same-named skill. See [Skills](./skills). + --- ## What isolation does NOT cover @@ -197,6 +203,15 @@ Each channel binds to exactly one agent, so transitively to exactly one workspac --- +## Default storage root and desktop local-tools whitelist (2.0.0+) + +Two items landed from issue #512: + +- **The default workspace storage root is configurable in the UI.** Each workspace's `base_path` could always be set individually in Security → Workspaces, but the global fallback sandbox root (`mateclaw.workspace.sandbox.root`, default `data/workspace`) used to require an env var or yml edit. It is now a **"default workspace storage path"** setting in the console: files for newly created conversations and workspaces live under it; changing it affects only future creations and **never migrates existing data**. +- **The desktop local-tools whitelist supports per-entry removal.** The whitelist of directories local tools may access on desktop used to be managed through a native dialog offering only "add" and "disable" — the delete API was dead code. Whitelisted directories are now **listed and individually removable** in the UI. + +--- + ## Moving resources between workspaces Not supported directly. You have two options: diff --git a/mateclaw-server/src/main/resources/docs/zh/channels.md b/mateclaw-server/src/main/resources/docs/zh/channels.md index 200a5feb..86b6edbe 100644 --- a/mateclaw-server/src/main/resources/docs/zh/channels.md +++ b/mateclaw-server/src/main/resources/docs/zh/channels.md @@ -650,7 +650,34 @@ IM 渠道(企业微信、微信、钉钉)都支持语音输入。语音识 ## 按会话选模型(全 IM 渠道) -从 1.4.0 起,IM 渠道的会话和 Web 一样会**按会话记住模型**——每个 IM 会话在创建时 seed 一个会话级模型,之后的回复都尊重这个选择,而不是永远用 Agent 的默认模型。Web 侧的切换细节见 [聊天与消息](./chat)。 +从 1.4.0 起,IM 渠道的会话和 Web 一样会**按会话记住模型**——每个 IM 会话在创建时 seed 一个会话级模型,之后的回复都尊重这个选择,而不是永远用 Agent 的默认模型。Web 侧的切换细节见 [聊天与消息](./chat)。2.0.0 起还可以在 IM 里直接用 `/model` 魔法命令切换(见下节)。 + +--- + +## 渠道魔法命令(2.0.0+) + +在任何 IM 渠道里,以 `/` 开头的整条消息会在进入 LLM **之前**被统一分发器拦截——命令注册一次、全渠道生效,不烧 token、即时响应: + +| 命令 | 干什么 | +|------|--------| +| `/new` | 开一个新会话(当前上下文归档) | +| `/clear` | 清空当前会话上下文(保留会话本身;1.8 时代的 clear 命令并入本框架) | +| `/status` | 查看当前会话状态——绑定的员工、模型、是否有任务在跑 | +| `/stop` | 停止正在执行的任务——在入队口拦截,可抢在长任务中途生效 | +| `/model` | 不带参数列出可用模型(标出当前钉选);`/model <名称>` 或 `/model <提供商>:<名称>` 切换**本会话**模型,下一条消息生效;`/model reset` 恢复默认。名称模糊时给出候选建议 | +| `/help` | 列出全部可用命令与说明 | + +每条命令都带中英文别名(如 `清空` / `新会话` / `状态`),大小写不敏感。匹配规则有两层:**裸别名只做整条消息精确匹配**("帮助我写周报"是正常提问,不会触发 `/help`);**斜杠形式按首词匹配、参数透传**(所以 `/model qwen-max` 能带参数)。正文里夹着 `/stop` 字样的普通消息不会误触发。命令确认消息走渠道的正常渲染发送链路,所以已经贴出的"思考中"占位气泡会被正确消掉,不会留下一个永远转圈的气泡。 + +--- + +## 同步 IM 渠道的分阶段进度叙述(2.0.0+) + +长任务在 IM 里最大的问题是"发出去像石沉大海"。2.0.0 起,走同步收发的 IM 渠道(企业微信、微信等)不再只回最终答案: + +- Agent 执行的**每个阶段叙述**(正在做什么、调用了什么工具)作为独立消息依次送达——你在手机上能看到任务推进的脚印; +- 企业微信更进一步:**事件驱动的进度气泡**原地滚动更新——思考状态、工具调用轨迹、耗时实时刷新,最终答案到达时气泡原地渐变为答案(细节与调优见 [企业微信深度优化](./wecom-tuning)); +- Web SSE 与 IM 同步路径共享同一套**流累积器**,两边看到的执行元数据(工具调用、token 用量)完全一致。 --- @@ -659,6 +686,7 @@ IM 渠道(企业微信、微信、钉钉)都支持语音输入。语音识 - **Webhook 模式需要 HTTPS。** 生产部署应该用 Nginx + SSL 挡在 MateClaw 前面。 - **长连接模式不需要公网 IP。** Telegram Long-Polling、钉钉 Stream、飞书 WebSocket、Discord Gateway、Slack Socket mode、企业微信长连接——全都可以跑在 NAT 后面。 - **一个渠道一个 Agent。** 不同渠道可以指向不同 Agent。 +- **会话 id 按渠道隔离(2.0.0)。** 会话 id 的生成编入了渠道标识——不同工作空间各自新建的同类型渠道,即使面对同一个外部用户,也各有各的会话,不会再把两个工作空间的对话串进同一条会话里。 - **凭证在 `mate_channel` 里加密存储。** - **国内网络**大概率需要配 `http_proxy` 来访问 Telegram 和 Discord。 diff --git a/mateclaw-server/src/main/resources/docs/zh/chat.md b/mateclaw-server/src/main/resources/docs/zh/chat.md index 7981b646..bf15f8c2 100644 --- a/mateclaw-server/src/main/resources/docs/zh/chat.md +++ b/mateclaw-server/src/main/resources/docs/zh/chat.md @@ -105,6 +105,15 @@ ChatConsole 不只是你自己聊天的地方。它是一个**运营控制台** 员工调工具生成的文件(文档 / 图片 / 音频…)现在**落盘**到 `data/generated-files/`,带 7 天保留窗口 + 6 小时定时清理,内存里再放一层 LRU——下载链接重启后依然有效,不再受原来 10 分钟内存窗口限制。前端用一个全局点击代理拦截 `/api/v1/files/generated/{id}` 下载:成功走鉴权 fetch → blob 下载,失败(404/410/过期)只弹一个 toast,**不再因为一个失效链接把整个页面卡死**。 ::: +### 在线预览:Office / PDF / HTML / 文本不用下载就能看(2.0.0+) + +以前图片、音视频、3D 模型都能内联预览,但 Agent 生成的 Word 报告只有一个下载按钮——要看一眼就得下载、找文件、开本地程序。现在**文档类附件在聊天里直接看**: + +- **点开即预览**:pdf / docx / xlsx / html / markdown / txt / 代码文件,点击附件卡片在玻璃拟态风格的预览层里打开——上传的和 AI 生成的都一样。 +- **纯前端渲染**:PDF、Word、Excel 都在浏览器里解析渲染,不出网、不依赖任何外部预览服务,单 JAR 与桌面端打包形态不变。 +- **啃不动的格式走服务端兜底**:pptx 与老版二进制 Office(doc / xls / ppt)由服务端转成 PDF 再预览;转换组件(LibreOffice)不在时优雅降级为下载,不报错不卡壳。 +- **HTML 附件安全预览**:在沙箱 iframe 里渲染——交互页面和图表完整可用(脚本可执行),但 iframe 处于隔离源,读不到应用的登录态与本地存储。 + ### 主模型不支持图片?走"多模态旁路" ::: tip 1.3.0 新增 @@ -190,6 +199,15 @@ SSE 流 / 直接响应 ← segment 一段一段送 Segment 的结构是渐进展示的底层。它也让**数据库成为单一事实源**——UI 可以把任何一条历史回复完整地复现成它流式时的样子。 +### 回退与重新生成(2.0.0+) + +两个高频动作在 2.0.0 变成了**服务端语义**,不再是前端的障眼法: + +- **回退到此处**:把会话截断回某条消息——之后的消息在数据库里真实删除、会话统计(消息数、最后消息摘要)同步重算。刷新页面、换个端打开,看到的都是回退后的状态,被"删掉"的回答不会复活。 +- **重新生成**:删掉末尾那条 assistant 回答、**复用原来的 user 消息**重新执行——不再重复插入 user 行。以前每点一次"重新生成",数据库里就多一条重复的提问、旧回答还阴魂不散;现在历史干干净净,怎么刷新都一致。 + +同一套语义覆盖 Admin 控制台、WebChat 挂件和 API——三个入口的行为完全一致。有在途流的会话会先拒绝回退,避免截断正在写入的回合。 + ### 按会话选模型 ::: tip 1.4.0 新增 diff --git a/mateclaw-server/src/main/resources/docs/zh/index.md b/mateclaw-server/src/main/resources/docs/zh/index.md index 7ec5c7ac..2887d5b5 100644 --- a/mateclaw-server/src/main/resources/docs/zh/index.md +++ b/mateclaw-server/src/main/resources/docs/zh/index.md @@ -23,6 +23,9 @@ features: - icon: 🧑‍💼 title: 数字员工,不是聊天机器人 details: 你雇佣同事,不是开聊天框。每位有角色 / 目标 / 背景故事、像素艺术头像、专属配色——5 个职业模板开箱可用。ReAct + Plan-and-Execute 双模式,员工之间并行委派。 + - icon: 🤝 + title: 团队,不是单打独斗 + details: 建一个团队:Lead 把目标拆成任务落到共享任务板,成员并行执行,依赖自动编排、前置结果自动传递、结果自动通报汇总。执行租约、取消即中断、审批卡点、交付物与任务时间线——2.0.0 起,一支队伍围着一块看板干活。 - icon: 🧩 title: 技能是骨架,不是插件 details: 一份 SKILL.md + 一份 LESSONS.md(用得越多越聪明)。8 个起步模板,向导 5 步出包,安装前自动 Pre-flight 检查。MCP / ACP 双桥接,连 Claude Code、Codex 都能进来当员工。 diff --git a/mateclaw-server/src/main/resources/docs/zh/memory.md b/mateclaw-server/src/main/resources/docs/zh/memory.md index 4c973c78..8a152b29 100644 --- a/mateclaw-server/src/main/resources/docs/zh/memory.md +++ b/mateclaw-server/src/main/resources/docs/zh/memory.md @@ -563,6 +563,64 @@ mate: --- +## Mem0 集成(可选) + +::: warning 非默认栈 +Mem0 集成是**可选的社区贡献项**,不在 MateClaw 的默认安装里。它需要你**自己部署一份 Mem0 服务**(FastAPI + pgvector + 可选 Neo4j)。MateClaw 的"本地优先、零外部依赖"定位不变——这个插件只是给愿意多跑一套服务的人一个**叠加的语义召回通道**。 +::: + +[Mem0](https://github.com/mem0ai/mem0) 是一个独立的记忆服务,做的是 LLM 记忆的提取、去重、向量化召回。MateClaw 的 `mateclaw-plugin-mem0` 模块把它作为一个**插件式 memory provider** 接进来——内部 4 个 provider(Builtin / Structured / Session / Fact)一个都不动,Mem0 作为第 5 个外部 provider 叠加上去,**互不替代**。 + +### 它做什么 + +| 钩子 | 行为 | +|------|------| +| `systemPromptBlock` | 返回空——常驻 system prompt 不动,避免每轮 token 膨胀 | +| `prefetch(agentId, query, ownerKey)` | 当 `searchEnabled=true` 且 `ownerKey` 非空时,调 `POST {baseUrl}/memories/search/`,返回一个 `[Mem0 Recall]` 块拼进本轮上下文 | +| `syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey)` | 当 `syncEnabled=true` 且 `ownerKey` 非空时,**异步**把这一轮的 user/assistant 消息以 `user_id = ownerKey` 推到 `POST {baseUrl}/memories/` —— 与召回查询用同一个标识。失败只记日志、不阻塞响应 | +| `getToolBeans` | 空列表——v1 不暴露 Agent 可调用的工具 | + +**故障隔离**:recall 或 sync 任何一边抛异常,插件自己吞掉、写日志,平台继续走其他 provider。Mem0 挂了不会影响 MateClaw 的本地记忆。 + +### per-owner 隔离的映射 + +Mem0 用 `user_id` + `agent_id` 做隔离。MateClaw 的映射: + +| MateClaw 字段 | Mem0 字段 | 说明 | +|---|---|---| +| `ownerKey`(如 `user:42` / `feishu:sender_abc`) | `user_id` | 透传,原样作为 user_id | +| `agentId` | `agent_id` | 数字员工 ID | + +`prefetch` 和 `syncTurn` 都能从平台拿到 `ownerKey`,写入和召回用同一个 `user_id`。拿不到 `ownerKey` 的变体会直接跳过(召回返回空 / 放弃写入)——Mem0 要求 `user_id`,没它无法隔离。 + +### 安装步骤 + +1. **部署 Mem0 服务**:参考 Mem0 官方文档,自托管一份(FastAPI + pgvector + 可选 Neo4j)。记下它的 base URL,比如 `http://localhost:8080`。 +2. **构建插件 JAR**:在 MateClaw 仓库根目录跑 `mvn -pl mateclaw-plugin-mem0 -am package`,得到 `mateclaw-plugin-mem0/target/mateclaw-plugin-mem0-*.jar`。 +3. **放 JAR**:把 JAR 丢进 MateClaw 的 `plugins/` 目录。 +4. **配置**:在插件管理 UI 里填 `baseUrl`(必填),按需填 `apiKey`、调其他参数。重启或重载插件。 + +### 配置项 + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|------|------|------|------|------| +| `baseUrl` | string | 是 | — | Mem0 REST API 地址,如 `http://localhost:8080` | +| `apiKey` | string | 否 | — | Bearer token,作为 `Authorization` 头发给 Mem0 | +| `searchEnabled` | boolean | 否 | `true` | 是否在 prefetch 时调 `/memories/search/` 做语义召回 | +| `syncEnabled` | boolean | 否 | `true` | 是否在 syncTurn 时把每轮对话推到 `/memories/` | +| `maxResults` | integer | 否 | `5` | 每次召回返回的记忆条数上限 | +| `timeoutMs` | integer | 否 | `3000` | HTTP 超时(毫秒),recall 和 sync 共用 | + +配置只在插件加载时读一次——改了要重载插件才会生效。 + +### 已知限制(v1) + +- **没有解析出 owner 的轮次不会同步**:`syncTurn` 要求 `ownerKey`;平台解析不出 owner 的轮次(如系统触发的运行)会直接跳过,而不是用一个召回永远查不到的降级标识写入。 +- **没有 token 预算控制**:prefetch 返回的 `[Mem0 Recall]` 块直接拼进上下文,不受 `system-block-max-chars` 那套注入预算约束(那套只管 `user`/`feedback` 结构化条目)。`maxResults` 是唯一的尺寸闸门。 +- **没有 Agent 工具**:v1 不暴露 `mem0_search` / `mem0_add` 之类的工具给 Agent 主动调用。Agent 只能被动接收 prefetch 的结果。 + +--- + ## 下一步 - [Agent 引擎](./agents)——Agent 在一个回合里怎么用记忆 diff --git a/mateclaw-server/src/main/resources/docs/zh/models.md b/mateclaw-server/src/main/resources/docs/zh/models.md index e312ebf6..847245fc 100644 --- a/mateclaw-server/src/main/resources/docs/zh/models.md +++ b/mateclaw-server/src/main/resources/docs/zh/models.md @@ -199,6 +199,18 @@ token 持久化和刷新走的是和浏览器回调流**完全相同**的代码 对 OpenRouter 特别有用——**让 200+ 免费档模型全都可见**。挑一个免费模型零成本有一套能用的环境。 +### 自建供应商的模型发现 + +自己「添加供应商」建的兼容端点(vLLM / Xinference / LocalAI / 各类兼容网关)默认按协议开启发现:`openai-compatible`、`dashscope-native`、`gemini-native`、`anthropic-messages` 会自动带上「发现模型」按钮;OAuth 类协议(ChatGPT OAuth、Claude Code OAuth)不带(它们的发现走专属登录回调,与 baseUrl 无关)。 + +如果端点的模型列举路径不是标准的 `/v1/models`(例如反向代理加了前缀 `/openai/v1/models`),在「生成参数(JSON)」里加一行 `modelsPath` 覆盖即可: + +```json +{ "modelsPath": "/openai/v1/models" } +``` + +同一个 JSON 里的 `completionsPath` 用来覆盖对话补全路径(默认 `/v1/chat/completions`),两者互不影响。若端点根本不提供 OpenAI 风格的列举接口,直接用「添加模型」手动录入模型 id。 + ### Ollama 启动时自动检测 不用手动配。启动时: @@ -394,6 +406,15 @@ MateClaw 用一个**活跃模型**作为全局默认。没有指定自己模型 - **出口 sanitizer** —— provider 专属选项(如 OpenAI 推理模型的 `reasoning_effort`)在 failover 到不支持的 provider 时被剥离,泄漏的选项不会让 fallback 报 400 - **UI 区分 401 与会话过期** —— provider 认证错误和用户会话过期现在显示不同消息、不同处置 +### 策略化错误恢复与限流感知退避(2.0.0+) + +failover 决定"切给谁",2.0.0 把"什么错该怎么恢复"也做成了**策略即分类**——每种错误类型自带恢复属性(可否重试、是否压缩上下文、是否轮换、是否降级),重试循环只消费策略,不再散落 if 链。几处关键语义: + +- **"服务端过载"与"自己被限流"分开治**。新增 OVERLOADED 分类:503/529 这类**服务端过载**是全网都在排队,切 provider 只会把整条链白白烧一遍(单 key 用户更是无处可切)——正确动作是**同 provider 退避等待**;而 429 打在**自己 key** 上的限流才值得快速切换。以前这两种被混在一起、策略相反。 +- **Provider 说几点恢复,就几点恢复**。响应头里的 `Retry-After` / ratelimit-reset 以前只进日志,现在**直接回馈到退避时长与健康冷却**——不再对着限流窗口盲退避浪费时间。 +- **摘除不是判死,是带 TTL 的冷却**。认证失败、欠费被硬摘除的 provider 现在按 TTL 自动回收重试(例如换绑了新 key、账户充了值,系统自己恢复,不再需要人工重启);provider 明确给出恢复时刻时以其为准。 +- **随机抖动防重试风暴**。并发会话撞上同一个限流 provider 时,退避带随机抖动(过载按档位 ±30%、通用路径指数退避外加随机分量)——不会所有会话以同一节奏集体重试、持续触发限流。 + ### 偏好提供商决定主模型(1.5.0) 1.5.0 之前,"每个 agent 自定义优先级"只影响 **failover 顺序**——主模型仍是全局默认。1.5.0 让这个偏好**真的决定主模型选择**。完整优先级链是: diff --git a/mateclaw-server/src/main/resources/docs/zh/releases.md b/mateclaw-server/src/main/resources/docs/zh/releases.md index 03886f2b..00b1bcc5 100644 --- a/mateclaw-server/src/main/resources/docs/zh/releases.md +++ b/mateclaw-server/src/main/resources/docs/zh/releases.md @@ -10,6 +10,7 @@ | 版本 | 日期 | 亮点 | |------|------|------| +| [v2.0.0](./releases/2.0.0) | 2026-07-31 | **Agent 团队与共享任务板**——Lead 拆任务、成员并行执行(团队/角色 · 八状态看板 · `blockedBy` 依赖编排 · 前置结果自动传递 · 结果通报唤醒 Lead · 交付物登记下载 · 任务时间线 + 团队 SSE 实时看板 · 执行租约心跳 + 取消即中断 + `in_review` 审批卡点) · **Plan-Execute 计划整体移交任务板**(步骤→任务 · 依赖→并行 · 停靠恢复门确定性汇总) · 工作空间隔离全面收口(渠道会话 id 编入渠道 · 同名技能跨工作空间共存且运行时按会话工作空间解析) · 渠道魔法命令(`/new` `/clear` `/status` `/stop` `/model` `/help`)+ 企微事件驱动进度气泡(实时工具轨迹 · 分阶段滚动叙述) · 会话回退/重新生成服务端语义 · 自动批准未命中可解释(原因码落审计行 + 一键补策略 + 表单防呆) · LLM 错误恢复策略化(过载/限流分治 · `Retry-After` 回馈退避 · provider TTL 回收 · 抖动防重试风暴) · 聊天附件在线预览(pdf/docx/xlsx/html/文本) · SKILL.md 单一事实源 + 捆绑文件控制台管理 · Mem0 可选插件记忆 provider · 知识图谱关系模式白名单 | | [v1.8.0](./releases/1.8.0) | 2026-07-12 | 内容工作室——一句话到可发布成品(预置「内容工作室」员工跑通 选题→搜集→成文→配图→去AI化→排版→交付) · **微信公众号(公众号)** 图文文章(`gzh_article` · 内联样式 HTML · `gzh_publish` 推进草稿箱)+ **小红书** 以图为主图文笔记(`xhs_note` · ≥3 张竖版 3:4 卡片 · 在线预览) · 可度量**去 AI 化**(启发式 AI 痕迹评分 → 检测/改写/复检闭环,硬上限 3 轮) · 发布链加固(正文图上传进微信 · AES-GCM 密钥加密 · 微信服务+token 复用 · 重试 + 中文错误提示 · 兜底封面) · **内容日历**(交付即合规扫描 + 自动落台账 · 选题指纹去重 · 只读页) · 浏览器 Agent **无障碍树 ref 交互** + 真实浏览器隐私护栏 + 受控 CDP 逃生舱 · 注意力锚定 + 工具调用循环护栏 + 改动后校验提醒 · 快加载(初始包体 ↓约 78%) · 上下文占用面板 · 跨知识库 wikilink · MCP 进度通知 · 火山方舟供应商 · PostgreSQL 16 | | [v1.7.0](./releases/1.7.0) | 2026-07-04 | 生产化加固 —— 审批体系打通三条链路(工作流审批渠道通知 + resolve→resume 桥接 · WebChat/API-Key 渠道审批 resolve+replay · 飞书/企微卡片点击 resolve 工作流审批) · 长任务看得见(「运行总览」侧栏 + 本轮 Token 明细含缓存命中/未命中/写入 + 子 Agent 成本向上滚加 + 生成文件一键下载) · 装得下真实模型窗口(本地模型上下文窗口探测 + prefix 注入统一 Token 预算 + 小上下文降级 + 工具 schema 预算门) · 开放出去(知识库 / Deep Research 开放 API 含 API-Key+限流+SSE · 插件化搜索 Provider SPI · MCP 身份透传) · 桌面端远程 Server 连接 + `mateclaw-desktop` 源码开源 + 局域网部署模式 · 运营数据一键导出(Dashboard 9 表 Excel + CLI 命令行) · Wiki 处理失败可视化 · 按员工模型链 · OpenAPI/Swagger 可调试 | | [v1.6.0](./releases/1.6.0) | 2026-06-22 | 跑在国产数据库上 —— KingbaseES(人大金仓)+ PostgreSQL(共用一套 PostgreSQL 家族迁移树 · 按需金仓驱动 · Docker 最小权限角色) · 新感官与双手(图片跨轮次留在上下文 + `image_analyze` · `execute_code` 运行员工编写的代码) · 你来塑造员工(AGENTS.md 编辑器 + About You 身份 + 运行时模型身份 + KB 范围绑定 + 花名册标签) · Wiki Sources 标签(素材与监听合并、按 KB 自动同步、多路径/glob、pageType 表单编辑器) · 全局出站 HTTP/SOCKS 代理 · 确定性 Markdown 回答 · Claude Fable 5 | diff --git a/mateclaw-server/src/main/resources/docs/zh/roadmap.md b/mateclaw-server/src/main/resources/docs/zh/roadmap.md index ff51f61a..07791a73 100644 --- a/mateclaw-server/src/main/resources/docs/zh/roadmap.md +++ b/mateclaw-server/src/main/resources/docs/zh/roadmap.md @@ -130,36 +130,39 @@ MateClaw 就是这个东西。 完整故事:[v1.8.0 Release Notes](./releases/1.8.0.md)。 +### v2.0 —— 它带队干活 ✅ 已发布(2026-07-26) + +从"一个能干活的人"到"一支能协作的队伍"——**Agent 团队**成为常设编制,围着一块共享任务板协作。 + +- **团队实体与角色编制**:团队 = 名字 + Lead + 成员 + reviewer,持久化、可复用;团队名册与协作准则注入成员提示词 +- **共享任务板**:八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 Lead +- **队长调度**:Lead 拆任务、指派、验收;Plan-Execute 型 Lead 的计划**整体移交任务板**——步骤变任务、依赖变并行 +- **执行加固**:租约心跳防双重执行、取消即中断、`in_review` 人工审批卡点、失败/过期可重试 +- **交付物与全程可观测**:产出文件登记到任务、任务时间线、团队 SSE 实时看板、跳进成员子会话看逐字执行 +- 外加:工作空间隔离全面收口、渠道魔法命令 + 企微进度气泡、会话回退/重新生成、自动批准可解释性、LLM 错误恢复策略化、附件在线预览、SKILL.md 单一事实源、Mem0 插件 provider + +完整故事:[v2.0.0 Release Notes](./releases/2.0.0.md),使用指南:[团队协作](./teams)。 + --- -## 下一站:Agent Team 与 Agent Loop +## 下一站:Agent Loop 与团队进阶 > "伟大的事业不是一个人做成的,是一个团队做成的。" -回头看这条线:v1.2 员工有了身份,v1.3 流程能编排,v1.4 员工会自主跟目标、能临时拉起委派树,v1.5 自主变得可验证,v1.7 长任务看得见。 +回头看这条线:v1.2 员工有了身份,v1.3 流程能编排,v1.4 员工会自主跟目标、能临时拉起委派树,v1.7 长任务看得见,**v2.0 团队成了常设编制**。 -但今天的 MateClaw 还有两个"停": +还剩一个"停":**员工是被动的。** 目标的自动延续只活在**单次运行内**;cron 和触发器能定时叫醒它,但每次醒来都是一次孤立的响应。没有一个员工真正"在岗"——持续盯着自己的职责范围,自己决定什么时候该干什么。 -**协作是一次性的。** v1.4 的委派树很强,但它是**任务级**的——parent 委派 child,任务结束,树就散了。下一个任务再从零拉起。团队没有名字、没有编制、没有沉淀——像每个项目都重新招一批临时工。 +### Agent Team 进阶 —— 编制有了,接下来长本事 -**员工是被动的。** 目标的自动延续只活在**单次运行内**;cron 和触发器能定时叫醒它,但每次醒来都是一次孤立的响应。没有一个员工真正"在岗"——持续盯着自己的职责范围,自己决定什么时候该干什么。 +2.0 交付了团队实体、任务板、队长调度与执行链(见上),团队方向还想做: -v1.9 要把这两个"停"变成"续"。 - -### Agent Team(智能体团队)—— 从"临时拉人"到"常设编制" - -一个团队不再是委派时临时长出来、任务结束就消失的树,而是一个**持久化的组织单元**: - -- [ ] **团队实体**:一个团队 = 名字 + 队长(Leader)+ 成员编制 + 章程,持久化、可复用、可导出分享 -- [ ] **团队章程(TEAM.md)**:分工、协作规则、升级路径——像 AGENTS.md 塑造个人一样塑造团队 -- [ ] **队长调度**:任务进来队长拆解、指派给最合适的成员、验收结果;干不了的向上汇报而不是硬编 -- [ ] **成员互审(peer review)**:关键产出可以配置"另一个成员复核后才交付" -- [ ] **团队共享记忆**:基于 v1.5 的 TEAM scope——团队成员共享一份团队记忆和团队文件空间,个人记忆仍然互不串台 +- [ ] **成员互审(peer review)**:关键产出可以配置"另一个成员复核后才交付"(2.0 的 `in_review` 是人工审批;成员互审是下一步) - [ ] **团队级目标**:一个 goal 拆成成员子目标,清单跨成员汇总——hover 队长头像,看到整个团队还差哪几条 - [ ] **团队绑渠道**:一个飞书群 / 钉钉群绑一个团队,群里 @ 团队,队长决定谁接 - [ ] **团队复盘**:任务收尾自动生成 retrospective,沉淀进团队的 LESSONS.md——这个团队下次会做得更好 -- [ ] **「数字员工构建器」升级**:v1.4 已经能一句话建一批员工,v1.9 让它直接产出一个**带章程的常设团队** -- [ ] **运行总览升级为团队视图**:每个成员在岗 / 忙碌 / 空闲一眼看清,点进去看它正在干的事 +- [ ] **协作 DAG / 泳道视图**:在时间线数据之上画出任务依赖与成员泳道 +- [ ] **「数字员工构建器」升级**:一句话直接产出一个**带编制的常设团队** ### Agent Loop(智能体循环)—— 从"答完就停"到"长期在岗" @@ -220,8 +223,9 @@ v1.9 要把这两个"停"变成"续"。 | **v1.5** | 它可验证 | 目标清单 + Wiki 自维护 + 记忆认人 | ✅ 已发布 | | **v1.6** | 它来到你所在的地方 | 国产数据库 + 视觉留存 + 代码执行 + 身份塑造 | ✅ 已发布 | | **v1.7** | 它敢放进生产 | 审批三链路闭环 + 运行总览与成本可见 + 上下文/Token 预算 + 开放 API/Deep Research + 桌面远程/局域网 + 运营导出 | ✅ 已发布 | -| **v1.8** | **它干完一整件活** | **内容工作室 —— 一句话到可发布的公众号 / 小红书成品 + 浏览器 ref 交互** | ✅ 已发布 | -| **v1.9** | **它长期在岗** | **Agent Team 常设团队 + Agent Loop 常驻循环 = 会自己运转的数字部门** | 📋 规划中 | +| **v1.8** | 它干完一整件活 | 内容工作室 —— 一句话到可发布的公众号 / 小红书成品 + 浏览器 ref 交互 | ✅ 已发布 | +| **v2.0** | **它带队干活** | **Agent 团队 + 共享任务板 —— Lead 拆解派发、成员并行执行、交付物与全程可观测** | ✅ 已发布 | +| **下一站** | **它长期在岗** | **Agent Loop 常驻循环 + 团队进阶(互审 / 团队目标 / 群绑定 / 复盘)= 会自己运转的数字部门** | 📋 规划中 | --- diff --git a/mateclaw-server/src/main/resources/docs/zh/security.md b/mateclaw-server/src/main/resources/docs/zh/security.md index 964b3849..38b8e8d1 100644 --- a/mateclaw-server/src/main/resources/docs/zh/security.md +++ b/mateclaw-server/src/main/resources/docs/zh/security.md @@ -308,6 +308,17 @@ curl http://localhost:18088/api/v1/approval/grants \ -H "Authorization: Bearer " ``` +### 自动批准:命中要可见,未命中要可解释(2.0.0+) + +以前最气人的场景:你按直觉配好了自动批准策略,工具调用**还是**进了人审——策略页显示"已启用",审计日志只写"需审批",全程没有一处告诉你为什么没放行。2.0.0 把这条链路做成透明的: + +- **未命中原因细分**。自动批准解析器不再把所有未命中笼统记成"无策略":**有策略但严重度上限不够**(比如上限 LOW 挡住了 HIGH 的调用——最常见的踩坑)、根本没有候选策略、工作区不匹配、CRITICAL 强制人审……每种原因都有独立的原因码。 +- **结果落在审计行上**。守卫审计日志的每一行都记录自动批准的最终结果与原因——自动放行的调用不再误导性地显示"需审批",进人审的调用能一眼看到"为什么"。审计行还挂上了真实的待审批 ID,能从审计直接跳到那次审批。 +- **审计页一键补策略**。看到"严重度上限不够"这类未命中,审计行旁边就是**创建授权**快捷入口,带着工具名、范围、建议的严重度上限直接进表单——不用凭记忆重新配。 +- **表单防呆**。范围 ID 从自由文本换成**按范围类型的选择器**(AGENT 选智能体、CONVERSATION 选会话、WORKSPACE 选工作区),配错类型的死策略从源头绝迹;严重度上限带语义提示("LOW 只放行低危调用");**跨工作区的死配置在创建时直接拒绝**——配了永远不可能生效的策略,系统当场告诉你,而不是让你在审计日志里猜。 + +CRITICAL 永远人审、safety floor 硬拦截的底线语义不变。 + --- ## File Guard diff --git a/mateclaw-server/src/main/resources/docs/zh/skills.md b/mateclaw-server/src/main/resources/docs/zh/skills.md index 0a733274..c81e1042 100644 --- a/mateclaw-server/src/main/resources/docs/zh/skills.md +++ b/mateclaw-server/src/main/resources/docs/zh/skills.md @@ -217,8 +217,8 @@ scripts: 启动时跑两遍同步,保证每个节点拿到的都是最新的: -1. `SkillWorkspaceBootstrapRunner` → `BundledSkillSyncer` 扫描 classpath 的 `skills/` 目录,把**捆绑技能**同步到工作空间根。**只在目标目录不存在时同步**,不会覆盖本地修改。 -2. `SkillFileSyncer` 比对 `mate_skill_file`(DB)与本地工作空间(FS),按 `sha256` 增量物化缺失或过期的文件。 +1. `SkillWorkspaceBootstrapRunner` → `BundledSkillSyncer` 扫描 classpath 的 `skills/` 目录,把**捆绑技能**同步到工作空间根,并把 `scripts/` 与 `references/` 一并持久化进 `mate_skill_file`。常规情况下不覆盖本地修改;但如果磁盘上的 `scripts/` 目录整个丢了(2.0.0 加固),会强制从 classpath 恢复——内置技能的脚本不会因为一次误删就永久残缺。 +2. `SkillFileSyncer` 比对 `mate_skill_file`(DB)与本地工作空间(FS),按 `sha256` 增量物化缺失或过期的文件;内置技能在 DB 与磁盘都没有脚本文件时,还会从 classpath 回填(2.0.0 自愈路径)。 **多实例部署的意义**:一个节点上传 zip,DB row 与 file rows 写入;其他节点重启或调一次 `POST /api/v1/skills/{id}/sync-files` 就能拿到完整 bundle,不用 NFS、不用脚本拷贝、桌面端跨机也能接力。 @@ -231,6 +231,7 @@ scripts: - **两遍扫描**——先把所有条目缓存(受总大小上限保护,默认 50 MB,可用 `mateclaw.skill.upload.max-total-size-mb` 调整),定位 `SKILL.md` 算出 wrapper 前缀,再分类。**条目顺序不再影响结果**。 - **根目录扩展名兜底**——SKILL.md 同级的非约定文件按扩展名归类:`.sh / .py / .js / .rb / ...` → `scripts/`,`.md / .json / .yaml / .csv / ...` → `references/`,未识别扩展名落 `WARN` 日志。 - **写后裁剪 + 空 bundle 守卫**——重装时**先写新文件再裁剪不在新 bundle 里的旧文件**。如果新 bundle 某个桶(`scripts/` 或 `references/`)一个条目都没有,**保留磁盘上的旧文件**——一个解析失败的损坏 zip 不会再把你的 skill 擦干净。要强制清空就传 `forcePrune=true`。 +- **中文文件名不再乱码**(2.0.0)——zip 条目名与文件内容**分别独立探测编码**:Windows 压缩工具打出的 GBK 文件名和 UTF-8 内容各按各的编码解,安装后不再出现"文件名乱码但内容正常"或反过来的组合。 > 这道门管得住的实际场景:上次实测中腾讯会议 zip 的 `setup.sh` 在根(不在 `scripts/` 子目录),旧 extractor 静默丢弃;新 extractor 自动归到 `scripts/setup.sh`,安装完直接可跑。 @@ -248,6 +249,27 @@ mateclaw: --- +## SKILL.md 单一事实源(2.0.0+) + +2.0.0 之前有一个隐蔽的分叉:运行时从工作空间目录读 SKILL.md,控制台读数据库列——员工在会话里用 shell 工具改了文件,运行行为变了,控制台却看不见;反过来一次失败的导出会让员工执行着过期内容,控制台还声称一切正常。 + +现在两边**三向调和**:以一个记录上次同步哈希的 sidecar 为锚——文件侧改动自动入库,DB 侧改动自动物化到文件,两边都改过的冲突按 **DB 赢**并把文件侧留成 `SKILL.md.bak` 备份。空文件永远不会覆盖非空的 DB 内容;空 DB 会从文件回填。调和在每次按约定路径解析时执行,控制台打开技能详情也会做一次读取时调和——**你在控制台看到的,就是员工正在执行的。** + +(显式指定 `skillDir` 的技能保持文件权威,内容镜像进 DB 列仅供展示。) + +--- + +## 捆绑文件管理:脚本、参考资料、模板都能在控制台改(2.0.0+) + +以前技能详情抽屉只能看和改 SKILL.md;`scripts/` 和 `references/` 在控制台完全没有入口,`templates/` 甚至不在权威存储的桶清单里。现在: + +- **`/api/v1/skills/{id}/files` 管理端点**:列出 / 读取 / 写入 / 删除技能的捆绑文件。写入落权威的 `mate_skill_file` 行、同步物化工作空间缓存、并立即让技能重新解析——**员工下一次调用就用上新脚本**。 +- **`templates/` 成为一等桶**:与 `scripts/`、`references/` 一样入库持久化、参与同步与回填、受空 bundle 裁剪守卫保护。 +- **路径信封防护**:只允许三个约定桶,阻断路径穿越;内置技能的文件保持只读(升级时从出厂 bundle 恢复);MCP / ACP 虚拟技能没有文件。 +- **员工侧写入同样入库**:会话里技能用 `write_file` 改自己的捆绑文件时,改动同步镜像进权威存储——控制台与运行时不再各说各话。 + +--- + ## 技能市场(以及 ClawHub) **技能市场** 页面(`/skills`)是你浏览、安装、编辑、管理技能的地方。三个来源: @@ -338,9 +360,17 @@ parameters: --- -## 工作空间隔离 +## 工作空间隔离(2.0.0 全面收口) -每个工作空间都有自己的一份技能副本。给某个工作空间启用一个技能时,它的文件被 stage 到那个工作空间的目录下、技能的工具被 scope 到这个工作空间、技能写任何文件都在工作空间边界内。v1.4 起技能**目录与运行时也按工作空间隔离**,每个工作空间只看到、只运行属于自己的技能。见 [工作空间](./workspaces)。 +每个工作空间都有自己的一份技能副本。给某个工作空间启用一个技能时,它的文件被 stage 到那个工作空间的目录下、技能的工具被 scope 到这个工作空间、技能写任何文件都在工作空间边界内。 + +2.0.0 把隔离从"目录展示"收口到**存储与执行的每一层**: + +- **同名技能可在不同工作空间共存**。安装查重、名称唯一性、重装/卸载查找全部按工作空间过滤——A 工作空间装了「预约会议」,不再挡住 B 工作空间装自己的同名技能。 +- **文件系统路径编入工作空间**。技能目录 scheme 带上 workspaceId,两个工作空间的同名技能各有各的磁盘目录——不会共用一个目录互相覆盖、脚本落到对方家里。 +- **运行时按会话工作空间解析**。`load_skill` / 读技能文件 / 跑技能脚本 / 自动重定向,全部只在「本会话工作空间 + builtin + 全局 virtual」范围内解析——一个工作空间的员工不可能读到或执行另一个工作空间的同名技能。 + +见 [工作空间](./workspaces)。 --- diff --git a/mateclaw-server/src/main/resources/docs/zh/teams.md b/mateclaw-server/src/main/resources/docs/zh/teams.md new file mode 100644 index 00000000..65a302cd --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/teams.md @@ -0,0 +1,161 @@ +--- +title: 团队协作 — 一个 Lead 带一群数字员工,在共享任务板上并行干活 +description: MateClaw 的 Agent 团队让一个 Lead 员工把复杂目标拆成任务、派给团队成员并行执行,任务板负责依赖、审批、交付物与全程可观测。 +head: + - - meta + - name: keywords + content: Agent团队,任务板,看板,多Agent协作,派发,交付物,团队协作,MateClaw +--- + +# 团队协作(2.0.0+) + +> **以前是"一个员工带子任务"。现在是"一个团队围着一块任务板"。** + +子员工委派(`delegateToAgent`)解决的是"一个人临时叫帮手":同步等结果、一对一、过程黑盒。但真实的复杂交付不长这样——它长得像一个项目:**拆任务、标依赖、并行推进、卡点审批、交付物归档、随时能看谁在干什么**。 + +团队协作把这套项目机制搬进 MateClaw:你建一个**团队**,指定一个 **Lead** 员工、若干**成员**员工;对 Lead 说一句目标,它把目标拆成任务落到**共享任务板**上;派发引擎把任务自动分给成员**并行执行**;成员完成后结果自动通报回 Lead,由它汇总、补派、直到整件事干完。你全程在 Teams 页旁观——或者直接往板上投任务。 + +--- + +## 核心概念 + +| 概念 | 说明 | +|------|------| +| **团队(Team)** | 一组员工 + 一块任务板。一个员工可以加入多个团队。 | +| **角色(Role)** | `lead` / `member` / `reviewer` 三种。Lead 负责拆解与汇总,成员负责执行,reviewer 参与审阅。 | +| **任务(Task)** | 板上的一条工作项:标题、描述、指派人、依赖(`blockedBy`)、进度、结果、交付物、评论、时间线。 | +| **任务板(Board)** | 按状态分列的看板。状态机由数据库条件更新守卫——并发场景下谁先改成功谁算数,不会出现双重状态。 | + +任务状态一共八种: + +``` +pending → in_progress → completed / failed / cancelled + ↘ in_review(要求人工审批的任务) +blocked(等前置任务) stale(租约过期,可重试) +``` + +`failed` 与 `stale` 的任务可以重试;`completed` / `cancelled` 会放行依赖它的下游任务。 + +--- + +## 一次典型协作长什么样 + +1. **你对 Lead 说目标**:“做一份竞品分析:先各自调研 A、B 两家,再汇总成一份报告。” +2. **Lead 拆任务上板**:调用 `team_tasks` 工具建三条任务——“调研 A”“调研 B”并行,“汇总报告”声明 `blockedBy` 前两条,自动进入 `blocked`。 +3. **派发引擎接手**:常驻扫描(30 秒一轮,工具/REST 动作后立即加扫)把 pending 任务派给指派成员——每个任务开一个独立子会话,成员在里面跑完整的 Agent 图。每个成员同时只吃一个任务,其余排队。 +4. **前置结果自动传递**:“调研 A/B”完成后,“汇总报告”被放行,派发信封里**自动带上两条前置任务的结果与交付物链接**——成员 C 不需要 Lead 人肉转述成员 A 干了什么。 +5. **结果通报唤醒 Lead**:任务落定(完成/失败)后合批通报给 Lead,Lead 被唤醒发起真实新一轮——检查结果、补派任务或宣布收工。 +6. **你全程可见**:Teams 页的看板实时刷新(SSE 事件驱动,不靠轮询),活动横幅滚动播报“#3 已派发给内容工作室”;点开任务能看时间线、进度、评论、交付物,还能**跳进成员子会话看它逐字执行的全过程**——运行中打开就是打字机直播。 + +--- + +## Lead 手里的工具:`team_tasks` + +Lead(和成员)通过 `team_tasks` 工具操作任务板,动作按角色门禁: + +| 动作 | 谁能用 | 干什么 | +|------|--------|--------| +| `list` | 所有成员 | 渲染当前任务板(Lead 每轮还会自动收到实时看板快照,见下) | +| `get` | 所有成员 | 查看单个任务详情 | +| `create` | Lead | 建任务:标题、描述、指派成员、`blockedBy` 依赖、`requireApproval` 是否需人工审批 | +| `complete` | 执行成员 | 提交结果完成任务(要求审批的任务转入 `in_review` 等人批) | +| `progress` | 执行成员 | 上报进度百分比与当前步骤(同时给执行租约续期并广播到看板) | +| `comment` | 所有成员 | 在任务下留言 | +| `attach` | 执行成员 | 给任务挂**交付物**(文件名 + 下载链接) | +| `cancel` | Lead | 取消任务——**会真的中断**正在执行的成员会话,不是只改个状态 | +| `retry` | Lead | 重试 `failed` / `stale` 的任务 | + +**团队上下文注入**:加入团队的员工,system prompt 会注入团队名册与协作行为准则;Lead 每轮对话还会**动态注入实时看板快照**——它不需要先调 `list` 才知道板上有什么,多轮对话也不会因为"忘了看板"重复建任务。 + +--- + +## 为长任务而生的执行加固 + +团队面向的是深度研究、长文档处理这类**一跑几十分钟起步**的任务,执行链路按此加固: + +- **执行租约 + 运行期心跳**。任务派发即持有 60 分钟执行租约,成员运行期间后台自动续期;真正失联的任务(进程崩溃、重启)租约到期被判 `stale`,可安全重派——**不会**出现"任务还在跑就被重派、两个实例互相覆盖"的双重执行。 +- **取消即中断**。`cancel` 不只是状态转移:成员子会话注册进流跟踪器,图节点每轮检查停止位,取消后运行中的成员会话在下一个节点边界停下——不再有"取消了还在烧 token 到自然结束"。 +- **人工审批卡点**。建任务时声明 `requireApproval`,成员提交后任务停在 `in_review`,由你在 Teams 页 approve / reject——敏感产出离开团队前先过人。 +- **手动投任务**。任务不非得 Lead 建:Teams 页可以直接建任务指派给某个成员,结果落板上由你查看(没有 Lead 会话要唤醒时,通报自动降级为 no-op)。 + +--- + +## 交付物与执行过程可见性 + +复杂任务的产出不是一段文本,是**文件 + 摘要**: + +- 成员用文档渲染工具(docx / pptx / xlsx / pdf)或技能产出文件后,通过 `attach` 把交付物登记到任务上;任务详情渲染**可下载附件列表**,结果通报也会带上附件——链接不再埋在被截断的长文本里。 +- 每个任务详情都有**"查看执行过程"**入口:跳进成员子会话的完整转写——派发信封、逐轮思考、工具调用、中间产物一览无余。任务运行中打开,就是实时打字机直播(断线重连自动回放缓冲)。 +- **任务时间线**:谁在什么时候创建/派发/上报进度/挂附件/审批/取消,逐条落 `mate_team_task_event` 审计表,任务详情按时间线渲染——协同过程有史可查。 + +--- + +## Plan-Execute Lead:计划直接变任务板 + +Lead 不限定 Agent 类型。**ReAct 型 Lead** 用 `team_tasks` 逐条建任务;**Plan-Execute 型 Lead** 更进一步——规划节点产出的计划**整体移交任务板**: + +- 计划步骤逐条映射为看板任务,步骤依赖链变成 `blockedBy`——原本严格串行的计划从此**能并行的并行**; +- 移交后计划停靠(`delegated`),Lead 回合正常结束,等待由派发/通报闭环接管; +- 全部任务落定后,通报唤醒经**停靠计划恢复门**确定性地路由到计划汇总节点,从任务结果与交付物重建上下文、产出总结——与工具审批"落库停靠、新一轮续跑"同构,不引入检查点机制。 + +移交是**全有或全无**:只有当计划的每一步都能落到某个团队成员头上时才整体上板;有任何一步指不到成员,整个计划回落到原有的串行委派管线,行为与从前完全一致。 + +一句话:**会规划的 Lead,规划能力直接变成团队编排能力。** + +--- + +## Teams 页 + +管理控制台新增 **Teams** 页(`/teams`): + +- **团队管理**:建团队、加/减成员、指定角色; +- **看板**:按状态分列,事件驱动实时刷新,列内**分页加载**且列头显示**数据库侧统计的真实总数**——千条任务的板也拖不垮页面; +- **活动横幅**:滚动播报派发、完成、失败等团队事件; +- **任务详情**:时间线、进度、评论、交付物下载、执行过程入口、approve / reject; +- **手动建任务**:直接往板上投活。 + +--- + +## REST API + +管理面 API 全部挂在 `/api/v1/teams` 下: + +| 端点 | 说明 | +|------|------| +| `GET / POST /api/v1/teams` | 列出 / 创建团队 | +| `GET / PUT / DELETE /api/v1/teams/{id}` | 团队详情 / 更新 / 删除 | +| `POST /api/v1/teams/{id}/members` · `DELETE …/members/{agentId}` | 成员增删 | +| `GET / POST /api/v1/teams/{id}/tasks` | 任务列表(窗口化分页)/ 建任务 | +| `GET /api/v1/teams/{id}/tasks/stats` | 各状态任务数(数据库侧统计) | +| `GET /api/v1/teams/{id}/tasks/{taskId}` | 任务详情 | +| `POST …/tasks/{taskId}/approve · reject · retry · cancel` | 审批 / 重试 / 取消 | +| `POST …/tasks/{taskId}/comments` | 评论 | +| `GET …/tasks/{taskId}/events` | 任务时间线 | +| `GET /api/v1/teams/{id}/events` | 团队级 SSE 事件流(看板实时刷新的数据源) | + +所有校验失败都以**可读错误**返回——不是裸 500。 + +数据落五张表:`mate_agent_team`、`mate_agent_team_member`、`mate_team_task`、`mate_team_task_comment`、`mate_team_task_event`。 + +--- + +## 与子员工委派(delegateToAgent)怎么选 + +| | `delegateToAgent` | 团队任务板 | +|---|---|---| +| 形态 | 一对一临时叫帮手 | 常设团队 + 共享看板 | +| 并行 | 单点异步 | 成员级并行 + 依赖编排 | +| 过程 | 黑盒等结果 | 时间线 + 实时旁观 + 交付物 | +| 中断/恢复 | 随父会话 | 租约、取消中断、重试 | +| 适合 | 单个子问题外包 | 多角色多步骤的项目型交付 | + +两者共存:团队成员在自己的任务里照样可以再 `delegateToAgent` 叫帮手。 + +--- + +## 接下来读什么 + +- [Agent 引擎](./agents)——ReAct 与 Plan-Execute 图的工作方式 +- [持久化目标](./goals)——单个员工的跨轮任务跟进 +- [工作流](./workflow)——确定性步骤编排(流程固定时用工作流,流程要临场拆解时用团队) +- [安全与审批](./security)——工具审批与团队任务审批的关系 diff --git a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md index 758bff2d..c71c8e28 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md @@ -216,6 +216,21 @@ MateClaw 在 link 分支检测到 `mp.weixin.qq.com` 后,会自动给模型追 --- +## 进度气泡:长任务不再像卡死(2.0.0+) + +2.0.0 之前,企业微信收到消息后只回一条静态"🤔 思考中...",直到最终答案前**没有任何变化**——30 秒到 3 分钟的长任务普遍被误认为卡死。现在占位气泡是**事件驱动**的: + +- **实时工具轨迹**:气泡内容随 agent 执行滚动更新——思考状态、正在调用的工具、已完成的调用与耗时,逐条追加; +- **按阶段滚动**:每进入一个新阶段(新一轮推理、新的工具调用),气泡原地刷新为当前阶段叙述——一眼看出任务推进到哪了; +- **原地渐变为答案**:首个真实内容分片到达时取消保活、**复用同一个 stream 槽位**,进度气泡原地变成答案,不留孤儿气泡; +- **覆写节流**:刷新有最小间隔与"上一次未完成则跳过"双保险,不会触发企业微信的频控。 + +思考内容与工具轨迹是否展示,仍受渠道编辑页"消息过滤"开关控制——2.0.0 起这两个开关是真正的"过程消息要不要发",不再只是从最终答案里剥内联标签。 + +配套的**流式回复管理加固**:stream 槽位的生命周期集中管理,保活、强制收尾、上下文失效各就各位——不再出现"答案发完了气泡还在转"或"槽位悬挂导致下一条消息发不出"的病态。生成的文件(图片、文档)也会通过微信渠道真实送达,而不是只留一个本地链接。 + +--- + ## 模型行为:假装调用工具 观察:**qwen3.6-plus** 在长上下文 + 工具调用密集的场景下偶发地"懒"——它会用 Markdown 代码块**伪装**自己调了工具,但实际 `toolCallCount=0`: diff --git a/mateclaw-server/src/main/resources/docs/zh/wiki.md b/mateclaw-server/src/main/resources/docs/zh/wiki.md index c9db8c0b..700ec1e2 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wiki.md +++ b/mateclaw-server/src/main/resources/docs/zh/wiki.md @@ -552,6 +552,12 @@ mate: `Wiki → 配置 → 实体抽取` 卡片里:打开开关后出现一个标签编辑器(可多选、可搜索、可现场新建)。内置建议就是上面六种,你可以直接敲入自定义类型(比如 `technology`、`law`)按回车加进去。留空则回退到内置六种。类型列表存在 KB 的 `configContent` JSON 的 `entityTypes` 字段里。 +### 关系模式:闭合三元组白名单(2.0.0+) + +实体类型限住了"抽什么实体",但关系一层以前是放开的——模型可以在任意两个实体之间编造任意谓词,边缘实体因为"参与了某条关系"被当作重要实体持久化,稀释你真正关心的那几条确定关系。 + +现在每个 KB 可以配置一个可选的**关系模式**:一张 `主语类型 → 谓词 → 宾语类型` 的闭合三元组白名单(比如 `person → works_at → organization`)。开启后,抽取**只保留匹配模式的关系与参与这些关系的实体**——模型不再自由发挥,图谱里只有你定义过的关系形态。留空则维持原来的开放抽取。配置同样存进 KB 的 `configContent`,无需迁移。 + ### 在图上看关系 Wiki 图谱视图工具栏上多了**页面图 / 实体图**切换。切到实体图后: diff --git a/mateclaw-server/src/main/resources/docs/zh/workspaces.md b/mateclaw-server/src/main/resources/docs/zh/workspaces.md index 1a772b85..5f58fbf9 100644 --- a/mateclaw-server/src/main/resources/docs/zh/workspaces.md +++ b/mateclaw-server/src/main/resources/docs/zh/workspaces.md @@ -190,6 +190,12 @@ Wiki KB 的数据**永远不会离开它的工作空间**。工作空间 B 里 每个渠道绑一个 Agent,传递地绑一个工作空间。工作空间 A 里配置的一个钉钉机器人和工作空间 B 里配置的一个钉钉机器人**完全独立**,即使它们被配置成连接同一个钉钉应用(你大概率不想这样,但技术上允许)。 +2.0.0 起,**会话 id 的生成编入渠道标识**——不同工作空间各自新建的同类型渠道,即使面对同一个外部用户,也各有各的会话行;两个工作空间的对话不可能再落进同一条会话。 + +### 技能(2.0.0) + +同名技能可在不同工作空间独立共存:安装查重按工作空间过滤、磁盘目录编入 workspaceId、运行时的 load / 读文件 / 跑脚本只在本会话工作空间(+ builtin + 全局 virtual)范围内解析。一个工作空间的员工读不到、也执行不了另一个工作空间的同名技能。详见[技能系统](./skills)。 + --- ## 工作空间隔离**不**覆盖的 @@ -201,6 +207,15 @@ Wiki KB 的数据**永远不会离开它的工作空间**。工作空间 B 里 --- +## 默认存储路径与桌面本地工具白名单(2.0.0+) + +两个来自 issue #512 的落地: + +- **默认工作空间存储路径可在界面配置**。每个工作空间的 `base_path` 一直可以在 Security → Workspaces 单独设置,但全局兜底的沙箱根(`mateclaw.workspace.sandbox.root`,默认 `data/workspace`)以前只能改环境变量或 yml。现在它是设置页里的一个**「默认工作空间存储路径」**设置项:新建会话与工作空间的文件集中存放在该路径下;修改只影响之后新建的,**不迁移已有数据**。 +- **桌面端本地工具白名单支持逐条删除**。桌面端允许本地工具访问的目录白名单,以前的管理入口是一个原生对话框,只能"添加"和"停用",删除 API 是死代码。现在白名单目录在界面里**逐条可见、逐条可删**。 + +--- + ## 在工作空间之间移动资源 **不直接支持。** 你有两个选项: diff --git a/mateclaw-server/src/main/resources/mapper/ApprovalGrantMapper.xml b/mateclaw-server/src/main/resources/mapper/ApprovalGrantMapper.xml index cc66f5ff..98189f69 100644 --- a/mateclaw-server/src/main/resources/mapper/ApprovalGrantMapper.xml +++ b/mateclaw-server/src/main/resources/mapper/ApprovalGrantMapper.xml @@ -12,6 +12,9 @@ WORKSPACE-scope matches against {workspaceScopeId} — pre-converted to a string on the Java side (`String.valueOf(workspaceId)`), so the SQL itself avoids CAST(... AS VARCHAR/CHAR). + + NOTE: findFirstMatchingIgnoringSeverity below is this query minus the severity + comparison — edits to the shared clauses must be applied to BOTH. --> + + + + + @@ -18,6 +22,7 @@ import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore' import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick' import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick' import McConfirmHost from '@/components/common/McConfirmHost.vue' +import FilePreviewDialog from '@/components/chat/preview/FilePreviewDialog.vue' // Initialize theme — applies .dark class to immediately useThemeStore() diff --git a/mateclaw-ui/src/api/__tests__/wikiUpload.test.ts b/mateclaw-ui/src/api/__tests__/wikiUpload.test.ts new file mode 100644 index 00000000..9b63dfe7 --- /dev/null +++ b/mateclaw-ui/src/api/__tests__/wikiUpload.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { http, wikiApi } from '@/api' + +describe('wikiApi.uploadRaw', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the dedicated five-minute upload timeout instead of the global request timeout', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ data: { id: 'raw-1' } }) + const formData = new FormData() + formData.append('file', new File(['fixture'], 'fixture.txt', { type: 'text/plain' })) + + await wikiApi.uploadRaw(42, formData) + + expect(post).toHaveBeenCalledWith( + '/wiki/knowledge-bases/42/raw/upload', + formData, + expect.objectContaining({ timeout: 300_000 }), + ) + }) +}) diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 62f6e7fa..6e08100e 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -1,5 +1,6 @@ import axios from 'axios' import { handleAuthFailure, updateTokenFromHeader } from '@/utils/auth' +import { WIKI_UPLOAD_TIMEOUT_MS } from '@/utils/wikiUpload' import type { ApprovalGrant, ApprovalGrantPage, @@ -211,6 +212,9 @@ export const conversationApi = { http.delete(`/conversations/${encId(conversationId)}`), clearMessages: (conversationId: string) => http.delete(`/conversations/${encId(conversationId)}/messages`), + // messageId is a snowflake ID — keep it a string end-to-end (never Number()). + rewindMessage: (conversationId: string, messageId: string) => + http.post(`/conversations/${encId(conversationId)}/messages/${messageId}/rewind`), rename: (conversationId: string, title: string) => http.put(`/conversations/${encId(conversationId)}/title`, { title }), setPinned: (conversationId: string, pinned: boolean) => @@ -261,6 +265,18 @@ export const skillApi = { refreshRuntime: () => http.post('/skills/runtime/refresh'), exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`), getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`), + /** + * Bundle files (scripts/ + references/ + templates/) — canonical rows in + * mate_skill_file; writes also materialize the workspace cache and + * re-resolve the skill. + */ + listFiles: (id: string | number) => http.get(`/skills/${id}/files`), + getFileContent: (id: string | number, path: string) => + http.get(`/skills/${id}/files/content`, { params: { path } }), + saveFileContent: (id: string | number, path: string, content: string) => + http.put(`/skills/${id}/files/content`, { path, content }), + deleteFile: (id: string | number, path: string) => + http.delete(`/skills/${id}/files`, { params: { path } }), // RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md + reverse lookup requirements: (id: string | number) => http.get(`/skills/${id}/requirements`), getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`), @@ -706,6 +722,12 @@ export const agentContextApi = { http.put(`/agents/${agentId}/workspace/files/${encodeFilePath(filename)}`, { content }), deleteFile: (agentId: string | number, filename: string) => http.delete(`/agents/${agentId}/workspace/files/${encodeFilePath(filename)}`), + // Per-owner PERSONAL memory copies written by agents during conversations. + // Admin-only on the backend; callers should treat a 403 as "hide the section". + listPersonalFiles: (agentId: string | number) => + http.get(`/agents/${agentId}/workspace/memory/personal-files`), + getPersonalFile: (agentId: string | number, filename: string, ownerKey: string) => + http.get(`/agents/${agentId}/workspace/memory/personal-file`, { params: { filename, ownerKey } }), getPromptFiles: (agentId: string | number) => http.get(`/agents/${agentId}/workspace/prompt-files`), setPromptFiles: (agentId: string | number, files: string[]) => @@ -790,6 +812,138 @@ export const cronJobApi = { http.get('/cron-jobs/active-runs', { params: { conversationId } }), } +// ==================== Agent Teams ==================== +// All ids are strings end-to-end (global Long→String Jackson config) — never +// coerce them to number, Snowflake ids exceed Number.MAX_SAFE_INTEGER. + +export interface AgentTeam { + id: string + name: string + description: string | null + leadAgentId: string + status: string + settings: string | null + createTime?: string +} + +export interface TeamVO { + team: AgentTeam + leadName: string | null + leadIcon?: string | null + memberCount: number +} + +export interface TeamMemberVO { + agentId: string + name: string + role: 'lead' | 'member' | 'reviewer' + icon?: string | null +} + +export interface TeamTask { + id: string + teamId: string + taskNumber: number + subject: string + description: string | null + status: string + priority: number + assigneeAgentId: string | null + ownerAgentId: string | null + blockedBy: string | null + requireApproval: boolean | null + progressPercent: number | null + progressStep: string | null + result: string | null + reason: string | null + dispatchCount: number + conversationId: string | null + leadConversationId: string | null + metadata: string | null + createTime?: string + updateTime?: string +} + +export interface TeamTaskDeliverable { + name: string + url: string + time?: string +} + +export interface TeamTaskVO { + task: TeamTask + assigneeName: string | null + ownerName: string | null +} + +export interface TeamTaskComment { + id: string + taskId: string + authorType: string + authorId: string + commentType: string + content: string + createTime?: string +} + +export interface TeamTaskEvent { + id: string + teamId: string + taskId: string + eventType: string + actorType: string | null + actorId: string | null + detail: string | null + createTime?: string +} + +export const teamApi = { + list: () => http.get('/teams'), + get: (id: string) => http.get(`/teams/${id}`), + create: (data: { + name: string + description?: string + leadAgentId: string + memberAgentIds: string[] + }) => http.post('/teams', data), + update: (id: string, data: { name?: string; description?: string; settings?: string }) => + http.put(`/teams/${id}`, data), + delete: (id: string) => http.delete(`/teams/${id}`), + addMember: (id: string, agentId: string, role: string) => + http.post(`/teams/${id}/members`, { agentId, role }), + removeMember: (id: string, agentId: string) => http.delete(`/teams/${id}/members/${agentId}`), + listTasks: (id: string, status?: string[], opts?: { limit?: number; offset?: number }) => + http.get(`/teams/${id}/tasks`, { + params: { + ...(status?.length ? { status: status.join(',') } : {}), + ...(opts?.limit != null ? { limit: opts.limit } : {}), + ...(opts?.offset != null ? { offset: opts.offset } : {}), + }, + }), + taskStats: (id: string) => http.get(`/teams/${id}/tasks/stats`), + getTask: (id: string, taskId: string) => http.get(`/teams/${id}/tasks/${taskId}`), + createTask: ( + id: string, + data: { + subject: string + description?: string + assigneeAgentId: string + priority?: number + blockedBy?: string[] + requireApproval?: boolean + }, + ) => http.post(`/teams/${id}/tasks`, data), + listTaskEvents: (id: string, taskId: string) => http.get(`/teams/${id}/tasks/${taskId}/events`), + approveTask: (id: string, taskId: string) => http.post(`/teams/${id}/tasks/${taskId}/approve`), + rejectTask: (id: string, taskId: string, reason?: string) => + http.post(`/teams/${id}/tasks/${taskId}/reject`, { reason }), + retryTask: (id: string, taskId: string) => http.post(`/teams/${id}/tasks/${taskId}/retry`), + cancelTask: (id: string, taskId: string, reason?: string) => + http.post(`/teams/${id}/tasks/${taskId}/cancel`, { reason }), + commentTask: (id: string, taskId: string, content: string) => + http.post(`/teams/${id}/tasks/${taskId}/comments`, { content }), +} + // ==================== Wiki Knowledge Base ==================== // One row in the cross-KB failure center. ids are strings (global Long→String // Jackson config) to avoid Snowflake precision loss. @@ -840,6 +994,7 @@ export const wikiApi = { uploadRaw: (kbId: number, formData: FormData, onProgress?: (pct: number) => void) => http.post(`/wiki/knowledge-bases/${kbId}/raw/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, + timeout: WIKI_UPLOAD_TIMEOUT_MS, onUploadProgress: onProgress ? (e) => { if (e.total) onProgress(Math.round((e.loaded / e.total) * 100)) } : undefined, diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index c1b9dc56..4ebff9f1 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -397,10 +397,10 @@ -

        +
        -
        +
        @@ -407,9 +412,9 @@ - + + + (), { const emit = defineEmits<{ regenerate: [] + rewind: [] 'toggle-thinking': [expanded: boolean] approve: [pendingId: string] deny: [pendingId: string] @@ -601,6 +632,9 @@ const emit = defineEmits<{ const role = computed(() => props.message.role) const status = computed(() => props.message.status) const isGenerating = computed(() => status.value === 'generating' || status.value === 'awaiting_approval') +// Only persisted messages can be rewound: DB snowflake ids are pure digits, +// client temp ids carry an underscore (`${Date.now()}_${random}`). +const canRewind = computed(() => /^\d+$/.test(String(props.message.id ?? ''))) const hovered = ref(false) const avatarIcon = computed(() => { @@ -1064,11 +1098,19 @@ const segments = computed(() => { } } - // 去重:相同 toolName + toolArgs 的 tool_call segment 只保留第一个 + // 去重 tool_call segment。优先用 LLM 提供的 toolCallId —— 它端到端稳定 + // (live 流与持久化两侧都带,见 useChat handleToolCallStarted / 后端 + // accumulator),既能正确识别"同一次调用被 live+reload 渲染两遍"(两侧 + // toolArgs 序列化可能有空白/键序差异,用 toolName::toolArgs 会漏判 → 重复 + // 显示,issue #521),又不会把"同名同参的多次真实调用"(如重试 shell/python) + // 误合并成一次。仅当没有 toolCallId(历史/遗留 segment)时才退回 + // toolName::toolArgs。 const seenToolCalls = new Set() const deduped = segs.filter(seg => { if (seg.type !== 'tool_call') return true - const key = `${seg.toolName}::${seg.toolArgs || ''}` + const key = seg.toolCallId + ? `id::${seg.toolCallId}` + : `na::${seg.toolName}::${seg.toolArgs || ''}` if (seenToolCalls.has(key)) return false seenToolCalls.add(key) return true @@ -2451,6 +2493,16 @@ watch(isGenerating, (generating) => { opacity: 0.76; } +.message-attachment__download { + flex-shrink: 0; + opacity: 0.6; + transition: opacity 0.15s; +} + +.message-attachment__download:hover { + opacity: 1; +} + /* ==================== Markdown 样式 ==================== */ .markdown-body :deep(p) { margin: 0 0 10px; diff --git a/mateclaw-ui/src/components/chat/MessageList.vue b/mateclaw-ui/src/components/chat/MessageList.vue index 0106df2b..f7b78dec 100644 --- a/mateclaw-ui/src/components/chat/MessageList.vue +++ b/mateclaw-ui/src/components/chat/MessageList.vue @@ -72,6 +72,7 @@ :user-icon="userIcon" :show-cursor="showCursorForMessage(msg)" @regenerate="$emit('regenerate', msg)" + @rewind="$emit('rewind', msg)" @toggle-thinking="(expanded) => $emit('toggle-thinking', msg, expanded)" @approve="(pendingId) => $emit('approve', pendingId)" @deny="(pendingId) => $emit('deny', pendingId)" @@ -159,6 +160,7 @@ const props = withDefaults(defineProps(), { const emit = defineEmits<{ regenerate: [message: Message] + rewind: [message: Message] 'toggle-thinking': [message: Message, expanded: boolean] 'suggestion-click': [suggestion: string] scroll: [event: Event] diff --git a/mateclaw-ui/src/components/chat/__tests__/toolCallDedup.test.ts b/mateclaw-ui/src/components/chat/__tests__/toolCallDedup.test.ts new file mode 100644 index 00000000..b4c616bf --- /dev/null +++ b/mateclaw-ui/src/components/chat/__tests__/toolCallDedup.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' +import type { MessageSegment } from '@/types' + +/** + * 复刻 MessageBubble.vue segments computed 里的 tool_call 去重逻辑做纯函数测试。 + * + * 修复的 bug(issue #521):工具/MCP 调用在对话中显示 2 次或多次。工具实际只 + * 调 1 次——重复来自去重 key 曾用 `toolName::toolArgs`:当同一次调用被 live 流 + * 与 reload 各渲染一遍时,两侧 toolArgs 的序列化可能有空白/键序差异,逃过去重 + * → 重复显示;同一 key 还会把"同名同参的多次真实调用"(重试)误合并成一次。 + * + * 修复:优先用端到端稳定的 toolCallId 去重,无 id 时才退回 toolName::toolArgs。 + */ + +function dedupeToolCalls(segs: MessageSegment[]): MessageSegment[] { + const seen = new Set() + return segs.filter(seg => { + if (seg.type !== 'tool_call') return true + const key = seg.toolCallId + ? `id::${seg.toolCallId}` + : `na::${seg.toolName}::${seg.toolArgs || ''}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function toolCall(id: string | undefined, name: string, args: string): MessageSegment { + return { id: `seg-${Math.random()}`, type: 'tool_call', status: 'completed', + toolName: name, toolArgs: args, toolCallId: id } +} + +describe('dedupeToolCalls — 按 toolCallId 去重', () => { + it('同一 toolCallId、args 序列化不同(live vs reload)→ 合并为一个(修复重复显示)', () => { + const segs = [ + toolCall('call_1', 'wiki_search', '{"query":"a"}'), + toolCall('call_1', 'wiki_search', '{ "query": "a" }'), // 空白差异 + ] + const out = dedupeToolCalls(segs) + expect(out).toHaveLength(1) + expect(out[0].toolArgs).toBe('{"query":"a"}') + }) + + it('同名同参但 toolCallId 不同(真实重试)→ 全部保留(修复误合并)', () => { + const segs = [ + toolCall('call_1', 'execute_shell', '{"cmd":"ls"}'), + toolCall('call_2', 'execute_shell', '{"cmd":"ls"}'), + ] + const out = dedupeToolCalls(segs) + expect(out).toHaveLength(2) + }) + + it('无 toolCallId 的遗留 segment → 退回 toolName::toolArgs 去重', () => { + const segs = [ + toolCall(undefined, 'wiki_read_page', '{"slug":"x"}'), + toolCall(undefined, 'wiki_read_page', '{"slug":"x"}'), + toolCall(undefined, 'wiki_read_page', '{"slug":"y"}'), + ] + const out = dedupeToolCalls(segs) + expect(out).toHaveLength(2) + expect(out.map(s => s.toolArgs)).toEqual(['{"slug":"x"}', '{"slug":"y"}']) + }) + + it('混合有/无 id:有 id 按 id、无 id 按 name+args,互不干扰', () => { + const segs = [ + toolCall('call_1', 'search', '{"q":"1"}'), + toolCall('call_1', 'search', '{"q":"1"}'), // dup by id + toolCall(undefined, 'search', '{"q":"1"}'), // 无 id,保留(key 前缀不同) + toolCall(undefined, 'search', '{"q":"1"}'), // dup of 上一条 + ] + const out = dedupeToolCalls(segs) + expect(out).toHaveLength(2) + }) + + it('非 tool_call segment 一律保留', () => { + const segs: MessageSegment[] = [ + { id: 't1', type: 'thinking', status: 'completed', thinkingText: '...' }, + { id: 'c1', type: 'content', status: 'completed', text: 'hello' }, + toolCall('call_1', 'search', '{}'), + toolCall('call_1', 'search', '{}'), + ] + const out = dedupeToolCalls(segs) + expect(out).toHaveLength(3) + expect(out.filter(s => s.type === 'tool_call')).toHaveLength(1) + }) +}) diff --git a/mateclaw-ui/src/components/chat/preview/DocxPreview.vue b/mateclaw-ui/src/components/chat/preview/DocxPreview.vue new file mode 100644 index 00000000..79ee41d2 --- /dev/null +++ b/mateclaw-ui/src/components/chat/preview/DocxPreview.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/mateclaw-ui/src/components/chat/preview/FilePreviewDialog.vue b/mateclaw-ui/src/components/chat/preview/FilePreviewDialog.vue new file mode 100644 index 00000000..f82c31fa --- /dev/null +++ b/mateclaw-ui/src/components/chat/preview/FilePreviewDialog.vue @@ -0,0 +1,301 @@ + + + + + + + diff --git a/mateclaw-ui/src/components/chat/preview/HtmlPreview.vue b/mateclaw-ui/src/components/chat/preview/HtmlPreview.vue new file mode 100644 index 00000000..cc3e75cf --- /dev/null +++ b/mateclaw-ui/src/components/chat/preview/HtmlPreview.vue @@ -0,0 +1,64 @@ +